diff --git a/next_pass.py b/next_pass.py index 6b75b94..a94699a 100755 --- a/next_pass.py +++ b/next_pass.py @@ -105,8 +105,10 @@ def create_parser() -> argparse.ArgumentParser: "--tide", action="store_true", help=( - "Display NOAA Tide Predictions for future and/or " - "past overpasses, respectively" + "Display NOAA Tide Predictions for future and/or past overpasses. " + "Note: For Landsat and NISAR, displayed passes are limited to those " + "within 60 days (NOAA API forecast limit) to improve output readability; " + "a summary note lists any passes scheduled beyond that window." ), ) parser.add_argument( @@ -193,11 +195,11 @@ def find_next_overpass(args: argparse.Namespace, timestamp_dir: Path) -> dict: if "nisar" in selected: LOGGER.info("Fetching NISAR data...") - nisar = next_nisar_pass(geometry, n_day_past) + nisar = next_nisar_pass(geometry, n_day_past, arg_tide=pred_tide) if "landsat" in selected: LOGGER.info("Fetching Landsat data...") - landsat = next_landsat_pass(lat_min, lon_min, geometry, n_day_past) + landsat = next_landsat_pass(lat_min, lon_min, geometry, n_day_past, pred_tide) return { "sentinel-1": sentinel1, diff --git a/tests/helpers.py b/tests/helpers.py index 07790d2..abddcf6 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -97,6 +97,12 @@ def __getattr__(self, name): def __getitem__(self, key): return self._mapping[key] + def __setitem__(self, key, value): + self._mapping[key] = value + + def get(self, key, default=None): + return self._mapping.get(key, default) + class _ILocAccessor: def __init__(self, frame): @@ -264,7 +270,13 @@ def groupby(self, group_cols, dropna=False, sort=False): def apply(self, func, axis=0): if axis != 1: raise ValueError(axis) - return [func(FakeRow(row)) for row in self.rows] + results = [func(FakeRow(row)) for row in self.rows] + # When func returns FakeRow (row-level transformation), return a FakeFrame + # so callers can chain .dropna()/.reset_index(). Scalar returns stay as a list. + if any(isinstance(r, FakeRow) for r in results): + kept = [r._mapping for r in results if isinstance(r, FakeRow)] + return FakeFrame(kept) + return results def drop(self, columns=None): columns = columns or [] diff --git a/tests/test_landsat_pass_extended.py b/tests/test_landsat_pass_extended.py index d1623c6..a0fd748 100644 --- a/tests/test_landsat_pass_extended.py +++ b/tests/test_landsat_pass_extended.py @@ -196,7 +196,8 @@ def close(self): n_day_past=13, ) - assert result["next_collect_info"] == "formatted-table" + assert result["next_collect_info"].startswith("formatted-table") + assert "±15-40 minutes accuracy" in result["next_collect_info"] assert result["next_collect_geometry"][0].name == "merged" assert "Warning: stale warning" in result["next_collect_summary"][0] @@ -236,3 +237,47 @@ def fake_tabulate(rows, headers=None, tablefmt=None): assert captured["rows"][0][:3] == ["Ascending", "N/A", "N/A"] assert captured["rows"][1][:3] == ["Descending", "N/A", "N/A"] assert result["next_collect_geometry"] == [] + + +def test_estimate_landsat_overpass_time_western_hemisphere_stays_on_same_day(): + """Los Angeles: 10:12 AM local ≈ 18:04 UTC — no day rollover.""" + from datetime import timezone + + result = landsat_pass.estimate_landsat_overpass_time("06/28/2026", 34.0, -118.0) + + assert result.tzinfo == timezone.utc + assert result.date().isoformat() == "2026-06-28" + assert result.hour == 18 + assert result.minute == 4 + + +def test_estimate_landsat_overpass_time_far_eastern_rolls_to_previous_day(): + """Fiji / dateline: 10:12 AM local is late on the previous UTC day. + + Regression guard: the old implementation used `% 24` + `.replace(hour=...)` + which kept the local calendar date, producing a date one day too late for + far-eastern longitudes. + """ + from datetime import timezone + + # lon = 175 (near dateline): utc_hour = 10.2 - 175/15 = -1.4666... + # → previous day at 22:32 UTC + result = landsat_pass.estimate_landsat_overpass_time("06/28/2026", -18.0, 175.0) + + assert result.tzinfo == timezone.utc + assert result.date().isoformat() == "2026-06-27" + assert result.hour == 22 + assert result.minute == 32 + + +def test_estimate_landsat_overpass_time_midpacific_boundary(): + """Just past the rollover boundary — confirms the day flips correctly.""" + from datetime import timezone + + # lon = 155: utc_hour = 10.2 - 10.333 = -0.133 → previous day, 23:52 UTC + result = landsat_pass.estimate_landsat_overpass_time("06/28/2026", 0.0, 155.0) + + assert result.tzinfo == timezone.utc + assert result.date().isoformat() == "2026-06-27" + assert result.hour == 23 + assert result.minute == 52 diff --git a/tests/test_next_pass_cli.py b/tests/test_next_pass_cli.py index 2172a7f..bb3b68a 100644 --- a/tests/test_next_pass_cli.py +++ b/tests/test_next_pass_cli.py @@ -92,12 +92,12 @@ def fake_make_opera_granule_map(results_dict, bbox, timestamp_dir): "next_collect_geometry": [geometry], "next_collect_summary": [sat], }} -nisar_pass.next_nisar_pass = lambda geometry, n_day_past: {{ +nisar_pass.next_nisar_pass = lambda geometry, n_day_past, arg_tide=False: {{ "next_collect_info": "nisar", "next_collect_geometry": [geometry], "next_collect_summary": ["nisar"], }} -landsat_pass.next_landsat_pass = lambda lat, lon, geometry, n_day_past: {{ +landsat_pass.next_landsat_pass = lambda lat, lon, geometry, n_day_past, arg_tide=False: {{ "next_collect_info": "landsat", "next_collect_geometry": [geometry], "next_collect_summary": ["landsat"], @@ -236,12 +236,12 @@ def test_find_next_overpass_routes_all_satellites(monkeypatch, tmp_path): monkeypatch.setattr( nisar_pass, "next_nisar_pass", - lambda geometry, n_day_past: {"next_collect_info": f"nisar-{geometry.name}-{n_day_past}"}, + lambda geometry, n_day_past, arg_tide=False: {"next_collect_info": f"nisar-{geometry.name}-{n_day_past}"}, ) monkeypatch.setattr( landsat_pass, "next_landsat_pass", - lambda lat, lon, geometry, n_day_past: {"next_collect_info": f"landsat-{lat}-{lon}-{n_day_past}"}, + lambda lat, lon, geometry, n_day_past, arg_tide=False: {"next_collect_info": f"landsat-{lat}-{lon}-{n_day_past}"}, ) args = argparse.Namespace( @@ -275,7 +275,7 @@ def test_find_next_overpass_routes_single_satellite(monkeypatch, tmp_path): monkeypatch.setattr( landsat_pass, "next_landsat_pass", - lambda lat, lon, geometry, n_day_past: {"lat": lat, "lon": lon, "name": geometry.name, "days": n_day_past}, + lambda lat, lon, geometry, n_day_past, arg_tide=False: {"lat": lat, "lon": lon, "name": geometry.name, "days": n_day_past}, ) args = argparse.Namespace( diff --git a/tests/test_nisar_pass.py b/tests/test_nisar_pass.py index 29bcb37..5eb9baa 100644 --- a/tests/test_nisar_pass.py +++ b/tests/test_nisar_pass.py @@ -131,6 +131,67 @@ def test_next_nisar_pass_groups_by_best_overlap(monkeypatch): result = nisar_pass.next_nisar_pass(FakePolygon("aoi"), 13) - assert result["next_collect_info"] == "table" + assert result["next_collect_info"].startswith("table") + assert "±20-40 minutes accuracy" in result["next_collect_info"] assert result["intersection_pct"] == [90.0] + +def test_estimate_nisar_overpass_time_western_descending_rolls_forward_one_day(): + """Los Angeles descending: 6 PM local ≈ 01:52 UTC on the following day. + + Regression guard: the old implementation used `% 24` + `.replace(hour=...)` + which produced 02:00 UTC on the SAME day for LA descending — off by 24 hours + and causing tide lookups to fetch the wrong day. + """ + result = nisar_pass.estimate_nisar_overpass_time( + "2026-06-28", 34.0, -118.0, "Descending" + ) + + assert result.tzinfo == timezone.utc + assert result.date().isoformat() == "2026-06-29" + assert result.hour == 1 + assert result.minute == 52 + + +def test_estimate_nisar_overpass_time_eastern_ascending_rolls_back_one_day(): + """Tokyo ascending: 6 AM local ≈ 20:40 UTC on the previous day. + + Regression guard: the old implementation produced 20:40 UTC on the SAME + calendar day for eastern-hemisphere ascending passes — off by 24 hours. + """ + # lon = 140: utc_hour = 6 - 140/15 = -3.333... → previous day, 20:40 UTC + result = nisar_pass.estimate_nisar_overpass_time( + "2026-06-28", 35.0, 140.0, "Ascending" + ) + + assert result.tzinfo == timezone.utc + assert result.date().isoformat() == "2026-06-27" + assert result.hour == 20 + assert result.minute == 40 + + +def test_estimate_nisar_overpass_time_prime_meridian_no_rollover(): + """Sanity check: ascending pass at longitude 0 stays on the same UTC day.""" + result = nisar_pass.estimate_nisar_overpass_time( + "2026-06-28", 0.0, 0.0, "Ascending" + ) + + assert result.tzinfo == timezone.utc + assert result.date().isoformat() == "2026-06-28" + assert result.hour == 6 + assert result.minute == 0 + + +def test_estimate_nisar_overpass_time_dateline_descending_double_rollover(): + """Far western dateline descending: rolls forward one full UTC day.""" + # lon = -175: utc_hour = 18 - (-175/15) = 18 + 11.666... = 29.666... + # → next day at 05:40 UTC + result = nisar_pass.estimate_nisar_overpass_time( + "2026-06-28", -18.0, -175.0, "Descending" + ) + + assert result.tzinfo == timezone.utc + assert result.date().isoformat() == "2026-06-29" + assert result.hour == 5 + assert result.minute == 40 + diff --git a/tests/test_sentinel_pass.py b/tests/test_sentinel_pass.py index 79ed267..501594e 100644 --- a/tests/test_sentinel_pass.py +++ b/tests/test_sentinel_pass.py @@ -170,8 +170,11 @@ def test_next_sentinel_pass_returns_tide_for_point_aoi(monkeypatch): ) monkeypatch.setattr( sentinel_pass, - "make_get_tide_for_row", - lambda aoi_geometry, station_dicts: (lambda row: [{"nearest": "1.23(H-rising)", "per_station": {"9432780": "1.23(H-rising)"}}]), + "get_tide_info_batch", + lambda polygon, target_isos, station_dicts, allow_interpolation: [ + {"nearest": "1.23(H-rising)", "per_station": {"9432780": "1.23(H-rising)"}} + for _ in target_isos + ], ) monkeypatch.setattr(sentinel_pass, "format_collects", lambda grouped: "tide-table") monkeypatch.setattr(sentinel_pass, "build_collect_summaries", lambda grouped: ["tide-summary"]) diff --git a/tests/test_timezone_contract.py b/tests/test_timezone_contract.py new file mode 100644 index 0000000..23e0080 --- /dev/null +++ b/tests/test_timezone_contract.py @@ -0,0 +1,163 @@ +""" +Test timezone contract enforcement in tide_prediction module. + +These tests verify that the validation checks properly reject timezone-aware +datetimes and accept naive datetimes representing UTC. +""" + +import pytest +from datetime import datetime, timezone +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from utils.tide_prediction import ( + interpolate_tide, + _find_tide_direction, + _find_nearest_hilo_label, +) + + +class TestTimezoneContractEnforcement: + """Test that tide prediction functions enforce the naive datetime contract.""" + + def test_interpolate_tide_rejects_timezone_aware(self): + """interpolate_tide should raise TypeError for timezone-aware datetime.""" + times = ["2026-06-28T17:00:00", "2026-06-28T18:00:00", "2026-06-28T19:00:00"] + values = [1.15, 1.23, 1.30] + + # Timezone-aware datetime (UTC) + dt_aware = datetime(2026, 6, 28, 18, 30, tzinfo=timezone.utc) + + with pytest.raises(TypeError) as excinfo: + interpolate_tide(times, values, dt_aware) + + assert "naive datetime (UTC)" in str(excinfo.value) + assert "timezone-aware" in str(excinfo.value) + + def test_interpolate_tide_accepts_naive(self): + """interpolate_tide should accept naive datetime.""" + times = ["2026-06-28T17:00:00", "2026-06-28T18:00:00", "2026-06-28T19:00:00"] + values = [1.15, 1.23, 1.30] + + # Naive datetime (represents UTC) + dt_naive = datetime(2026, 6, 28, 18, 30) + + # Should not raise, should interpolate + result = interpolate_tide(times, values, dt_naive) + + assert result is not None + assert isinstance(result, float) + assert 1.23 < result < 1.30 # Between 18:00 and 19:00 values + + def test_find_tide_direction_rejects_timezone_aware(self): + """_find_tide_direction should raise TypeError for timezone-aware datetime.""" + times = ["2026-06-28T17:00:00", "2026-06-28T18:00:00", "2026-06-28T19:00:00"] + values = [1.15, 1.23, 1.30] + + # Timezone-aware datetime + dt_aware = datetime(2026, 6, 28, 18, 30, tzinfo=timezone.utc) + + with pytest.raises(TypeError) as excinfo: + _find_tide_direction(times, values, dt_aware) + + assert "naive datetime (UTC)" in str(excinfo.value) + assert "timezone-aware" in str(excinfo.value) + + def test_find_tide_direction_accepts_naive(self): + """_find_tide_direction should accept naive datetime.""" + times = ["2026-06-28T17:00:00", "2026-06-28T18:00:00", "2026-06-28T19:00:00"] + values = [1.15, 1.23, 1.30] + + # Naive datetime + dt_naive = datetime(2026, 6, 28, 18, 30) + + # Should not raise + result = _find_tide_direction(times, values, dt_naive) + + assert result in ["rising", "falling", "slack", ""] + assert result == "rising" # Values are increasing + + def test_find_nearest_hilo_label_rejects_timezone_aware(self): + """_find_nearest_hilo_label should raise TypeError for timezone-aware datetime.""" + hilo_predictions = [ + {"t": "2026-06-28 17:45", "type": "L"}, + {"t": "2026-06-28 23:30", "type": "H"}, + ] + + # Timezone-aware datetime + dt_aware = datetime(2026, 6, 28, 18, 30, tzinfo=timezone.utc) + + with pytest.raises(TypeError) as excinfo: + _find_nearest_hilo_label(hilo_predictions, dt_aware) + + assert "naive datetime (UTC)" in str(excinfo.value) + assert "timezone-aware" in str(excinfo.value) + + def test_find_nearest_hilo_label_accepts_naive(self): + """_find_nearest_hilo_label should accept naive datetime.""" + hilo_predictions = [ + {"t": "2026-06-28 17:45", "type": "L"}, + {"t": "2026-06-28 23:30", "type": "H"}, + ] + + # Naive datetime + dt_naive = datetime(2026, 6, 28, 18, 30) + + # Should not raise + result = _find_nearest_hilo_label(hilo_predictions, dt_naive) + + assert result in ["H", "HH", "L", "LL", ""] + assert result == "L" # Closer to 17:45 low than 23:30 high + + +class TestErrorMessageQuality: + """Test that error messages are helpful for developers.""" + + def test_error_message_includes_timezone_info(self): + """Error message should tell developer which timezone was detected.""" + times = ["2026-06-28T17:00:00", "2026-06-28T18:00:00"] + values = [1.15, 1.23] + dt_aware = datetime(2026, 6, 28, 18, 0, tzinfo=timezone.utc) + + with pytest.raises(TypeError) as excinfo: + interpolate_tide(times, values, dt_aware) + + error_msg = str(excinfo.value) + assert "UTC" in error_msg # Should mention the specific timezone + assert "module docstring" in error_msg # Should point to documentation + + def test_error_message_is_consistent_across_functions(self): + """All functions should have consistent error message format.""" + times = ["2026-06-28T17:00:00", "2026-06-28T18:00:00"] + values = [1.15, 1.23] + dt_aware = datetime(2026, 6, 28, 18, 0, tzinfo=timezone.utc) + + errors = [] + + try: + interpolate_tide(times, values, dt_aware) + except TypeError as e: + errors.append(str(e)) + + try: + _find_tide_direction(times, values, dt_aware) + except TypeError as e: + errors.append(str(e)) + + try: + _find_nearest_hilo_label([], dt_aware) + except TypeError as e: + errors.append(str(e)) + + # All should mention the same key concepts + for error in errors: + assert "naive datetime (UTC)" in error + assert "timezone-aware" in error + assert "module docstring" in error + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_timezone_validation.py b/tests/test_timezone_validation.py new file mode 100644 index 0000000..e3dbee8 --- /dev/null +++ b/tests/test_timezone_validation.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Test timezone validation in tide prediction system. + +This test verifies that the timezone validation enhancement works correctly: +1. UTC datetimes pass through without warnings +2. Non-UTC datetimes are converted to UTC with warnings +3. Naive datetimes trigger warnings but are assumed to be UTC +""" + +import logging +from datetime import datetime, timezone, timedelta +from io import StringIO +from shapely.geometry import Point + +# Set up logging to capture warnings +log_stream = StringIO() +handler = logging.StreamHandler(log_stream) +handler.setLevel(logging.WARNING) +formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s') +handler.setFormatter(formatter) + +logger = logging.getLogger("tide_prediction") +logger.addHandler(handler) +logger.setLevel(logging.WARNING) + +# Import after setting up logging # noqa: E402 +from utils.tide_prediction import make_get_tide_for_row # noqa: E402 + + +def test_utc_datetime(): + """Test that UTC-aware datetimes pass through without warnings.""" + print("Test 1: UTC-aware datetime (should pass without warning)") + log_stream.truncate(0) + log_stream.seek(0) + + # Create a mock row with UTC datetime (dict-like access) + class MockRow: + def __init__(self): + dt = datetime(2026, 6, 28, 18, 0, 0, tzinfo=timezone.utc) + self._data = {"begin_date": dt} + + def __getitem__(self, key): + return self._data[key] + + # Create tide function with mock geometry and empty stations + aoi = Point(-118.0, 34.0).buffer(0.1) + tide_func = make_get_tide_for_row(aoi, []) + + # This should not produce warnings (though it will fail with no stations) + try: + tide_func(MockRow()) + except Exception: + pass # Expected to fail with no stations + + warnings = log_stream.getvalue() + assert "timezone" not in warnings.lower() and "naive" not in warnings.lower(), \ + f"Unexpected warning: {warnings}" + print(" ✅ PASS: No timezone warnings") + + +def test_non_utc_datetime(): + """Test that non-UTC datetimes are converted with warnings.""" + print("\nTest 2: Non-UTC datetime (should trigger conversion warning)") + log_stream.truncate(0) + log_stream.seek(0) + + # Create a mock row with Pacific timezone datetime + class MockRow: + def __init__(self): + # PST is UTC-8 + pst = timezone(timedelta(hours=-8)) + self._data = { + "begin_date": datetime(2026, 6, 28, 10, 0, 0, tzinfo=pst) + } + + def __getitem__(self, key): + return self._data[key] + + # Create tide function + aoi = Point(-118.0, 34.0).buffer(0.1) + tide_func = make_get_tide_for_row(aoi, []) + + try: + tide_func(MockRow()) + except Exception: + pass # Expected to fail with no stations + + warnings = log_stream.getvalue() + assert "Non-UTC datetime detected" in warnings, \ + f"Expected warning not found. Got: {warnings}" + print(f" ✅ PASS: Got expected warning: {warnings.strip()}") + + +def test_naive_datetime(): + """Test that naive datetimes trigger warnings.""" + print("\nTest 3: Naive datetime (should trigger assumption warning)") + log_stream.truncate(0) + log_stream.seek(0) + + # Create a mock row with naive datetime (no timezone) + class MockRow: + def __init__(self): + # No tzinfo + self._data = {"begin_date": datetime(2026, 6, 28, 18, 0, 0)} + + def __getitem__(self, key): + return self._data[key] + + # Create tide function + aoi = Point(-118.0, 34.0).buffer(0.1) + tide_func = make_get_tide_for_row(aoi, []) + + try: + tide_func(MockRow()) + except Exception: + pass # Expected to fail with no stations + + warnings = log_stream.getvalue() + assert "Naive datetime detected" in warnings, \ + f"Expected warning not found. Got: {warnings}" + print(f" ✅ PASS: Got expected warning: {warnings.strip()}") + + +def test_datetime_list(): + """Test that lists of datetimes are handled correctly.""" + print("\nTest 4: List of mixed datetimes (should handle all correctly)") + log_stream.truncate(0) + log_stream.seek(0) + + # Create a mock row with list of different datetime types + class MockRow: + def __init__(self): + pst = timezone(timedelta(hours=-8)) + self._data = { + "begin_date": [ + datetime(2026, 6, 28, 18, 0, 0, tzinfo=timezone.utc), + datetime(2026, 6, 29, 10, 0, 0, tzinfo=pst), + datetime(2026, 6, 30, 18, 0, 0), # Naive + ] + } + + def __getitem__(self, key): + return self._data[key] + + # Create tide function + aoi = Point(-118.0, 34.0).buffer(0.1) + tide_func = make_get_tide_for_row(aoi, []) + + try: + tide_func(MockRow()) + except Exception: + pass # Expected to fail with no stations + + warnings = log_stream.getvalue() + has_non_utc_warning = "Non-UTC datetime detected" in warnings + has_naive_warning = "Naive datetime detected" in warnings + + assert has_non_utc_warning and has_naive_warning, ( + f"Missing expected warnings. Non-UTC: {has_non_utc_warning}, " + f"Naive: {has_naive_warning}. Got: {warnings}" + ) + print(" ✅ PASS: Got both expected warnings") + print(f" {warnings.strip()}") + + +if __name__ == "__main__": + print("=" * 60) + print("Timezone Validation Tests") + print("=" * 60) + + tests = [ + test_utc_datetime, + test_non_utc_datetime, + test_naive_datetime, + test_datetime_list, + ] + + results = [] + for test in tests: + try: + test() + results.append(True) + print(f"✓ {test.__name__}") + except AssertionError as e: + results.append(False) + print(f"✗ {test.__name__}: {e}") + except Exception as e: + results.append(False) + print(f"✗ {test.__name__} (unexpected error): {e}") + + print("\n" + "=" * 60) + print(f"Results: {sum(results)}/{len(results)} tests passed") + print("=" * 60) + + if all(results): + print("\n✅ All tests passed!") + exit(0) + + print("\n❌ Some tests failed!") + exit(1) diff --git a/tests/test_utils_geometry.py b/tests/test_utils_geometry.py index d49f49a..541ed0f 100644 --- a/tests/test_utils_geometry.py +++ b/tests/test_utils_geometry.py @@ -180,3 +180,171 @@ def to_crs(self, epsg=None): result = utils_mod.get_spatial_extent_km({"type": "Polygon", "coordinates": []}) assert result == {"width_km": 2.0, "height_km": 3.0, "area_km2": 5.0} + + +def test_filter_dates_beyond_window_with_datetime_objects(): + """Test filtering with datetime objects (NISAR-style).""" + from datetime import datetime, timedelta, timezone + + now = datetime.now(timezone.utc) + dates = [ + now + timedelta(days=10), + now + timedelta(days=30), + now + timedelta(days=50), + now + timedelta(days=70), + now + timedelta(days=90), + ] + tides = ["+1.0m", "+1.2m", "+1.5m", "+0.8m", "+1.0m"] + + filtered_dates, filtered_tides, count, min_date, max_date = ( + utils_mod.filter_dates_beyond_window(dates, tides, max_days=60) + ) + + # Should keep first 3 dates (10, 30, 50 days), filter out last 2 (70, 90 days) + assert len(filtered_dates) == 3 + assert len(filtered_tides) == 3 + assert filtered_tides == ["+1.0m", "+1.2m", "+1.5m"] + assert count == 2 + assert min_date == (now + timedelta(days=70)).date() + assert max_date == (now + timedelta(days=90)).date() + + +def test_filter_dates_beyond_window_with_date_strings(): + """Test filtering with date strings (Landsat-style).""" + from datetime import datetime, timedelta + + # Create date strings relative to today + now = datetime.now() + dates = [ + (now + timedelta(days=10)).strftime("%m/%d/%Y"), + (now + timedelta(days=40)).strftime("%m/%d/%Y"), + (now + timedelta(days=80)).strftime("%m/%d/%Y"), + ] + tides = ["+1.1m", "+1.3m", "+0.9m"] + + filtered_dates, filtered_tides, count, min_date, max_date = ( + utils_mod.filter_dates_beyond_window( + dates, tides, max_days=60, date_format="%m/%d/%Y" + ) + ) + + # Should keep first 2 dates, filter out last 1 + assert len(filtered_dates) == 2 + assert len(filtered_tides) == 2 + assert count == 1 + + +def test_filter_dates_beyond_window_all_within_window(): + """Test when all dates are within the window.""" + from datetime import datetime, timedelta, timezone + + now = datetime.now(timezone.utc) + dates = [ + now + timedelta(days=10), + now + timedelta(days=20), + now + timedelta(days=30), + ] + tides = ["+1.0m", "+1.2m", "+1.5m"] + + filtered_dates, filtered_tides, count, min_date, max_date = ( + utils_mod.filter_dates_beyond_window(dates, tides, max_days=60) + ) + + # All dates should be kept + assert len(filtered_dates) == 3 + assert len(filtered_tides) == 3 + assert count == 0 + assert min_date is None + assert max_date is None + + +def test_filter_dates_beyond_window_all_beyond_window(): + """Test when all dates are beyond the window.""" + from datetime import datetime, timedelta, timezone + + now = datetime.now(timezone.utc) + dates = [ + now + timedelta(days=70), + now + timedelta(days=80), + now + timedelta(days=90), + ] + tides = ["+1.0m", "+1.2m", "+1.5m"] + + filtered_dates, filtered_tides, count, min_date, max_date = ( + utils_mod.filter_dates_beyond_window(dates, tides, max_days=60) + ) + + # All dates should be filtered out + assert len(filtered_dates) == 0 + assert len(filtered_tides) == 0 + assert count == 3 + assert min_date == (now + timedelta(days=70)).date() + assert max_date == (now + timedelta(days=90)).date() + + +def test_filter_dates_beyond_window_empty_lists(): + """Test with empty input lists.""" + filtered_dates, filtered_tides, count, min_date, max_date = ( + utils_mod.filter_dates_beyond_window([], [], max_days=60) + ) + + assert filtered_dates == [] + assert filtered_tides == [] + assert count == 0 + assert min_date is None + assert max_date is None + + +def test_filter_dates_beyond_window_mismatched_tide_data(): + """Test when tide data is shorter than dates list.""" + from datetime import datetime, timedelta, timezone + + now = datetime.now(timezone.utc) + dates = [ + now + timedelta(days=10), + now + timedelta(days=20), + now + timedelta(days=30), + ] + tides = ["+1.0m"] # Only 1 tide value for 3 dates + + filtered_dates, filtered_tides, count, min_date, max_date = ( + utils_mod.filter_dates_beyond_window(dates, tides, max_days=60) + ) + + # All dates kept, but only 1 tide value + assert len(filtered_dates) == 3 + assert len(filtered_tides) == 1 + assert count == 0 + + +def test_filter_dates_beyond_window_custom_max_days(): + """Test with custom max_days parameter.""" + from datetime import datetime, timedelta, timezone + + now = datetime.now(timezone.utc) + dates = [ + now + timedelta(days=5), + now + timedelta(days=15), + now + timedelta(days=25), + ] + tides = ["+1.0m", "+1.2m", "+1.5m"] + + # Use max_days=10 instead of default 60 + filtered_dates, filtered_tides, count, min_date, max_date = ( + utils_mod.filter_dates_beyond_window(dates, tides, max_days=10) + ) + + # Only first date (5 days) should be kept + assert len(filtered_dates) == 1 + assert len(filtered_tides) == 1 + assert count == 2 + + +def test_filter_dates_beyond_window_requires_date_format_for_strings(): + """Test that date_format is required when dates are strings.""" + dates = ["07/10/2026", "08/20/2026"] + tides = ["+1.0m", "+1.2m"] + + # Should raise ValueError when date_format is not provided + with pytest.raises(ValueError, match="date_format required"): + utils_mod.filter_dates_beyond_window(dates, tides, max_days=60) diff --git a/utils/landsat_pass.py b/utils/landsat_pass.py index 4a22f31..225912e 100644 --- a/utils/landsat_pass.py +++ b/utils/landsat_pass.py @@ -10,7 +10,14 @@ from shapely.ops import unary_union from tabulate import tabulate -from utils.utils import arcgis_to_polygon +from utils.utils import ( + arcgis_to_polygon, + filter_dates_beyond_window, + LANDSAT_EQUATORIAL_CROSSING_HOUR, + HOURS_PER_LONGITUDE_DEGREE, + TIDE_PREDICTION_WINDOW_DAYS, +) +from utils.tide_prediction import get_stations_in_aoi, get_tide_info_batch logger = logging.getLogger(__name__) @@ -36,6 +43,53 @@ UNIX_EPOCH = date(1970, 1, 1) +def estimate_landsat_overpass_time(date_str: str, lat: float, lon: float) -> datetime: + """ + Estimate Landsat overpass time based on USGS orbital specifications. + + Landsat 8 & 9 cross the equator at 10:12 AM local solar time (±5 minutes) + on descending (daytime) passes. This function estimates the UTC overpass + time for a given location based on this specification. + + Args: + date_str: Date in format MM/DD/YYYY (e.g., "06/28/2026") + lat: Latitude of the location + lon: Longitude of the location (negative for Western hemisphere) + + Returns: + datetime: Estimated overpass time in UTC timezone + + Notes: + - Based on USGS specification: equatorial crossing at 10:12 AM local time + - Source: https://www.usgs.gov/landsat-missions/landsat-8 + - Accuracy: ±15-20 minutes near equator, ±20-40 minutes at higher latitudes + - Simplified calculation does not account for equation of time or orbital perturbations + - Uses local solar time approximation: LST ≈ UTC + (lon/15) hours + + Example: + >>> estimate_landsat_overpass_time("06/28/2026", 34.0, -118.0) + # Los Angeles: ~10:12 AM local ≈ 18:04 UTC same day + >>> estimate_landsat_overpass_time("06/28/2026", -35.0, 150.0) + # Sydney: ~10:12 AM local ≈ 00:12 UTC same day + """ + from datetime import time, timezone + + # Parse the calendar date (MM/DD/YYYY format) + date_obj = datetime.strptime(date_str, DATE_FORMAT).date() + + # Landsat crosses the equator at 10:12 AM local solar time + local_solar_hour = LANDSAT_EQUATORIAL_CROSSING_HOUR + + # Local Solar Time (LST) approximation: + # LST ≈ UTC + (longitude / HOURS_PER_LONGITUDE_DEGREE) hours + # utc_hour may be negative or > 24; timedelta below rolls the day accordingly. + local_solar_offset_hours = lon / HOURS_PER_LONGITUDE_DEGREE + utc_hour = local_solar_hour - local_solar_offset_hours + + base = datetime.combine(date_obj, time.min, tzinfo=timezone.utc) + return base + timedelta(hours=utc_hour) + + @dataclass class LandsatScheduleSource: """Normalized Landsat schedule inputs from modern or legacy USGS sources.""" @@ -50,9 +104,11 @@ class LandsatScheduleSource: def format_date_lines(date_strings: list[str], per_line: int = 5) -> str: """Wrap Landsat pass dates across multiple lines.""" + from datetime import timezone + formatted_dates = [ date_str - + (" (P)" if datetime.strptime(date_str, DATE_FORMAT) < datetime.now() else "") + + (" (P)" if datetime.strptime(date_str, DATE_FORMAT).replace(tzinfo=timezone.utc) < datetime.now(timezone.utc) else "") for date_str in date_strings ] return "\n".join( @@ -384,6 +440,7 @@ def next_landsat_pass( lon: float, geometryAOI, n_day_past: float, + arg_tide: bool = False, ) -> dict | None: """ Retrieve and format the next Landsat passes for a given location. @@ -394,6 +451,7 @@ def next_landsat_pass( geometryAOI: Geometry of the area of interest used for computing intersection percentage. n_day_past (float): Number of days in the past to search cycles JSON. + arg_tide (bool): Whether to compute NOAA tide predictions per overpass. Returns: dict or None: Dictionary containing next Landsat passes information @@ -409,6 +467,8 @@ def next_landsat_pass( ) geometry_groups = defaultdict(list) + # First pass: collect all features by key + features_by_key = defaultdict(list) for direction, features in results.items(): if features: for feature in features: @@ -417,38 +477,170 @@ def next_landsat_pass( geom = feature.get("geometry") polygon = arcgis_to_polygon(geom) - if geometryAOI.geom_type == "Point": - intersection_pct = 100 - elif polygon and polygon.is_valid and geometryAOI.is_valid: - intersection = polygon.intersection(geometryAOI) - intersection_pct = 100 * (intersection.area / geometryAOI.area) - else: - intersection_pct = 0.0 - next_pass_dates, schedule_warnings = find_next_landsat_pass( path, n_day_past, schedule_source=schedule_source, num_passes=5, ) + for mission, dates in next_pass_dates.items(): key = (direction.capitalize(), path, mission.capitalize()) - aggregated_data[key]["rows"].add(row) - aggregated_data[key]["overlap_pct"] += intersection_pct - if aggregated_data[key]["dates"] is None: - aggregated_data[key]["dates"] = dates - if schedule_warnings: - aggregated_data[key]["warnings"] = schedule_warnings - - if polygon: - geometry_groups[key].append(polygon) + features_by_key[key].append({ + "row": row, + "polygon": polygon, + "dates": dates, + "warnings": schedule_warnings, + }) + + # Second pass: aggregate features with proper geometry union + for key, features in features_by_key.items(): + for feature in features: + aggregated_data[key]["rows"].add(feature["row"]) + if aggregated_data[key]["dates"] is None: + aggregated_data[key]["dates"] = feature["dates"] + if feature["warnings"]: + aggregated_data[key]["warnings"] = feature["warnings"] + if feature["polygon"]: + geometry_groups[key].append(feature["polygon"]) + + # Calculate intersection percentage from merged geometries + polygons = geometry_groups.get(key, []) + if polygons: + merged_polygon = unary_union(polygons) + if geometryAOI.geom_type == "Point": + intersection_pct = 100 + elif merged_polygon.is_valid and geometryAOI.is_valid: + intersection = merged_polygon.intersection(geometryAOI) + intersection_pct = 100 * (intersection.area / geometryAOI.area) + else: + intersection_pct = 0.0 + aggregated_data[key]["overlap_pct"] = intersection_pct else: + aggregated_data[key]["overlap_pct"] = 0.0 + + # Handle empty results + for direction, features in results.items(): + if not features: key = (direction.capitalize(), "N/A", "N/A") aggregated_data[key]["rows"].add("N/A") aggregated_data[key]["dates"] = [] aggregated_data[key]["overlap_pct"] = 0.0 + # Tide prediction (if requested) + noaa_stations = None + tide_data_by_key = {} + if arg_tide: + try: + noaa_stations = get_stations_in_aoi(geometryAOI) + if not noaa_stations: + logger.warning( + "No NOAA stations found in AOI - " + "tide predictions will be empty" + ) + except Exception as e: + logger.warning( + "Could not retrieve NOAA stations for AOI: %s", e + ) + noaa_stations = None + + if noaa_stations: + logger.info( + "Calculating tides for Landsat overpasses using %d " + "stations ...", + len(noaa_stations), + ) + # Collect ALL target times across all keys into a single batch + # This drastically reduces NOAA API calls (avoiding rate limiting) + all_target_isos = [] + key_to_indices = {} # key -> list of indices into all_target_isos + + for key, data in aggregated_data.items(): + if data["dates"]: + estimated_datetimes = [ + estimate_landsat_overpass_time( + date_str, lat, lon + ) + for date_str in data["dates"] + ] + # Convert to naive ISO strings (timezone stripped intentionally) + # NOAA API is configured for GMT in tide_prediction.py, so all times are UTC + key_isos = [ + dt.strftime("%Y-%m-%dT%H:%M:%S") + for dt in estimated_datetimes + ] + # Track where each key's tides land in the batch result + start_idx = len(all_target_isos) + all_target_isos.extend(key_isos) + key_to_indices[key] = list( + range(start_idx, start_idx + len(key_isos)) + ) + else: + key_to_indices[key] = [] + + # ONE batched call for all keys - drastically fewer NOAA API requests + if all_target_isos: + all_tide_results = get_tide_info_batch( + polygon=geometryAOI, + target_isos=all_target_isos, + station_dicts=noaa_stations, + allow_interpolation=True, + ) + else: + all_tide_results = [] + + # Distribute results back to each key + for key, indices in key_to_indices.items(): + tide_data_by_key[key] = [all_tide_results[i] for i in indices] + + # Filter: only show rows with at least one date within 2 months if tide is requested + # Track future passes beyond tide window for summary + future_passes_count = 0 + future_passes_min_date = None + future_passes_max_date = None + + if arg_tide: + # Filter dates within each key's data + filtered_aggregated_data = {} + for key, data in aggregated_data.items(): + if data["dates"]: + # Filter dates to only those within 2 months, keeping tide predictions in sync + tide_results = tide_data_by_key.get(key, []) + + ( + filtered_dates, + filtered_tides, + count, + min_date, + max_date, + ) = filter_dates_beyond_window( + data["dates"], + tide_results, + max_days=TIDE_PREDICTION_WINDOW_DAYS, + date_format=DATE_FORMAT, + ) + + # Update tracking variables + future_passes_count += count + if min_date is not None: + if future_passes_min_date is None or min_date < future_passes_min_date: + future_passes_min_date = min_date + if max_date is not None: + if future_passes_max_date is None or max_date > future_passes_max_date: + future_passes_max_date = max_date + + # Only include this row if it has at least one valid date + if filtered_dates: + filtered_data = data.copy() + filtered_data["dates"] = filtered_dates + filtered_aggregated_data[key] = filtered_data + tide_data_by_key[key] = filtered_tides + else: + filtered_aggregated_data[key] = data + aggregated_data = filtered_aggregated_data + row_data_with_keys = [] + header_time_str = "" for key, data in aggregated_data.items(): direction, path, mission = key row_list = sorted(data["rows"]) @@ -456,8 +648,19 @@ def next_landsat_pass( overlap = data["overlap_pct"] overlap_str = f"{overlap:.2f}%" if overlap > 0 else "N/A" + # Estimate overpass time (consistent for all dates at this location) + estimated_time_str = "" if data["dates"]: - dates_str = format_date_lines(data["dates"]) + # Estimate time from first date (time is same for all passes at this location) + first_date = data["dates"][0] + estimated_dt = estimate_landsat_overpass_time(first_date, lat, lon) + estimated_time_str = f" at ~{estimated_dt.strftime('%H:%M')} UTC" + if not header_time_str: + header_time_str = estimated_time_str + + # Format dates with time header (like the popup format) + formatted_dates = format_date_lines(data["dates"]) + dates_str = formatted_dates else: dates_str = "No Landsat passes found." @@ -467,17 +670,62 @@ def next_landsat_pass( ) dates_str = f"{dates_str}\n{warning_text}" - row_data = [direction, path, rows_str, mission, dates_str, overlap_str] - summary = "\n".join( - [ - f"Direction: {direction}", - f"Path: {path}", - f"Row: {rows_str}", - f"Mission: {mission}", - f"Passes UTC dates (P for past):\n{dates_str}", - f"AOI % Overlap: {overlap_str}", - ] - ) + # Tide data (if available) - use "nearest" station only (same as Sentinel) + tide_str = "N/A" + if arg_tide and key in tide_data_by_key: + tide_results = tide_data_by_key[key] + if tide_results: + # Extract "nearest" field from each result (same as Sentinel) + tide_values = [ + result["nearest"] if (isinstance(result, dict) and "nearest" in result) else "N/A" + for result in tide_results + ] + tide_str = ", ".join(tide_values) + + row_data = [ + direction, + path, + rows_str, + mission, + dates_str, + overlap_str, + ] + if arg_tide: + row_data.append(tide_str) + + summary_parts = [ + f"Direction: {direction}", + f"Path: {path}", + f"Row: {rows_str}", + f"Mission: {mission}", + f"Passes dates{estimated_time_str} (P for past):\n{dates_str}", + f"AOI % Overlap: {overlap_str}", + ] + if arg_tide: + tide_lines = "N/A" + if key in tide_data_by_key and tide_data_by_key[key]: + by_station: dict = {} + for result in tide_data_by_key[key]: + if isinstance(result, dict) and "per_station" in result: + for sid, val in result["per_station"].items(): + by_station.setdefault(sid, []).append(val) + if by_station: + station_ids = list(by_station.keys()) + prefix_len = 0 + if len(station_ids) > 1: + for chars in zip(*station_ids): + if len(set(chars)) == 1: + prefix_len += 1 + else: + break + tide_lines = "\n".join( + f"{'*' * prefix_len}{sid[prefix_len:]}: {', '.join(vals)}" + for sid, vals in by_station.items() + ) + summary_parts.append( + f"Tide in m, MLLW (High/Low):\n{tide_lines}" + ) + summary = "\n".join(summary_parts) # Include key for geometry ordering row_data_with_keys.append((overlap, row_data, key, summary)) @@ -494,21 +742,38 @@ def next_landsat_pass( merged = unary_union(polygons) geometry_data.append(merged) + # Time-accuracy annotation appears in header only when we have an estimated time + time_accuracy_str = " ±15-40 min" if header_time_str else "" + headers = [ + "Direction", + "Path", + "Row", + "Mission", + f"Acquisition Dates{header_time_str}{time_accuracy_str} (P for past)", + "AOI % Overlap", + ] + if arg_tide: + headers.append("Tide in m, MLLW (HH/H/LL/L)") + + table_output = tabulate( + table_data, + headers=headers, + tablefmt="grid", + ) + + # Accuracy disclaimer: Landsat overpass times are estimated (not from acquisition plans) + if header_time_str: + table_output += "\n\nNote: Overpass times are estimates based on orbital specifications (±15-40 minutes accuracy)." + + # Add summary about future passes if any were filtered + if arg_tide and future_passes_count > 0: + table_output += f"\nNote: {future_passes_count} additional pass{'es' if future_passes_count > 1 else ''} scheduled between {future_passes_min_date.strftime('%Y-%m-%d')} and {future_passes_max_date.strftime('%Y-%m-%d')} — dates and tide predictions are not displayed for readability." + return { - "next_collect_info": tabulate( - table_data, - headers=[ - "Direction", - "Path", - "Row", - "Mission", - "Passes UTC dates (P for past)", - "AOI % Overlap", - ], - tablefmt="grid", - ), + "next_collect_info": table_output, "next_collect_geometry": geometry_data, "next_collect_summary": summaries, + "noaa_stations": noaa_stations if arg_tide else None, } except Exception as error: # noqa: BLE001 diff --git a/utils/nisar_pass.py b/utils/nisar_pass.py index 7cfa13b..d8120ad 100644 --- a/utils/nisar_pass.py +++ b/utils/nisar_pass.py @@ -12,7 +12,19 @@ from shapely.geometry import Polygon from tabulate import tabulate -from utils.utils import find_intersecting_collects +from utils.utils import ( + find_intersecting_collects, + filter_dates_beyond_window, + NISAR_ASCENDING_CROSSING_HOUR, + NISAR_DESCENDING_CROSSING_HOUR, + HOURS_PER_LONGITUDE_DEGREE, + TIDE_PREDICTION_WINDOW_DAYS, +) +from utils.tide_prediction import ( + get_stations_in_aoi, + get_tide_info_batch, + make_get_tide_for_row, +) LOGGER = logging.getLogger(__name__) @@ -28,6 +40,60 @@ TRACK_FRAME_RE = re.compile(r"^T(?P\d+)_F(?P\d+)$", re.IGNORECASE) +def estimate_nisar_overpass_time(date_str: str, lat: float, lon: float, pass_direction: str) -> datetime: + """ + Estimate NISAR overpass time based on orbital specifications. + + NISAR is in a near-sun-synchronous orbit with nodal crossing times: + - Ascending node: 6:00 AM local solar time + - Descending node: 6:00 PM (18:00) local solar time + + This function estimates the UTC overpass time for a given location based on + these specifications and the pass direction. + + Args: + date_str: Date in format YYYY-MM-DD (e.g., "2026-06-28") + lat: Latitude of the location + lon: Longitude of the location (negative for Western hemisphere) + pass_direction: "Ascending" or "Descending" + + Returns: + datetime: Estimated overpass time in UTC timezone + + Notes: + - Based on NASA specification: nodal crossing at 6 AM (asc) / 6 PM (desc) local solar time + - Source: https://science.nasa.gov/mission/nisar/mission-overview/ + - Accuracy: ±20-40 minutes (similar to Landsat estimation) + - Simplified calculation does not account for equation of time or orbital perturbations + - Uses local solar time approximation: LST ≈ UTC + (lon/15) hours + + Example: + >>> estimate_nisar_overpass_time("2026-06-28", 34.0, -118.0, "Descending") + # Los Angeles descending: 6:00 PM local ≈ 01:52 UTC the following day + """ + # Parse the calendar date (YYYY-MM-DD format) + date_obj = datetime.strptime(date_str, "%Y-%m-%d").date() + + # NISAR nodal crossing times (local solar time) + if pass_direction == "Ascending": + local_solar_hour = NISAR_ASCENDING_CROSSING_HOUR + elif pass_direction == "Descending": + local_solar_hour = NISAR_DESCENDING_CROSSING_HOUR + else: + # Unknown direction - default to descending (most common for SAR) + LOGGER.warning(f"Unknown pass direction '{pass_direction}', assuming Descending") + local_solar_hour = NISAR_DESCENDING_CROSSING_HOUR + + # Local Solar Time (LST) approximation: + # LST ≈ UTC + (longitude / HOURS_PER_LONGITUDE_DEGREE) hours + # utc_hour may be negative or > 24; timedelta below rolls the day accordingly. + local_solar_offset_hours = lon / HOURS_PER_LONGITUDE_DEGREE + utc_hour = local_solar_hour - local_solar_offset_hours + + base = datetime.combine(date_obj, time.min, tzinfo=timezone.utc) + return base + timedelta(hours=utc_hour) + + def download_nisar_plan(url: str, output_path: Path) -> Path: """Download the NISAR observation-plan KMZ if needed.""" output_path.parent.mkdir(parents=True, exist_ok=True) @@ -169,79 +235,146 @@ def create_nisar_collection_plan() -> Path: def format_collects(gdf: gpd.GeoDataFrame) -> str: """Format NISAR collects for CLI output.""" gdf_sorted = gdf.sort_values("intersection_pct", ascending=False).reset_index(drop=True) + has_tide = "tide" in gdf_sorted.columns table = [] for index, row in gdf_sorted.iterrows(): dates = row.begin_date if isinstance(row.begin_date, list) else [row.begin_date] - formatted_dates = [ - stamp.strftime("%Y-%m-%d") - + (" (P)" if stamp < datetime.now(timezone.utc) else "") - for stamp in dates - ] - date_lines = [ - ", ".join(formatted_dates[i:i + 5]) - for i in range(0, len(formatted_dates), 5) - ] - dates_str = "\n".join(date_lines) - - table.append( - [ - index + 1, - row.pass_direction, - row.track, - row.frame, - dates_str, - f"{row.intersection_pct:.2f}", + + # For NISAR, all dates in same track/direction have same time (local solar time) + # Show time in Direction column, dates only in separate column (more compact) + if dates: + first_time = dates[0].strftime("%H:%M") + direction_with_time = f"{row.pass_direction} (~{first_time} UTC ±20-40 min)" + formatted_dates = [ + stamp.strftime("%Y-%m-%d") + + (" (P)" if stamp < datetime.now(timezone.utc) else "") + for stamp in dates ] - ) + date_lines = [ + ", ".join(formatted_dates[i:i + 5]) + for i in range(0, len(formatted_dates), 5) + ] + dates_str = "\n".join(date_lines) + else: + direction_with_time = row.pass_direction + dates_str = "N/A" + + base_row = [ + index + 1, + direction_with_time, + row.track, + row.frame, + dates_str, + f"{row.intersection_pct:.2f}", + ] - return tabulate( - table, - headers=[ - "#", - "Direction", - "Track", - "Frame", - "Acquisition Date (P = past)", - "AOI % Overlap", - ], - tablefmt="grid", - ) + # Add tide column if present - use "nearest" station only (same as Sentinel) + if has_tide: + if isinstance(row.tide, list): + tide_str = ", ".join( + v["nearest"] if (isinstance(v, dict) and "nearest" in v) else "N/A" + for v in row.tide + ) + else: + tide_str = row.tide["nearest"] if (isinstance(row.tide, dict) and "nearest" in row.tide) else "N/A" + base_row.append(tide_str) + + table.append(base_row) + + headers = [ + "#", + "Direction", + "Track", + "Frame", + "Acquisition Dates (P = past)", + "AOI % Overlap", + ] + if has_tide: + headers.append("Tide in m, MLLW (HH/H/LL/L)") + + return tabulate(table, headers=headers, tablefmt="grid") def build_collect_summaries(gdf: gpd.GeoDataFrame) -> list[str]: """Build per-row summaries for map popups without scraping the table.""" summaries: list[str] = [] + has_tide = "tide" in gdf.columns for _, row in gdf.iterrows(): dates = row.begin_date if isinstance(row.begin_date, list) else [row.begin_date] - formatted_dates = [ - stamp.strftime("%Y-%m-%d") - + (" (P)" if stamp < datetime.now(timezone.utc) else "") - for stamp in dates - ] - date_lines = [ - ", ".join(formatted_dates[i:i + 5]) - for i in range(0, len(formatted_dates), 5) + + # For NISAR, all dates in same track/direction have same time + # Show time with direction, dates only in separate section + if dates: + first_time = dates[0].strftime("%H:%M") + formatted_dates = [ + stamp.strftime("%Y-%m-%d") + + (" (P)" if stamp < datetime.now(timezone.utc) else "") + for stamp in dates + ] + date_display = ", ".join(formatted_dates) + else: + first_time = "N/A" + date_display = "N/A" + + lines = [ + f"Direction: {row.pass_direction} (~{first_time} UTC)", + f"Track: {row.track}", + f"Frame: {row.frame}", + f"Acquisition Dates (P = past):", + date_display, + f"AOI % Overlap: {row.intersection_pct:.2f}", ] - summaries.append( - "\n".join( - [ - f"Direction: {row.pass_direction}", - f"Track: {row.track}", - f"Frame: {row.frame}", - "Acquisition Date (P = past):", - "\n".join(date_lines), - f"AOI % Overlap: {row.intersection_pct:.2f}", - ] - ) - ) + + # Add tide information if available + if has_tide and row.tide is not None: + if isinstance(row.tide, list): + tide_entries = row.tide + if tide_entries and any(t is not None for t in tide_entries): + # Aggregate by station like Sentinel + from collections import defaultdict + by_station = defaultdict(list) + for entry in tide_entries: + if entry and isinstance(entry, dict) and "per_station" in entry: + for station_id, tide_val in entry["per_station"].items(): + by_station[station_id].append(tide_val) + + if by_station: + station_ids = list(by_station.keys()) + prefix_len = 0 + if len(station_ids) > 1: + for chars in zip(*station_ids): + if len(set(chars)) == 1: + prefix_len += 1 + else: + break + tide_lines = [ + f" {'*' * prefix_len}{station_id[prefix_len:]}: {', '.join(vals)}" + for station_id, vals in by_station.items() + ] + lines.append("Tide in m, MLLW (H/L):") + lines.extend(tide_lines) + elif isinstance(row.tide, dict): + tide_str = row.tide.get("nearest", "N/A") + lines.append(f"Tide in m, MLLW (H/L): {tide_str}") + + summaries.append("\n".join(lines)) return summaries -def next_nisar_pass(geometry, n_day_past: float) -> dict: - """Return formatted NISAR overpasses intersecting the AOI.""" +def next_nisar_pass(geometry, n_day_past: float, arg_tide: bool = False) -> dict: + """Return formatted NISAR overpasses intersecting the AOI. + + Args: + geometry: AOI geometry (Point or Polygon) + n_day_past: Number of days in the past to include + arg_tide: Whether to compute NOAA tide predictions per overpass + + Returns: + Dict with overpass information, including tide predictions if requested + """ try: collection_path = create_nisar_collection_plan() if not collection_path: @@ -299,9 +432,144 @@ def next_nisar_pass(geometry, n_day_past: float) -> dict: drop=True ) + # Estimate overpass times from dates using orbit parameters + # This replaces midnight UTC placeholders with estimated actual overpass times + centroid = geometry.centroid + lat, lon = centroid.y, centroid.x + + def estimate_times_for_dates(row): + """Apply time estimation to each date in the row's begin_date list.""" + dates = row["begin_date"] if isinstance(row["begin_date"], list) else [row["begin_date"]] + pass_direction = row["pass_direction"] + + estimated_times = [] + for dt in dates: + # Convert datetime to date string for estimation + date_str = dt.strftime("%Y-%m-%d") + estimated_dt = estimate_nisar_overpass_time(date_str, lat, lon, pass_direction) + estimated_times.append(estimated_dt) + + return estimated_times + + # Apply time estimation to all rows + grouped["begin_date"] = grouped.apply(estimate_times_for_dates, axis=1) + + # Tide prediction (if requested) + noaa_stations = None + if arg_tide: + grouped["tide"] = None + # Get stations once for the full AOI (used for all overpasses and map display) + try: + noaa_stations = get_stations_in_aoi(geometry) + if not noaa_stations: + LOGGER.warning("No NOAA stations found in AOI - tide predictions will be empty") + except Exception as e: + LOGGER.warning("Could not retrieve NOAA stations for AOI: %s", e) + noaa_stations = None + + # Track future passes for summary (initialize before noaa_stations check) + future_passes_count = 0 + future_passes_min_date = None + future_passes_max_date = None + + if noaa_stations: + LOGGER.info( + "Calculating tides for %d NISAR overpasses using %d stations ...", + len(grouped), + len(noaa_stations), + ) + # Batch ALL target times across rows into a single NOAA API call + # This avoids rate limiting (HTTP 403) from too many requests + all_target_isos = [] + row_ranges = [] # list of (start, end) tuples in row order + + for _, row in grouped.iterrows(): + dates = row["begin_date"] if isinstance(row["begin_date"], list) else [row["begin_date"]] + row_isos = [] + for t in dates: + if isinstance(t, datetime): + # Normalize to naive UTC string + if t.tzinfo is not None and t.tzinfo != timezone.utc: + t = t.astimezone(timezone.utc) + row_isos.append(t.strftime("%Y-%m-%dT%H:%M:%S")) + else: + row_isos.append(t) + + start_idx = len(all_target_isos) + all_target_isos.extend(row_isos) + row_ranges.append((start_idx, start_idx + len(row_isos))) + + # ONE batched call for all rows + if all_target_isos: + all_tide_results = get_tide_info_batch( + polygon=geometry, + target_isos=all_target_isos, + station_dicts=noaa_stations, + allow_interpolation=True, + ) + else: + all_tide_results = [] + + # Distribute results back to each row in order + tide_per_row = [ + all_tide_results[start:end] for start, end in row_ranges + ] + grouped["tide"] = tide_per_row + + # Filter dates within each row to only those within 2 months from now + def filter_dates_and_tides(row): + """Keep only dates and corresponding tides within 2 months.""" + nonlocal future_passes_count, future_passes_min_date, future_passes_max_date + + dates = row["begin_date"] if isinstance(row["begin_date"], list) else [row["begin_date"]] + tides = row["tide"] if isinstance(row["tide"], list) else [row["tide"]] + + # Use shared filtering function + ( + filtered_dates, + filtered_tides, + count, + min_date, + max_date, + ) = filter_dates_beyond_window( + dates, + tides, + max_days=TIDE_PREDICTION_WINDOW_DAYS, + ) + + # Update tracking variables + future_passes_count += count + if min_date is not None: + if future_passes_min_date is None or min_date < future_passes_min_date: + future_passes_min_date = min_date + if max_date is not None: + if future_passes_max_date is None or max_date > future_passes_max_date: + future_passes_max_date = max_date + + if filtered_dates: + row["begin_date"] = filtered_dates + row["tide"] = filtered_tides + return row + return None # Drop row if no valid dates + + grouped = grouped.apply(filter_dates_and_tides, axis=1) + grouped = grouped.dropna().reset_index(drop=True) + + table_output = format_collects(grouped) + + # Accuracy disclaimer: NISAR overpass times are estimated (not from acquisition plans) + if not grouped.empty: + table_output += "\n\nNote: Overpass times are estimates based on orbital specifications (±20-40 minutes accuracy)." + + # Add summary about future passes if any were filtered + if arg_tide and future_passes_count > 0: + table_output += f"\nNote: {future_passes_count} additional pass{'es' if future_passes_count > 1 else ''} scheduled between {future_passes_min_date.strftime('%Y-%m-%d')} and {future_passes_max_date.strftime('%Y-%m-%d')} — dates and tide predictions are not displayed for readability." + return { - "next_collect_info": format_collects(grouped), + "next_collect_info": table_output, "next_collect_geometry": grouped["geometry"].tolist(), "next_collect_summary": build_collect_summaries(grouped), "intersection_pct": grouped["intersection_pct"].tolist(), + "tide": grouped["tide"].tolist() if arg_tide else None, + "noaa_stations": noaa_stations, } diff --git a/utils/plot_maps.py b/utils/plot_maps.py index 70827e3..deaf0ab 100644 --- a/utils/plot_maps.py +++ b/utils/plot_maps.py @@ -494,6 +494,7 @@ def make_overpasses_map( color = "gray" geojson_data = gpd.GeoSeries([polygon]).__geo_interface__ + info_html = info.replace("\n", "
") if isinstance(info, str) else str(info) folium.GeoJson( geojson_data, name=f"{sat_name} Path/Row", @@ -502,7 +503,7 @@ def make_overpasses_map( "weight": 2, "fillOpacity": 0.3, }, - popup=folium.Popup(f"{sat_name}: {info}", max_width=300), + popup=folium.Popup(f"{sat_name}
{info_html}", max_width=600), ).add_to(group) fg_8_asc.add_to(map_object) @@ -524,7 +525,7 @@ def make_overpasses_map( "weight": 2, "fillOpacity": 0.3, }, - popup=folium.Popup(f"{sat_name}
{info_html}", max_width=350), + popup=folium.Popup(f"{sat_name}
{info_html}", max_width=600), ).add_to(fg) fg.add_to(map_object) @@ -534,9 +535,9 @@ def make_overpasses_map( style_function=lambda x: {"color": "black", "weight": 2, "fillOpacity": 0.0}, ).add_to(map_object) - # NOAA tide stations — collect from Sentinel results, deduplicate by ID + # NOAA tide stations — collect from all satellite results, deduplicate by ID noaa_stations: dict = {} - for result in (result_s1, result_s2): + for result in (result_s1, result_s2, result_l, result_nisar): if result and result.get("noaa_stations"): for st in result["noaa_stations"]: noaa_stations[st["id"]] = st diff --git a/utils/sentinel_pass.py b/utils/sentinel_pass.py index 90b8e17..b1b8f28 100644 --- a/utils/sentinel_pass.py +++ b/utils/sentinel_pass.py @@ -7,7 +7,11 @@ from tabulate import tabulate from utils.cloudiness import make_get_cloudiness_for_row -from utils.tide_prediction import make_get_tide_for_row, get_stations_in_aoi +from utils.tide_prediction import ( + make_get_tide_for_row, + get_stations_in_aoi, + get_tide_info_batch, +) from utils.collection_builder import build_sentinel_collection from utils.utils import find_intersecting_collects, scrape_esa_download_urls @@ -110,9 +114,12 @@ def create_s2_collection_plan(n_day_past: float) -> Path: """Prepare Sentinel-2 acquisition plan collection.""" urls_a = scrape_esa_download_urls(SENT2_URL, "sentinel-2a") urls_b = scrape_esa_download_urls(SENT2_URL, "sentinel-2b") - urls = urls_a + urls_b + urls_c = scrape_esa_download_urls(SENT2_URL, "sentinel-2c") + urls = urls_a + urls_b + urls_c - platforms = ["S2A"] * len(urls_a) + ["S2B"] * len(urls_b) + platforms = ( + ["S2A"] * len(urls_a) + ["S2B"] * len(urls_b) + ["S2C"] * len(urls_c) + ) return build_sentinel_collection( urls, @@ -168,11 +175,11 @@ def format_collects(gdf: gpd.GeoDataFrame) -> str: if has_tide: if isinstance(row.tide, list): tide_str = ", ".join( - v["nearest"] if isinstance(v, dict) else (v if v is not None else "N/A") + v["nearest"] if (isinstance(v, dict) and "nearest" in v) else "N/A" for v in row.tide ) else: - tide_str = row.tide["nearest"] if isinstance(row.tide, dict) else (row.tide if row.tide is not None else "N/A") + tide_str = row.tide["nearest"] if (isinstance(row.tide, dict) and "nearest" in row.tide) else "N/A" base_row.append(tide_str) table.append(base_row) @@ -352,11 +359,43 @@ def next_sentinel_pass( num_rows, len(noaa_stations), ) - get_tide_for_row = make_get_tide_for_row(geometry, noaa_stations) - collects_grouped["tide"] = collects_grouped.apply( - get_tide_for_row, - axis=1, - ) + # Batch ALL target times across rows into a single NOAA API call + # This avoids rate limiting (HTTP 403) from too many requests + all_target_isos = [] + row_ranges = [] # list of (start_idx, end_idx) tuples in row order + + for _, row in collects_grouped.iterrows(): + dates = row["begin_date"] if isinstance(row["begin_date"], list) else [row["begin_date"]] + row_isos = [] + for t in dates: + if isinstance(t, datetime): + if t.tzinfo is not None and t.tzinfo != timezone.utc: + t = t.astimezone(timezone.utc) + row_isos.append(t.strftime("%Y-%m-%dT%H:%M:%S")) + else: + row_isos.append(t) + + start_idx = len(all_target_isos) + all_target_isos.extend(row_isos) + row_ranges.append((start_idx, start_idx + len(row_isos))) + + # ONE batched call for all rows + if all_target_isos: + all_tide_results = get_tide_info_batch( + polygon=geometry, + target_isos=all_target_isos, + station_dicts=noaa_stations, + allow_interpolation=True, + ) + else: + all_tide_results = [] + + # Distribute results back to each row in order + tide_per_row = [ + all_tide_results[start:end] for start, end in row_ranges + ] + collects_grouped["tide"] = tide_per_row + return { "next_collect_info": format_collects(collects_grouped), "next_collect_geometry": collects_grouped["geometry"].tolist(), diff --git a/utils/tide_prediction.py b/utils/tide_prediction.py index b1a9e0f..a3b2084 100644 --- a/utils/tide_prediction.py +++ b/utils/tide_prediction.py @@ -1,3 +1,31 @@ +""" +NOAA Tide Prediction Module + +CRITICAL TIMEZONE CONTRACT: +=========================== +All datetimes in this module are NAIVE datetimes representing UTC/GMT. + +- parse_datetime() returns naive datetimes (UTC) +- NOAA API is always called with time_zone="gmt" +- All comparisons assume naive datetimes are UTC +- DO NOT pass timezone-aware datetimes to functions in this module +- DO NOT change NOAA API time_zone parameter + +This design is intentional for NOAA API compatibility. +See claude_md/timezone_handling.md for full explanation. + +DEVELOPER GUIDELINES: +===================== +If you modify this code: + +1. ✅ ALWAYS keep all datetimes as naive representing UTC +2. ✅ ALWAYS configure NOAA API with time_zone="gmt" +3. ✅ NEVER pass timezone-aware datetimes to tide prediction functions +4. ✅ NEVER change NOAA API time_zone parameter + +Breaking these rules will cause TypeError in datetime comparisons. +""" + import requests import os import time @@ -11,6 +39,8 @@ from shapely.geometry.base import BaseGeometry from shapely.ops import nearest_points +from utils.utils import TIDE_PREDICTION_WINDOW_DAYS + LOGGER = logging.getLogger("tide_prediction") NOAA_URL = "https://api.tidesandcurrents.noaa.gov/api/prod/datagetter" @@ -66,6 +96,20 @@ def ensure_station_cache(filepath: str | Path | None = None, max_age_days=30): def parse_datetime(dt_str: str) -> datetime: + """Parse ISO datetime string into naive datetime representing UTC. + + IMPORTANT: Returns naive datetime. All timestamps in this module + are naive datetimes representing UTC/GMT. NOAA API is always + configured with time_zone="gmt". + + Do not mix with timezone-aware datetimes - will cause TypeError. + + Args: + dt_str: ISO datetime string (e.g., "2026-06-28T18:03:59" or "2026-06-28 18:00") + + Returns: + Naive datetime object representing UTC time + """ return datetime.fromisoformat(dt_str.replace("Z", "")) @@ -153,6 +197,29 @@ def get_stations_in_aoi( def interpolate_tide(times, values, target_dt): + """Interpolate tide height at target time. + + WARNING: All inputs must be naive datetimes representing UTC. + Do not mix timezone-aware and naive datetimes. + + Args: + times: List of ISO datetime strings + values: List of tide heights in meters + target_dt: Naive datetime representing UTC + + Returns: + Interpolated tide height in meters, or None if outside time range + + Raises: + TypeError: If target_dt is timezone-aware + """ + # Safety check: reject timezone-aware datetimes + if hasattr(target_dt, 'tzinfo') and target_dt.tzinfo is not None: + raise TypeError( + f"interpolate_tide expects naive datetime (UTC), got timezone-aware: {target_dt.tzinfo}. " + f"See module docstring for timezone contract." + ) + for i in range(len(times) - 1): t1 = parse_datetime(times[i]) t2 = parse_datetime(times[i + 1]) @@ -172,7 +239,31 @@ def interpolate_tide(times, values, target_dt): def _find_tide_direction(times_iso: list, values: list, target_dt: datetime) -> str: - """Return 'rising' or 'falling' based on hourly values bracketing target_dt.""" + """Return 'rising', 'falling', or 'slack' based on hourly values bracketing target_dt. + + Calculates the rate of change (slope) in meters per hour and compares to a threshold + of 0.02 m/hr to distinguish active tide movement from slack water. + + WARNING: target_dt must be naive datetime representing UTC. + + Args: + times_iso: List of ISO datetime strings + values: List of tide heights in meters + target_dt: Naive datetime representing UTC + + Returns: + 'rising', 'falling', or 'slack' (TIDE_DIRECTION_UNKNOWN) + + Raises: + TypeError: If target_dt is timezone-aware + """ + # Safety check: reject timezone-aware datetimes + if hasattr(target_dt, 'tzinfo') and target_dt.tzinfo is not None: + raise TypeError( + f"_find_tide_direction expects naive datetime (UTC), got timezone-aware: {target_dt.tzinfo}. " + f"See module docstring for timezone contract." + ) + before_val = before_t = None after_val = after_t = None @@ -185,17 +276,46 @@ def _find_tide_direction(times_iso: list, values: list, target_dt: datetime) -> if after_t is None or t < after_t: after_t, after_val = t, values[i] - if before_val is not None and after_val is not None: - if after_val > before_val: - return "rising" - if after_val < before_val: - return "falling" - return TIDE_DIRECTION_UNKNOWN + if before_val is not None and after_val is not None and before_t is not None and after_t is not None: + # Calculate slope (rate of change) in meters per hour + time_diff_hours = (after_t - before_t).total_seconds() / 3600.0 + if time_diff_hours > 0: + slope = (after_val - before_val) / time_diff_hours + + # Apply threshold to distinguish active movement from slack water + THRESHOLD_M_PER_HOUR = 0.02 + if slope > THRESHOLD_M_PER_HOUR: + return "rising" + elif slope < -THRESHOLD_M_PER_HOUR: + return "falling" + else: + return TIDE_DIRECTION_UNKNOWN # "slack" + return "" def _find_nearest_hilo_label(hilo_predictions: list, target_dt: datetime) -> str: - """Return the type (H, HH, L, LL) of the hilo event nearest to target_dt.""" + """Return the type (H, HH, L, LL) of the hilo event nearest to target_dt. + + WARNING: target_dt must be naive datetime representing UTC. + + Args: + hilo_predictions: List of dicts with 't' (timestamp) and 'type' (H/HH/L/LL) + target_dt: Naive datetime representing UTC + + Returns: + 'H', 'HH', 'L', 'LL', or '' if no predictions + + Raises: + TypeError: If target_dt is timezone-aware + """ + # Safety check: reject timezone-aware datetimes + if hasattr(target_dt, 'tzinfo') and target_dt.tzinfo is not None: + raise TypeError( + f"_find_nearest_hilo_label expects naive datetime (UTC), got timezone-aware: {target_dt.tzinfo}. " + f"See module docstring for timezone contract." + ) + if not hilo_predictions: return "" nearest_label = "" @@ -245,12 +365,33 @@ def get_tide_info_batch( )["id"] target_dts = [parse_datetime(t) for t in target_isos] - begin_date = (min(target_dts) - timedelta(days=1)).strftime("%Y%m%d") - end_date = (max(target_dts) + timedelta(days=1)).strftime("%Y%m%d") + + # Filter out dates more than TIDE_PREDICTION_WINDOW_DAYS in the future + # (NOAA API limit). Keep all past dates. + now = datetime.now() + max_future_date = now + timedelta(days=TIDE_PREDICTION_WINDOW_DAYS) + + # Track which indices are beyond the 2-month limit + valid_indices = [] + valid_dts = [] + for i, dt in enumerate(target_dts): + if dt <= max_future_date: + valid_indices.append(i) + valid_dts.append(dt) + + # If no valid dates, return all None + if not valid_dts: + LOGGER.warning("All requested dates are more than 2 months in the future - skipping tide predictions") + return [None] * len(target_isos) + + # Use only valid dates for API request + begin_date = (min(valid_dts) - timedelta(days=1)).strftime("%Y%m%d") + end_date = (max(valid_dts) + timedelta(days=1)).strftime("%Y%m%d") # per_station_results[i] = list of (station_id, formatted_string) - per_station_results = {i: [] for i in range(len(target_isos))} - nearest_results = {i: None for i in range(len(target_isos))} + # Only initialize for valid indices (within 2-month limit) + per_station_results = {i: [] for i in valid_indices} + nearest_results = {i: None for i in valid_indices} for st in station_dicts: station_id = st["id"] @@ -285,7 +426,10 @@ def get_tide_info_batch( values = [float(p["v"]) for p in predictions] times_iso_short = [t[:16] for t in times_iso] - for i, (target_iso, target_dt) in enumerate(zip(target_isos, target_dts)): + # Only process valid indices (within 2-month limit) + for j, valid_idx in enumerate(valid_indices): + target_iso = target_isos[valid_idx] + target_dt = valid_dts[j] target_iso_short = target_iso[:16] if target_iso_short in times_iso_short: value = values[times_iso_short.index(target_iso_short)] @@ -304,20 +448,21 @@ def get_tide_info_batch( label_str = f"({tag})" if tag else "" formatted = f"{value:.2f}{label_str}" - per_station_results[i].append((station_id, formatted)) + per_station_results[valid_idx].append((station_id, formatted)) if station_id == nearest_id: - nearest_results[i] = formatted + nearest_results[valid_idx] = formatted except requests.RequestException: continue results = [] for i in range(len(target_isos)): - per = per_station_results[i] + # Skip indices beyond 2-month limit (not in valid_indices) + per = per_station_results.get(i, []) if not per: results.append(None) continue - nearest = nearest_results[i] or per[0][1] + nearest = nearest_results.get(i) or per[0][1] results.append({"nearest": nearest, "per_station": {sid: v for sid, v in per}}) return results @@ -338,6 +483,8 @@ def make_get_tide_for_row(aoi_geometry, station_dicts): Function that takes a dataframe row and returns tide info """ def get_tide_for_row(row): + from datetime import timezone + times = row["begin_date"] if not isinstance(times, list): @@ -346,6 +493,20 @@ def get_tide_for_row(row): target_isos = [] for t in times: if isinstance(t, datetime): + # Ensure datetime is in UTC before converting to naive string + # This prevents silent errors if a non-UTC datetime somehow enters the system + if t.tzinfo is not None and t.tzinfo != timezone.utc: + LOGGER.warning( + "Non-UTC datetime detected (%s), converting to UTC for tide prediction", + t.tzinfo + ) + t = t.astimezone(timezone.utc) + elif t.tzinfo is None: + LOGGER.warning( + "Naive datetime detected (no timezone), assuming UTC for tide prediction" + ) + # Convert to naive ISO string (timezone stripped intentionally) + # NOAA API is configured with time_zone="gmt" to interpret these as UTC t = t.strftime("%Y-%m-%dT%H:%M:%S") target_isos.append(t) diff --git a/utils/utils.py b/utils/utils.py index a239732..c439b91 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -24,6 +24,30 @@ LOGGER = logging.getLogger("acquisition_utils") +# ============================================================================ +# Orbital and physical constants +# ============================================================================ + +# Landsat 8/9 cross the equator at 10:12 AM local solar time (descending pass) +# Source: https://www.usgs.gov/landsat-missions/landsat-8 +LANDSAT_EQUATORIAL_CROSSING_HOUR = 10.2 # 10:12 AM in decimal hours + +# NISAR nodal crossing times (local solar time) +# Source: https://science.nasa.gov/mission/nisar/mission-overview/ +NISAR_ASCENDING_CROSSING_HOUR = 6.0 # 6:00 AM local +NISAR_DESCENDING_CROSSING_HOUR = 18.0 # 6:00 PM local + +# Earth rotates 15° of longitude per hour (360° / 24 hours) +# Used for local solar time approximation: LST ≈ UTC + (lon / 15) +HOURS_PER_LONGITUDE_DEGREE = 15.0 + +# ============================================================================ +# API and service limits +# ============================================================================ + +# NOAA tide prediction only supports forecasts up to ~60 days in the future for Landsat and NISAR to improve output readability +TIDE_PREDICTION_WINDOW_DAYS = 60 + class Tee: """Write to multiple streams (e.g., terminal and log file).""" @@ -81,8 +105,10 @@ def download_kml(url: str, out_path: str = "collection.kml") -> Path: def parse_placemark(placemark: etree.Element) -> Optional[Tuple]: """Parse a single placemark from KML.""" ns = ".//{http://www.opengis.net/kml/2.2}" - begin_date = datetime.fromisoformat(placemark.find(f"{ns}begin").text) - end_date = datetime.fromisoformat(placemark.find(f"{ns}end").text) + # Replace 'Z' with '+00:00' for Python 3.10 compatibility + # Sentinel KML files use ISO format with 'Z' suffix (e.g., "2026-06-28T18:03:59Z") + begin_date = datetime.fromisoformat(placemark.find(f"{ns}begin").text.replace("Z", "+00:00")) + end_date = datetime.fromisoformat(placemark.find(f"{ns}end").text.replace("Z", "+00:00")) data = placemark.find(f"{ns}ExtendedData") mode = data.find(f"{ns}Data[@name='Mode']/{ns}value").text @@ -663,3 +689,78 @@ def format_satellite_arg(sat_list): return " and ".join(formatted) else: return ", ".join(formatted[:-1]) + f", and {formatted[-1]}" + + +def filter_dates_beyond_window( + dates: List[Union[str, datetime]], + tide_data: List, + max_days: int = TIDE_PREDICTION_WINDOW_DAYS, + date_format: Optional[str] = None, +) -> Tuple[List, List, int, Optional[datetime.date], Optional[datetime.date]]: + """ + Filter dates and tide data to only those within max_days from now. + + Tracks filtered dates for summary statistics (count, date range). + + Args: + dates: List of date strings or datetime objects + tide_data: List of tide results (same length as dates, or empty) + max_days: Maximum days into the future to include (default: 60) + date_format: Format string if dates are strings (e.g., "%m/%d/%Y") + + Returns: + tuple: ( + filtered_dates: dates within the window, + filtered_tide_data: corresponding tide data, + future_passes_count: number of filtered dates, + future_passes_min_date: earliest filtered date (or None), + future_passes_max_date: latest filtered date (or None) + ) + + Example: + >>> dates = ["07/01/2026", "09/15/2026", "11/20/2026"] + >>> tides = ["+1.2m", "+1.5m", "+0.8m"] + >>> filtered_dates, filtered_tides, count, min_d, max_d = \\ + ... filter_dates_beyond_window(dates, tides, max_days=60, date_format="%m/%d/%Y") + """ + from datetime import datetime, timedelta, timezone + + max_future_date = datetime.now(timezone.utc).date() + timedelta(days=max_days) + + filtered_dates = [] + filtered_tide_data = [] + future_passes_count = 0 + future_passes_min_date = None + future_passes_max_date = None + + for i, date_item in enumerate(dates): + # Convert to date object for comparison + if isinstance(date_item, str): + if date_format is None: + raise ValueError("date_format required when dates are strings") + date_obj = datetime.strptime(date_item, date_format).date() + elif isinstance(date_item, datetime): + date_obj = date_item.date() if hasattr(date_item, 'date') else date_item + else: + date_obj = date_item # Already a date object + + # Check if within window + if date_obj <= max_future_date: + filtered_dates.append(date_item) + if i < len(tide_data): + filtered_tide_data.append(tide_data[i]) + else: + # Track filtered date for summary + future_passes_count += 1 + if future_passes_min_date is None or date_obj < future_passes_min_date: + future_passes_min_date = date_obj + if future_passes_max_date is None or date_obj > future_passes_max_date: + future_passes_max_date = date_obj + + return ( + filtered_dates, + filtered_tide_data, + future_passes_count, + future_passes_min_date, + future_passes_max_date, + )