KSvend Claude Happy commited on
Commit
934a3a1
·
1 Parent(s): 7d3e78e

docs: add Phase 2 implementation plan for analysis window and monthly refactor

Browse files

11 tasks: data model season fields, frontend month picker, worker
plumbing, 6 indicator refactors to monthly chart data, fires season
filter, full verification.

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-analysis-window-monthly-refactor.md ADDED
@@ -0,0 +1,1236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Phase 2: Analysis Window & Monthly Indicator Refactor — 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:** Let users select an analysis season (start/end month with year-boundary wrap), refactor all indicators to output monthly chart data within that window, and upgrade baseline overlays to full monthly band+line.
6
+
7
+ **Architecture:** Add `season_start`/`season_end` to `JobRequest` (flows through `TimeRange`), add month picker dropdowns to the frontend's "Define Area" page, refactor each indicator's data-fetching and chart-building to produce monthly `chart_data` aligned to the user's season. The Phase 1 chart renderer already handles monthly baseline arrays — no chart renderer changes needed.
8
+
9
+ **Tech Stack:** Python 3.11 (Pydantic models, FastAPI), vanilla JS frontend, Open-Meteo API, STAC/pystac-client, matplotlib
10
+
11
+ ---
12
+
13
+ ## File Map
14
+
15
+ | File | Action | Responsibility |
16
+ |------|--------|----------------|
17
+ | `app/models.py` | Modify | Add `season_start`, `season_end` to `JobRequest` + `season_months()` helper |
18
+ | `tests/test_models.py` | Modify | Test season fields, wrap logic, defaults |
19
+ | `frontend/index.html` | Modify | Add month picker dropdowns to "Define Area" page |
20
+ | `frontend/js/app.js` | Modify | Capture season values in state, include in POST payload, show in confirm |
21
+ | `app/indicators/vegetation.py` | Modify | Output monthly chart data, accept season_months |
22
+ | `app/indicators/cropland.py` | Modify | Remove `GROWING_SEASON`, use season_months, output monthly |
23
+ | `app/indicators/water.py` | Modify | Output monthly chart data, accept season_months |
24
+ | `app/indicators/rainfall.py` | Modify | Filter to season_months, pass monthly baseline arrays |
25
+ | `app/indicators/lst.py` | Modify | Aggregate daily→monthly, output monthly chart data |
26
+ | `app/indicators/no2.py` | Modify | Aggregate hourly→monthly, output monthly chart data |
27
+ | `app/indicators/fires.py` | Modify | Filter chart output to season_months |
28
+ | `app/indicators/base.py` | Modify | Update `process()` signature to accept season_months |
29
+ | `app/worker.py` | Modify | Pass season_months from job request to indicator.process() |
30
+ | `tests/test_indicator_vegetation.py` | Modify | Test monthly chart output |
31
+ | `tests/test_indicator_cropland.py` | Modify | Test monthly chart output, no more GROWING_SEASON |
32
+ | `tests/test_indicator_water.py` | Modify | Test monthly chart output |
33
+ | `tests/test_indicator_rainfall.py` | Modify | Test season filtering |
34
+ | `tests/test_indicator_lst.py` | Modify | Test monthly chart output |
35
+ | `tests/test_indicator_no2.py` | Modify | Test monthly chart output |
36
+
37
+ ---
38
+
39
+ ### Task 1: Add Season Fields to Data Model
40
+
41
+ **Files:**
42
+ - Modify: `app/models.py:91-96` (JobRequest)
43
+ - Modify: `tests/test_models.py`
44
+
45
+ - [ ] **Step 1: Write tests for season fields**
46
+
47
+ Add to `tests/test_models.py`:
48
+
49
+ ```python
50
+ from app.models import JobRequest, AOI, TimeRange
51
+ from datetime import date
52
+
53
+
54
+ def test_job_request_season_defaults():
55
+ """Default season is full year (1-12)."""
56
+ req = JobRequest(
57
+ aoi=AOI(name="Test", bbox=[36.75, -1.35, 36.95, -1.20]),
58
+ indicator_ids=["fires"],
59
+ email="t@t.com",
60
+ )
61
+ assert req.season_start == 1
62
+ assert req.season_end == 12
63
+
64
+
65
+ def test_job_request_season_months_normal():
66
+ """Non-wrapping season: Apr-Sep."""
67
+ req = JobRequest(
68
+ aoi=AOI(name="Test", bbox=[36.75, -1.35, 36.95, -1.20]),
69
+ indicator_ids=["fires"],
70
+ email="t@t.com",
71
+ season_start=4,
72
+ season_end=9,
73
+ )
74
+ assert req.season_months() == [4, 5, 6, 7, 8, 9]
75
+
76
+
77
+ def test_job_request_season_months_wrapping():
78
+ """Wrapping season: Oct-Mar (Southern Hemisphere)."""
79
+ req = JobRequest(
80
+ aoi=AOI(name="Test", bbox=[36.75, -1.35, 36.95, -1.20]),
81
+ indicator_ids=["fires"],
82
+ email="t@t.com",
83
+ season_start=10,
84
+ season_end=3,
85
+ )
86
+ assert req.season_months() == [10, 11, 12, 1, 2, 3]
87
+
88
+
89
+ def test_job_request_season_months_full_year():
90
+ """Full year default."""
91
+ req = JobRequest(
92
+ aoi=AOI(name="Test", bbox=[36.75, -1.35, 36.95, -1.20]),
93
+ indicator_ids=["fires"],
94
+ email="t@t.com",
95
+ )
96
+ assert req.season_months() == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
97
+
98
+
99
+ def test_job_request_season_validation():
100
+ """Season months must be 1-12."""
101
+ import pytest
102
+ with pytest.raises(Exception):
103
+ JobRequest(
104
+ aoi=AOI(name="Test", bbox=[36.75, -1.35, 36.95, -1.20]),
105
+ indicator_ids=["fires"],
106
+ email="t@t.com",
107
+ season_start=0,
108
+ )
109
+ with pytest.raises(Exception):
110
+ JobRequest(
111
+ aoi=AOI(name="Test", bbox=[36.75, -1.35, 36.95, -1.20]),
112
+ indicator_ids=["fires"],
113
+ email="t@t.com",
114
+ season_end=13,
115
+ )
116
+ ```
117
+
118
+ - [ ] **Step 2: Run tests to verify they fail**
119
+
120
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_models.py -v -k season`
121
+
122
+ Expected: FAIL — `JobRequest` doesn't have `season_start`/`season_end` fields.
123
+
124
+ - [ ] **Step 3: Add season fields to JobRequest**
125
+
126
+ In `app/models.py`, modify the `JobRequest` class (lines 91-96):
127
+
128
+ ```python
129
+ class JobRequest(BaseModel):
130
+ aoi: AOI
131
+ time_range: TimeRange = Field(default_factory=TimeRange)
132
+ indicator_ids: list[str]
133
+ email: str
134
+ season_start: int = Field(default=1, ge=1, le=12)
135
+ season_end: int = Field(default=12, ge=1, le=12)
136
+
137
+ def season_months(self) -> list[int]:
138
+ """Return ordered list of month numbers in the analysis season.
139
+
140
+ Supports year-boundary wrapping: season_start=10, season_end=3
141
+ yields [10, 11, 12, 1, 2, 3].
142
+ """
143
+ if self.season_start <= self.season_end:
144
+ return list(range(self.season_start, self.season_end + 1))
145
+ else:
146
+ return list(range(self.season_start, 13)) + list(range(1, self.season_end + 1))
147
+
148
+ @field_validator("indicator_ids")
149
+ @classmethod
150
+ def require_at_least_one_indicator(cls, v: list[str]) -> list[str]:
151
+ if len(v) == 0:
152
+ raise ValueError("At least one indicator must be selected")
153
+ return v
154
+ ```
155
+
156
+ - [ ] **Step 4: Run tests to verify they pass**
157
+
158
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_models.py -v`
159
+
160
+ Expected: All PASS (both old and new tests).
161
+
162
+ - [ ] **Step 5: Commit**
163
+
164
+ ```bash
165
+ git add app/models.py tests/test_models.py
166
+ git commit -m "feat: add season_start/season_end to JobRequest with wrap support"
167
+ ```
168
+
169
+ ---
170
+
171
+ ### Task 2: Add Month Picker to Frontend
172
+
173
+ **Files:**
174
+ - Modify: `frontend/index.html:229-242` (date range section in "Define Area" sidebar)
175
+ - Modify: `frontend/js/app.js:248` (state capture), `frontend/js/app.js:307-318` (payload), `frontend/js/app.js:280-286` (confirm summary)
176
+
177
+ - [ ] **Step 1: Add month picker HTML to the "Define Area" sidebar**
178
+
179
+ In `frontend/index.html`, after the date range section (after line 242 — the closing `</div>` of the date-row form-group), add:
180
+
181
+ ```html
182
+ <!-- Analysis season -->
183
+ <div class="form-group">
184
+ <label class="label">Analysis season</label>
185
+ <p style="font-size: var(--text-xxs); color: var(--ink-muted); margin-bottom: var(--space-3);">
186
+ Select the months relevant to your analysis. For year-round monitoring, leave as January–December.
187
+ </p>
188
+ <div class="date-row">
189
+ <div>
190
+ <label class="label" for="season-start" style="font-size: var(--text-xxs);">Start month</label>
191
+ <select id="season-start" class="input">
192
+ <option value="1" selected>January</option>
193
+ <option value="2">February</option>
194
+ <option value="3">March</option>
195
+ <option value="4">April</option>
196
+ <option value="5">May</option>
197
+ <option value="6">June</option>
198
+ <option value="7">July</option>
199
+ <option value="8">August</option>
200
+ <option value="9">September</option>
201
+ <option value="10">October</option>
202
+ <option value="11">November</option>
203
+ <option value="12">December</option>
204
+ </select>
205
+ </div>
206
+ <div>
207
+ <label class="label" for="season-end" style="font-size: var(--text-xxs);">End month</label>
208
+ <select id="season-end" class="input">
209
+ <option value="1">January</option>
210
+ <option value="2">February</option>
211
+ <option value="3">March</option>
212
+ <option value="4">April</option>
213
+ <option value="5">May</option>
214
+ <option value="6">June</option>
215
+ <option value="7">July</option>
216
+ <option value="8">August</option>
217
+ <option value="9">September</option>
218
+ <option value="10">October</option>
219
+ <option value="11">November</option>
220
+ <option value="12" selected>December</option>
221
+ </select>
222
+ </div>
223
+ </div>
224
+ <p id="season-wrap-hint" style="font-size: var(--text-xxs); color: var(--iris); margin-top: var(--space-2); display: none;">
225
+ ↻ Season crosses year boundary
226
+ </p>
227
+ </div>
228
+ ```
229
+
230
+ - [ ] **Step 2: Add wrap-hint logic and capture season in state**
231
+
232
+ In `frontend/js/app.js`, inside `setupDefineArea()` function, after the date default lines (after line 239), add:
233
+
234
+ ```javascript
235
+ // Season wrap hint
236
+ const seasonStart = document.getElementById('season-start');
237
+ const seasonEnd = document.getElementById('season-end');
238
+ const wrapHint = document.getElementById('season-wrap-hint');
239
+
240
+ function updateWrapHint() {
241
+ const s = parseInt(seasonStart.value);
242
+ const e = parseInt(seasonEnd.value);
243
+ wrapHint.style.display = (s > e) ? '' : 'none';
244
+ }
245
+ seasonStart.addEventListener('change', updateWrapHint);
246
+ seasonEnd.addEventListener('change', updateWrapHint);
247
+ ```
248
+
249
+ Then in the continue button click handler (around line 248 `state.timeRange = ...`), add after that line:
250
+
251
+ ```javascript
252
+ state.seasonStart = parseInt(document.getElementById('season-start').value);
253
+ state.seasonEnd = parseInt(document.getElementById('season-end').value);
254
+ ```
255
+
256
+ - [ ] **Step 3: Add season to state object**
257
+
258
+ In `frontend/js/app.js`, update the state object (line 13-20) to include season:
259
+
260
+ ```javascript
261
+ const state = {
262
+ session: null, // { email, token }
263
+ aoi: null, // { name, bbox }
264
+ timeRange: null, // { start, end }
265
+ seasonStart: 1, // 1-12
266
+ seasonEnd: 12, // 1-12
267
+ indicators: [], // string[]
268
+ jobId: null, // string
269
+ jobData: null, // full job response
270
+ };
271
+ ```
272
+
273
+ - [ ] **Step 4: Include season in POST payload**
274
+
275
+ In `frontend/js/app.js`, update the submit payload (around line 307-318):
276
+
277
+ ```javascript
278
+ const payload = {
279
+ aoi: {
280
+ name: state.aoi.name,
281
+ bbox: state.aoi.bbox,
282
+ },
283
+ time_range: {
284
+ start: state.timeRange.start,
285
+ end: state.timeRange.end,
286
+ },
287
+ indicator_ids: state.indicators,
288
+ email,
289
+ season_start: state.seasonStart,
290
+ season_end: state.seasonEnd,
291
+ };
292
+ ```
293
+
294
+ - [ ] **Step 5: Show season in confirm summary**
295
+
296
+ In `frontend/index.html`, inside the confirm summary div (after the "Period" confirm-row, around line 345), add:
297
+
298
+ ```html
299
+ <div class="confirm-row">
300
+ <span class="confirm-row-label">Season</span>
301
+ <span id="confirm-season" class="confirm-row-value">January – December</span>
302
+ </div>
303
+ ```
304
+
305
+ In `frontend/js/app.js`, inside `setupConfirm()` (around line 286), add:
306
+
307
+ ```javascript
308
+ const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
309
+ const seasonText = `${monthNames[state.seasonStart - 1]} – ${monthNames[state.seasonEnd - 1]}`;
310
+ document.getElementById('confirm-season').textContent = seasonText;
311
+ ```
312
+
313
+ - [ ] **Step 6: Commit**
314
+
315
+ ```bash
316
+ git add frontend/index.html frontend/js/app.js
317
+ git commit -m "feat: add analysis season month picker to frontend"
318
+ ```
319
+
320
+ ---
321
+
322
+ ### Task 3: Update Worker to Pass Season Months to Indicators
323
+
324
+ **Files:**
325
+ - Modify: `app/indicators/base.py:42-44` (process signature)
326
+ - Modify: `app/worker.py:58` (process call)
327
+ - Modify: `tests/test_worker.py`
328
+
329
+ - [ ] **Step 1: Write test for worker passing season_months**
330
+
331
+ Add to `tests/test_worker.py`:
332
+
333
+ ```python
334
+ @pytest.mark.asyncio
335
+ async def test_process_job_passes_season_months(temp_db_path):
336
+ """Worker should pass season_months from job request to indicator.process()."""
337
+ from unittest.mock import AsyncMock, MagicMock, patch
338
+ from app.database import Database
339
+ from app.indicators.base import IndicatorRegistry
340
+ from app.models import JobRequest, AOI, TimeRange, IndicatorResult, StatusLevel, TrendDirection, ConfidenceLevel
341
+ from app.worker import process_job
342
+ from datetime import date
343
+
344
+ db = Database(temp_db_path)
345
+ await db.init()
346
+
347
+ req = JobRequest(
348
+ aoi=AOI(name="Test", bbox=[36.75, -1.35, 36.95, -1.20]),
349
+ time_range=TimeRange(start=date(2025, 3, 1), end=date(2026, 3, 1)),
350
+ indicator_ids=["fires"],
351
+ email="test@example.com",
352
+ season_start=4,
353
+ season_end=9,
354
+ )
355
+ job_id = await db.create_job(req)
356
+
357
+ mock_indicator = MagicMock()
358
+ mock_result = IndicatorResult(
359
+ indicator_id="fires",
360
+ headline="test",
361
+ status=StatusLevel.GREEN,
362
+ trend=TrendDirection.STABLE,
363
+ confidence=ConfidenceLevel.MODERATE,
364
+ map_layer_path="",
365
+ chart_data={"dates": [], "values": []},
366
+ summary="test",
367
+ methodology="test",
368
+ limitations=[],
369
+ )
370
+ mock_indicator.process = AsyncMock(return_value=mock_result)
371
+ mock_indicator.get_spatial_data = MagicMock(return_value=None)
372
+
373
+ mock_registry = MagicMock(spec=IndicatorRegistry)
374
+ mock_registry.get.return_value = mock_indicator
375
+
376
+ with patch("app.worker.render_timeseries_chart"), \
377
+ patch("app.worker.render_status_map"), \
378
+ patch("app.worker.generate_pdf_report"), \
379
+ patch("app.worker.create_data_package"), \
380
+ patch("app.worker.send_completion_email", new_callable=AsyncMock):
381
+ await process_job(job_id, db, mock_registry)
382
+
383
+ # Verify season_months was passed
384
+ mock_indicator.process.assert_called_once()
385
+ call_kwargs = mock_indicator.process.call_args
386
+ assert call_kwargs[1].get("season_months") == [4, 5, 6, 7, 8, 9] or \
387
+ (len(call_kwargs[0]) >= 3 and call_kwargs[0][2] == [4, 5, 6, 7, 8, 9])
388
+ ```
389
+
390
+ - [ ] **Step 2: Run test to verify it fails**
391
+
392
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_worker.py::test_process_job_passes_season_months -v`
393
+
394
+ Expected: FAIL.
395
+
396
+ - [ ] **Step 3: Update BaseIndicator.process() signature**
397
+
398
+ In `app/indicators/base.py`, change the abstract method (line 42-44):
399
+
400
+ ```python
401
+ @abc.abstractmethod
402
+ async def process(self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None) -> IndicatorResult:
403
+ ...
404
+ ```
405
+
406
+ - [ ] **Step 4: Update worker to pass season_months**
407
+
408
+ In `app/worker.py`, update line 58:
409
+
410
+ ```python
411
+ result = await indicator.process(
412
+ job.request.aoi,
413
+ job.request.time_range,
414
+ season_months=job.request.season_months(),
415
+ )
416
+ ```
417
+
418
+ - [ ] **Step 5: Update all indicator process() signatures to accept season_months**
419
+
420
+ In each indicator file, update the `process` method signature to accept the new parameter. This is a mechanical change — add `season_months: list[int] | None = None` to each `async def process(self, aoi, time_range, ...)`:
421
+
422
+ - `app/indicators/vegetation.py`: `async def process(self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None)`
423
+ - `app/indicators/cropland.py`: same
424
+ - `app/indicators/water.py`: same
425
+ - `app/indicators/rainfall.py`: same
426
+ - `app/indicators/lst.py`: same
427
+ - `app/indicators/no2.py`: same
428
+ - `app/indicators/nightlights.py`: same
429
+ - `app/indicators/fires.py`: same
430
+ - `app/indicators/food_security.py`: same
431
+
432
+ For now, all indicators just accept the parameter and ignore it. Later tasks will make them use it.
433
+
434
+ - [ ] **Step 6: Run tests**
435
+
436
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_worker.py -v`
437
+
438
+ Expected: All PASS.
439
+
440
+ - [ ] **Step 7: Run full test suite to verify no regressions**
441
+
442
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/ -v`
443
+
444
+ Expected: All 109+ tests PASS.
445
+
446
+ - [ ] **Step 8: Commit**
447
+
448
+ ```bash
449
+ git add app/indicators/base.py app/worker.py app/indicators/*.py tests/test_worker.py
450
+ git commit -m "feat: pass season_months from job request to indicator.process()"
451
+ ```
452
+
453
+ ---
454
+
455
+ ### Task 4: Refactor Vegetation to Monthly Chart Data
456
+
457
+ **Files:**
458
+ - Modify: `app/indicators/vegetation.py`
459
+ - Modify: `tests/test_indicator_vegetation.py`
460
+
461
+ - [ ] **Step 1: Write test for monthly chart output**
462
+
463
+ Add to `tests/test_indicator_vegetation.py`:
464
+
465
+ ```python
466
+ def test_build_monthly_chart_data():
467
+ """When monthly data is available, chart_data should have per-month arrays."""
468
+ from app.indicators.vegetation import VegetationIndicator
469
+ from datetime import date
470
+ from app.models import TimeRange
471
+
472
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
473
+ current_monthly = {1: 30.0, 2: 32.0, 3: 35.0, 4: 38.0, 5: 40.0, 6: 42.0}
474
+ baseline_pool = {
475
+ 1: {"medians": [28.0, 30.0, 32.0], "min": 28.0, "max": 32.0, "mean": 30.0},
476
+ 2: {"medians": [30.0, 32.0, 34.0], "min": 30.0, "max": 34.0, "mean": 32.0},
477
+ 3: {"medians": [33.0, 35.0, 37.0], "min": 33.0, "max": 37.0, "mean": 35.0},
478
+ 4: {"medians": [36.0, 38.0, 40.0], "min": 36.0, "max": 40.0, "mean": 38.0},
479
+ 5: {"medians": [38.0, 40.0, 42.0], "min": 38.0, "max": 42.0, "mean": 40.0},
480
+ 6: {"medians": [40.0, 42.0, 44.0], "min": 40.0, "max": 44.0, "mean": 42.0},
481
+ }
482
+ season_months = [1, 2, 3, 4, 5, 6]
483
+ result = VegetationIndicator._build_monthly_chart_data(
484
+ current_monthly=current_monthly,
485
+ baseline_stats=baseline_pool,
486
+ time_range=tr,
487
+ season_months=season_months,
488
+ )
489
+ assert len(result["dates"]) == 6
490
+ assert all(d.startswith("2025-") for d in result["dates"])
491
+ assert len(result["values"]) == 6
492
+ assert "baseline_mean" in result
493
+ assert "baseline_min" in result
494
+ assert "baseline_max" in result
495
+ assert len(result["baseline_mean"]) == 6
496
+ for i in range(6):
497
+ assert result["baseline_min"][i] <= result["baseline_mean"][i] <= result["baseline_max"][i]
498
+ ```
499
+
500
+ - [ ] **Step 2: Run test to verify it fails**
501
+
502
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_vegetation.py::test_build_monthly_chart_data -v`
503
+
504
+ Expected: FAIL — `_build_monthly_chart_data` doesn't exist yet.
505
+
506
+ - [ ] **Step 3: Add `_build_monthly_chart_data` and refactor `_stac_comparison` to expose monthly data**
507
+
508
+ In `app/indicators/vegetation.py`, the `_stac_comparison` method currently computes monthly medians but collapses them. Refactor to preserve per-month data for the chart.
509
+
510
+ Change `_stac_comparison` to also store `self._current_monthly` and `self._baseline_stats` before collapsing:
511
+
512
+ After building `baseline_pool` and computing `baseline_medians`/`current_medians` (lines 202-221), add before the return:
513
+
514
+ ```python
515
+ # Store monthly data for chart rendering
516
+ self._current_monthly = {}
517
+ for month in range(1, 13):
518
+ c_vals = current_monthly.get(month, [])
519
+ if c_vals:
520
+ self._current_monthly[month] = float(np.median(c_vals))
521
+
522
+ self._baseline_stats = {}
523
+ for month in range(1, 13):
524
+ b_vals = baseline_pool.get(month, [])
525
+ if b_vals:
526
+ # Group by year to get per-year medians, then compute stats
527
+ self._baseline_stats[month] = {
528
+ "mean": float(np.mean([np.median(b_vals)])),
529
+ "min": float(np.min(b_vals)),
530
+ "max": float(np.max(b_vals)),
531
+ }
532
+ ```
533
+
534
+ Wait — `baseline_pool[month]` is a flat list of all scene values across years, not grouped by year. We need per-year stats. Refactor to track per-year medians:
535
+
536
+ Replace the baseline loop (lines 202-212) with:
537
+
538
+ ```python
539
+ baseline_pool: dict[int, list[float]] = defaultdict(list)
540
+ baseline_yearly_means: list[float] = []
541
+ baseline_per_year_monthly: dict[int, list[float]] = defaultdict(list) # month -> [yr1_median, yr2_median, ...]
542
+ for yr in range(baseline_start_year, current_year):
543
+ yr_monthly = await loop.run_in_executor(None, _query_monthly, yr)
544
+ yr_medians = []
545
+ for month, vals in yr_monthly.items():
546
+ baseline_pool[month].extend(vals)
547
+ if vals:
548
+ med = float(np.median(vals))
549
+ yr_medians.append(med)
550
+ baseline_per_year_monthly[month].append(med)
551
+ if yr_medians:
552
+ baseline_yearly_means.append(float(np.mean(yr_medians)))
553
+ ```
554
+
555
+ Then store monthly chart data before the return:
556
+
557
+ ```python
558
+ # Store monthly data for chart building
559
+ self._current_monthly_medians = {}
560
+ for month in range(1, 13):
561
+ c_vals = current_monthly.get(month, [])
562
+ if c_vals:
563
+ self._current_monthly_medians[month] = float(np.median(c_vals))
564
+
565
+ self._baseline_per_year_monthly = dict(baseline_per_year_monthly)
566
+ ```
567
+
568
+ Add the new static method:
569
+
570
+ ```python
571
+ @staticmethod
572
+ def _build_monthly_chart_data(
573
+ current_monthly: dict[int, float],
574
+ baseline_stats: dict[int, list[float]],
575
+ time_range: TimeRange,
576
+ season_months: list[int],
577
+ ) -> dict[str, Any]:
578
+ """Build monthly chart data with baseline arrays for the given season."""
579
+ year = time_range.end.year
580
+ dates = []
581
+ values = []
582
+ b_mean = []
583
+ b_min = []
584
+ b_max = []
585
+ for m in season_months:
586
+ dates.append(f"{year}-{m:02d}")
587
+ values.append(round(current_monthly.get(m, 0.0), 1))
588
+ yr_medians = baseline_stats.get(m, [])
589
+ if yr_medians:
590
+ b_mean.append(round(float(np.mean(yr_medians)), 1))
591
+ b_min.append(round(float(min(yr_medians)), 1))
592
+ b_max.append(round(float(max(yr_medians)), 1))
593
+ else:
594
+ b_mean.append(0.0)
595
+ b_min.append(0.0)
596
+ b_max.append(0.0)
597
+ result: dict[str, Any] = {
598
+ "dates": dates,
599
+ "values": values,
600
+ "label": "Vegetation cover (%)",
601
+ }
602
+ if any(v > 0 for v in b_mean):
603
+ result["baseline_mean"] = b_mean
604
+ result["baseline_min"] = b_min
605
+ result["baseline_max"] = b_max
606
+ return result
607
+ ```
608
+
609
+ - [ ] **Step 4: Update process() to use monthly chart data when season_months is provided**
610
+
611
+ In `app/indicators/vegetation.py`, update the `process()` method. After `_fetch_comparison` returns (line 31-43), change the chart_data building:
612
+
613
+ ```python
614
+ # Build chart data — monthly if season provided, 2-point fallback otherwise
615
+ if season_months and hasattr(self, '_current_monthly_medians') and hasattr(self, '_baseline_per_year_monthly'):
616
+ chart_data = self._build_monthly_chart_data(
617
+ self._current_monthly_medians,
618
+ self._baseline_per_year_monthly,
619
+ time_range,
620
+ season_months,
621
+ )
622
+ else:
623
+ chart_data = self._build_chart_data(
624
+ baseline_mean, current_mean, time_range,
625
+ getattr(self, '_baseline_yearly_means', None),
626
+ )
627
+ ```
628
+
629
+ - [ ] **Step 5: Run vegetation tests**
630
+
631
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_vegetation.py -v`
632
+
633
+ Expected: All PASS.
634
+
635
+ - [ ] **Step 6: Commit**
636
+
637
+ ```bash
638
+ git add app/indicators/vegetation.py tests/test_indicator_vegetation.py
639
+ git commit -m "feat: vegetation outputs monthly chart data with baseline arrays"
640
+ ```
641
+
642
+ ---
643
+
644
+ ### Task 5: Refactor Cropland to Monthly Chart Data (Remove GROWING_SEASON)
645
+
646
+ **Files:**
647
+ - Modify: `app/indicators/cropland.py`
648
+ - Modify: `tests/test_indicator_cropland.py`
649
+
650
+ - [ ] **Step 1: Write test for monthly chart output**
651
+
652
+ Add to `tests/test_indicator_cropland.py`:
653
+
654
+ ```python
655
+ def test_build_monthly_chart_data():
656
+ """Cropland monthly chart data should respect season_months."""
657
+ from app.indicators.cropland import CroplandIndicator
658
+ from datetime import date
659
+ from app.models import TimeRange
660
+
661
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
662
+ current_monthly = {4: 35.0, 5: 40.0, 6: 42.0, 7: 41.0, 8: 38.0, 9: 34.0}
663
+ baseline_stats = {
664
+ 4: [33.0, 35.0, 37.0],
665
+ 5: [38.0, 40.0, 42.0],
666
+ 6: [40.0, 42.0, 44.0],
667
+ 7: [39.0, 41.0, 43.0],
668
+ 8: [36.0, 38.0, 40.0],
669
+ 9: [32.0, 34.0, 36.0],
670
+ }
671
+ season_months = [4, 5, 6, 7, 8, 9]
672
+ result = CroplandIndicator._build_monthly_chart_data(
673
+ current_monthly=current_monthly,
674
+ baseline_stats=baseline_stats,
675
+ time_range=tr,
676
+ season_months=season_months,
677
+ )
678
+ assert len(result["dates"]) == 6
679
+ assert result["dates"][0] == "2025-04"
680
+ assert result["dates"][-1] == "2025-09"
681
+ assert "baseline_mean" in result
682
+ assert len(result["baseline_mean"]) == 6
683
+ ```
684
+
685
+ - [ ] **Step 2: Run test to verify it fails**
686
+
687
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_cropland.py::test_build_monthly_chart_data -v`
688
+
689
+ Expected: FAIL.
690
+
691
+ - [ ] **Step 3: Refactor cropland — same pattern as vegetation**
692
+
693
+ In `app/indicators/cropland.py`:
694
+
695
+ 1. Remove the `GROWING_SEASON` constant (line 22). The season filtering is now done by `season_months` passed in.
696
+
697
+ 2. Update `_stac_comparison` — currently uses `_query_growing_season` which hardcodes Apr-Sep. Change to `_query_monthly` that accepts season_months:
698
+
699
+ Replace `_query_growing_season` (lines 195-213) with a general `_query_months`:
700
+
701
+ ```python
702
+ def _query_months(year: int, months: list[int]) -> dict[int, list[float]]:
703
+ """Return {month: [vegetation_pct, ...]} for specified months only."""
704
+ min_month = min(months)
705
+ max_month = max(months)
706
+ start = date(year, min_month, 1)
707
+ # Handle last day of month
708
+ if max_month == 12:
709
+ end = date(year, 12, 31)
710
+ else:
711
+ end = date(year, max_month + 1, 1) - timedelta(days=1)
712
+ items = catalog.search(
713
+ collections=["sentinel-2-l2a"],
714
+ bbox=aoi.bbox,
715
+ datetime=f"{start.isoformat()}/{end.isoformat()}",
716
+ query={"eo:cloud_cover": {"lt": 30}},
717
+ max_items=MAX_ITEMS,
718
+ ).item_collection()
719
+ by_month: dict[int, list[float]] = defaultdict(list)
720
+ for item in items:
721
+ veg = item.properties.get("s2:vegetation_percentage")
722
+ if veg is not None and item.datetime:
723
+ month = item.datetime.month
724
+ if month in months:
725
+ by_month[month].append(float(veg))
726
+ return dict(by_month)
727
+ ```
728
+
729
+ Add `from datetime import date, timedelta` at the top if not already imported.
730
+
731
+ 3. Update `_stac_comparison` to accept and use `season_months`:
732
+
733
+ ```python
734
+ async def _stac_comparison(
735
+ self, aoi: AOI, time_range: TimeRange, season_months: list[int] | None = None
736
+ ) -> tuple[float, float, int]:
737
+ ```
738
+
739
+ Use `season_months or list(range(1, 13))` as the months to query. Track per-year monthly medians same as vegetation.
740
+
741
+ 4. Add `_build_monthly_chart_data` — identical logic to vegetation but with label `"Vegetation cover (%)"` (cropland uses same unit).
742
+
743
+ 5. Update `process()` to pass `season_months` through to `_stac_comparison` and use `_build_monthly_chart_data`.
744
+
745
+ 6. Also update `_fetch_tile_footprints` to respect season_months (query only the selected months for the current year).
746
+
747
+ - [ ] **Step 4: 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 5: Commit**
754
+
755
+ ```bash
756
+ git add app/indicators/cropland.py tests/test_indicator_cropland.py
757
+ git commit -m "feat: cropland uses season_months instead of hardcoded GROWING_SEASON, outputs monthly chart data"
758
+ ```
759
+
760
+ ---
761
+
762
+ ### Task 6: Refactor Water to Monthly Chart Data
763
+
764
+ **Files:**
765
+ - Modify: `app/indicators/water.py`
766
+ - Modify: `tests/test_indicator_water.py`
767
+
768
+ Same pattern as vegetation. The `_stac_comparison` method is nearly identical to vegetation's.
769
+
770
+ - [ ] **Step 1: Write test for monthly chart output**
771
+
772
+ Add to `tests/test_indicator_water.py`:
773
+
774
+ ```python
775
+ def test_build_monthly_chart_data():
776
+ from app.indicators.water import WaterIndicator
777
+ from datetime import date
778
+ from app.models import TimeRange
779
+
780
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
781
+ current_monthly = {1: 5.0, 2: 5.2, 3: 4.8}
782
+ baseline_stats = {1: [4.5, 5.0, 5.5], 2: [4.8, 5.2, 5.6], 3: [4.3, 4.8, 5.3]}
783
+ season_months = [1, 2, 3]
784
+ result = WaterIndicator._build_monthly_chart_data(
785
+ current_monthly=current_monthly,
786
+ baseline_stats=baseline_stats,
787
+ time_range=tr,
788
+ season_months=season_months,
789
+ )
790
+ assert len(result["dates"]) == 3
791
+ assert "baseline_mean" in result
792
+ assert len(result["baseline_mean"]) == 3
793
+ ```
794
+
795
+ - [ ] **Step 2: Implement — same pattern as vegetation Task 4**
796
+
797
+ Refactor `_stac_comparison` to track `_baseline_per_year_monthly` and `_current_monthly_medians`. Add `_build_monthly_chart_data`. Update `process()` to use monthly chart data when `season_months` is provided. Water uses 2 decimal places for rounding.
798
+
799
+ - [ ] **Step 3: Run water tests**
800
+
801
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_water.py -v`
802
+
803
+ Expected: All PASS.
804
+
805
+ - [ ] **Step 4: Commit**
806
+
807
+ ```bash
808
+ git add app/indicators/water.py tests/test_indicator_water.py
809
+ git commit -m "feat: water outputs monthly chart data with baseline arrays"
810
+ ```
811
+
812
+ ---
813
+
814
+ ### Task 7: Refactor Rainfall to Filter by Season Months
815
+
816
+ **Files:**
817
+ - Modify: `app/indicators/rainfall.py`
818
+ - Modify: `tests/test_indicator_rainfall.py`
819
+
820
+ Rainfall already outputs monthly chart data. The change is to filter to `season_months` only.
821
+
822
+ - [ ] **Step 1: Write test for season filtering**
823
+
824
+ Add to `tests/test_indicator_rainfall.py`:
825
+
826
+ ```python
827
+ def test_build_chart_data_filters_by_season():
828
+ """Chart data should only include months in the season."""
829
+ from app.indicators.rainfall import RainfallIndicator
830
+
831
+ current = {f"2025-{m:02d}": float(m * 10) for m in range(1, 13)}
832
+ baseline = {f"2025-{m:02d}": float(m * 9) for m in range(1, 13)}
833
+ baseline_per_year = {f"{m:02d}": [float(m * 8), float(m * 9), float(m * 10)] for m in range(1, 13)}
834
+ season_months = [4, 5, 6, 7, 8, 9]
835
+ result = RainfallIndicator._build_chart_data(current, baseline, baseline_per_year, season_months)
836
+ assert len(result["dates"]) == 6
837
+ assert result["dates"][0] == "2025-04"
838
+ assert result["dates"][-1] == "2025-09"
839
+ assert len(result["baseline_mean"]) == 6
840
+ ```
841
+
842
+ - [ ] **Step 2: Run test to verify it fails**
843
+
844
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_rainfall.py::test_build_chart_data_filters_by_season -v`
845
+
846
+ Expected: FAIL — `_build_chart_data` doesn't accept `season_months`.
847
+
848
+ - [ ] **Step 3: Update `_build_chart_data` to accept and filter by season_months**
849
+
850
+ In `app/indicators/rainfall.py`, update `_build_chart_data`:
851
+
852
+ ```python
853
+ @staticmethod
854
+ def _build_chart_data(
855
+ current: dict[str, float],
856
+ baseline: dict[str, float],
857
+ baseline_per_year: dict[str, list[float]] | None = None,
858
+ season_months: list[int] | None = None,
859
+ ) -> dict[str, Any]:
860
+ all_keys = sorted(set(list(current.keys()) + list(baseline.keys())))
861
+ # Filter to season months if provided
862
+ if season_months:
863
+ all_keys = [k for k in all_keys if int(k.split("-")[1]) in season_months]
864
+ result: dict[str, Any] = {
865
+ "dates": all_keys,
866
+ "values": [current.get(k, baseline.get(k, 0.0)) for k in all_keys],
867
+ "baseline_values": [baseline.get(k, 0.0) for k in all_keys],
868
+ "label": "Monthly rainfall (mm)",
869
+ }
870
+ if baseline_per_year:
871
+ b_mean: list[float] = []
872
+ b_min: list[float] = []
873
+ b_max: list[float] = []
874
+ for k in all_keys:
875
+ month_num = k.split("-")[1]
876
+ year_vals = baseline_per_year.get(month_num, [])
877
+ if year_vals:
878
+ b_mean.append(float(np.mean(year_vals)))
879
+ b_min.append(float(min(year_vals)))
880
+ b_max.append(float(max(year_vals)))
881
+ else:
882
+ fallback = baseline.get(k, 0.0)
883
+ b_mean.append(fallback)
884
+ b_min.append(fallback)
885
+ b_max.append(fallback)
886
+ result["baseline_mean"] = b_mean
887
+ result["baseline_min"] = b_min
888
+ result["baseline_max"] = b_max
889
+ return result
890
+ ```
891
+
892
+ - [ ] **Step 4: Update caller in process() to pass season_months**
893
+
894
+ ```python
895
+ chart_data = self._build_chart_data(
896
+ current_monthly, baseline_monthly,
897
+ getattr(self, '_baseline_per_year', None),
898
+ season_months,
899
+ )
900
+ ```
901
+
902
+ - [ ] **Step 5: Run rainfall tests**
903
+
904
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_rainfall.py -v`
905
+
906
+ Expected: All PASS.
907
+
908
+ - [ ] **Step 6: Commit**
909
+
910
+ ```bash
911
+ git add app/indicators/rainfall.py tests/test_indicator_rainfall.py
912
+ git commit -m "feat: rainfall filters chart data to season_months"
913
+ ```
914
+
915
+ ---
916
+
917
+ ### Task 8: Refactor LST to Monthly Chart Data
918
+
919
+ **Files:**
920
+ - Modify: `app/indicators/lst.py`
921
+ - Modify: `tests/test_indicator_lst.py`
922
+
923
+ LST currently queries daily data from Open-Meteo but collapses to a single annual mean. Refactor to aggregate daily→monthly and output per-month chart data.
924
+
925
+ - [ ] **Step 1: Write test for monthly chart output**
926
+
927
+ Add to `tests/test_indicator_lst.py`:
928
+
929
+ ```python
930
+ def test_build_monthly_chart_data():
931
+ from app.indicators.lst import LSTIndicator
932
+ from datetime import date
933
+ from app.models import TimeRange
934
+ import numpy as np
935
+
936
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
937
+ current_monthly = {1: 28.0, 2: 29.0, 3: 31.0, 4: 33.0, 5: 35.0, 6: 36.0}
938
+ baseline_per_year_monthly = {
939
+ 1: [26.0, 27.0, 28.0],
940
+ 2: [27.0, 28.0, 29.0],
941
+ 3: [29.0, 30.0, 31.0],
942
+ 4: [31.0, 32.0, 33.0],
943
+ 5: [33.0, 34.0, 35.0],
944
+ 6: [34.0, 35.0, 36.0],
945
+ }
946
+ season_months = [1, 2, 3, 4, 5, 6]
947
+ result = LSTIndicator._build_monthly_chart_data(
948
+ current_monthly=current_monthly,
949
+ baseline_per_year_monthly=baseline_per_year_monthly,
950
+ time_range=tr,
951
+ season_months=season_months,
952
+ )
953
+ assert len(result["dates"]) == 6
954
+ assert "baseline_mean" in result
955
+ assert len(result["baseline_mean"]) == 6
956
+ assert result["label"] == "Daily max temperature (°C)"
957
+ ```
958
+
959
+ - [ ] **Step 2: Run test to verify it fails**
960
+
961
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_lst.py::test_build_monthly_chart_data -v`
962
+
963
+ Expected: FAIL.
964
+
965
+ - [ ] **Step 3: Refactor `_api_query` to aggregate daily→monthly**
966
+
967
+ In `app/indicators/lst.py`, the current `_api_query` fetches daily data and computes a single annual mean. Refactor to also compute monthly means.
968
+
969
+ After fetching `current_vals` (line 135-138), aggregate into monthly means:
970
+
971
+ ```python
972
+ # Aggregate daily to monthly means for current period
973
+ current_monthly: dict[int, list[float]] = defaultdict(list)
974
+ for t, v in zip(
975
+ current_resp.json()["daily"]["time"],
976
+ current_resp.json()["daily"]["temperature_2m_max"],
977
+ ):
978
+ if v is not None:
979
+ month = int(t[5:7])
980
+ current_monthly[month].append(v)
981
+ self._current_monthly_means = {
982
+ m: float(np.mean(vals)) for m, vals in current_monthly.items()
983
+ }
984
+ ```
985
+
986
+ For baseline years, track per-year monthly means:
987
+
988
+ ```python
989
+ baseline_yearly_means: list[float] = []
990
+ baseline_per_year_monthly: dict[int, list[float]] = defaultdict(list)
991
+ for yr in range(baseline_start, current_year):
992
+ resp = await client.get(ARCHIVE_API, params={...})
993
+ resp.raise_for_status()
994
+ data = resp.json()["daily"]
995
+ yr_monthly: dict[int, list[float]] = defaultdict(list)
996
+ for t, v in zip(data["time"], data["temperature_2m_max"]):
997
+ if v is not None:
998
+ yr_monthly[int(t[5:7])].append(v)
999
+ yr_means = []
1000
+ for m, vals in yr_monthly.items():
1001
+ monthly_mean = float(np.mean(vals))
1002
+ yr_means.append(monthly_mean)
1003
+ baseline_per_year_monthly[m].append(monthly_mean)
1004
+ if yr_means:
1005
+ baseline_yearly_means.append(float(np.mean(yr_means)))
1006
+
1007
+ self._baseline_per_year_monthly = dict(baseline_per_year_monthly)
1008
+ ```
1009
+
1010
+ Add `_build_monthly_chart_data`:
1011
+
1012
+ ```python
1013
+ @staticmethod
1014
+ def _build_monthly_chart_data(
1015
+ current_monthly: dict[int, float],
1016
+ baseline_per_year_monthly: dict[int, list[float]],
1017
+ time_range: TimeRange,
1018
+ season_months: list[int],
1019
+ ) -> dict[str, Any]:
1020
+ year = time_range.end.year
1021
+ dates, values, b_mean, b_min, b_max = [], [], [], [], []
1022
+ for m in season_months:
1023
+ dates.append(f"{year}-{m:02d}")
1024
+ values.append(round(current_monthly.get(m, 0.0), 1))
1025
+ yr_means = baseline_per_year_monthly.get(m, [])
1026
+ if yr_means:
1027
+ b_mean.append(round(float(np.mean(yr_means)), 1))
1028
+ b_min.append(round(float(min(yr_means)), 1))
1029
+ b_max.append(round(float(max(yr_means)), 1))
1030
+ else:
1031
+ b_mean.append(0.0)
1032
+ b_min.append(0.0)
1033
+ b_max.append(0.0)
1034
+ result: dict[str, Any] = {
1035
+ "dates": dates,
1036
+ "values": values,
1037
+ "label": "Daily max temperature (°C)",
1038
+ }
1039
+ if any(v > 0 for v in b_mean):
1040
+ result["baseline_mean"] = b_mean
1041
+ result["baseline_min"] = b_min
1042
+ result["baseline_max"] = b_max
1043
+ return result
1044
+ ```
1045
+
1046
+ Update `process()` to use monthly chart data when season_months is provided.
1047
+
1048
+ - [ ] **Step 4: Run LST tests**
1049
+
1050
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_lst.py -v`
1051
+
1052
+ Expected: All PASS.
1053
+
1054
+ - [ ] **Step 5: Commit**
1055
+
1056
+ ```bash
1057
+ git add app/indicators/lst.py tests/test_indicator_lst.py
1058
+ git commit -m "feat: LST aggregates daily to monthly, outputs monthly chart data"
1059
+ ```
1060
+
1061
+ ---
1062
+
1063
+ ### Task 9: Refactor NO2 to Monthly Chart Data
1064
+
1065
+ **Files:**
1066
+ - Modify: `app/indicators/no2.py`
1067
+ - Modify: `tests/test_indicator_no2.py`
1068
+
1069
+ Same pattern as LST — NO2 fetches hourly data from Open-Meteo, needs to aggregate to monthly.
1070
+
1071
+ - [ ] **Step 1: Write test for monthly chart output**
1072
+
1073
+ Add to `tests/test_indicator_no2.py`:
1074
+
1075
+ ```python
1076
+ def test_build_monthly_chart_data():
1077
+ from app.indicators.no2 import NO2Indicator
1078
+ from datetime import date
1079
+ from app.models import TimeRange
1080
+
1081
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
1082
+ current_monthly = {1: 12.0, 2: 13.0, 3: 14.0}
1083
+ baseline_per_year_monthly = {1: [11.0, 12.0, 13.0], 2: [12.0, 13.0, 14.0], 3: [13.0, 14.0, 15.0]}
1084
+ season_months = [1, 2, 3]
1085
+ result = NO2Indicator._build_monthly_chart_data(
1086
+ current_monthly=current_monthly,
1087
+ baseline_per_year_monthly=baseline_per_year_monthly,
1088
+ time_range=tr,
1089
+ season_months=season_months,
1090
+ )
1091
+ assert len(result["dates"]) == 3
1092
+ assert "baseline_mean" in result
1093
+ assert result["label"] == "NO2 concentration (µg/m³)"
1094
+ ```
1095
+
1096
+ - [ ] **Step 2: Implement — same pattern as LST Task 8**
1097
+
1098
+ Refactor `_api_query` to aggregate hourly→daily→monthly. Track per-year monthly means in `_baseline_per_year_monthly`. Add `_build_monthly_chart_data`. Update `process()`.
1099
+
1100
+ Note: NO2 uses hourly data (`nitrogen_dioxide`). The timestamps are like `"2025-01-01T00:00"`. Parse month from `t[5:7]`.
1101
+
1102
+ - [ ] **Step 3: Run NO2 tests**
1103
+
1104
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_no2.py -v`
1105
+
1106
+ Expected: All PASS.
1107
+
1108
+ - [ ] **Step 4: Commit**
1109
+
1110
+ ```bash
1111
+ git add app/indicators/no2.py tests/test_indicator_no2.py
1112
+ git commit -m "feat: NO2 aggregates hourly to monthly, outputs monthly chart data"
1113
+ ```
1114
+
1115
+ ---
1116
+
1117
+ ### Task 10: Filter Fires Chart Data by Season Months
1118
+
1119
+ **Files:**
1120
+ - Modify: `app/indicators/fires.py`
1121
+ - Modify: `tests/test_indicator_fires.py`
1122
+
1123
+ Fires already outputs monthly chart data. Just filter to season_months.
1124
+
1125
+ - [ ] **Step 1: Write test for season filtering**
1126
+
1127
+ Add to `tests/test_indicator_fires.py`:
1128
+
1129
+ ```python
1130
+ def test_build_chart_data_filters_by_season():
1131
+ from app.indicators.fires import FiresIndicator
1132
+
1133
+ rows = [
1134
+ {"acq_date": f"2025-{m:02d}-15"} for m in range(1, 13)
1135
+ for _ in range(3) # 3 fires per month
1136
+ ]
1137
+ season_months = [6, 7, 8, 9]
1138
+ result = FiresIndicator._build_chart_data(rows, season_months)
1139
+ assert len(result["dates"]) == 4
1140
+ assert result["dates"][0] == "2025-06"
1141
+ assert result["dates"][-1] == "2025-09"
1142
+ ```
1143
+
1144
+ - [ ] **Step 2: Run test to verify it fails**
1145
+
1146
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_fires.py::test_build_chart_data_filters_by_season -v`
1147
+
1148
+ Expected: FAIL.
1149
+
1150
+ - [ ] **Step 3: Update `_build_chart_data` to accept and filter by season_months**
1151
+
1152
+ In `app/indicators/fires.py`, update `_build_chart_data` (lines 189-203):
1153
+
1154
+ ```python
1155
+ @staticmethod
1156
+ def _build_chart_data(rows: list[dict], season_months: list[int] | None = None) -> dict[str, Any]:
1157
+ monthly: dict[str, int] = defaultdict(int)
1158
+ for row in rows:
1159
+ acq = row.get("acq_date", "")
1160
+ if acq and len(acq) >= 7:
1161
+ month_key = acq[:7] # "YYYY-MM"
1162
+ month_num = int(month_key[5:7])
1163
+ if season_months is None or month_num in season_months:
1164
+ monthly[month_key] += 1
1165
+
1166
+ sorted_months = sorted(monthly.keys())
1167
+ return {
1168
+ "dates": sorted_months,
1169
+ "values": [monthly[m] for m in sorted_months],
1170
+ "label": "Fire detections per month",
1171
+ }
1172
+ ```
1173
+
1174
+ Update the caller in `process()` to pass `season_months`.
1175
+
1176
+ - [ ] **Step 4: Run fires tests**
1177
+
1178
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/test_indicator_fires.py -v`
1179
+
1180
+ Expected: All PASS.
1181
+
1182
+ - [ ] **Step 5: Commit**
1183
+
1184
+ ```bash
1185
+ git add app/indicators/fires.py tests/test_indicator_fires.py
1186
+ git commit -m "feat: fires chart data filters to season_months"
1187
+ ```
1188
+
1189
+ ---
1190
+
1191
+ ### Task 11: Full Test Suite & Visual Verification
1192
+
1193
+ **Files:** None (verification only)
1194
+
1195
+ - [ ] **Step 1: Run the full test suite**
1196
+
1197
+ Run: `cd /Users/kmini/Github/Aperture && python -m pytest tests/ -v`
1198
+
1199
+ Expected: All tests PASS.
1200
+
1201
+ - [ ] **Step 2: Verify no import errors**
1202
+
1203
+ Run: `cd /Users/kmini/Github/Aperture && python -c "from app.models import JobRequest; r = JobRequest(aoi={'name':'T','bbox':[36.75,-1.35,36.95,-1.20]}, indicator_ids=['fires'], email='t@t.com', season_start=10, season_end=3); print(r.season_months())"`
1204
+
1205
+ Expected: `[10, 11, 12, 1, 2, 3]`
1206
+
1207
+ - [ ] **Step 3: Generate a sample monthly vegetation chart**
1208
+
1209
+ ```bash
1210
+ cd /Users/kmini/Github/Aperture && python -c "
1211
+ from app.outputs.charts import render_timeseries_chart
1212
+ from app.models import StatusLevel, TrendDirection
1213
+ render_timeseries_chart(
1214
+ chart_data={
1215
+ 'dates': ['2025-04','2025-05','2025-06','2025-07','2025-08','2025-09'],
1216
+ 'values': [35, 38, 42, 41, 39, 34],
1217
+ 'baseline_mean': [33, 36, 40, 39, 37, 32],
1218
+ 'baseline_min': [30, 33, 37, 36, 34, 29],
1219
+ 'baseline_max': [36, 39, 43, 42, 40, 35],
1220
+ 'label': 'Vegetation cover (%)',
1221
+ },
1222
+ indicator_name='Vegetation & Forest Cover',
1223
+ status=StatusLevel.GREEN,
1224
+ trend=TrendDirection.STABLE,
1225
+ output_path='/tmp/test_monthly_veg.png',
1226
+ y_label='Vegetation cover (%)',
1227
+ )
1228
+ print('Saved to /tmp/test_monthly_veg.png')
1229
+ "
1230
+ ```
1231
+
1232
+ Verify: Monthly baseline band with 6 growing-season months, current data line on top.
1233
+
1234
+ - [ ] **Step 4: Verify frontend month picker loads**
1235
+
1236
+ Start dev server briefly and check that the month picker dropdowns appear on the "Define Area" page below the date range.