Skip to content
Merged
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
3 changes: 3 additions & 0 deletions app/components/dnr_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ def build_lake_map(
show_bathymetry: bool = False,
species_zones: Optional[List[Dict[str, Any]]] = None,
bathymetry_dir: Optional[Path] = None,
lake_coord_overrides: Optional[Dict[str, Tuple[float, float]]] = None,
) -> folium.Map:
m = folium.Map(
location=center,
Expand All @@ -169,6 +170,8 @@ def build_lake_map(
)

for name, (lat, lon, dow) in WRIGHT_COUNTY_LAKES.items():
if lake_coord_overrides and name in lake_coord_overrides:
lat, lon = lake_coord_overrides[name]
is_selected = name == selected_lake
folium.CircleMarker(
location=(lat, lon),
Expand Down
20 changes: 18 additions & 2 deletions app/components/fishing_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,25 @@ def _generate_plan_cached(
):
import json as _json
from onkia.plan_generator import generate_evening_plan as _gp
from onkia.weather import WeatherResult as _WR
from onkia.weather import WeatherResult as _WR, PressureTrend as _PT, PressureInterpretation as _PI
from onkia.models import WaterTempPreference as _WTP
weather = _WR(**_json.loads(_weather_json))

_TREND_DEFAULTS = {
_PT.FALLING: ("Falling rapidly", "Fish feed aggressively before a front — best bite window!"),
_PT.RISING: ("Rising", "Fish may be less active — try deeper water and slower presentations."),
_PT.RISING_AFTER_DROP: ("Rising after a drop", "Fish resume feeding after pressure stabilizes — good action."),
_PT.HIGH_STEADY: ("High and steady", "High pressure = slower fishing. Focus on deep structure and live bait."),
_PT.STABLE: ("Stable", "Normal fish activity expected."),
}

raw = _json.loads(_weather_json)
if raw.get("pressure_trend"):
trend = _PT(raw["pressure_trend"])
raw["pressure_trend"] = trend
label, note = _TREND_DEFAULTS.get(trend, (trend.value, ""))
raw["pressure_interpretation"] = _PI(trend=trend, label=label, fishing_note=note)

weather = _WR(**raw)
pref_objs = [_WTP(**p) for p in _json.loads(_prefs_json)]
survey = _json.loads(_survey_json) if _survey_json else None
return _gp(weather, water_temp_f, pref_objs, survey, wind_dir_deg, start_hour, end_hour)
Expand Down
9 changes: 9 additions & 0 deletions app/pages/fishing.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,11 +316,20 @@ def _build_depth_temp_chart(
"color": SPECIES_COLORS.get(sp, "#457b9d"),
})

_dnr_coord_overrides = {}
for _r in st.session_state["dnr_search_results"]:
_rname = _r.get("name", "")
if _rname in WRIGHT_COUNTY_LAKES:
_coords = _r.get("point", {}).get("epsg:4326", [])
if len(_coords) >= 2:
_dnr_coord_overrides[_rname] = (float(_coords[1]), float(_coords[0]))

fmap = build_lake_map(
selected_lake=st.session_state["selected_lake_name"],
search_results=st.session_state["dnr_search_results"],
show_bathymetry=show_bathymetry,
species_zones=species_zones if species_zones else None,
lake_coord_overrides=_dnr_coord_overrides if _dnr_coord_overrides else None,
)
map_data = st_folium(fmap, width="100%", height=400, returned_objects=["last_object_clicked"])

Expand Down
39 changes: 39 additions & 0 deletions tests/test_dnr_map_bathymetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,42 @@ def test_lake_markers_still_present(self):
m = build_lake_map()
html = m._repr_html_()
assert "Clearwater" in html or "Wright" in html


class TestLakeCoordOverrides:
def test_override_replaces_hardcoded_coordinates(self):
"""Coordinate override must appear in the rendered HTML; hardcoded default must not."""
# Clearwater hardcoded: (45.3052, -94.1184)
override_lat, override_lon = 45.9999, -94.7777
m = build_lake_map(
lake_coord_overrides={"Clearwater": (override_lat, override_lon)},
)
html = m._repr_html_()
assert str(override_lat) in html
assert str(override_lon) in html
# The default hardcoded value should NOT appear (it was replaced)
assert "45.3052" not in html

def test_non_overridden_lakes_keep_hardcoded_coords(self):
"""Lakes without an override must still use their hardcoded coordinates."""
# Clearwater (overridden) and Howard Lake (not overridden, lat=45.0618)
m = build_lake_map(
lake_coord_overrides={"Clearwater": (45.9999, -94.7777)},
)
html = m._repr_html_()
assert "45.0618" in html

def test_none_override_uses_hardcoded_coords(self):
"""Passing lake_coord_overrides=None must not replace any hardcoded coordinates."""
m = build_lake_map(lake_coord_overrides=None)
html = m._repr_html_()
# Clearwater hardcoded coordinates must still appear
assert "45.3052" in html
assert "-94.1184" in html

def test_empty_override_dict_uses_hardcoded_coords(self):
"""An empty dict override must leave all hardcoded coords unchanged."""
m = build_lake_map(lake_coord_overrides={})
html = m._repr_html_()
assert "45.3052" in html
assert "-94.1184" in html
93 changes: 93 additions & 0 deletions tests/test_plan_generator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for onkia.plan_generator — evening fishing plan generator."""
from __future__ import annotations

import json
from typing import Dict, List, Optional

import pytest
Expand Down Expand Up @@ -486,3 +487,95 @@ def test_cold_water_changes_plan(self):
techniques_warm = [b.technique for b in plan_warm.blocks]
techniques_cold = [b.technique for b in plan_cold.blocks]
assert techniques_warm != techniques_cold or warm_species != cold_species


def _serialize_weather_like_fishing_page(weather: WeatherResult) -> str:
"""Reproduce the JSON serialization performed by fishing.py before calling the
Streamlit-cached plan generator. pressure_trend is stored as its .value string
and pressure_interpretation is omitted entirely."""
return json.dumps({
"air_temp_f": weather.air_temp_f,
"pressure_hpa": weather.pressure_hpa,
"pressure_inhg": weather.pressure_inhg,
"wind_speed_mph": weather.wind_speed_mph,
"wind_direction_deg": weather.wind_direction_deg,
"cloud_cover_pct": weather.cloud_cover_pct,
"pressure_trend": weather.pressure_trend.value if weather.pressure_trend else None,
"source": weather.source,
"fallback_used": weather.fallback_used,
})


def _deserialize_weather_like_cache_layer(weather_json: str) -> WeatherResult:
"""Reproduce the fixed deserialization in fishing_data._generate_plan_cached."""
raw = json.loads(weather_json)
if raw.get("pressure_trend"):
trend = PressureTrend(raw["pressure_trend"])
raw["pressure_trend"] = trend
_TREND_DEFAULTS = {
PressureTrend.FALLING: ("Falling rapidly", "Fish feed aggressively before a front — best bite window!"),
PressureTrend.RISING: ("Rising", "Fish may be less active — try deeper water and slower presentations."),
PressureTrend.RISING_AFTER_DROP: ("Rising after a drop", "Fish resume feeding after pressure stabilizes — good action."),
PressureTrend.HIGH_STEADY: ("High and steady", "High pressure = slower fishing. Focus on deep structure and live bait."),
PressureTrend.STABLE: ("Stable", "Normal fish activity expected."),
}
label, note = _TREND_DEFAULTS.get(trend, (trend.value, ""))
raw["pressure_interpretation"] = PressureInterpretation(trend=trend, label=label, fishing_note=note)
return WeatherResult(**raw)


class TestWeatherJsonRoundTrip:
"""Regression tests for the Streamlit cache JSON round-trip bug.

Before the fix, fishing.py serialised pressure_trend as a plain string and
_generate_plan_cached reconstructed WeatherResult without converting it back
to the PressureTrend enum. plan_generator._build_conditions_summary then
called pressure_trend.value on the string, raising AttributeError.
"""

@pytest.mark.parametrize("trend", list(PressureTrend))
def test_round_trip_preserves_enum(self, trend):
weather = _make_weather(pressure_trend=trend)
recovered = _deserialize_weather_like_cache_layer(
_serialize_weather_like_fishing_page(weather)
)
assert recovered.pressure_trend is trend

@pytest.mark.parametrize("trend", list(PressureTrend))
def test_round_trip_preserves_pressure_interpretation(self, trend):
weather = _make_weather(pressure_trend=trend)
recovered = _deserialize_weather_like_cache_layer(
_serialize_weather_like_fishing_page(weather)
)
assert recovered.pressure_interpretation is not None
assert recovered.pressure_interpretation.trend is trend
assert recovered.pressure_interpretation.label

@pytest.mark.parametrize("trend", list(PressureTrend))
def test_generate_plan_does_not_raise_with_round_tripped_weather(self, trend):
"""The AttributeError must not be raised after the fix."""
weather = _make_weather(air_temp_f=68.0, pressure_trend=trend)
recovered = _deserialize_weather_like_cache_layer(
_serialize_weather_like_fishing_page(weather)
)
plan = generate_evening_plan(
weather=recovered,
water_temp_f=68.0,
target_species_prefs=_TARGET_PREFS,
)
assert plan.conditions_summary
assert trend.value in plan.conditions_summary

def test_none_pressure_trend_round_trips_safely(self):
weather = _make_weather(pressure_trend=None)
recovered = _deserialize_weather_like_cache_layer(
_serialize_weather_like_fishing_page(weather)
)
assert recovered.pressure_trend is None
assert recovered.pressure_interpretation is None
plan = generate_evening_plan(
weather=recovered,
water_temp_f=68.0,
target_species_prefs=_TARGET_PREFS,
)
assert plan.conditions_summary
Loading