KSvend Claude Happy commited on
Commit
1e8f4b0
·
1 Parent(s): 3d48a34

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 CHANGED
@@ -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
 
app/outputs/maps.py CHANGED
@@ -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,
app/outputs/narrative.py CHANGED
@@ -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)
app/outputs/report.py CHANGED
@@ -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.headline[:80], styles["body_muted"]),
 
449
  ])
450
 
451
  ov_col_w = PAGE_W - 2 * MARGIN
452
  ov_table = Table(
453
  summary_rows,
454
  colWidths=[
455
- ov_col_w * 0.15,
456
- ov_col_w * 0.10,
457
- ov_col_w * 0.12,
458
- ov_col_w * 0.13,
459
- ov_col_w * 0.50,
 
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()
app/worker.py CHANGED
@@ -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
 
tests/test_narrative.py ADDED
@@ -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()