Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions next_pass.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,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,
Expand Down
14 changes: 13 additions & 1 deletion tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 []
Expand Down
44 changes: 44 additions & 0 deletions tests/test_landsat_pass_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,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
10 changes: 5 additions & 5 deletions tests/test_next_pass_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
60 changes: 60 additions & 0 deletions tests/test_nisar_pass.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,63 @@ def test_next_nisar_pass_groups_by_best_overlap(monkeypatch):
assert result["next_collect_info"] == "table"
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

163 changes: 163 additions & 0 deletions tests/test_timezone_contract.py
Original file line number Diff line number Diff line change
@@ -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"])
Loading