KSvend Claude Happy commited on
Commit
b9fed9a
·
1 Parent(s): 065bbda

docs: add Phase 1 implementation plan for basemap fix and baseline charts

Browse files

11 tasks: Dockerfile bundling, maps.py 50m scale, charts.py dual
baseline overlay modes, and 7 indicator updates to pass baseline
range data through chart_data.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

docs/superpowers/plans/2026-03-31-report-basemap-baseline-charts.md ADDED
@@ -0,0 +1,1240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Phase 1: Basemap Fix & Baseline Comparison Charts — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Fix report maps to show proper basemaps and add baseline comparison overlays to time-series charts.
6
+
7
+ **Architecture:** Bundle Cartopy's 50m Natural Earth shapefiles in the Docker image so maps render land/ocean/borders offline. Extend the chart renderer with two baseline overlay modes: monthly band+line for indicators with monthly data, and horizontal reference band for indicators with 2-point data. Update each indicator's `_build_chart_data` to include baseline range values.
8
+
9
+ **Tech Stack:** Python 3.11, matplotlib, Cartopy, ReportLab, Docker multi-stage build
10
+
11
+ ---
12
+
13
+ ## File Map
14
+
15
+ | File | Action | Responsibility |
16
+ |------|--------|----------------|
17
+ | `Dockerfile` | Modify | Add Natural Earth 50m download + cache copy |
18
+ | `app/outputs/maps.py` | Modify | Use `.with_scale("50m")` on basemap features |
19
+ | `app/outputs/charts.py` | Modify | Add monthly + summary baseline overlay rendering |
20
+ | `tests/test_maps.py` | Modify | Add test verifying basemap features are used |
21
+ | `tests/test_charts.py` | Modify | Add tests for both baseline overlay modes |
22
+ | `app/indicators/rainfall.py` | Modify | Add per-month baseline min/mean/max arrays |
23
+ | `app/indicators/vegetation.py` | Modify | Add scalar baseline range to `chart_data` |
24
+ | `app/indicators/cropland.py` | Modify | Add scalar baseline range to `chart_data` |
25
+ | `app/indicators/water.py` | Modify | Add scalar baseline range to `chart_data` |
26
+ | `app/indicators/lst.py` | Modify | Add scalar baseline range to `chart_data` |
27
+ | `app/indicators/no2.py` | Modify | Add scalar baseline range to `chart_data` |
28
+ | `app/indicators/nightlights.py` | Modify | Add scalar baseline range to `chart_data` |
29
+ | `tests/test_indicator_rainfall.py` | Modify | Test baseline arrays in chart_data |
30
+ | `tests/test_indicator_cropland.py` | Modify | Test baseline scalars in chart_data |
31
+
32
+ ---
33
+
34
+ ### Task 1: Bundle 50m Natural Earth Shapefiles in Docker
35
+
36
+ **Files:**
37
+ - Modify: `Dockerfile:14-17` (builder stage, after cartopy install)
38
+ - Modify: `Dockerfile:43-44` (runtime stage, after copying pip packages)
39
+
40
+ - [ ] **Step 1: Add Natural Earth download to Dockerfile builder stage**
41
+
42
+ In `Dockerfile`, after the line `&& pip install --no-cache-dir --prefer-binary cartopy`, add a new `RUN` command:
43
+
44
+ ```dockerfile
45
+ # Pre-download 50m Natural Earth data so Cartopy works offline
46
+ RUN python -c "\
47
+ import cartopy.io.shapereader as shpreader; \
48
+ [shpreader.natural_earth(resolution='50m', category=cat, name=name) \
49
+ for cat, name in [('physical','land'),('physical','ocean'),('physical','coastline'),('cultural','admin_0_boundary_lines_lake')]]"
50
+ ```
51
+
52
+ - [ ] **Step 2: Copy Cartopy cache to runtime stage**
53
+
54
+ In `Dockerfile`, after the `COPY --from=builder /usr/local/bin /usr/local/bin` line (line 45), add:
55
+
56
+ ```dockerfile
57
+ COPY --from=builder /root/.local/share/cartopy /root/.local/share/cartopy
58
+ ```
59
+
60
+ - [ ] **Step 3: Verify Docker build succeeds**
61
+
62
+ Run: `docker build -t aperture-test --target builder .`
63
+
64
+ Expected: Build completes and downloads Natural Earth shapefiles during the builder stage. If running on a machine without Docker, skip this step — it will be verified on deploy.
65
+
66
+ - [ ] **Step 4: Commit**
67
+
68
+ ```bash
69
+ git add Dockerfile
70
+ git commit -m "feat: bundle 50m Natural Earth shapefiles in Docker for offline basemaps"
71
+ ```
72
+
73
+ ---
74
+
75
+ ### Task 2: Update Map Renderer to Use 50m Scale
76
+
77
+ **Files:**
78
+ - Modify: `app/outputs/maps.py:57-59`
79
+ - Modify: `tests/test_maps.py`
80
+
81
+ - [ ] **Step 1: Write test for 50m scale basemap features**
82
+
83
+ Add to `tests/test_maps.py`:
84
+
85
+ ```python
86
+ def test_basemap_uses_50m_scale(aoi):
87
+ """Verify that _base_ax requests 50m scale features when cartopy is available."""
88
+ from app.outputs.maps import _base_ax
89
+ import matplotlib.pyplot as plt
90
+
91
+ fig, ax, transform = _base_ax(aoi)
92
+ try:
93
+ if transform is not None:
94
+ # Cartopy is available — check that features were added
95
+ # ax.artists + ax.collections should be non-empty (land, ocean, borders)
96
+ feature_count = len([
97
+ f for f in ax._feature_store
98
+ ]) if hasattr(ax, '_feature_store') else 0
99
+ # At minimum, we should have land, ocean, borders = 3 features
100
+ assert len(ax.patches) + len(ax.collections) >= 0 # non-crash is key
101
+ finally:
102
+ plt.close(fig)
103
+ ```
104
+
105
+ - [ ] **Step 2: Run test to verify it passes (baseline — confirms cartopy works locally)**
106
+
107
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_maps.py::test_basemap_uses_50m_scale -v`
108
+
109
+ Expected: PASS (or SKIP if Cartopy not installed locally). This test mainly validates the code doesn't crash.
110
+
111
+ - [ ] **Step 3: Update `_base_ax()` to use 50m scale features**
112
+
113
+ In `app/outputs/maps.py`, replace lines 57-59:
114
+
115
+ ```python
116
+ ax.add_feature(cfeature.LAND, facecolor="#E8E6E0", edgecolor="none")
117
+ ax.add_feature(cfeature.OCEAN, facecolor="#D4E6F1", edgecolor="none")
118
+ ax.add_feature(cfeature.BORDERS, linewidth=0.5, edgecolor=INK_MUTED)
119
+ ```
120
+
121
+ With:
122
+
123
+ ```python
124
+ ax.add_feature(cfeature.LAND.with_scale("50m"), facecolor="#E8E6E0", edgecolor="none")
125
+ ax.add_feature(cfeature.OCEAN.with_scale("50m"), facecolor="#D4E6F1", edgecolor="none")
126
+ ax.add_feature(cfeature.BORDERS.with_scale("50m"), linewidth=0.5, edgecolor=INK_MUTED)
127
+ ```
128
+
129
+ - [ ] **Step 4: Run all map tests**
130
+
131
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_maps.py -v`
132
+
133
+ Expected: All tests PASS.
134
+
135
+ - [ ] **Step 5: Commit**
136
+
137
+ ```bash
138
+ git add app/outputs/maps.py tests/test_maps.py
139
+ git commit -m "feat: use 50m Natural Earth features for sharper basemaps"
140
+ ```
141
+
142
+ ---
143
+
144
+ ### Task 3: Add Monthly Baseline Overlay to Chart Renderer
145
+
146
+ **Files:**
147
+ - Modify: `app/outputs/charts.py:58-145`
148
+ - Modify: `tests/test_charts.py`
149
+
150
+ - [ ] **Step 1: Write test for monthly baseline overlay**
151
+
152
+ Add to `tests/test_charts.py`:
153
+
154
+ ```python
155
+ def test_render_timeseries_chart_with_monthly_baseline():
156
+ """Chart with baseline_mean/min/max arrays renders band + dashed line."""
157
+ chart_data = {
158
+ "dates": ["2025-01", "2025-02", "2025-03", "2025-04", "2025-05", "2025-06"],
159
+ "values": [50, 55, 60, 58, 62, 65],
160
+ "baseline_mean": [45, 48, 52, 50, 55, 58],
161
+ "baseline_min": [40, 42, 46, 44, 48, 52],
162
+ "baseline_max": [50, 54, 58, 56, 62, 64],
163
+ "label": "Monthly rainfall (mm)",
164
+ }
165
+ with tempfile.TemporaryDirectory() as tmpdir:
166
+ out_path = os.path.join(tmpdir, "baseline_chart.png")
167
+ render_timeseries_chart(
168
+ chart_data=chart_data,
169
+ indicator_name="Rainfall Adequacy",
170
+ status=StatusLevel.GREEN,
171
+ trend=TrendDirection.STABLE,
172
+ output_path=out_path,
173
+ y_label="Monthly rainfall (mm)",
174
+ )
175
+ assert os.path.exists(out_path)
176
+ assert os.path.getsize(out_path) > 1000
177
+ ```
178
+
179
+ - [ ] **Step 2: Run test to verify it passes (existing code ignores extra keys)**
180
+
181
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_charts.py::test_render_timeseries_chart_with_monthly_baseline -v`
182
+
183
+ Expected: PASS (but chart won't show baseline yet — we need to visually verify later).
184
+
185
+ - [ ] **Step 3: Write test for summary (horizontal) baseline overlay**
186
+
187
+ Add to `tests/test_charts.py`:
188
+
189
+ ```python
190
+ def test_render_timeseries_chart_with_summary_baseline():
191
+ """Chart with baseline_range_mean/min/max scalars renders horizontal band."""
192
+ chart_data = {
193
+ "dates": ["2024", "2025"],
194
+ "values": [35.2, 38.1],
195
+ "baseline_range_mean": 34.0,
196
+ "baseline_range_min": 30.5,
197
+ "baseline_range_max": 37.5,
198
+ "label": "Vegetation cover (%)",
199
+ }
200
+ with tempfile.TemporaryDirectory() as tmpdir:
201
+ out_path = os.path.join(tmpdir, "summary_chart.png")
202
+ render_timeseries_chart(
203
+ chart_data=chart_data,
204
+ indicator_name="Vegetation",
205
+ status=StatusLevel.GREEN,
206
+ trend=TrendDirection.STABLE,
207
+ output_path=out_path,
208
+ y_label="Vegetation cover (%)",
209
+ )
210
+ assert os.path.exists(out_path)
211
+ assert os.path.getsize(out_path) > 1000
212
+ ```
213
+
214
+ - [ ] **Step 4: Write test that chart without baseline still works (backward compat)**
215
+
216
+ Add to `tests/test_charts.py`:
217
+
218
+ ```python
219
+ def test_render_timeseries_chart_no_baseline_still_works():
220
+ """Chart without any baseline keys renders the same as before."""
221
+ chart_data = {
222
+ "dates": ["2025-01", "2025-02", "2025-03"],
223
+ "values": [10, 20, 15],
224
+ "label": "Test metric",
225
+ }
226
+ with tempfile.TemporaryDirectory() as tmpdir:
227
+ out_path = os.path.join(tmpdir, "no_baseline.png")
228
+ render_timeseries_chart(
229
+ chart_data=chart_data,
230
+ indicator_name="Test",
231
+ status=StatusLevel.AMBER,
232
+ trend=TrendDirection.STABLE,
233
+ output_path=out_path,
234
+ y_label="Test metric",
235
+ )
236
+ assert os.path.exists(out_path)
237
+ assert os.path.getsize(out_path) > 1000
238
+ ```
239
+
240
+ - [ ] **Step 5: Run all new chart tests**
241
+
242
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_charts.py -v`
243
+
244
+ Expected: All PASS (baseline data is ignored by current renderer).
245
+
246
+ - [ ] **Step 6: Implement baseline overlay in `render_timeseries_chart()`**
247
+
248
+ In `app/outputs/charts.py`, after the existing `ax.fill_between` block (line 109), add the baseline rendering logic. Replace the block from line 99 (`ax.plot(`) through line 109 (`)`):
249
+
250
+ ```python
251
+ # Current data line
252
+ ax.plot(
253
+ parsed_dates, values,
254
+ color=status_color, linewidth=2, marker="o",
255
+ markersize=5, markerfacecolor="white",
256
+ markeredgecolor=status_color, markeredgewidth=1.5,
257
+ zorder=3, label="Current",
258
+ )
259
+ ax.fill_between(
260
+ parsed_dates, values,
261
+ alpha=0.15, color=status_color,
262
+ )
263
+
264
+ # Monthly baseline overlay (list-based: band + dashed mean)
265
+ b_mean = chart_data.get("baseline_mean", [])
266
+ b_min = chart_data.get("baseline_min", [])
267
+ b_max = chart_data.get("baseline_max", [])
268
+ if (
269
+ b_mean and b_min and b_max
270
+ and len(b_mean) == len(parsed_dates)
271
+ ):
272
+ ax.fill_between(
273
+ parsed_dates, b_min, b_max,
274
+ color="#D5D3CE", alpha=0.3, label="Baseline range", zorder=1,
275
+ )
276
+ ax.plot(
277
+ parsed_dates, b_mean,
278
+ color="#9B9B9B", linewidth=1.5, linestyle="--",
279
+ label="Baseline mean", zorder=2,
280
+ )
281
+ ax.legend(fontsize=7, loc="upper left", framealpha=0.8)
282
+
283
+ # Summary baseline overlay (scalar-based: horizontal band + line)
284
+ elif "baseline_range_mean" in chart_data:
285
+ br_mean = chart_data["baseline_range_mean"]
286
+ br_min = chart_data.get("baseline_range_min", br_mean)
287
+ br_max = chart_data.get("baseline_range_max", br_mean)
288
+ ax.axhspan(
289
+ br_min, br_max,
290
+ color="#D5D3CE", alpha=0.3, label="Baseline range", zorder=1,
291
+ )
292
+ ax.axhline(
293
+ br_mean, color="#9B9B9B", linewidth=1.5, linestyle="--",
294
+ label="Baseline mean", zorder=2,
295
+ )
296
+ ax.legend(fontsize=7, loc="upper left", framealpha=0.8)
297
+ ```
298
+
299
+ - [ ] **Step 7: Run all chart tests**
300
+
301
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_charts.py -v`
302
+
303
+ Expected: All PASS.
304
+
305
+ - [ ] **Step 8: Commit**
306
+
307
+ ```bash
308
+ git add app/outputs/charts.py tests/test_charts.py
309
+ git commit -m "feat: add monthly and summary baseline overlays to time-series charts"
310
+ ```
311
+
312
+ ---
313
+
314
+ ### Task 4: Add Baseline Range to Rainfall Indicator (Monthly Mode)
315
+
316
+ **Files:**
317
+ - Modify: `app/indicators/rainfall.py:125-160` (`_api_query`) and `app/indicators/rainfall.py:256-266` (`_build_chart_data`)
318
+ - Modify: `tests/test_indicator_rainfall.py`
319
+
320
+ - [ ] **Step 1: Write test for baseline arrays in rainfall chart_data**
321
+
322
+ Add to `tests/test_indicator_rainfall.py`:
323
+
324
+ ```python
325
+ def test_build_chart_data_includes_baseline_range():
326
+ """Rainfall chart_data should include baseline_mean, baseline_min, baseline_max arrays."""
327
+ from app.indicators.rainfall import RainfallIndicator
328
+
329
+ current = {"2025-01": 50.0, "2025-02": 60.0, "2025-03": 45.0}
330
+ baseline = {"2025-01": 55.0, "2025-02": 58.0, "2025-03": 50.0}
331
+ baseline_per_year = {
332
+ "01": [50.0, 55.0, 60.0],
333
+ "02": [52.0, 58.0, 64.0],
334
+ "03": [45.0, 50.0, 55.0],
335
+ }
336
+ result = RainfallIndicator._build_chart_data(current, baseline, baseline_per_year)
337
+
338
+ assert "baseline_mean" in result
339
+ assert "baseline_min" in result
340
+ assert "baseline_max" in result
341
+ assert len(result["baseline_mean"]) == len(result["dates"])
342
+ assert len(result["baseline_min"]) == len(result["dates"])
343
+ assert len(result["baseline_max"]) == len(result["dates"])
344
+ # Check min <= mean <= max for each position
345
+ for i in range(len(result["dates"])):
346
+ assert result["baseline_min"][i] <= result["baseline_mean"][i] <= result["baseline_max"][i]
347
+ ```
348
+
349
+ - [ ] **Step 2: Run test to verify it fails**
350
+
351
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_rainfall.py::test_build_chart_data_includes_baseline_range -v`
352
+
353
+ Expected: FAIL — `_build_chart_data` currently takes 2 args, not 3.
354
+
355
+ - [ ] **Step 3: Update `_api_query` to preserve per-year monthly data**
356
+
357
+ In `app/indicators/rainfall.py`, modify `_api_query` (lines 125-160). The `baseline_pool` dict currently maps month_num to a flat list of values. We need to also track per-year values for min/max. Add a new dict alongside `baseline_pool`:
358
+
359
+ Replace lines 140-154:
360
+
361
+ ```python
362
+ # Baseline: average monthly totals across baseline years
363
+ baseline_pool: dict[str, list[float]] = defaultdict(list)
364
+ baseline_per_year: dict[str, list[float]] = defaultdict(list)
365
+ for yr in range(baseline_start, current_year):
366
+ yr_monthly = await self._query_monthly(
367
+ client, lat, lon, date(yr, 1, 1), date(yr, 12, 31)
368
+ )
369
+ # Build per-month-number totals for this year
370
+ yr_by_month: dict[str, float] = {}
371
+ for month_key, mm in yr_monthly.items():
372
+ month_num = month_key.split("-")[1]
373
+ yr_by_month[month_num] = mm
374
+ baseline_pool[month_num].append(mm)
375
+
376
+ # Store each year's monthly total for min/max calculation
377
+ for month_num, mm in yr_by_month.items():
378
+ baseline_per_year[month_num].append(mm)
379
+
380
+ # Average each month across baseline years, keyed as current_year-MM
381
+ baseline_monthly: dict[str, float] = {}
382
+ for month_num, vals in baseline_pool.items():
383
+ key = f"{current_year}-{month_num}"
384
+ baseline_monthly[key] = sum(vals) / len(vals)
385
+ ```
386
+
387
+ Store `baseline_per_year` on `self` so `_build_chart_data` can use it:
388
+
389
+ After line 160 (`return current_monthly, baseline_monthly`), change the return to also pass the per-year data. Update the method signature and caller.
390
+
391
+ Actually, the cleanest approach: store it as an instance attribute. Add before the return:
392
+
393
+ ```python
394
+ self._baseline_per_year = dict(baseline_per_year)
395
+ ```
396
+
397
+ And update the return to remain unchanged.
398
+
399
+ - [ ] **Step 4: Update `_build_chart_data` to accept and use per-year baseline data**
400
+
401
+ Replace `_build_chart_data` (lines 256-266):
402
+
403
+ ```python
404
+ @staticmethod
405
+ def _build_chart_data(
406
+ current: dict[str, float],
407
+ baseline: dict[str, float],
408
+ baseline_per_year: dict[str, list[float]] | None = None,
409
+ ) -> dict[str, Any]:
410
+ all_keys = sorted(set(list(current.keys()) + list(baseline.keys())))
411
+ result: dict[str, Any] = {
412
+ "dates": all_keys,
413
+ "values": [current.get(k, baseline.get(k, 0.0)) for k in all_keys],
414
+ "baseline_values": [baseline.get(k, 0.0) for k in all_keys],
415
+ "label": "Monthly rainfall (mm)",
416
+ }
417
+ if baseline_per_year:
418
+ b_mean: list[float] = []
419
+ b_min: list[float] = []
420
+ b_max: list[float] = []
421
+ for k in all_keys:
422
+ month_num = k.split("-")[1]
423
+ year_vals = baseline_per_year.get(month_num, [])
424
+ if year_vals:
425
+ b_mean.append(float(np.mean(year_vals)))
426
+ b_min.append(float(min(year_vals)))
427
+ b_max.append(float(max(year_vals)))
428
+ else:
429
+ fallback = baseline.get(k, 0.0)
430
+ b_mean.append(fallback)
431
+ b_min.append(fallback)
432
+ b_max.append(fallback)
433
+ result["baseline_mean"] = b_mean
434
+ result["baseline_min"] = b_min
435
+ result["baseline_max"] = b_max
436
+ return result
437
+ ```
438
+
439
+ - [ ] **Step 5: Update the caller in `process()` to pass per-year data**
440
+
441
+ In `app/indicators/rainfall.py`, line 55, update the call:
442
+
443
+ ```python
444
+ chart_data = self._build_chart_data(
445
+ current_monthly, baseline_monthly,
446
+ getattr(self, '_baseline_per_year', None),
447
+ )
448
+ ```
449
+
450
+ Also update `_synthetic_data` to set `self._baseline_per_year = None` — but since it's a static method, handle via the `getattr` fallback above (already handled).
451
+
452
+ - [ ] **Step 6: Run the rainfall test**
453
+
454
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_rainfall.py::test_build_chart_data_includes_baseline_range -v`
455
+
456
+ Expected: PASS.
457
+
458
+ - [ ] **Step 7: Run all rainfall tests**
459
+
460
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_rainfall.py -v`
461
+
462
+ Expected: All PASS.
463
+
464
+ - [ ] **Step 8: Commit**
465
+
466
+ ```bash
467
+ git add app/indicators/rainfall.py tests/test_indicator_rainfall.py
468
+ git commit -m "feat: add per-month baseline min/mean/max to rainfall chart data"
469
+ ```
470
+
471
+ ---
472
+
473
+ ### Task 5: Add Baseline Range to Vegetation Indicator (Summary Mode)
474
+
475
+ **Files:**
476
+ - Modify: `app/indicators/vegetation.py:172-226` (`_stac_comparison`) and `app/indicators/vegetation.py:251-259` (`_build_chart_data`)
477
+
478
+ - [ ] **Step 1: Write test for baseline scalars in vegetation chart_data**
479
+
480
+ Create or append to a test file. Since there's no `tests/test_indicator_vegetation.py`, add a focused test:
481
+
482
+ ```python
483
+ # tests/test_indicator_vegetation.py
484
+ def test_build_chart_data_includes_baseline_range():
485
+ """Vegetation chart_data should include baseline_range_mean/min/max scalars."""
486
+ from app.indicators.vegetation import VegetationIndicator
487
+ from datetime import date
488
+ from app.models import TimeRange
489
+
490
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
491
+ result = VegetationIndicator._build_chart_data(
492
+ baseline=35.0, current=38.0, time_range=tr,
493
+ baseline_yearly_means=[32.0, 35.0, 38.0, 34.0, 36.0],
494
+ )
495
+ assert "baseline_range_mean" in result
496
+ assert "baseline_range_min" in result
497
+ assert "baseline_range_max" in result
498
+ assert result["baseline_range_min"] == 32.0
499
+ assert result["baseline_range_max"] == 38.0
500
+ assert result["baseline_range_min"] <= result["baseline_range_mean"] <= result["baseline_range_max"]
501
+ ```
502
+
503
+ - [ ] **Step 2: Run test to verify it fails**
504
+
505
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_vegetation.py::test_build_chart_data_includes_baseline_range -v`
506
+
507
+ Expected: FAIL — `_build_chart_data` doesn't accept `baseline_yearly_means`.
508
+
509
+ - [ ] **Step 3: Update `_stac_comparison` to track per-year means**
510
+
511
+ In `app/indicators/vegetation.py`, modify `_stac_comparison` (lines 172-226). After the baseline loop (lines 202-206), add per-year median tracking. Replace lines 208-226:
512
+
513
+ ```python
514
+ # Per-year medians for baseline range
515
+ baseline_yearly_means: list[float] = []
516
+ for yr in range(baseline_start_year, current_year):
517
+ yr_monthly = await loop.run_in_executor(None, _query_monthly, yr)
518
+ yr_medians = []
519
+ for month, vals in yr_monthly.items():
520
+ if vals:
521
+ yr_medians.append(float(np.median(vals)))
522
+ if yr_medians:
523
+ baseline_yearly_means.append(float(np.mean(yr_medians)))
524
+
525
+ baseline_medians = []
526
+ current_medians = []
527
+ for month in range(1, 13):
528
+ b_vals = baseline_pool.get(month, [])
529
+ c_vals = current_monthly.get(month, [])
530
+ if b_vals and c_vals:
531
+ baseline_medians.append(float(np.median(b_vals)))
532
+ current_medians.append(float(np.median(c_vals)))
533
+
534
+ n_months = len(baseline_medians)
535
+ if n_months == 0:
536
+ self._is_placeholder = True
537
+ return self._synthetic()
538
+
539
+ self._baseline_yearly_means = baseline_yearly_means
540
+
541
+ return (
542
+ float(np.mean(baseline_medians)),
543
+ float(np.mean(current_medians)),
544
+ n_months,
545
+ )
546
+ ```
547
+
548
+ Wait — the baseline_pool loop already iterates baseline years. We should compute per-year means *inside* that loop rather than re-querying. Refactor: track per-year data while building the pool. Replace lines 202-226:
549
+
550
+ ```python
551
+ baseline_pool: dict[int, list[float]] = defaultdict(list)
552
+ baseline_yearly_means: list[float] = []
553
+ for yr in range(baseline_start_year, current_year):
554
+ yr_monthly = await loop.run_in_executor(None, _query_monthly, yr)
555
+ yr_medians = []
556
+ for month, vals in yr_monthly.items():
557
+ baseline_pool[month].extend(vals)
558
+ if vals:
559
+ yr_medians.append(float(np.median(vals)))
560
+ if yr_medians:
561
+ baseline_yearly_means.append(float(np.mean(yr_medians)))
562
+
563
+ baseline_medians = []
564
+ current_medians = []
565
+ for month in range(1, 13):
566
+ b_vals = baseline_pool.get(month, [])
567
+ c_vals = current_monthly.get(month, [])
568
+ if b_vals and c_vals:
569
+ baseline_medians.append(float(np.median(b_vals)))
570
+ current_medians.append(float(np.median(c_vals)))
571
+
572
+ n_months = len(baseline_medians)
573
+ if n_months == 0:
574
+ self._is_placeholder = True
575
+ return self._synthetic()
576
+
577
+ self._baseline_yearly_means = baseline_yearly_means
578
+
579
+ return (
580
+ float(np.mean(baseline_medians)),
581
+ float(np.mean(current_medians)),
582
+ n_months,
583
+ )
584
+ ```
585
+
586
+ - [ ] **Step 4: Update `_build_chart_data` to accept and include baseline range**
587
+
588
+ Replace `_build_chart_data` (lines 251-259):
589
+
590
+ ```python
591
+ @staticmethod
592
+ def _build_chart_data(
593
+ baseline: float,
594
+ current: float,
595
+ time_range: TimeRange,
596
+ baseline_yearly_means: list[float] | None = None,
597
+ ) -> dict[str, Any]:
598
+ result: dict[str, Any] = {
599
+ "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
600
+ "values": [round(baseline, 1), round(current, 1)],
601
+ "label": "Vegetation cover (%)",
602
+ }
603
+ if baseline_yearly_means and len(baseline_yearly_means) >= 2:
604
+ result["baseline_range_mean"] = round(float(np.mean(baseline_yearly_means)), 1)
605
+ result["baseline_range_min"] = round(float(min(baseline_yearly_means)), 1)
606
+ result["baseline_range_max"] = round(float(max(baseline_yearly_means)), 1)
607
+ return result
608
+ ```
609
+
610
+ - [ ] **Step 5: Update the caller in `process()` to pass yearly means**
611
+
612
+ In `app/indicators/vegetation.py`, line 43, update:
613
+
614
+ ```python
615
+ chart_data = self._build_chart_data(
616
+ baseline_mean, current_mean, time_range,
617
+ getattr(self, '_baseline_yearly_means', None),
618
+ )
619
+ ```
620
+
621
+ - [ ] **Step 6: Run the vegetation test**
622
+
623
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_vegetation.py::test_build_chart_data_includes_baseline_range -v`
624
+
625
+ Expected: PASS.
626
+
627
+ - [ ] **Step 7: Commit**
628
+
629
+ ```bash
630
+ git add app/indicators/vegetation.py tests/test_indicator_vegetation.py
631
+ git commit -m "feat: add baseline range scalars to vegetation chart data"
632
+ ```
633
+
634
+ ---
635
+
636
+ ### Task 6: Add Baseline Range to Cropland Indicator (Summary Mode)
637
+
638
+ **Files:**
639
+ - Modify: `app/indicators/cropland.py:183-246` (`_stac_comparison`) and `app/indicators/cropland.py:272-280` (`_build_chart_data`)
640
+ - Modify: `tests/test_indicator_cropland.py`
641
+
642
+ - [ ] **Step 1: Write test for baseline scalars in cropland chart_data**
643
+
644
+ Add to `tests/test_indicator_cropland.py`:
645
+
646
+ ```python
647
+ def test_build_chart_data_includes_baseline_range():
648
+ """Cropland chart_data should include baseline_range_mean/min/max scalars."""
649
+ from app.indicators.cropland import CroplandIndicator
650
+ from datetime import date
651
+ from app.models import TimeRange
652
+
653
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
654
+ result = CroplandIndicator._build_chart_data(
655
+ baseline=40.0, current=42.0, time_range=tr,
656
+ baseline_yearly_means=[38.0, 40.0, 42.0],
657
+ )
658
+ assert "baseline_range_mean" in result
659
+ assert "baseline_range_min" in result
660
+ assert "baseline_range_max" in result
661
+ assert result["baseline_range_min"] == 38.0
662
+ assert result["baseline_range_max"] == 42.0
663
+ ```
664
+
665
+ - [ ] **Step 2: Run test to verify it fails**
666
+
667
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_cropland.py::test_build_chart_data_includes_baseline_range -v`
668
+
669
+ Expected: FAIL.
670
+
671
+ - [ ] **Step 3: Update `_stac_comparison` to track per-year means**
672
+
673
+ In `app/indicators/cropland.py`, replace lines 221-246:
674
+
675
+ ```python
676
+ baseline_pool: dict[int, list[float]] = defaultdict(list)
677
+ baseline_yearly_means: list[float] = []
678
+ for yr in range(baseline_start_year, current_year):
679
+ yr_monthly = await loop.run_in_executor(None, _query_growing_season, yr)
680
+ yr_medians = []
681
+ for month, vals in yr_monthly.items():
682
+ baseline_pool[month].extend(vals)
683
+ if vals:
684
+ yr_medians.append(float(np.median(vals)))
685
+ if yr_medians:
686
+ baseline_yearly_means.append(float(np.mean(yr_medians)))
687
+
688
+ # Month-matched comparison: only growing-season months with data in BOTH periods
689
+ baseline_medians = []
690
+ current_medians = []
691
+ for month in GROWING_SEASON:
692
+ b_vals = baseline_pool.get(month, [])
693
+ c_vals = current_monthly.get(month, [])
694
+ if b_vals and c_vals:
695
+ baseline_medians.append(float(np.median(b_vals)))
696
+ current_medians.append(float(np.median(c_vals)))
697
+
698
+ n_months = len(baseline_medians)
699
+ if n_months == 0:
700
+ self._is_placeholder = True
701
+ return self._synthetic()
702
+
703
+ self._baseline_yearly_means = baseline_yearly_means
704
+
705
+ return (
706
+ float(np.mean(baseline_medians)),
707
+ float(np.mean(current_medians)),
708
+ n_months,
709
+ )
710
+ ```
711
+
712
+ - [ ] **Step 4: Update `_build_chart_data` to include baseline range**
713
+
714
+ Replace `_build_chart_data` (lines 272-280):
715
+
716
+ ```python
717
+ @staticmethod
718
+ def _build_chart_data(
719
+ baseline: float,
720
+ current: float,
721
+ time_range: TimeRange,
722
+ baseline_yearly_means: list[float] | None = None,
723
+ ) -> dict[str, Any]:
724
+ result: dict[str, Any] = {
725
+ "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
726
+ "values": [round(baseline, 1), round(current, 1)],
727
+ "label": "Vegetation cover (%)",
728
+ }
729
+ if baseline_yearly_means and len(baseline_yearly_means) >= 2:
730
+ result["baseline_range_mean"] = round(float(np.mean(baseline_yearly_means)), 1)
731
+ result["baseline_range_min"] = round(float(min(baseline_yearly_means)), 1)
732
+ result["baseline_range_max"] = round(float(max(baseline_yearly_means)), 1)
733
+ return result
734
+ ```
735
+
736
+ - [ ] **Step 5: Update the caller in `process()` to pass yearly means**
737
+
738
+ In `app/indicators/cropland.py`, line 48:
739
+
740
+ ```python
741
+ chart_data = self._build_chart_data(
742
+ baseline_mean, current_mean, time_range,
743
+ getattr(self, '_baseline_yearly_means', None),
744
+ )
745
+ ```
746
+
747
+ - [ ] **Step 6: Run cropland tests**
748
+
749
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_cropland.py -v`
750
+
751
+ Expected: All PASS.
752
+
753
+ - [ ] **Step 7: Commit**
754
+
755
+ ```bash
756
+ git add app/indicators/cropland.py tests/test_indicator_cropland.py
757
+ git commit -m "feat: add baseline range scalars to cropland chart data"
758
+ ```
759
+
760
+ ---
761
+
762
+ ### Task 7: Add Baseline Range to Water Indicator (Summary Mode)
763
+
764
+ **Files:**
765
+ - Modify: `app/indicators/water.py:175-229` (`_stac_comparison`) and `app/indicators/water.py:256-264` (`_build_chart_data`)
766
+
767
+ - [ ] **Step 1: Write test**
768
+
769
+ ```python
770
+ # tests/test_indicator_water.py
771
+ def test_build_chart_data_includes_baseline_range():
772
+ from app.indicators.water import WaterIndicator
773
+ from datetime import date
774
+ from app.models import TimeRange
775
+
776
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
777
+ result = WaterIndicator._build_chart_data(
778
+ baseline=5.0, current=4.5, time_range=tr,
779
+ baseline_yearly_means=[4.5, 5.0, 5.5],
780
+ )
781
+ assert "baseline_range_mean" in result
782
+ assert result["baseline_range_min"] == 4.5
783
+ assert result["baseline_range_max"] == 5.5
784
+ ```
785
+
786
+ - [ ] **Step 2: Run test to verify it fails**
787
+
788
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_water.py::test_build_chart_data_includes_baseline_range -v`
789
+
790
+ Expected: FAIL.
791
+
792
+ - [ ] **Step 3: Update `_stac_comparison` to track per-year means**
793
+
794
+ Same pattern as vegetation. In `app/indicators/water.py`, replace lines 205-229:
795
+
796
+ ```python
797
+ baseline_pool: dict[int, list[float]] = defaultdict(list)
798
+ baseline_yearly_means: list[float] = []
799
+ for yr in range(baseline_start_year, current_year):
800
+ yr_monthly = await loop.run_in_executor(None, _query_monthly, yr)
801
+ yr_medians = []
802
+ for month, vals in yr_monthly.items():
803
+ baseline_pool[month].extend(vals)
804
+ if vals:
805
+ yr_medians.append(float(np.median(vals)))
806
+ if yr_medians:
807
+ baseline_yearly_means.append(float(np.mean(yr_medians)))
808
+
809
+ baseline_medians = []
810
+ current_medians = []
811
+ for month in range(1, 13):
812
+ b_vals = baseline_pool.get(month, [])
813
+ c_vals = current_monthly.get(month, [])
814
+ if b_vals and c_vals:
815
+ baseline_medians.append(float(np.median(b_vals)))
816
+ current_medians.append(float(np.median(c_vals)))
817
+
818
+ n_months = len(baseline_medians)
819
+ if n_months == 0:
820
+ self._is_placeholder = True
821
+ return self._synthetic()
822
+
823
+ self._baseline_yearly_means = baseline_yearly_means
824
+
825
+ return (
826
+ float(np.mean(baseline_medians)),
827
+ float(np.mean(current_medians)),
828
+ n_months,
829
+ )
830
+ ```
831
+
832
+ - [ ] **Step 4: Update `_build_chart_data`**
833
+
834
+ Replace `_build_chart_data` (lines 256-264):
835
+
836
+ ```python
837
+ @staticmethod
838
+ def _build_chart_data(
839
+ baseline: float,
840
+ current: float,
841
+ time_range: TimeRange,
842
+ baseline_yearly_means: list[float] | None = None,
843
+ ) -> dict[str, Any]:
844
+ result: dict[str, Any] = {
845
+ "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
846
+ "values": [round(baseline, 2), round(current, 2)],
847
+ "label": "Water body coverage (%)",
848
+ }
849
+ if baseline_yearly_means and len(baseline_yearly_means) >= 2:
850
+ result["baseline_range_mean"] = round(float(np.mean(baseline_yearly_means)), 2)
851
+ result["baseline_range_min"] = round(float(min(baseline_yearly_means)), 2)
852
+ result["baseline_range_max"] = round(float(max(baseline_yearly_means)), 2)
853
+ return result
854
+ ```
855
+
856
+ - [ ] **Step 5: Update the caller in `process()`**
857
+
858
+ In `app/indicators/water.py`, line 46:
859
+
860
+ ```python
861
+ chart_data = self._build_chart_data(
862
+ baseline_mean, current_mean, time_range,
863
+ getattr(self, '_baseline_yearly_means', None),
864
+ )
865
+ ```
866
+
867
+ - [ ] **Step 6: Run water tests**
868
+
869
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_water.py -v`
870
+
871
+ Expected: PASS.
872
+
873
+ - [ ] **Step 7: Commit**
874
+
875
+ ```bash
876
+ git add app/indicators/water.py tests/test_indicator_water.py
877
+ git commit -m "feat: add baseline range scalars to water chart data"
878
+ ```
879
+
880
+ ---
881
+
882
+ ### Task 8: Add Baseline Range to LST Indicator (Summary Mode)
883
+
884
+ **Files:**
885
+ - Modify: `app/indicators/lst.py:116-167` (`_api_query`) and `app/indicators/lst.py:229-241` (`_build_chart_data`)
886
+
887
+ - [ ] **Step 1: Write test**
888
+
889
+ ```python
890
+ # tests/test_indicator_lst.py
891
+ def test_build_chart_data_includes_baseline_range():
892
+ from app.indicators.lst import LSTIndicator
893
+ from datetime import date
894
+ from app.models import TimeRange
895
+
896
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
897
+ result = LSTIndicator._build_chart_data(
898
+ current=34.0, baseline_mean=32.0, baseline_std=2.5, time_range=tr,
899
+ baseline_yearly_means=[30.0, 31.5, 32.0, 33.0, 33.5],
900
+ )
901
+ assert "baseline_range_mean" in result
902
+ assert result["baseline_range_min"] == 30.0
903
+ assert result["baseline_range_max"] == 33.5
904
+ ```
905
+
906
+ - [ ] **Step 2: Run test to verify it fails**
907
+
908
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_lst.py::test_build_chart_data_includes_baseline_range -v`
909
+
910
+ Expected: FAIL.
911
+
912
+ - [ ] **Step 3: The `_api_query` already stores `baseline_yearly_means` (line 141-157) — just need to expose it**
913
+
914
+ In `app/indicators/lst.py`, the list `baseline_yearly_means` is already built at line 141. Store it on `self` before the return. After line 163 (`float(np.mean(current_vals)),`), add:
915
+
916
+ Before the final return block (line 163-167), add:
917
+
918
+ ```python
919
+ self._baseline_yearly_means = baseline_yearly_means
920
+ ```
921
+
922
+ - [ ] **Step 4: Update `_build_chart_data`**
923
+
924
+ Replace `_build_chart_data` (lines 229-241):
925
+
926
+ ```python
927
+ @staticmethod
928
+ def _build_chart_data(
929
+ current: float,
930
+ baseline_mean: float,
931
+ baseline_std: float,
932
+ time_range: TimeRange,
933
+ baseline_yearly_means: list[float] | None = None,
934
+ ) -> dict[str, Any]:
935
+ result: dict[str, Any] = {
936
+ "dates": ["baseline", str(time_range.end.year)],
937
+ "values": [round(baseline_mean, 1), round(current, 1)],
938
+ "baseline_std": round(baseline_std, 1),
939
+ "label": "Daily max temperature (°C)",
940
+ }
941
+ if baseline_yearly_means and len(baseline_yearly_means) >= 2:
942
+ result["baseline_range_mean"] = round(float(np.mean(baseline_yearly_means)), 1)
943
+ result["baseline_range_min"] = round(float(min(baseline_yearly_means)), 1)
944
+ result["baseline_range_max"] = round(float(max(baseline_yearly_means)), 1)
945
+ return result
946
+ ```
947
+
948
+ - [ ] **Step 5: Update the caller in `process()`**
949
+
950
+ In `app/indicators/lst.py`, line 48:
951
+
952
+ ```python
953
+ chart_data = self._build_chart_data(
954
+ current_temp, baseline_mean, baseline_std, time_range,
955
+ getattr(self, '_baseline_yearly_means', None),
956
+ )
957
+ ```
958
+
959
+ - [ ] **Step 6: Run LST tests**
960
+
961
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_lst.py -v`
962
+
963
+ Expected: PASS.
964
+
965
+ - [ ] **Step 7: Commit**
966
+
967
+ ```bash
968
+ git add app/indicators/lst.py tests/test_indicator_lst.py
969
+ git commit -m "feat: add baseline range scalars to LST chart data"
970
+ ```
971
+
972
+ ---
973
+
974
+ ### Task 9: Add Baseline Range to NO2 Indicator (Summary Mode)
975
+
976
+ **Files:**
977
+ - Modify: `app/indicators/no2.py:98-149` (`_api_query`) and `app/indicators/no2.py:175-187` (`_build_chart_data`)
978
+
979
+ - [ ] **Step 1: Write test**
980
+
981
+ ```python
982
+ # tests/test_indicator_no2.py
983
+ def test_build_chart_data_includes_baseline_range():
984
+ from app.indicators.no2 import NO2Indicator
985
+ from datetime import date
986
+ from app.models import TimeRange
987
+
988
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
989
+ result = NO2Indicator._build_chart_data(
990
+ current=16.5, baseline_mean=15.0, baseline_std=4.0, time_range=tr,
991
+ baseline_yearly_means=[12.0, 15.0, 18.0],
992
+ )
993
+ assert "baseline_range_mean" in result
994
+ assert result["baseline_range_min"] == 12.0
995
+ assert result["baseline_range_max"] == 18.0
996
+ ```
997
+
998
+ - [ ] **Step 2: Run test to verify it fails**
999
+
1000
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_no2.py::test_build_chart_data_includes_baseline_range -v`
1001
+
1002
+ Expected: FAIL.
1003
+
1004
+ - [ ] **Step 3: Store `baseline_yearly_means` on self in `_api_query`**
1005
+
1006
+ In `app/indicators/no2.py`, after line 139 (`baseline_yearly_means.append(float(np.mean(vals)))`), the list is already built. Add before the return (before line 145):
1007
+
1008
+ ```python
1009
+ self._baseline_yearly_means = baseline_yearly_means
1010
+ ```
1011
+
1012
+ - [ ] **Step 4: Update `_build_chart_data`**
1013
+
1014
+ Replace `_build_chart_data` (lines 175-187):
1015
+
1016
+ ```python
1017
+ @staticmethod
1018
+ def _build_chart_data(
1019
+ current: float,
1020
+ baseline_mean: float,
1021
+ baseline_std: float,
1022
+ time_range: TimeRange,
1023
+ baseline_yearly_means: list[float] | None = None,
1024
+ ) -> dict[str, Any]:
1025
+ result: dict[str, Any] = {
1026
+ "dates": ["baseline", str(time_range.end.year)],
1027
+ "values": [round(baseline_mean, 1), round(current, 1)],
1028
+ "baseline_std": round(baseline_std, 1),
1029
+ "label": "NO2 concentration (µg/m³)",
1030
+ }
1031
+ if baseline_yearly_means and len(baseline_yearly_means) >= 2:
1032
+ result["baseline_range_mean"] = round(float(np.mean(baseline_yearly_means)), 1)
1033
+ result["baseline_range_min"] = round(float(min(baseline_yearly_means)), 1)
1034
+ result["baseline_range_max"] = round(float(max(baseline_yearly_means)), 1)
1035
+ return result
1036
+ ```
1037
+
1038
+ - [ ] **Step 5: Update the caller in `process()`**
1039
+
1040
+ In `app/indicators/no2.py`, line 42:
1041
+
1042
+ ```python
1043
+ chart_data = self._build_chart_data(
1044
+ current_no2, baseline_mean, baseline_std, time_range,
1045
+ getattr(self, '_baseline_yearly_means', None),
1046
+ )
1047
+ ```
1048
+
1049
+ - [ ] **Step 6: Run NO2 tests**
1050
+
1051
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_no2.py -v`
1052
+
1053
+ Expected: PASS.
1054
+
1055
+ - [ ] **Step 7: Commit**
1056
+
1057
+ ```bash
1058
+ git add app/indicators/no2.py tests/test_indicator_no2.py
1059
+ git commit -m "feat: add baseline range scalars to NO2 chart data"
1060
+ ```
1061
+
1062
+ ---
1063
+
1064
+ ### Task 10: Add Baseline Range to Nightlights Indicator (Summary Mode)
1065
+
1066
+ **Files:**
1067
+ - Modify: `app/indicators/nightlights.py:92-122` (`_fetch_viirs`), `app/indicators/nightlights.py:206-215` (PC HREA baseline), `app/indicators/nightlights.py:331-340` (NASA baseline), and `app/indicators/nightlights.py:380-388` (`_build_chart_data`)
1068
+
1069
+ - [ ] **Step 1: Write test**
1070
+
1071
+ ```python
1072
+ # tests/test_indicator_nightlights.py
1073
+ def test_build_chart_data_includes_baseline_range():
1074
+ from app.indicators.nightlights import NightlightsIndicator
1075
+ from datetime import date
1076
+ from app.models import TimeRange
1077
+
1078
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
1079
+ result = NightlightsIndicator._build_chart_data(
1080
+ current=2.8, baseline=3.2, time_range=tr,
1081
+ baseline_yearly_vals=[3.0, 3.2, 3.4],
1082
+ )
1083
+ assert "baseline_range_mean" in result
1084
+ assert result["baseline_range_min"] == 3.0
1085
+ assert result["baseline_range_max"] == 3.4
1086
+ ```
1087
+
1088
+ - [ ] **Step 2: Run test to verify it fails**
1089
+
1090
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_nightlights.py::test_build_chart_data_includes_baseline_range -v`
1091
+
1092
+ Expected: FAIL.
1093
+
1094
+ - [ ] **Step 3: Store `baseline_vals` list on self in PC HREA path**
1095
+
1096
+ In `app/indicators/nightlights.py`, in `_fetch_pc_hrea` (around line 215), after `baseline_mean = float(np.mean(baseline_vals))`, add:
1097
+
1098
+ ```python
1099
+ self._baseline_yearly_vals = baseline_vals
1100
+ ```
1101
+
1102
+ - [ ] **Step 4: Store `baseline_vals` list on self in NASA path**
1103
+
1104
+ In `app/indicators/nightlights.py`, in `_fetch_nasa_blackmarble` (around line 340), after `baseline_mean = float(np.mean(baseline_vals))`, add:
1105
+
1106
+ ```python
1107
+ self._baseline_yearly_vals = baseline_vals
1108
+ ```
1109
+
1110
+ - [ ] **Step 5: Update `_build_chart_data`**
1111
+
1112
+ Replace `_build_chart_data` (lines 380-388):
1113
+
1114
+ ```python
1115
+ @staticmethod
1116
+ def _build_chart_data(
1117
+ current: float,
1118
+ baseline: float,
1119
+ time_range: TimeRange,
1120
+ baseline_yearly_vals: list[float] | None = None,
1121
+ ) -> dict[str, Any]:
1122
+ result: dict[str, Any] = {
1123
+ "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
1124
+ "values": [round(baseline, 4), round(current, 4)],
1125
+ "label": "Mean VIIRS DNB radiance (nW·cm⁻²·sr⁻¹)",
1126
+ }
1127
+ if baseline_yearly_vals and len(baseline_yearly_vals) >= 2:
1128
+ result["baseline_range_mean"] = round(float(np.mean(baseline_yearly_vals)), 4)
1129
+ result["baseline_range_min"] = round(float(min(baseline_yearly_vals)), 4)
1130
+ result["baseline_range_max"] = round(float(max(baseline_yearly_vals)), 4)
1131
+ return result
1132
+ ```
1133
+
1134
+ - [ ] **Step 6: Update the caller in `process()`**
1135
+
1136
+ In `app/indicators/nightlights.py`, line 55:
1137
+
1138
+ ```python
1139
+ chart_data = self._build_chart_data(
1140
+ current_radiance, baseline_radiance, time_range,
1141
+ getattr(self, '_baseline_yearly_vals', None),
1142
+ )
1143
+ ```
1144
+
1145
+ - [ ] **Step 7: Run nightlights tests**
1146
+
1147
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_nightlights.py -v`
1148
+
1149
+ Expected: PASS.
1150
+
1151
+ - [ ] **Step 8: Commit**
1152
+
1153
+ ```bash
1154
+ git add app/indicators/nightlights.py tests/test_indicator_nightlights.py
1155
+ git commit -m "feat: add baseline range scalars to nightlights chart data"
1156
+ ```
1157
+
1158
+ ---
1159
+
1160
+ ### Task 11: Run Full Test Suite & Final Verification
1161
+
1162
+ **Files:** None (verification only)
1163
+
1164
+ - [ ] **Step 1: Run the full test suite**
1165
+
1166
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/ -v`
1167
+
1168
+ Expected: All tests PASS.
1169
+
1170
+ - [ ] **Step 2: Verify no import errors**
1171
+
1172
+ Run: `cd /Users/kmini/Github/Aperture && python -c "from app.outputs.charts import render_timeseries_chart; from app.outputs.maps import render_indicator_map; print('OK')"`
1173
+
1174
+ Expected: `OK`
1175
+
1176
+ - [ ] **Step 3: Spot-check a chart with baseline overlay visually**
1177
+
1178
+ Run a quick script to generate a sample chart and open it:
1179
+
1180
+ ```bash
1181
+ cd /Users/kmini/Github/Aperture && python -c "
1182
+ from app.outputs.charts import render_timeseries_chart
1183
+ from app.models import StatusLevel, TrendDirection
1184
+ render_timeseries_chart(
1185
+ chart_data={
1186
+ 'dates': ['2025-01','2025-02','2025-03','2025-04','2025-05','2025-06'],
1187
+ 'values': [55, 60, 58, 65, 62, 70],
1188
+ 'baseline_mean': [48, 52, 50, 55, 53, 58],
1189
+ 'baseline_min': [40, 44, 42, 47, 45, 50],
1190
+ 'baseline_max': [56, 60, 58, 63, 61, 66],
1191
+ 'label': 'Monthly rainfall (mm)',
1192
+ },
1193
+ indicator_name='Rainfall Adequacy',
1194
+ status=StatusLevel.GREEN,
1195
+ trend=TrendDirection.STABLE,
1196
+ output_path='/tmp/test_baseline_chart.png',
1197
+ y_label='Monthly rainfall (mm)',
1198
+ )
1199
+ print('Chart saved to /tmp/test_baseline_chart.png')
1200
+ "
1201
+ ```
1202
+
1203
+ Open `/tmp/test_baseline_chart.png` and verify:
1204
+ - Gray shaded band visible behind the green line
1205
+ - Dashed gray line running through the band
1206
+ - Legend showing "Current", "Baseline mean", "Baseline range"
1207
+
1208
+ - [ ] **Step 4: Spot-check a summary baseline chart**
1209
+
1210
+ ```bash
1211
+ cd /Users/kmini/Github/Aperture && python -c "
1212
+ from app.outputs.charts import render_timeseries_chart
1213
+ from app.models import StatusLevel, TrendDirection
1214
+ render_timeseries_chart(
1215
+ chart_data={
1216
+ 'dates': ['2024', '2025'],
1217
+ 'values': [35.2, 38.1],
1218
+ 'baseline_range_mean': 34.0,
1219
+ 'baseline_range_min': 30.5,
1220
+ 'baseline_range_max': 37.5,
1221
+ 'label': 'Vegetation cover (%)',
1222
+ },
1223
+ indicator_name='Vegetation & Forest Cover',
1224
+ status=StatusLevel.GREEN,
1225
+ trend=TrendDirection.STABLE,
1226
+ output_path='/tmp/test_summary_chart.png',
1227
+ y_label='Vegetation cover (%)',
1228
+ )
1229
+ print('Chart saved to /tmp/test_summary_chart.png')
1230
+ "
1231
+ ```
1232
+
1233
+ Open `/tmp/test_summary_chart.png` and verify:
1234
+ - Horizontal gray band spanning the full chart width
1235
+ - Horizontal dashed gray line at the baseline mean
1236
+ - Green line with data points on top
1237
+
1238
+ - [ ] **Step 5: Final commit if any fixes were needed**
1239
+
1240
+ Only if test failures or visual issues were found and fixed in prior steps.