Skip to content

Commit dbdcd16

Browse files
committed
feat(display): show minutely precipitation timeline in current conditions (#568)
1 parent ed2e3da commit dbdcd16

3 files changed

Lines changed: 329 additions & 2 deletions

File tree

src/accessiweather/display/presentation/current_conditions.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
get_temperature_precision,
3535
get_uv_description,
3636
)
37+
from .minutely_timeline import build_minutely_timeline, generate_minutely_summary
3738

3839
logger = logging.getLogger(__name__)
3940

@@ -550,8 +551,16 @@ def build_current_conditions(
550551
# Reorder metrics by priority before adding non-reorderable metrics
551552
metrics = _order_metrics_by_priority(metrics, ordered_categories)
552553

553-
if minutely_precipitation and minutely_precipitation.summary:
554-
metrics.insert(0, Metric("Precipitation outlook", minutely_precipitation.summary))
554+
if minutely_precipitation:
555+
summary = minutely_precipitation.summary
556+
if not summary and minutely_precipitation.points:
557+
summary = generate_minutely_summary(minutely_precipitation)
558+
if summary:
559+
metrics.insert(0, Metric("Precipitation outlook", summary))
560+
timeline = build_minutely_timeline(minutely_precipitation)
561+
if timeline:
562+
insert_pos = 1 if summary else 0
563+
metrics.insert(insert_pos, Metric("Next hour precipitation", timeline))
555564

556565
# Add astronomical metrics (these don't need reordering - always at end)
557566
metrics.extend(
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Minutely precipitation timeline and summary generation for UI display."""
2+
3+
from __future__ import annotations
4+
5+
from ...models import MinutelyPrecipitationForecast
6+
from ...notifications.minutely_precipitation import (
7+
detect_minutely_precipitation_transition,
8+
is_wet,
9+
precipitation_type_label,
10+
)
11+
12+
INTENSITY_THRESHOLD_LIGHT = 0.01
13+
INTENSITY_THRESHOLD_MODERATE = 0.1
14+
INTENSITY_THRESHOLD_HEAVY = 1.0
15+
16+
17+
def _classify_intensity(intensity: float | None) -> str:
18+
"""Classify a precipitation intensity value into a human-readable label."""
19+
if intensity is None or intensity <= 0:
20+
return "None"
21+
if intensity < INTENSITY_THRESHOLD_MODERATE:
22+
return "Light"
23+
if intensity < INTENSITY_THRESHOLD_HEAVY:
24+
return "Moderate"
25+
return "Heavy"
26+
27+
28+
def _dominant_intensity_label(forecast: MinutelyPrecipitationForecast) -> str:
29+
"""Return a lowercase intensity descriptor for the wettest points."""
30+
max_intensity = 0.0
31+
for point in forecast.points:
32+
if point.precipitation_intensity is not None:
33+
max_intensity = max(max_intensity, point.precipitation_intensity)
34+
classification = _classify_intensity(max_intensity)
35+
return classification.lower() if classification != "None" else "light"
36+
37+
38+
def _dominant_precip_type(forecast: MinutelyPrecipitationForecast) -> str:
39+
"""Return the most common precipitation type label from wet points."""
40+
for point in forecast.points:
41+
if is_wet(point) and point.precipitation_type:
42+
return precipitation_type_label(point.precipitation_type).lower()
43+
return "precipitation"
44+
45+
46+
def generate_minutely_summary(
47+
forecast: MinutelyPrecipitationForecast | None,
48+
) -> str | None:
49+
"""
50+
Generate a human-readable summary from minutely precipitation data.
51+
52+
Returns *None* when *forecast* is ``None`` or has no points.
53+
"""
54+
if forecast is None or not forecast.points:
55+
return None
56+
57+
# Check for a transition first
58+
transition = detect_minutely_precipitation_transition(forecast)
59+
if transition is not None:
60+
precip_label = precipitation_type_label(transition.precipitation_type).capitalize()
61+
if transition.transition_type == "starting":
62+
return f"{precip_label} starting in ~{transition.minutes_until} minutes"
63+
return f"{precip_label} stopping in ~{transition.minutes_until} minutes"
64+
65+
# No transition – either all dry or all wet
66+
if is_wet(forecast.points[0]):
67+
intensity = _dominant_intensity_label(forecast)
68+
precip_type = _dominant_precip_type(forecast)
69+
return f"{intensity.capitalize()} {precip_type} for the next hour"
70+
71+
return "No precipitation expected"
72+
73+
74+
def build_minutely_timeline(
75+
forecast: MinutelyPrecipitationForecast | None,
76+
) -> str | None:
77+
"""
78+
Build a screen-reader-friendly timeline at 5-minute intervals.
79+
80+
Returns *None* when *forecast* is ``None`` or has no points.
81+
"""
82+
if forecast is None or not forecast.points:
83+
return None
84+
85+
parts: list[str] = []
86+
# Sample at indices 0, 5, 10, ... up to 60 minutes (index 60)
87+
for i in range(0, min(len(forecast.points), 61), 5):
88+
point = forecast.points[i]
89+
intensity = _classify_intensity(point.precipitation_intensity)
90+
label = "Now" if i == 0 else f"+{i}m"
91+
parts.append(f"{label}: {intensity}")
92+
93+
if not parts:
94+
return None
95+
96+
return ", ".join(parts)

tests/test_minutely_timeline.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
"""Tests for accessiweather.display.presentation.minutely_timeline module."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import UTC, datetime, timedelta
6+
7+
from accessiweather.display.presentation.minutely_timeline import (
8+
build_minutely_timeline,
9+
generate_minutely_summary,
10+
)
11+
from accessiweather.models import (
12+
MinutelyPrecipitationForecast,
13+
MinutelyPrecipitationPoint,
14+
)
15+
16+
17+
def _make_point(
18+
offset_minutes: int = 0,
19+
intensity: float | None = None,
20+
probability: float | None = None,
21+
precip_type: str | None = None,
22+
) -> MinutelyPrecipitationPoint:
23+
return MinutelyPrecipitationPoint(
24+
time=datetime(2025, 6, 1, 12, 0, tzinfo=UTC) + timedelta(minutes=offset_minutes),
25+
precipitation_intensity=intensity,
26+
precipitation_probability=probability,
27+
precipitation_type=precip_type,
28+
)
29+
30+
31+
def _make_forecast(
32+
points: list[MinutelyPrecipitationPoint],
33+
summary: str | None = None,
34+
) -> MinutelyPrecipitationForecast:
35+
return MinutelyPrecipitationForecast(summary=summary, points=points)
36+
37+
38+
# ── generate_minutely_summary ──
39+
40+
41+
class TestGenerateMinutelySummary:
42+
def test_none_forecast(self):
43+
assert generate_minutely_summary(None) is None
44+
45+
def test_empty_points(self):
46+
forecast = _make_forecast([])
47+
assert generate_minutely_summary(forecast) is None
48+
49+
def test_all_dry(self):
50+
points = [_make_point(i, intensity=0.0) for i in range(60)]
51+
result = generate_minutely_summary(_make_forecast(points))
52+
assert result == "No precipitation expected"
53+
54+
def test_all_wet_light_rain(self):
55+
points = [_make_point(i, intensity=0.05, precip_type="rain") for i in range(60)]
56+
result = generate_minutely_summary(_make_forecast(points))
57+
assert result is not None
58+
assert "rain" in result.lower()
59+
assert "next hour" in result.lower()
60+
61+
def test_all_wet_heavy_snow(self):
62+
points = [_make_point(i, intensity=2.0, precip_type="snow") for i in range(60)]
63+
result = generate_minutely_summary(_make_forecast(points))
64+
assert result is not None
65+
assert "snow" in result.lower()
66+
assert "heavy" in result.lower()
67+
68+
def test_dry_to_wet_transition(self):
69+
points = [_make_point(i, intensity=0.0) for i in range(12)]
70+
points += [_make_point(i, intensity=0.5, precip_type="rain") for i in range(12, 60)]
71+
result = generate_minutely_summary(_make_forecast(points))
72+
assert result is not None
73+
assert "starting" in result.lower()
74+
assert "12" in result
75+
76+
def test_wet_to_dry_transition(self):
77+
points = [_make_point(i, intensity=0.5, precip_type="rain") for i in range(20)]
78+
points += [_make_point(i, intensity=0.0) for i in range(20, 60)]
79+
result = generate_minutely_summary(_make_forecast(points))
80+
assert result is not None
81+
assert "stopping" in result.lower()
82+
assert "20" in result
83+
84+
def test_uses_precipitation_type_label(self):
85+
points = [_make_point(i, intensity=0.0) for i in range(5)]
86+
points += [_make_point(i, intensity=0.3, precip_type="snow") for i in range(5, 60)]
87+
result = generate_minutely_summary(_make_forecast(points))
88+
assert result is not None
89+
assert "Snow" in result
90+
91+
def test_all_wet_no_type(self):
92+
"""When no precipitation_type is set, falls back to 'precipitation'."""
93+
points = [_make_point(i, intensity=0.05) for i in range(60)]
94+
result = generate_minutely_summary(_make_forecast(points))
95+
assert result is not None
96+
assert "precipitation" in result.lower()
97+
98+
99+
# ── build_minutely_timeline ──
100+
101+
102+
class TestBuildMinutelyTimeline:
103+
def test_none_forecast(self):
104+
assert build_minutely_timeline(None) is None
105+
106+
def test_empty_points(self):
107+
forecast = _make_forecast([])
108+
assert build_minutely_timeline(forecast) is None
109+
110+
def test_samples_at_5_min_intervals(self):
111+
# 61 points (0..60), all dry
112+
points = [_make_point(i, intensity=0.0) for i in range(61)]
113+
result = build_minutely_timeline(_make_forecast(points))
114+
assert result is not None
115+
parts = [p.strip() for p in result.split(",")]
116+
assert parts[0].startswith("Now:")
117+
assert parts[1].startswith("+5m:")
118+
assert len(parts) == 13 # 0, 5, 10, ..., 60
119+
120+
def test_intensity_classification(self):
121+
points = [_make_point(0, intensity=0.0)] # None
122+
points += [_make_point(i, intensity=0.0) for i in range(1, 5)]
123+
points += [_make_point(5, intensity=0.05)] # Light
124+
points += [_make_point(i, intensity=0.0) for i in range(6, 10)]
125+
points += [_make_point(10, intensity=0.5)] # Moderate
126+
points += [_make_point(i, intensity=0.0) for i in range(11, 15)]
127+
points += [_make_point(15, intensity=2.0)] # Heavy
128+
result = build_minutely_timeline(_make_forecast(points))
129+
assert result is not None
130+
assert "Now: None" in result
131+
assert "+5m: Light" in result
132+
assert "+10m: Moderate" in result
133+
assert "+15m: Heavy" in result
134+
135+
def test_fewer_than_5_points(self):
136+
points = [_make_point(0, intensity=0.0), _make_point(1, intensity=0.05)]
137+
result = build_minutely_timeline(_make_forecast(points))
138+
assert result is not None
139+
# Only index 0 is sampled (next would be index 5 which doesn't exist)
140+
assert result == "Now: None"
141+
142+
def test_screen_reader_friendly(self):
143+
"""Output uses commas, no pipes or box-drawing chars."""
144+
points = [_make_point(i, intensity=0.0) for i in range(61)]
145+
result = build_minutely_timeline(_make_forecast(points))
146+
assert result is not None
147+
assert "|" not in result
148+
assert "─" not in result
149+
assert "," in result
150+
151+
def test_max_60_minutes(self):
152+
# 120 points - should only go up to index 60
153+
points = [_make_point(i, intensity=0.0) for i in range(120)]
154+
result = build_minutely_timeline(_make_forecast(points))
155+
parts = [p.strip() for p in result.split(",")]
156+
last_part = parts[-1]
157+
assert last_part.startswith("+60m:")
158+
159+
160+
# ── Integration with build_current_conditions ──
161+
162+
163+
class TestCurrentConditionsIntegration:
164+
"""Test that build_current_conditions uses generated summary when PW summary is None."""
165+
166+
def test_generated_summary_when_pw_summary_none(self):
167+
from accessiweather.display.presentation.current_conditions import (
168+
build_current_conditions,
169+
)
170+
from accessiweather.models import CurrentConditions, Location
171+
from accessiweather.utils import TemperatureUnit
172+
173+
current = CurrentConditions(
174+
temperature=72.0,
175+
condition="Clear",
176+
)
177+
location = Location(name="Test City", latitude=40.0, longitude=-74.0)
178+
179+
# Minutely forecast with points but no summary
180+
points = [_make_point(i, intensity=0.0) for i in range(60)]
181+
minutely = _make_forecast(points, summary=None)
182+
183+
result = build_current_conditions(
184+
current,
185+
location,
186+
TemperatureUnit.FAHRENHEIT,
187+
minutely_precipitation=minutely,
188+
)
189+
190+
metric_labels = [m.label for m in result.metrics]
191+
assert "Precipitation outlook" in metric_labels
192+
assert "Next hour precipitation" in metric_labels
193+
194+
# Find the summary metric
195+
outlook = next(m for m in result.metrics if m.label == "Precipitation outlook")
196+
assert outlook.value == "No precipitation expected"
197+
198+
def test_pw_summary_preferred_over_generated(self):
199+
from accessiweather.display.presentation.current_conditions import (
200+
build_current_conditions,
201+
)
202+
from accessiweather.models import CurrentConditions, Location
203+
from accessiweather.utils import TemperatureUnit
204+
205+
current = CurrentConditions(
206+
temperature=72.0,
207+
condition="Clear",
208+
)
209+
location = Location(name="Test City", latitude=40.0, longitude=-74.0)
210+
211+
points = [_make_point(i, intensity=0.0) for i in range(60)]
212+
minutely = _make_forecast(points, summary="PW provided summary")
213+
214+
result = build_current_conditions(
215+
current,
216+
location,
217+
TemperatureUnit.FAHRENHEIT,
218+
minutely_precipitation=minutely,
219+
)
220+
221+
outlook = next(m for m in result.metrics if m.label == "Precipitation outlook")
222+
assert outlook.value == "PW provided summary"

0 commit comments

Comments
 (0)