KSvend Claude Sonnet 4.6 commited on
Commit
a392efe
·
1 Parent(s): 1e77056

feat: add baseline range to NO2 and Nightlights chart data

Browse files

Store per-year baseline values during API queries and expose
baseline_range_mean/min/max in chart_data for both indicators when
>=2 baseline years are available. Tests cover range inclusion, the
single-year guard, and the None/fallback paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

app/indicators/nightlights.py CHANGED
@@ -52,7 +52,7 @@ class NightlightsIndicator(BaseIndicator):
52
  status = self._classify(ratio)
53
  trend = self._compute_trend(ratio)
54
  confidence = self._compute_confidence(spatial)
55
- chart_data = self._build_chart_data(current_radiance, baseline_radiance, time_range)
56
 
57
  if ratio >= 0.9:
58
  headline = f"Nighttime light intensity at {pct:.0f}% of baseline — normal activity"
@@ -213,6 +213,7 @@ class NightlightsIndicator(BaseIndicator):
213
  return None
214
 
215
  baseline_mean = float(np.mean(baseline_vals))
 
216
 
217
  # Build spatial data from whichever year we read
218
  spatial = None
@@ -338,6 +339,7 @@ class NightlightsIndicator(BaseIndicator):
338
  return None
339
 
340
  baseline_mean = float(np.mean(baseline_vals))
 
341
  # NASA source provides scalar means only (no 2D spatial data due to sinusoidal tiling)
342
  return current_mean, baseline_mean, None
343
 
@@ -379,10 +381,18 @@ class NightlightsIndicator(BaseIndicator):
379
 
380
  @staticmethod
381
  def _build_chart_data(
382
- current: float, baseline: float, time_range: TimeRange
 
 
 
383
  ) -> dict[str, Any]:
384
- return {
385
  "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
386
  "values": [round(baseline, 4), round(current, 4)],
387
  "label": "Mean VIIRS DNB radiance (nW·cm⁻²·sr⁻¹)",
388
  }
 
 
 
 
 
 
52
  status = self._classify(ratio)
53
  trend = self._compute_trend(ratio)
54
  confidence = self._compute_confidence(spatial)
55
+ chart_data = self._build_chart_data(current_radiance, baseline_radiance, time_range, getattr(self, '_baseline_yearly_vals', None))
56
 
57
  if ratio >= 0.9:
58
  headline = f"Nighttime light intensity at {pct:.0f}% of baseline — normal activity"
 
213
  return None
214
 
215
  baseline_mean = float(np.mean(baseline_vals))
216
+ self._baseline_yearly_vals = baseline_vals
217
 
218
  # Build spatial data from whichever year we read
219
  spatial = None
 
339
  return None
340
 
341
  baseline_mean = float(np.mean(baseline_vals))
342
+ self._baseline_yearly_vals = baseline_vals
343
  # NASA source provides scalar means only (no 2D spatial data due to sinusoidal tiling)
344
  return current_mean, baseline_mean, None
345
 
 
381
 
382
  @staticmethod
383
  def _build_chart_data(
384
+ current: float,
385
+ baseline: float,
386
+ time_range: TimeRange,
387
+ baseline_yearly_vals: list[float] | None = None,
388
  ) -> dict[str, Any]:
389
+ result: dict[str, Any] = {
390
  "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
391
  "values": [round(baseline, 4), round(current, 4)],
392
  "label": "Mean VIIRS DNB radiance (nW·cm⁻²·sr⁻¹)",
393
  }
394
+ if baseline_yearly_vals and len(baseline_yearly_vals) >= 2:
395
+ result["baseline_range_mean"] = round(float(np.mean(baseline_yearly_vals)), 4)
396
+ result["baseline_range_min"] = round(float(min(baseline_yearly_vals)), 4)
397
+ result["baseline_range_max"] = round(float(max(baseline_yearly_vals)), 4)
398
+ return result
app/indicators/no2.py CHANGED
@@ -39,7 +39,7 @@ class NO2Indicator(BaseIndicator):
39
  status = self._classify(abs(z_score))
40
  trend = self._compute_trend(z_score)
41
  confidence = ConfidenceLevel.MODERATE
42
- chart_data = self._build_chart_data(current_no2, baseline_mean, baseline_std, time_range)
43
 
44
  direction = "above" if z_score >= 0 else "below"
45
  abs_z = abs(z_score)
@@ -142,6 +142,7 @@ class NO2Indicator(BaseIndicator):
142
  self._is_placeholder = True
143
  return self._synthetic_no2()
144
 
 
145
  return (
146
  float(np.mean(current_vals)),
147
  float(np.mean(baseline_yearly_means)),
@@ -178,10 +179,16 @@ class NO2Indicator(BaseIndicator):
178
  baseline_mean: float,
179
  baseline_std: float,
180
  time_range: TimeRange,
 
181
  ) -> dict[str, Any]:
182
- return {
183
  "dates": ["baseline", str(time_range.end.year)],
184
  "values": [round(baseline_mean, 1), round(current, 1)],
185
  "baseline_std": round(baseline_std, 1),
186
  "label": "NO2 concentration (µg/m³)",
187
  }
 
 
 
 
 
 
39
  status = self._classify(abs(z_score))
40
  trend = self._compute_trend(z_score)
41
  confidence = ConfidenceLevel.MODERATE
42
+ chart_data = self._build_chart_data(current_no2, baseline_mean, baseline_std, time_range, getattr(self, '_baseline_yearly_means', None))
43
 
44
  direction = "above" if z_score >= 0 else "below"
45
  abs_z = abs(z_score)
 
142
  self._is_placeholder = True
143
  return self._synthetic_no2()
144
 
145
+ self._baseline_yearly_means = baseline_yearly_means
146
  return (
147
  float(np.mean(current_vals)),
148
  float(np.mean(baseline_yearly_means)),
 
179
  baseline_mean: float,
180
  baseline_std: float,
181
  time_range: TimeRange,
182
+ baseline_yearly_means: list[float] | None = None,
183
  ) -> dict[str, Any]:
184
+ result: dict[str, Any] = {
185
  "dates": ["baseline", str(time_range.end.year)],
186
  "values": [round(baseline_mean, 1), round(current, 1)],
187
  "baseline_std": round(baseline_std, 1),
188
  "label": "NO2 concentration (µg/m³)",
189
  }
190
+ if baseline_yearly_means and len(baseline_yearly_means) >= 2:
191
+ result["baseline_range_mean"] = round(float(np.mean(baseline_yearly_means)), 1)
192
+ result["baseline_range_min"] = round(float(min(baseline_yearly_means)), 1)
193
+ result["baseline_range_max"] = round(float(max(baseline_yearly_means)), 1)
194
+ return result
tests/test_indicator_nightlights.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_build_chart_data_includes_baseline_range():
2
+ from app.indicators.nightlights import NightlightsIndicator
3
+ from datetime import date
4
+ from app.models import TimeRange
5
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
6
+ result = NightlightsIndicator._build_chart_data(
7
+ current=2.8, baseline=3.2, time_range=tr,
8
+ baseline_yearly_vals=[3.0, 3.2, 3.4],
9
+ )
10
+ assert "baseline_range_mean" in result
11
+ assert result["baseline_range_min"] == 3.0
12
+ assert result["baseline_range_max"] == 3.4
13
+
14
+
15
+ def test_build_chart_data_no_range_when_single_year():
16
+ from app.indicators.nightlights import NightlightsIndicator
17
+ from datetime import date
18
+ from app.models import TimeRange
19
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
20
+ result = NightlightsIndicator._build_chart_data(
21
+ current=2.8, baseline=3.2, time_range=tr,
22
+ baseline_yearly_vals=[3.2],
23
+ )
24
+ assert "baseline_range_mean" not in result
25
+
26
+
27
+ def test_build_chart_data_no_range_when_none():
28
+ from app.indicators.nightlights import NightlightsIndicator
29
+ from datetime import date
30
+ from app.models import TimeRange
31
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
32
+ result = NightlightsIndicator._build_chart_data(
33
+ current=2.8, baseline=3.2, time_range=tr,
34
+ )
35
+ assert "baseline_range_mean" not in result
36
+ assert result["values"] == [3.2, 2.8]
37
+
38
+
39
+ def test_build_chart_data_uses_four_decimal_places():
40
+ from app.indicators.nightlights import NightlightsIndicator
41
+ from datetime import date
42
+ from app.models import TimeRange
43
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
44
+ result = NightlightsIndicator._build_chart_data(
45
+ current=2.8, baseline=3.2, time_range=tr,
46
+ baseline_yearly_vals=[3.0, 3.2, 3.4],
47
+ )
48
+ # mean of [3.0, 3.2, 3.4] = 3.2, check rounding to 4 dp
49
+ assert result["baseline_range_mean"] == 3.2
50
+ assert result["baseline_range_min"] == 3.0
51
+ assert result["baseline_range_max"] == 3.4
tests/test_indicator_no2.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_build_chart_data_includes_baseline_range():
2
+ from app.indicators.no2 import NO2Indicator
3
+ from datetime import date
4
+ from app.models import TimeRange
5
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
6
+ result = NO2Indicator._build_chart_data(
7
+ current=16.5, baseline_mean=15.0, baseline_std=4.0, time_range=tr,
8
+ baseline_yearly_means=[12.0, 15.0, 18.0],
9
+ )
10
+ assert "baseline_range_mean" in result
11
+ assert result["baseline_range_min"] == 12.0
12
+ assert result["baseline_range_max"] == 18.0
13
+
14
+
15
+ def test_build_chart_data_no_range_when_single_year():
16
+ from app.indicators.no2 import NO2Indicator
17
+ from datetime import date
18
+ from app.models import TimeRange
19
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
20
+ result = NO2Indicator._build_chart_data(
21
+ current=16.5, baseline_mean=15.0, baseline_std=4.0, time_range=tr,
22
+ baseline_yearly_means=[15.0],
23
+ )
24
+ assert "baseline_range_mean" not in result
25
+
26
+
27
+ def test_build_chart_data_no_range_when_none():
28
+ from app.indicators.no2 import NO2Indicator
29
+ from datetime import date
30
+ from app.models import TimeRange
31
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
32
+ result = NO2Indicator._build_chart_data(
33
+ current=16.5, baseline_mean=15.0, baseline_std=4.0, time_range=tr,
34
+ )
35
+ assert "baseline_range_mean" not in result
36
+ assert result["baseline_std"] == 4.0