KSvend Claude Happy commited on
Commit ·
dfdabfe
1
Parent(s): 31dea57
feat: rewrite PDF report with narrative structure (The Place, Assessment, Deep Dives, Annex)
Browse filesGenerated 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/report.py +188 -181
app/outputs/report.py
CHANGED
|
@@ -160,8 +160,16 @@ def _status_badge_table(status: StatusLevel, styles: dict) -> Table:
|
|
| 160 |
return t
|
| 161 |
|
| 162 |
|
| 163 |
-
def _indicator_block(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
"""Build the flowables for a single indicator section."""
|
|
|
|
|
|
|
|
|
|
| 165 |
elements = []
|
| 166 |
|
| 167 |
# Badge + headline row
|
|
@@ -190,30 +198,53 @@ def _indicator_block(result: IndicatorResult, styles: dict, map_path: str = "")
|
|
| 190 |
))
|
| 191 |
elements.append(Spacer(1, 2 * mm))
|
| 192 |
|
| 193 |
-
#
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
f"Trend: <b>{result.trend.value.capitalize()}</b>"
|
| 197 |
-
)
|
| 198 |
-
elements.append(Paragraph(meta, styles["body_muted"]))
|
| 199 |
-
elements.append(Spacer(1, 2 * mm))
|
| 200 |
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
img = Image(map_path, width=12 * cm, height=9 * cm)
|
| 205 |
img.hAlign = "CENTER"
|
| 206 |
elements.append(img)
|
| 207 |
elements.append(Spacer(1, 2 * mm))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
-
#
|
|
|
|
| 210 |
elements.append(Paragraph(result.summary, styles["body"]))
|
| 211 |
|
| 212 |
-
#
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
elements.append(Spacer(1, 4 * mm))
|
| 219 |
elements.append(
|
|
@@ -223,6 +254,19 @@ def _indicator_block(result: IndicatorResult, styles: dict, map_path: str = "")
|
|
| 223 |
return elements
|
| 224 |
|
| 225 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
def generate_pdf_report(
|
| 227 |
*,
|
| 228 |
aoi: AOI,
|
|
@@ -247,6 +291,8 @@ def generate_pdf_report(
|
|
| 247 |
output_path:
|
| 248 |
Absolute path where the PDF should be saved.
|
| 249 |
"""
|
|
|
|
|
|
|
| 250 |
styles = _build_styles()
|
| 251 |
PAGE_W, PAGE_H = A4
|
| 252 |
MARGIN = 2 * cm
|
|
@@ -282,16 +328,30 @@ def generate_pdf_report(
|
|
| 282 |
)
|
| 283 |
doc.pageBackgrounds = [colors.white]
|
| 284 |
|
| 285 |
-
story = []
|
| 286 |
-
|
| 287 |
-
# ------------------------------------------------------------------ #
|
| 288 |
-
# Title block #
|
| 289 |
-
# ------------------------------------------------------------------ #
|
| 290 |
from datetime import datetime as _dt, timezone as _tz
|
| 291 |
generated_at = _dt.now(_tz.utc).strftime("%Y-%m-%d %H:%M UTC")
|
| 292 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
story.append(Paragraph("MERLx Aperture — Situation Report", styles["title"]))
|
| 294 |
story.append(Paragraph(aoi.name, styles["subtitle"]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
story.append(
|
| 296 |
Paragraph(
|
| 297 |
f"Analysis period: {time_range.start} \u2013 {time_range.end}",
|
|
@@ -306,25 +366,28 @@ def generate_pdf_report(
|
|
| 306 |
styles["body_muted"],
|
| 307 |
)
|
| 308 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
story.append(Spacer(1, 4 * mm))
|
| 310 |
story.append(HRFlowable(width="100%", thickness=1, color=INK_MUTED))
|
| 311 |
story.append(Spacer(1, 6 * mm))
|
| 312 |
|
| 313 |
-
#
|
| 314 |
-
#
|
| 315 |
-
#
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
# Overview map (full width)
|
| 320 |
-
if overview_map_path and os.path.exists(overview_map_path):
|
| 321 |
-
from reportlab.platypus import Image
|
| 322 |
-
img = Image(overview_map_path, width=14 * cm, height=10.5 * cm)
|
| 323 |
-
img.hAlign = "CENTER"
|
| 324 |
-
story.append(img)
|
| 325 |
-
story.append(Spacer(1, 3 * mm))
|
| 326 |
|
| 327 |
-
|
|
|
|
| 328 |
composite_status_enum = {
|
| 329 |
"GREEN": StatusLevel.GREEN,
|
| 330 |
"AMBER": StatusLevel.AMBER,
|
|
@@ -349,178 +412,111 @@ def generate_pdf_report(
|
|
| 349 |
story.append(row)
|
| 350 |
story.append(Spacer(1, 4 * mm))
|
| 351 |
|
| 352 |
-
|
| 353 |
-
summary_header = [
|
| 354 |
-
Paragraph("<b>Indicator</b>", styles["body"]),
|
| 355 |
-
Paragraph("<b>Status</b>", styles["body"]),
|
| 356 |
-
Paragraph("<b>Trend</b>", styles["body"]),
|
| 357 |
-
Paragraph("<b>Confidence</b>", styles["body"]),
|
| 358 |
-
Paragraph("<b>Headline</b>", styles["body"]),
|
| 359 |
-
]
|
| 360 |
-
summary_rows = [summary_header]
|
| 361 |
-
for result in results:
|
| 362 |
-
label = _indicator_label(result.indicator_id)
|
| 363 |
-
status_color = STATUS_COLORS[result.status]
|
| 364 |
-
status_cell = Paragraph(
|
| 365 |
-
f'<font color="white"><b>{STATUS_LABELS[result.status]}</b></font>',
|
| 366 |
-
ParagraphStyle(
|
| 367 |
-
"ov_badge",
|
| 368 |
-
fontName="Helvetica-Bold",
|
| 369 |
-
fontSize=7,
|
| 370 |
-
textColor=colors.white,
|
| 371 |
-
alignment=TA_CENTER,
|
| 372 |
-
),
|
| 373 |
-
)
|
| 374 |
-
summary_rows.append([
|
| 375 |
-
Paragraph(label, styles["body_muted"]),
|
| 376 |
-
status_cell,
|
| 377 |
-
Paragraph(result.trend.value.capitalize(), styles["body_muted"]),
|
| 378 |
-
Paragraph(result.confidence.value.capitalize(), styles["body_muted"]),
|
| 379 |
-
Paragraph(result.headline[:80], styles["body_muted"]),
|
| 380 |
-
])
|
| 381 |
-
|
| 382 |
-
ov_col_w = (PAGE_W - 2 * MARGIN)
|
| 383 |
-
ov_table = Table(
|
| 384 |
-
summary_rows,
|
| 385 |
-
colWidths=[ov_col_w * 0.15, ov_col_w * 0.10, ov_col_w * 0.12, ov_col_w * 0.13, ov_col_w * 0.50],
|
| 386 |
-
)
|
| 387 |
-
ov_ts = TableStyle([
|
| 388 |
-
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E6E0")),
|
| 389 |
-
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor(_SHELL_HEX)]),
|
| 390 |
-
("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#D8D5CF")),
|
| 391 |
-
("TOPPADDING", (0, 0), (-1, -1), 3),
|
| 392 |
-
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
| 393 |
-
("LEFTPADDING", (0, 0), (-1, -1), 4),
|
| 394 |
-
("RIGHTPADDING", (0, 0), (-1, -1), 4),
|
| 395 |
-
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
| 396 |
-
("ALIGN", (1, 1), (1, -1), "CENTER"),
|
| 397 |
-
])
|
| 398 |
-
for row_idx, result in enumerate(results, start=1):
|
| 399 |
-
ov_ts.add("BACKGROUND", (1, row_idx), (1, row_idx), STATUS_COLORS[result.status])
|
| 400 |
-
ov_table.setStyle(ov_ts)
|
| 401 |
-
story.append(ov_table)
|
| 402 |
-
|
| 403 |
-
story.append(Spacer(1, 6 * mm))
|
| 404 |
-
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF")))
|
| 405 |
-
story.append(Spacer(1, 4 * mm))
|
| 406 |
-
|
| 407 |
-
# ------------------------------------------------------------------ #
|
| 408 |
-
# How to Read This Report #
|
| 409 |
-
# ------------------------------------------------------------------ #
|
| 410 |
-
story.append(Paragraph("How to Read This Report", styles["section_heading"]))
|
| 411 |
-
how_to = (
|
| 412 |
-
"Each indicator is assigned a traffic-light status: "
|
| 413 |
-
"<b><font color='{}'>GREEN</font></b> indicates conditions within the normal range, "
|
| 414 |
-
"<b><font color='{}'>AMBER</font></b> indicates elevated concern requiring monitoring, "
|
| 415 |
-
"and <b><font color='{}'>RED</font></b> indicates a critical situation requiring immediate attention. "
|
| 416 |
-
"Trend arrows indicate the direction of change over the analysis period. "
|
| 417 |
-
"Confidence levels reflect data quality and coverage."
|
| 418 |
-
).format(_GREEN_HEX, _AMBER_HEX, _RED_HEX)
|
| 419 |
-
story.append(Paragraph(how_to, styles["body"]))
|
| 420 |
-
story.append(Spacer(1, 4 * mm))
|
| 421 |
-
|
| 422 |
-
# ------------------------------------------------------------------ #
|
| 423 |
-
# Executive Summary #
|
| 424 |
-
# ------------------------------------------------------------------ #
|
| 425 |
red_count = sum(1 for r in results if r.status == StatusLevel.RED)
|
| 426 |
amber_count = sum(1 for r in results if r.status == StatusLevel.AMBER)
|
| 427 |
green_count = sum(1 for r in results if r.status == StatusLevel.GREEN)
|
| 428 |
total = len(results)
|
| 429 |
-
|
| 430 |
-
exec_summary_text = (
|
| 431 |
f"This report covers <b>{total}</b> indicator(s) for <b>{aoi.name}</b> "
|
| 432 |
f"over the period {time_range.start} to {time_range.end}. "
|
| 433 |
-
f"
|
| 434 |
-
f"<b><font color='{
|
| 435 |
-
f"<b><font color='{
|
| 436 |
-
f"<b><font color='{_AMBER_HEX}'>AMBER</font></b> status (elevated concern), and "
|
| 437 |
-
f"<b><font color='{_GREEN_HEX}'>{green_count} indicator(s)</font></b> are at "
|
| 438 |
-
f"<b><font color='{_GREEN_HEX}'>GREEN</font></b> status (within normal range)."
|
| 439 |
)
|
| 440 |
-
story.append(Paragraph(
|
| 441 |
-
story.append(Paragraph(exec_summary_text, styles["body"]))
|
| 442 |
-
story.append(Spacer(1, 6 * mm))
|
| 443 |
-
|
| 444 |
-
# ------------------------------------------------------------------ #
|
| 445 |
-
# Summary Map #
|
| 446 |
-
# ------------------------------------------------------------------ #
|
| 447 |
-
if summary_map_path and os.path.exists(summary_map_path):
|
| 448 |
-
from reportlab.platypus import Image
|
| 449 |
-
img = Image(summary_map_path, width=14 * cm, height=10.5 * cm)
|
| 450 |
-
img.hAlign = "CENTER"
|
| 451 |
-
story.append(img)
|
| 452 |
-
story.append(Spacer(1, 4 * mm))
|
| 453 |
-
|
| 454 |
-
# ------------------------------------------------------------------ #
|
| 455 |
-
# Indicator Results #
|
| 456 |
-
# ------------------------------------------------------------------ #
|
| 457 |
-
story.append(Paragraph("Indicator Results", styles["section_heading"]))
|
| 458 |
-
story.append(Spacer(1, 2 * mm))
|
| 459 |
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
map_path = (indicator_map_paths or {}).get(result.indicator_id, "")
|
| 464 |
-
block += _indicator_block(result, styles, map_path=map_path)
|
| 465 |
-
story.append(KeepTogether(block))
|
| 466 |
-
|
| 467 |
-
# ------------------------------------------------------------------ #
|
| 468 |
-
# Status Summary Table #
|
| 469 |
-
# ------------------------------------------------------------------ #
|
| 470 |
-
story.append(Paragraph("Status Summary", styles["section_heading"]))
|
| 471 |
-
story.append(Spacer(1, 2 * mm))
|
| 472 |
|
| 473 |
-
|
|
|
|
| 474 |
Paragraph("<b>Indicator</b>", styles["body"]),
|
| 475 |
Paragraph("<b>Status</b>", styles["body"]),
|
| 476 |
Paragraph("<b>Trend</b>", styles["body"]),
|
| 477 |
Paragraph("<b>Confidence</b>", styles["body"]),
|
|
|
|
| 478 |
]
|
| 479 |
-
|
| 480 |
for result in results:
|
| 481 |
label = _indicator_label(result.indicator_id)
|
| 482 |
-
status_color = STATUS_COLORS[result.status]
|
| 483 |
status_cell = Paragraph(
|
| 484 |
f'<font color="white"><b>{STATUS_LABELS[result.status]}</b></font>',
|
| 485 |
ParagraphStyle(
|
| 486 |
-
"
|
| 487 |
fontName="Helvetica-Bold",
|
| 488 |
-
fontSize=
|
| 489 |
textColor=colors.white,
|
| 490 |
alignment=TA_CENTER,
|
| 491 |
),
|
| 492 |
)
|
| 493 |
-
|
| 494 |
-
Paragraph(label, styles["
|
| 495 |
status_cell,
|
| 496 |
-
Paragraph(result.trend.value.capitalize(), styles["
|
| 497 |
-
Paragraph(result.confidence.value.capitalize(), styles["
|
|
|
|
| 498 |
])
|
| 499 |
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E6E0")),
|
| 504 |
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor(_SHELL_HEX)]),
|
| 505 |
("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#D8D5CF")),
|
| 506 |
-
("TOPPADDING", (0, 0), (-1, -1),
|
| 507 |
-
("BOTTOMPADDING", (0, 0), (-1, -1),
|
| 508 |
-
("LEFTPADDING", (0, 0), (-1, -1),
|
| 509 |
-
("RIGHTPADDING", (0, 0), (-1, -1),
|
| 510 |
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
| 511 |
("ALIGN", (1, 1), (1, -1), "CENTER"),
|
| 512 |
])
|
| 513 |
-
# Apply status cell backgrounds dynamically
|
| 514 |
for row_idx, result in enumerate(results, start=1):
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
story.append(
|
|
|
|
| 518 |
story.append(Spacer(1, 6 * mm))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
for result in results:
|
| 525 |
indicator_label = _indicator_label(result.indicator_id)
|
| 526 |
story.append(
|
|
@@ -529,22 +525,33 @@ def generate_pdf_report(
|
|
| 529 |
styles["body"],
|
| 530 |
)
|
| 531 |
)
|
| 532 |
-
story.append(Spacer(1,
|
| 533 |
|
| 534 |
-
#
|
| 535 |
-
|
| 536 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF")))
|
| 538 |
story.append(Spacer(1, 3 * mm))
|
| 539 |
disclaimer = (
|
| 540 |
"This report has been generated automatically by MERLx Aperture using satellite "
|
| 541 |
"remote sensing data. Results are intended to support humanitarian situation analysis "
|
| 542 |
"and should be interpreted alongside ground-truth information and expert judgement. "
|
| 543 |
-
"
|
| 544 |
-
"
|
| 545 |
-
"geospatial datasets. Temporal coverage, cloud contamination, and sensor resolution "
|
| 546 |
-
"may affect the reliability of individual indicators. Users are encouraged to review "
|
| 547 |
-
"the methodology and limitations sections before drawing operational conclusions."
|
| 548 |
)
|
| 549 |
story.append(Paragraph("Disclaimer", styles["section_heading"]))
|
| 550 |
story.append(Paragraph(disclaimer, styles["body_muted"]))
|
|
|
|
| 160 |
return t
|
| 161 |
|
| 162 |
|
| 163 |
+
def _indicator_block(
|
| 164 |
+
result: IndicatorResult,
|
| 165 |
+
styles: dict,
|
| 166 |
+
map_path: str = "",
|
| 167 |
+
chart_path: str = "",
|
| 168 |
+
) -> list:
|
| 169 |
"""Build the flowables for a single indicator section."""
|
| 170 |
+
from app.outputs.narrative import get_interpretation
|
| 171 |
+
from reportlab.platypus import Image
|
| 172 |
+
|
| 173 |
elements = []
|
| 174 |
|
| 175 |
# Badge + headline row
|
|
|
|
| 198 |
))
|
| 199 |
elements.append(Spacer(1, 2 * mm))
|
| 200 |
|
| 201 |
+
# Map and chart side by side (if both exist), or single image
|
| 202 |
+
map_exists = map_path and os.path.exists(map_path)
|
| 203 |
+
chart_exists = chart_path and os.path.exists(chart_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
+
if map_exists and chart_exists:
|
| 206 |
+
map_img = Image(map_path, width=8 * cm, height=6 * cm)
|
| 207 |
+
chart_img = Image(chart_path, width=8 * cm, height=6 * cm)
|
| 208 |
+
img_table = Table(
|
| 209 |
+
[[map_img, chart_img]],
|
| 210 |
+
colWidths=[8.5 * cm, 8.5 * cm],
|
| 211 |
+
)
|
| 212 |
+
img_table.setStyle(TableStyle([
|
| 213 |
+
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
| 214 |
+
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
| 215 |
+
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
| 216 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
| 217 |
+
]))
|
| 218 |
+
elements.append(img_table)
|
| 219 |
+
elements.append(Spacer(1, 2 * mm))
|
| 220 |
+
elif map_exists:
|
| 221 |
img = Image(map_path, width=12 * cm, height=9 * cm)
|
| 222 |
img.hAlign = "CENTER"
|
| 223 |
elements.append(img)
|
| 224 |
elements.append(Spacer(1, 2 * mm))
|
| 225 |
+
elif chart_exists:
|
| 226 |
+
img = Image(chart_path, width=12 * cm, height=9 * cm)
|
| 227 |
+
img.hAlign = "CENTER"
|
| 228 |
+
elements.append(img)
|
| 229 |
+
elements.append(Spacer(1, 2 * mm))
|
| 230 |
|
| 231 |
+
# What the data shows
|
| 232 |
+
elements.append(Paragraph("<b>What the data shows</b>", styles["body_muted"]))
|
| 233 |
elements.append(Paragraph(result.summary, styles["body"]))
|
| 234 |
|
| 235 |
+
# What this means
|
| 236 |
+
interpretation = get_interpretation(result.indicator_id, result.status)
|
| 237 |
+
elements.append(Paragraph("<b>What this means</b>", styles["body_muted"]))
|
| 238 |
+
elements.append(Paragraph(interpretation, styles["body"]))
|
| 239 |
+
|
| 240 |
+
# Data quality line
|
| 241 |
+
data_source_label = result.data_source or "unknown"
|
| 242 |
+
quality_line = (
|
| 243 |
+
f"Confidence: <b>{result.confidence.value.capitalize()}</b> | "
|
| 244 |
+
f"Trend: <b>{result.trend.value.capitalize()}</b> | "
|
| 245 |
+
f"Source: <b>{data_source_label}</b>"
|
| 246 |
+
)
|
| 247 |
+
elements.append(Paragraph(quality_line, styles["body_muted"]))
|
| 248 |
|
| 249 |
elements.append(Spacer(1, 4 * mm))
|
| 250 |
elements.append(
|
|
|
|
| 254 |
return elements
|
| 255 |
|
| 256 |
|
| 257 |
+
def _detect_data_sources(results: Sequence[IndicatorResult]) -> str:
|
| 258 |
+
"""Scan methodology text to auto-detect known satellite data sources."""
|
| 259 |
+
known_sources = ["Sentinel-2", "Sentinel-1", "FIRMS", "CHIRPS", "MODIS", "Landsat", "VIIRS"]
|
| 260 |
+
found: list[str] = []
|
| 261 |
+
combined_text = " ".join(r.methodology for r in results)
|
| 262 |
+
for src in known_sources:
|
| 263 |
+
if src.lower() in combined_text.lower() and src not in found:
|
| 264 |
+
found.append(src)
|
| 265 |
+
if not found:
|
| 266 |
+
return "Open-source satellite imagery"
|
| 267 |
+
return ", ".join(found)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
def generate_pdf_report(
|
| 271 |
*,
|
| 272 |
aoi: AOI,
|
|
|
|
| 291 |
output_path:
|
| 292 |
Absolute path where the PDF should be saved.
|
| 293 |
"""
|
| 294 |
+
from app.outputs.narrative import generate_narrative
|
| 295 |
+
|
| 296 |
styles = _build_styles()
|
| 297 |
PAGE_W, PAGE_H = A4
|
| 298 |
MARGIN = 2 * cm
|
|
|
|
| 328 |
)
|
| 329 |
doc.pageBackgrounds = [colors.white]
|
| 330 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
from datetime import datetime as _dt, timezone as _tz
|
| 332 |
generated_at = _dt.now(_tz.utc).strftime("%Y-%m-%d %H:%M UTC")
|
| 333 |
|
| 334 |
+
# Output directory for chart path auto-detection
|
| 335 |
+
output_dir = os.path.dirname(output_path)
|
| 336 |
+
|
| 337 |
+
story = []
|
| 338 |
+
|
| 339 |
+
# ================================================================== #
|
| 340 |
+
# SECTION 1: The Place #
|
| 341 |
+
# ================================================================== #
|
| 342 |
story.append(Paragraph("MERLx Aperture — Situation Report", styles["title"]))
|
| 343 |
story.append(Paragraph(aoi.name, styles["subtitle"]))
|
| 344 |
+
story.append(Spacer(1, 2 * mm))
|
| 345 |
+
|
| 346 |
+
# Overview map (full width)
|
| 347 |
+
if overview_map_path and os.path.exists(overview_map_path):
|
| 348 |
+
from reportlab.platypus import Image
|
| 349 |
+
img = Image(overview_map_path, width=14 * cm, height=10.5 * cm)
|
| 350 |
+
img.hAlign = "CENTER"
|
| 351 |
+
story.append(img)
|
| 352 |
+
story.append(Spacer(1, 3 * mm))
|
| 353 |
+
|
| 354 |
+
# AOI metadata
|
| 355 |
story.append(
|
| 356 |
Paragraph(
|
| 357 |
f"Analysis period: {time_range.start} \u2013 {time_range.end}",
|
|
|
|
| 366 |
styles["body_muted"],
|
| 367 |
)
|
| 368 |
)
|
| 369 |
+
|
| 370 |
+
# Auto-detected data sources
|
| 371 |
+
data_sources_str = _detect_data_sources(results)
|
| 372 |
+
story.append(
|
| 373 |
+
Paragraph(
|
| 374 |
+
f"Data sources: {data_sources_str}",
|
| 375 |
+
styles["body_muted"],
|
| 376 |
+
)
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
story.append(Spacer(1, 4 * mm))
|
| 380 |
story.append(HRFlowable(width="100%", thickness=1, color=INK_MUTED))
|
| 381 |
story.append(Spacer(1, 6 * mm))
|
| 382 |
|
| 383 |
+
# ================================================================== #
|
| 384 |
+
# SECTION 2: Situation Assessment #
|
| 385 |
+
# ================================================================== #
|
| 386 |
+
story.append(Paragraph("Situation Assessment", styles["section_heading"]))
|
| 387 |
+
story.append(Spacer(1, 2 * mm))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
|
| 389 |
+
# Composite badge + headline (if overview_score provided)
|
| 390 |
+
if overview_score:
|
| 391 |
composite_status_enum = {
|
| 392 |
"GREEN": StatusLevel.GREEN,
|
| 393 |
"AMBER": StatusLevel.AMBER,
|
|
|
|
| 412 |
story.append(row)
|
| 413 |
story.append(Spacer(1, 4 * mm))
|
| 414 |
|
| 415 |
+
# Cross-indicator narrative
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
red_count = sum(1 for r in results if r.status == StatusLevel.RED)
|
| 417 |
amber_count = sum(1 for r in results if r.status == StatusLevel.AMBER)
|
| 418 |
green_count = sum(1 for r in results if r.status == StatusLevel.GREEN)
|
| 419 |
total = len(results)
|
| 420 |
+
count_line = (
|
|
|
|
| 421 |
f"This report covers <b>{total}</b> indicator(s) for <b>{aoi.name}</b> "
|
| 422 |
f"over the period {time_range.start} to {time_range.end}. "
|
| 423 |
+
f"<b><font color='{_RED_HEX}'>{red_count}</font></b> at RED, "
|
| 424 |
+
f"<b><font color='{_AMBER_HEX}'>{amber_count}</font></b> at AMBER, "
|
| 425 |
+
f"<b><font color='{_GREEN_HEX}'>{green_count}</font></b> at GREEN."
|
|
|
|
|
|
|
|
|
|
| 426 |
)
|
| 427 |
+
story.append(Paragraph(count_line, styles["body"]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
|
| 429 |
+
narrative_text = generate_narrative(results)
|
| 430 |
+
story.append(Paragraph(narrative_text, styles["body"]))
|
| 431 |
+
story.append(Spacer(1, 4 * mm))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
+
# Compact summary table
|
| 434 |
+
summary_header = [
|
| 435 |
Paragraph("<b>Indicator</b>", styles["body"]),
|
| 436 |
Paragraph("<b>Status</b>", styles["body"]),
|
| 437 |
Paragraph("<b>Trend</b>", styles["body"]),
|
| 438 |
Paragraph("<b>Confidence</b>", styles["body"]),
|
| 439 |
+
Paragraph("<b>Headline</b>", styles["body"]),
|
| 440 |
]
|
| 441 |
+
summary_rows = [summary_header]
|
| 442 |
for result in results:
|
| 443 |
label = _indicator_label(result.indicator_id)
|
|
|
|
| 444 |
status_cell = Paragraph(
|
| 445 |
f'<font color="white"><b>{STATUS_LABELS[result.status]}</b></font>',
|
| 446 |
ParagraphStyle(
|
| 447 |
+
"ov_badge",
|
| 448 |
fontName="Helvetica-Bold",
|
| 449 |
+
fontSize=7,
|
| 450 |
textColor=colors.white,
|
| 451 |
alignment=TA_CENTER,
|
| 452 |
),
|
| 453 |
)
|
| 454 |
+
summary_rows.append([
|
| 455 |
+
Paragraph(label, styles["body_muted"]),
|
| 456 |
status_cell,
|
| 457 |
+
Paragraph(result.trend.value.capitalize(), styles["body_muted"]),
|
| 458 |
+
Paragraph(result.confidence.value.capitalize(), styles["body_muted"]),
|
| 459 |
+
Paragraph(result.headline[:80], styles["body_muted"]),
|
| 460 |
])
|
| 461 |
|
| 462 |
+
ov_col_w = PAGE_W - 2 * MARGIN
|
| 463 |
+
ov_table = Table(
|
| 464 |
+
summary_rows,
|
| 465 |
+
colWidths=[
|
| 466 |
+
ov_col_w * 0.15,
|
| 467 |
+
ov_col_w * 0.10,
|
| 468 |
+
ov_col_w * 0.12,
|
| 469 |
+
ov_col_w * 0.13,
|
| 470 |
+
ov_col_w * 0.50,
|
| 471 |
+
],
|
| 472 |
+
)
|
| 473 |
+
ov_ts = TableStyle([
|
| 474 |
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E6E0")),
|
| 475 |
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor(_SHELL_HEX)]),
|
| 476 |
("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#D8D5CF")),
|
| 477 |
+
("TOPPADDING", (0, 0), (-1, -1), 3),
|
| 478 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
| 479 |
+
("LEFTPADDING", (0, 0), (-1, -1), 4),
|
| 480 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 4),
|
| 481 |
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
| 482 |
("ALIGN", (1, 1), (1, -1), "CENTER"),
|
| 483 |
])
|
|
|
|
| 484 |
for row_idx, result in enumerate(results, start=1):
|
| 485 |
+
ov_ts.add("BACKGROUND", (1, row_idx), (1, row_idx), STATUS_COLORS[result.status])
|
| 486 |
+
ov_table.setStyle(ov_ts)
|
| 487 |
+
story.append(ov_table)
|
| 488 |
+
|
| 489 |
story.append(Spacer(1, 6 * mm))
|
| 490 |
+
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF")))
|
| 491 |
+
story.append(Spacer(1, 4 * mm))
|
| 492 |
+
|
| 493 |
+
# ================================================================== #
|
| 494 |
+
# SECTION 3: Indicator Deep Dives #
|
| 495 |
+
# ================================================================== #
|
| 496 |
+
story.append(Paragraph("Indicator Detail", styles["section_heading"]))
|
| 497 |
+
story.append(Spacer(1, 2 * mm))
|
| 498 |
|
| 499 |
+
for result in results:
|
| 500 |
+
indicator_label = _indicator_label(result.indicator_id)
|
| 501 |
+
map_path = (indicator_map_paths or {}).get(result.indicator_id, "")
|
| 502 |
+
|
| 503 |
+
# Auto-detect chart path from output directory
|
| 504 |
+
chart_path = os.path.join(output_dir, f"{result.indicator_id}_chart.png")
|
| 505 |
+
if not os.path.exists(chart_path):
|
| 506 |
+
chart_path = ""
|
| 507 |
+
|
| 508 |
+
block = [Paragraph(indicator_label, styles["section_heading"])]
|
| 509 |
+
block += _indicator_block(result, styles, map_path=map_path, chart_path=chart_path)
|
| 510 |
+
story.append(KeepTogether(block))
|
| 511 |
+
|
| 512 |
+
# ================================================================== #
|
| 513 |
+
# SECTION 4: Technical Annex #
|
| 514 |
+
# ================================================================== #
|
| 515 |
+
story.append(Paragraph("Technical Annex", styles["section_heading"]))
|
| 516 |
+
story.append(Spacer(1, 2 * mm))
|
| 517 |
+
|
| 518 |
+
# Methodology subsection
|
| 519 |
+
story.append(Paragraph("Methodology", styles["section_heading"]))
|
| 520 |
for result in results:
|
| 521 |
indicator_label = _indicator_label(result.indicator_id)
|
| 522 |
story.append(
|
|
|
|
| 525 |
styles["body"],
|
| 526 |
)
|
| 527 |
)
|
| 528 |
+
story.append(Spacer(1, 4 * mm))
|
| 529 |
|
| 530 |
+
# Limitations subsection — deduplicated across all indicators
|
| 531 |
+
all_limitations: list[str] = []
|
| 532 |
+
seen: set[str] = set()
|
| 533 |
+
for result in results:
|
| 534 |
+
for lim in (result.limitations or []):
|
| 535 |
+
normalized = lim.strip().rstrip(".")
|
| 536 |
+
if normalized.lower() not in seen:
|
| 537 |
+
seen.add(normalized.lower())
|
| 538 |
+
all_limitations.append(lim)
|
| 539 |
+
|
| 540 |
+
if all_limitations:
|
| 541 |
+
story.append(Paragraph("Limitations", styles["section_heading"]))
|
| 542 |
+
for lim in all_limitations:
|
| 543 |
+
story.append(Paragraph(f"\u2022 {lim}", styles["limitation"]))
|
| 544 |
+
story.append(Spacer(1, 4 * mm))
|
| 545 |
+
|
| 546 |
+
# Disclaimer
|
| 547 |
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF")))
|
| 548 |
story.append(Spacer(1, 3 * mm))
|
| 549 |
disclaimer = (
|
| 550 |
"This report has been generated automatically by MERLx Aperture using satellite "
|
| 551 |
"remote sensing data. Results are intended to support humanitarian situation analysis "
|
| 552 |
"and should be interpreted alongside ground-truth information and expert judgement. "
|
| 553 |
+
"Temporal coverage, cloud contamination, and sensor resolution may affect the "
|
| 554 |
+
"reliability of individual indicators."
|
|
|
|
|
|
|
|
|
|
| 555 |
)
|
| 556 |
story.append(Paragraph("Disclaimer", styles["section_heading"]))
|
| 557 |
story.append(Paragraph(disclaimer, styles["body_muted"]))
|