KSvend Claude Sonnet 4.6 commited on
Commit ·
a392efe
1
Parent(s): 1e77056
feat: add baseline range to NO2 and Nightlights chart data
Browse filesStore 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 +13 -3
- app/indicators/no2.py +9 -2
- tests/test_indicator_nightlights.py +51 -0
- tests/test_indicator_no2.py +36 -0
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,
|
|
|
|
|
|
|
|
|
|
| 383 |
) -> dict[str, Any]:
|
| 384 |
-
|
| 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 |
-
|
| 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
|