feat: add output pipeline for seasonal analysis and compound signals
Browse files- Charts: anomaly markers (red rings) on months exceeding z-score
threshold; default y-axis labels per indicator
- Maps: new render_hotspot_map() for z-score change visualization
over true-color base layer
- Narrative: generate_compound_signals_text() for cross-indicator
compound signal descriptions
- Report: anomaly column in summary table, compound signals section,
hotspot maps in indicator blocks, confidence breakdown table in
technical annex
- Worker: generates hotspot maps, detects compound signals via
spatial z-score overlap, passes both to PDF report
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>
- app/outputs/charts.py +22 -0
- app/outputs/maps.py +61 -0
- app/outputs/narrative.py +23 -0
- app/outputs/report.py +85 -7
- app/worker.py +75 -0
- tests/test_narrative.py +55 -0
|
@@ -59,6 +59,16 @@ def render_timeseries_chart(
|
|
| 59 |
dates = chart_data.get("dates", [])
|
| 60 |
values = chart_data.get("values", [])
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
status_color = STATUS_COLORS[status]
|
| 63 |
arrow = TREND_ARROWS[trend]
|
| 64 |
|
|
@@ -139,6 +149,18 @@ def render_timeseries_chart(
|
|
| 139 |
alpha=0.15, color=status_color,
|
| 140 |
)
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
if has_monthly_baseline or has_summary_baseline:
|
| 143 |
ax.legend(fontsize=7, loc="upper left", framealpha=0.8)
|
| 144 |
|
|
|
|
| 59 |
dates = chart_data.get("dates", [])
|
| 60 |
values = chart_data.get("values", [])
|
| 61 |
|
| 62 |
+
# Default y-axis labels per indicator
|
| 63 |
+
if not y_label:
|
| 64 |
+
_default_labels = {
|
| 65 |
+
"Vegetation (NDVI)": "NDVI (0\u20131)",
|
| 66 |
+
"Water Bodies": "Water extent (%)",
|
| 67 |
+
"SAR Backscatter": "VV backscatter (dB)",
|
| 68 |
+
"Settlement Extent": "Built-up area (%)",
|
| 69 |
+
}
|
| 70 |
+
y_label = _default_labels.get(indicator_name, "")
|
| 71 |
+
|
| 72 |
status_color = STATUS_COLORS[status]
|
| 73 |
arrow = TREND_ARROWS[trend]
|
| 74 |
|
|
|
|
| 149 |
alpha=0.15, color=status_color,
|
| 150 |
)
|
| 151 |
|
| 152 |
+
# Anomaly markers — red rings on months with |z| > threshold
|
| 153 |
+
anomaly_flags = chart_data.get("anomaly_flags")
|
| 154 |
+
if anomaly_flags and len(anomaly_flags) == len(parsed_dates):
|
| 155 |
+
anomaly_x = [d for d, f in zip(parsed_dates, anomaly_flags) if f]
|
| 156 |
+
anomaly_y = [v for v, f in zip(values, anomaly_flags) if f]
|
| 157 |
+
if anomaly_x:
|
| 158 |
+
ax.scatter(
|
| 159 |
+
anomaly_x, anomaly_y,
|
| 160 |
+
s=120, facecolors="none", edgecolors="#B83A2A",
|
| 161 |
+
linewidths=2, zorder=4, label="Anomaly",
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
if has_monthly_baseline or has_summary_baseline:
|
| 165 |
ax.legend(fontsize=7, loc="upper left", framealpha=0.8)
|
| 166 |
|
|
@@ -301,6 +301,67 @@ def render_raster_map(
|
|
| 301 |
plt.close(fig)
|
| 302 |
|
| 303 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
def render_overview_map(
|
| 305 |
*,
|
| 306 |
true_color_path: str,
|
|
|
|
| 301 |
plt.close(fig)
|
| 302 |
|
| 303 |
|
| 304 |
+
def render_hotspot_map(
|
| 305 |
+
*,
|
| 306 |
+
true_color_path: str | None,
|
| 307 |
+
zscore_raster: np.ndarray,
|
| 308 |
+
hotspot_mask: np.ndarray,
|
| 309 |
+
extent: list[float],
|
| 310 |
+
aoi: AOI,
|
| 311 |
+
status: StatusLevel,
|
| 312 |
+
output_path: str,
|
| 313 |
+
label: str = "Z-score",
|
| 314 |
+
) -> None:
|
| 315 |
+
"""Render a change hotspot map: significant pixels over true-color base.
|
| 316 |
+
|
| 317 |
+
Only pixels where |z-score| > threshold are shown; non-significant
|
| 318 |
+
pixels are transparent, letting the true-color base show through.
|
| 319 |
+
"""
|
| 320 |
+
import rasterio
|
| 321 |
+
|
| 322 |
+
fig, ax = plt.subplots(figsize=(6, 5), dpi=200, facecolor=SHELL)
|
| 323 |
+
ax.set_facecolor(SHELL)
|
| 324 |
+
|
| 325 |
+
# True-color base layer
|
| 326 |
+
if true_color_path is not None:
|
| 327 |
+
with rasterio.open(true_color_path) as src:
|
| 328 |
+
rgb = src.read([1, 2, 3]).astype(np.float32)
|
| 329 |
+
tc_extent = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
|
| 330 |
+
rgb_max = max(rgb.max(), 1.0)
|
| 331 |
+
scale = 3000.0 if rgb_max > 255 else 255.0
|
| 332 |
+
rgb_normalized = np.clip(rgb / scale, 0, 1).transpose(1, 2, 0)
|
| 333 |
+
ax.imshow(rgb_normalized, extent=tc_extent, aspect="auto", zorder=0)
|
| 334 |
+
|
| 335 |
+
# Hotspot overlay — only significant pixels, masked elsewhere
|
| 336 |
+
masked_z = np.ma.masked_where(~hotspot_mask, zscore_raster)
|
| 337 |
+
vmax = min(float(np.nanmax(np.abs(zscore_raster))), 5.0)
|
| 338 |
+
im = ax.imshow(
|
| 339 |
+
masked_z, extent=extent, cmap="RdBu_r", alpha=0.8,
|
| 340 |
+
vmin=-vmax, vmax=vmax, aspect="auto", zorder=1,
|
| 341 |
+
)
|
| 342 |
+
cbar = fig.colorbar(im, ax=ax, fraction=0.03, pad=0.04, shrink=0.85)
|
| 343 |
+
cbar.set_label(f"{label} (decline \u2190 \u2192 increase)", fontsize=7, color=INK_MUTED)
|
| 344 |
+
cbar.ax.tick_params(labelsize=6, colors=INK_MUTED)
|
| 345 |
+
|
| 346 |
+
# AOI outline
|
| 347 |
+
ax.set_xlim(extent[0], extent[1])
|
| 348 |
+
ax.set_ylim(extent[2], extent[3])
|
| 349 |
+
color = STATUS_COLORS[status]
|
| 350 |
+
_draw_aoi_rect(ax, aoi, color)
|
| 351 |
+
|
| 352 |
+
ax.tick_params(labelsize=6, colors=INK_MUTED)
|
| 353 |
+
ax.set_xlabel("Longitude", fontsize=7, color=INK_MUTED)
|
| 354 |
+
ax.set_ylabel("Latitude", fontsize=7, color=INK_MUTED)
|
| 355 |
+
|
| 356 |
+
for spine in ax.spines.values():
|
| 357 |
+
spine.set_color(INK_MUTED)
|
| 358 |
+
spine.set_linewidth(0.5)
|
| 359 |
+
|
| 360 |
+
plt.tight_layout()
|
| 361 |
+
fig.savefig(output_path, dpi=200, bbox_inches="tight", facecolor=SHELL)
|
| 362 |
+
plt.close(fig)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
def render_overview_map(
|
| 366 |
*,
|
| 367 |
true_color_path: str,
|
|
@@ -82,3 +82,26 @@ def generate_narrative(results: Sequence[IndicatorResult]) -> str:
|
|
| 82 |
break
|
| 83 |
|
| 84 |
return " ".join(parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
break
|
| 83 |
|
| 84 |
return " ".join(parts)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def generate_compound_signals_text(signals: list) -> str:
|
| 88 |
+
"""Generate text for compound signal section.
|
| 89 |
+
|
| 90 |
+
Parameters
|
| 91 |
+
----------
|
| 92 |
+
signals : list of CompoundSignal objects.
|
| 93 |
+
|
| 94 |
+
Returns text describing triggered compound signals.
|
| 95 |
+
"""
|
| 96 |
+
if not signals:
|
| 97 |
+
return "No compound signals detected across the indicator set."
|
| 98 |
+
|
| 99 |
+
triggered = [s for s in signals if s.triggered]
|
| 100 |
+
if not triggered:
|
| 101 |
+
return "No compound signals detected across the indicator set."
|
| 102 |
+
|
| 103 |
+
parts = []
|
| 104 |
+
for s in triggered:
|
| 105 |
+
parts.append(f"**{s.name.replace('_', ' ').title()}** ({s.confidence}): {s.description}")
|
| 106 |
+
|
| 107 |
+
return " ".join(parts)
|
|
@@ -163,6 +163,7 @@ def _indicator_block(
|
|
| 163 |
styles: dict,
|
| 164 |
map_path: str = "",
|
| 165 |
chart_path: str = "",
|
|
|
|
| 166 |
) -> list:
|
| 167 |
"""Build the flowables for a single indicator section."""
|
| 168 |
from app.outputs.narrative import get_interpretation
|
|
@@ -217,6 +218,15 @@ def _indicator_block(
|
|
| 217 |
elements.append(img)
|
| 218 |
elements.append(Spacer(1, 2 * mm))
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
# What the data shows
|
| 221 |
elements.append(Paragraph("<b>What the data shows</b>", styles["body_muted"]))
|
| 222 |
elements.append(Paragraph(result.summary, styles["body"]))
|
|
@@ -264,8 +274,10 @@ def generate_pdf_report(
|
|
| 264 |
output_path: str,
|
| 265 |
summary_map_path: str = "",
|
| 266 |
indicator_map_paths: dict[str, str] | None = None,
|
|
|
|
| 267 |
overview_score: dict | None = None,
|
| 268 |
overview_map_path: str = "",
|
|
|
|
| 269 |
) -> None:
|
| 270 |
"""Generate a styled PDF report and save to ``output_path``.
|
| 271 |
|
|
@@ -425,6 +437,7 @@ def generate_pdf_report(
|
|
| 425 |
Paragraph("<b>Status</b>", styles["body"]),
|
| 426 |
Paragraph("<b>Trend</b>", styles["body"]),
|
| 427 |
Paragraph("<b>Confidence</b>", styles["body"]),
|
|
|
|
| 428 |
Paragraph("<b>Headline</b>", styles["body"]),
|
| 429 |
]
|
| 430 |
summary_rows = [summary_header]
|
|
@@ -445,18 +458,20 @@ def generate_pdf_report(
|
|
| 445 |
status_cell,
|
| 446 |
Paragraph(result.trend.value.capitalize(), styles["body_muted"]),
|
| 447 |
Paragraph(result.confidence.value.capitalize(), styles["body_muted"]),
|
| 448 |
-
Paragraph(result.
|
|
|
|
| 449 |
])
|
| 450 |
|
| 451 |
ov_col_w = PAGE_W - 2 * MARGIN
|
| 452 |
ov_table = Table(
|
| 453 |
summary_rows,
|
| 454 |
colWidths=[
|
| 455 |
-
ov_col_w * 0.
|
| 456 |
-
ov_col_w * 0.
|
| 457 |
-
ov_col_w * 0.
|
| 458 |
-
ov_col_w * 0.
|
| 459 |
-
ov_col_w * 0.
|
|
|
|
| 460 |
],
|
| 461 |
)
|
| 462 |
ov_ts = TableStyle([
|
|
@@ -475,6 +490,27 @@ def generate_pdf_report(
|
|
| 475 |
ov_table.setStyle(ov_ts)
|
| 476 |
story.append(ov_table)
|
| 477 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
story.append(Spacer(1, 6 * mm))
|
| 479 |
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF")))
|
| 480 |
story.append(Spacer(1, 4 * mm))
|
|
@@ -488,6 +524,7 @@ def generate_pdf_report(
|
|
| 488 |
for result in results:
|
| 489 |
indicator_label = _indicator_label(result.indicator_id)
|
| 490 |
map_path = (indicator_map_paths or {}).get(result.indicator_id, "")
|
|
|
|
| 491 |
|
| 492 |
# Auto-detect chart path from output directory
|
| 493 |
chart_path = os.path.join(output_dir, f"{result.indicator_id}_chart.png")
|
|
@@ -495,7 +532,7 @@ def generate_pdf_report(
|
|
| 495 |
chart_path = ""
|
| 496 |
|
| 497 |
block = [Paragraph(indicator_label, styles["section_heading"])]
|
| 498 |
-
block += _indicator_block(result, styles, map_path=map_path, chart_path=chart_path)
|
| 499 |
story.append(KeepTogether(block))
|
| 500 |
|
| 501 |
# ================================================================== #
|
|
@@ -516,6 +553,47 @@ def generate_pdf_report(
|
|
| 516 |
)
|
| 517 |
story.append(Spacer(1, 4 * mm))
|
| 518 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
# Limitations subsection — deduplicated across all indicators
|
| 520 |
all_limitations: list[str] = []
|
| 521 |
seen: set[str] = set()
|
|
|
|
| 163 |
styles: dict,
|
| 164 |
map_path: str = "",
|
| 165 |
chart_path: str = "",
|
| 166 |
+
hotspot_path: str = "",
|
| 167 |
) -> list:
|
| 168 |
"""Build the flowables for a single indicator section."""
|
| 169 |
from app.outputs.narrative import get_interpretation
|
|
|
|
| 218 |
elements.append(img)
|
| 219 |
elements.append(Spacer(1, 2 * mm))
|
| 220 |
|
| 221 |
+
# Hotspot change map (if available)
|
| 222 |
+
hotspot_exists = hotspot_path and os.path.exists(hotspot_path)
|
| 223 |
+
if hotspot_exists:
|
| 224 |
+
from reportlab.platypus import Image as RLImage
|
| 225 |
+
hotspot_img = RLImage(hotspot_path, width=14 * cm, height=5.5 * cm)
|
| 226 |
+
hotspot_img.hAlign = "CENTER"
|
| 227 |
+
elements.append(hotspot_img)
|
| 228 |
+
elements.append(Spacer(1, 2 * mm))
|
| 229 |
+
|
| 230 |
# What the data shows
|
| 231 |
elements.append(Paragraph("<b>What the data shows</b>", styles["body_muted"]))
|
| 232 |
elements.append(Paragraph(result.summary, styles["body"]))
|
|
|
|
| 274 |
output_path: str,
|
| 275 |
summary_map_path: str = "",
|
| 276 |
indicator_map_paths: dict[str, str] | None = None,
|
| 277 |
+
indicator_hotspot_paths: dict[str, str] | None = None,
|
| 278 |
overview_score: dict | None = None,
|
| 279 |
overview_map_path: str = "",
|
| 280 |
+
compound_signals: list | None = None,
|
| 281 |
) -> None:
|
| 282 |
"""Generate a styled PDF report and save to ``output_path``.
|
| 283 |
|
|
|
|
| 437 |
Paragraph("<b>Status</b>", styles["body"]),
|
| 438 |
Paragraph("<b>Trend</b>", styles["body"]),
|
| 439 |
Paragraph("<b>Confidence</b>", styles["body"]),
|
| 440 |
+
Paragraph("<b>Anomalies</b>", styles["body"]),
|
| 441 |
Paragraph("<b>Headline</b>", styles["body"]),
|
| 442 |
]
|
| 443 |
summary_rows = [summary_header]
|
|
|
|
| 458 |
status_cell,
|
| 459 |
Paragraph(result.trend.value.capitalize(), styles["body_muted"]),
|
| 460 |
Paragraph(result.confidence.value.capitalize(), styles["body_muted"]),
|
| 461 |
+
Paragraph(f"{result.anomaly_months}/12", styles["body_muted"]),
|
| 462 |
+
Paragraph(result.headline[:70], styles["body_muted"]),
|
| 463 |
])
|
| 464 |
|
| 465 |
ov_col_w = PAGE_W - 2 * MARGIN
|
| 466 |
ov_table = Table(
|
| 467 |
summary_rows,
|
| 468 |
colWidths=[
|
| 469 |
+
ov_col_w * 0.14,
|
| 470 |
+
ov_col_w * 0.09,
|
| 471 |
+
ov_col_w * 0.11,
|
| 472 |
+
ov_col_w * 0.11,
|
| 473 |
+
ov_col_w * 0.09,
|
| 474 |
+
ov_col_w * 0.46,
|
| 475 |
],
|
| 476 |
)
|
| 477 |
ov_ts = TableStyle([
|
|
|
|
| 490 |
ov_table.setStyle(ov_ts)
|
| 491 |
story.append(ov_table)
|
| 492 |
|
| 493 |
+
# Compound signals section
|
| 494 |
+
if compound_signals:
|
| 495 |
+
triggered = [s for s in compound_signals if s.triggered]
|
| 496 |
+
if triggered:
|
| 497 |
+
story.append(Spacer(1, 4 * mm))
|
| 498 |
+
story.append(Paragraph("Compound Signals", styles["section_heading"]))
|
| 499 |
+
story.append(Spacer(1, 2 * mm))
|
| 500 |
+
for s in triggered:
|
| 501 |
+
signal_text = (
|
| 502 |
+
f"<b>{s.name.replace('_', ' ').title()}</b> "
|
| 503 |
+
f"({s.confidence}): {s.description}"
|
| 504 |
+
)
|
| 505 |
+
story.append(Paragraph(signal_text, styles["body"]))
|
| 506 |
+
story.append(Spacer(1, 2 * mm))
|
| 507 |
+
else:
|
| 508 |
+
story.append(Spacer(1, 2 * mm))
|
| 509 |
+
story.append(Paragraph(
|
| 510 |
+
"No compound signals detected across the indicator set.",
|
| 511 |
+
styles["body_muted"],
|
| 512 |
+
))
|
| 513 |
+
|
| 514 |
story.append(Spacer(1, 6 * mm))
|
| 515 |
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF")))
|
| 516 |
story.append(Spacer(1, 4 * mm))
|
|
|
|
| 524 |
for result in results:
|
| 525 |
indicator_label = _indicator_label(result.indicator_id)
|
| 526 |
map_path = (indicator_map_paths or {}).get(result.indicator_id, "")
|
| 527 |
+
hotspot_path = (indicator_hotspot_paths or {}).get(result.indicator_id, "")
|
| 528 |
|
| 529 |
# Auto-detect chart path from output directory
|
| 530 |
chart_path = os.path.join(output_dir, f"{result.indicator_id}_chart.png")
|
|
|
|
| 532 |
chart_path = ""
|
| 533 |
|
| 534 |
block = [Paragraph(indicator_label, styles["section_heading"])]
|
| 535 |
+
block += _indicator_block(result, styles, map_path=map_path, chart_path=chart_path, hotspot_path=hotspot_path)
|
| 536 |
story.append(KeepTogether(block))
|
| 537 |
|
| 538 |
# ================================================================== #
|
|
|
|
| 553 |
)
|
| 554 |
story.append(Spacer(1, 4 * mm))
|
| 555 |
|
| 556 |
+
# Confidence breakdown table
|
| 557 |
+
story.append(Paragraph("Confidence Breakdown", styles["section_heading"]))
|
| 558 |
+
story.append(Spacer(1, 2 * mm))
|
| 559 |
+
|
| 560 |
+
conf_header = [
|
| 561 |
+
Paragraph("<b>Indicator</b>", styles["body"]),
|
| 562 |
+
Paragraph("<b>Temporal</b>", styles["body"]),
|
| 563 |
+
Paragraph("<b>Obs. Density</b>", styles["body"]),
|
| 564 |
+
Paragraph("<b>Baseline Depth</b>", styles["body"]),
|
| 565 |
+
Paragraph("<b>Spatial Compl.</b>", styles["body"]),
|
| 566 |
+
Paragraph("<b>Overall</b>", styles["body"]),
|
| 567 |
+
]
|
| 568 |
+
conf_rows = [conf_header]
|
| 569 |
+
for result in results:
|
| 570 |
+
f = result.confidence_factors
|
| 571 |
+
if f:
|
| 572 |
+
conf_rows.append([
|
| 573 |
+
Paragraph(_indicator_label(result.indicator_id), styles["body_muted"]),
|
| 574 |
+
Paragraph(f"{f.get('temporal', 0):.2f}", styles["body_muted"]),
|
| 575 |
+
Paragraph(f"{f.get('observation_density', 0):.2f}", styles["body_muted"]),
|
| 576 |
+
Paragraph(f"{f.get('baseline_depth', 0):.2f}", styles["body_muted"]),
|
| 577 |
+
Paragraph(f"{f.get('spatial_completeness', 0):.2f}", styles["body_muted"]),
|
| 578 |
+
Paragraph(result.confidence.value.capitalize(), styles["body_muted"]),
|
| 579 |
+
])
|
| 580 |
+
|
| 581 |
+
if len(conf_rows) > 1:
|
| 582 |
+
conf_col_w = PAGE_W - 2 * MARGIN
|
| 583 |
+
conf_table = Table(
|
| 584 |
+
conf_rows,
|
| 585 |
+
colWidths=[conf_col_w * 0.18] + [conf_col_w * 0.164] * 5,
|
| 586 |
+
)
|
| 587 |
+
conf_table.setStyle(TableStyle([
|
| 588 |
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E6E0")),
|
| 589 |
+
("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#D8D5CF")),
|
| 590 |
+
("TOPPADDING", (0, 0), (-1, -1), 3),
|
| 591 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
| 592 |
+
("LEFTPADDING", (0, 0), (-1, -1), 4),
|
| 593 |
+
]))
|
| 594 |
+
story.append(conf_table)
|
| 595 |
+
story.append(Spacer(1, 4 * mm))
|
| 596 |
+
|
| 597 |
# Limitations subsection — deduplicated across all indicators
|
| 598 |
all_limitations: list[str] = []
|
| 599 |
seen: set[str] = set()
|
|
@@ -228,6 +228,79 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 228 |
spatial_json_path = os.path.join(results_dir, f"{result.indicator_id}_spatial.json")
|
| 229 |
_save_spatial_json(spatial, result.status.value, spatial_json_path)
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
# Build map paths dict for PDF
|
| 232 |
indicator_map_paths = {}
|
| 233 |
for result in job.results:
|
|
@@ -281,8 +354,10 @@ async def process_job(job_id: str, db: Database, registry: IndicatorRegistry) ->
|
|
| 281 |
output_path=report_path,
|
| 282 |
summary_map_path=summary_map_path,
|
| 283 |
indicator_map_paths=indicator_map_paths,
|
|
|
|
| 284 |
overview_score=overview_score,
|
| 285 |
overview_map_path=overview_map_path if true_color_path else "",
|
|
|
|
| 286 |
)
|
| 287 |
output_files.append(report_path)
|
| 288 |
|
|
|
|
| 228 |
spatial_json_path = os.path.join(results_dir, f"{result.indicator_id}_spatial.json")
|
| 229 |
_save_spatial_json(spatial, result.status.value, spatial_json_path)
|
| 230 |
|
| 231 |
+
# Generate hotspot maps for indicators with z-score data
|
| 232 |
+
from app.outputs.maps import render_hotspot_map
|
| 233 |
+
indicator_hotspot_paths = {}
|
| 234 |
+
for result in job.results:
|
| 235 |
+
indicator_obj = registry.get(result.indicator_id)
|
| 236 |
+
zscore_raster = getattr(indicator_obj, '_zscore_raster', None)
|
| 237 |
+
hotspot_mask = getattr(indicator_obj, '_hotspot_mask', None)
|
| 238 |
+
true_color_path_ind = getattr(indicator_obj, '_true_color_path', None)
|
| 239 |
+
|
| 240 |
+
if zscore_raster is not None and hotspot_mask is not None:
|
| 241 |
+
hotspot_path = os.path.join(results_dir, f"{result.indicator_id}_hotspot.png")
|
| 242 |
+
|
| 243 |
+
raster_path = getattr(indicator_obj, '_indicator_raster_path', None)
|
| 244 |
+
if raster_path:
|
| 245 |
+
import rasterio
|
| 246 |
+
with rasterio.open(raster_path) as src:
|
| 247 |
+
extent = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]
|
| 248 |
+
else:
|
| 249 |
+
b = job.request.aoi.bbox
|
| 250 |
+
extent = [b[0], b[2], b[1], b[3]]
|
| 251 |
+
|
| 252 |
+
render_hotspot_map(
|
| 253 |
+
true_color_path=true_color_path_ind,
|
| 254 |
+
zscore_raster=zscore_raster,
|
| 255 |
+
hotspot_mask=hotspot_mask,
|
| 256 |
+
extent=extent,
|
| 257 |
+
aoi=job.request.aoi,
|
| 258 |
+
status=result.status,
|
| 259 |
+
output_path=hotspot_path,
|
| 260 |
+
label=result.indicator_id.upper(),
|
| 261 |
+
)
|
| 262 |
+
indicator_hotspot_paths[result.indicator_id] = hotspot_path
|
| 263 |
+
output_files.append(hotspot_path)
|
| 264 |
+
|
| 265 |
+
# Cross-indicator compound signal detection
|
| 266 |
+
from app.analysis.compound import detect_compound_signals
|
| 267 |
+
import numpy as np
|
| 268 |
+
|
| 269 |
+
zscore_rasters = {}
|
| 270 |
+
for result in job.results:
|
| 271 |
+
indicator_obj = registry.get(result.indicator_id)
|
| 272 |
+
z = getattr(indicator_obj, '_zscore_raster', None)
|
| 273 |
+
if z is not None:
|
| 274 |
+
zscore_rasters[result.indicator_id] = z
|
| 275 |
+
|
| 276 |
+
compound_signals = []
|
| 277 |
+
if len(zscore_rasters) >= 2:
|
| 278 |
+
shapes = [z.shape for z in zscore_rasters.values()]
|
| 279 |
+
target_shape = min(shapes, key=lambda s: s[0] * s[1])
|
| 280 |
+
|
| 281 |
+
resampled = {}
|
| 282 |
+
for ind_id, z in zscore_rasters.items():
|
| 283 |
+
if z.shape != target_shape:
|
| 284 |
+
from scipy.ndimage import zoom
|
| 285 |
+
factors = (target_shape[0] / z.shape[0], target_shape[1] / z.shape[1])
|
| 286 |
+
resampled[ind_id] = zoom(z, factors, order=0)
|
| 287 |
+
else:
|
| 288 |
+
resampled[ind_id] = z
|
| 289 |
+
|
| 290 |
+
pixel_area_ha = (job.request.aoi.area_km2 * 100) / (target_shape[0] * target_shape[1])
|
| 291 |
+
|
| 292 |
+
compound_signals = detect_compound_signals(
|
| 293 |
+
zscore_rasters=resampled,
|
| 294 |
+
pixel_area_ha=pixel_area_ha,
|
| 295 |
+
threshold=2.0,
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
if compound_signals:
|
| 299 |
+
signals_path = os.path.join(results_dir, "compound_signals.json")
|
| 300 |
+
with open(signals_path, "w") as f:
|
| 301 |
+
json.dump([s.model_dump() for s in compound_signals], f, indent=2)
|
| 302 |
+
output_files.append(signals_path)
|
| 303 |
+
|
| 304 |
# Build map paths dict for PDF
|
| 305 |
indicator_map_paths = {}
|
| 306 |
for result in job.results:
|
|
|
|
| 354 |
output_path=report_path,
|
| 355 |
summary_map_path=summary_map_path,
|
| 356 |
indicator_map_paths=indicator_map_paths,
|
| 357 |
+
indicator_hotspot_paths=indicator_hotspot_paths,
|
| 358 |
overview_score=overview_score,
|
| 359 |
overview_map_path=overview_map_path if true_color_path else "",
|
| 360 |
+
compound_signals=compound_signals,
|
| 361 |
)
|
| 362 |
output_files.append(report_path)
|
| 363 |
|
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for updated narrative generation."""
|
| 2 |
+
import pytest
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def test_generate_narrative_includes_zscore_context(mock_indicator_result):
|
| 6 |
+
"""Narrative references z-score context when anomaly data is present."""
|
| 7 |
+
from app.outputs.narrative import generate_narrative
|
| 8 |
+
|
| 9 |
+
results = [
|
| 10 |
+
mock_indicator_result(
|
| 11 |
+
indicator_id="ndvi",
|
| 12 |
+
status="amber",
|
| 13 |
+
headline="Vegetation decline (z=-1.8)",
|
| 14 |
+
z_score_current=-1.8,
|
| 15 |
+
anomaly_months=3,
|
| 16 |
+
),
|
| 17 |
+
]
|
| 18 |
+
text = generate_narrative(results)
|
| 19 |
+
assert "concern" in text.lower() or "monitoring" in text.lower()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_generate_compound_signals_text():
|
| 23 |
+
"""Compound signal text generated from CompoundSignal objects."""
|
| 24 |
+
from app.outputs.narrative import generate_compound_signals_text
|
| 25 |
+
from app.models import CompoundSignal
|
| 26 |
+
|
| 27 |
+
signals = [
|
| 28 |
+
CompoundSignal(
|
| 29 |
+
name="land_conversion",
|
| 30 |
+
triggered=True,
|
| 31 |
+
confidence="strong",
|
| 32 |
+
description="NDVI decline overlaps with settlement growth (45% overlap, 120 ha).",
|
| 33 |
+
indicators=["ndvi", "buildup"],
|
| 34 |
+
overlap_pct=45.0,
|
| 35 |
+
affected_ha=120.0,
|
| 36 |
+
),
|
| 37 |
+
CompoundSignal(
|
| 38 |
+
name="flood_event",
|
| 39 |
+
triggered=False,
|
| 40 |
+
confidence="weak",
|
| 41 |
+
description="No flood signal detected.",
|
| 42 |
+
indicators=["sar", "water"],
|
| 43 |
+
),
|
| 44 |
+
]
|
| 45 |
+
text = generate_compound_signals_text(signals)
|
| 46 |
+
assert "Land Conversion" in text
|
| 47 |
+
assert "NDVI decline" in text
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_no_compound_signals_text():
|
| 51 |
+
"""When no signals triggered, text says so explicitly."""
|
| 52 |
+
from app.outputs.narrative import generate_compound_signals_text
|
| 53 |
+
|
| 54 |
+
text = generate_compound_signals_text([])
|
| 55 |
+
assert "no compound" in text.lower()
|