ed195ed96b
Terra-View side of the daily night-vs-baseline sound report for the John Myler 24/7 job. Engine is built and verified end-to-end against real meter data; SMTP send + scheduler/capture wiring still pending. - ingest: refactor upload_nrl_data into a callable ingest_nrl_zip(location_id, zip_bytes, db) sharing one core with the HTTP endpoint. Capture the .rnh percentile map + weightings into session metadata; dedup on store-name + start time. Ingest stays metric-agnostic (every Leq column preserved). - report_pipeline.py: metric registry, Evening/Nighttime windows, correct aggregation (Lmax=max, Ln=arithmetic, Leq=logarithmic), baseline = typical night, per-location + per-project builders. - report_renderers.py: HTML email-body renderer (Last/Base/delta layout). - report_email.py: config-driven SMTP via stdlib (env vars) with a dry-run fallback so the pipeline runs without credentials. - report_orchestrator.py: compute -> render -> always write report.html + report.json to disk -> best-effort email. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115 lines
5.0 KiB
Python
115 lines
5.0 KiB
Python
"""
|
||
Nightly Report Renderers.
|
||
|
||
Pluggable renderers over the `report_pipeline` data model. v1 ships the HTML
|
||
email body + the Excel attachment; PDF and an inline chart image are v1.1
|
||
(each needs a new dependency). Keeping renderers separate from the compute
|
||
core means a future report wizard just toggles metrics/renderers — the data
|
||
model is unchanged.
|
||
|
||
Email-client constraints: the HTML uses a table layout with **inline styles
|
||
only** (no <style> blocks, no external CSS, no fl/grid), which is the reliable
|
||
common denominator across Outlook / Gmail / Apple Mail.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from html import escape
|
||
|
||
from backend.services.report_pipeline import ProjectNightReport, LocationNightReport
|
||
|
||
|
||
# Colours: louder-than-baseline reads as a concern (red), quieter as fine (green).
|
||
_RED = "#b00020"
|
||
_GREEN = "#1a7f37"
|
||
_GREY = "#888888"
|
||
|
||
|
||
def _fmt_value(v) -> str:
|
||
return f"{v:.1f}" if isinstance(v, (int, float)) else "—"
|
||
|
||
|
||
def _fmt_delta(v) -> str:
|
||
"""Signed delta with colour; positive (louder) = red, negative (quieter) = green."""
|
||
if not isinstance(v, (int, float)):
|
||
return f'<span style="color:{_GREY}">—</span>'
|
||
if v > 0:
|
||
return f'<span style="color:{_RED}">+{v:.1f}</span>'
|
||
if v < 0:
|
||
return f'<span style="color:{_GREEN}">{v:.1f}</span>'
|
||
return f'<span style="color:{_GREY}">0.0</span>'
|
||
|
||
|
||
def _location_table(loc: LocationNightReport) -> str:
|
||
"""One location block: heading + Metric × (window: Last / Base / Δ) table."""
|
||
th = ('padding:5px 9px;border:1px solid #ccc;background:#f2f2f2;'
|
||
'font:bold 12px Arial,sans-serif;text-align:center')
|
||
sub = ('padding:4px 8px;border:1px solid #ccc;background:#fafafa;'
|
||
'font:11px Arial,sans-serif;text-align:center;color:#555')
|
||
td = 'padding:4px 9px;border:1px solid #ccc;font:12px Arial,sans-serif;text-align:center'
|
||
td_l = 'padding:4px 9px;border:1px solid #ccc;font:bold 12px Arial,sans-serif;text-align:left'
|
||
|
||
# Top header: blank label cell + each window spanning Last/Base/Δ
|
||
top = f'<th rowspan="2" style="{th}">Metric (dBA)</th>'
|
||
for w in loc.windows:
|
||
top += f'<th colspan="3" style="{th}">{escape(w.label)}</th>'
|
||
sub_row = ''.join(
|
||
f'<th style="{sub}">Last</th><th style="{sub}">Base</th><th style="{sub}">Δ</th>'
|
||
for _ in loc.windows
|
||
)
|
||
|
||
body = ''
|
||
for m in loc.metrics:
|
||
cells = ''
|
||
for w in loc.windows:
|
||
cp = loc.table[w.key][m.key]
|
||
cells += (f'<td style="{td}">{_fmt_value(cp.last_night)}</td>'
|
||
f'<td style="{td}">{_fmt_value(cp.baseline)}</td>'
|
||
f'<td style="{td}">{_fmt_delta(cp.delta)}</td>')
|
||
body += f'<tr><td style="{td_l}">{escape(m.label)}</td>{cells}</tr>'
|
||
|
||
meta = (f'{loc.night_interval_count} intervals'
|
||
+ (f' · baseline = {loc.baseline_nights_used} night(s)'
|
||
if loc.baseline_nights_used else ' · no baseline yet'))
|
||
notes = ''
|
||
if loc.notes:
|
||
notes = ('<div style="font:11px Arial,sans-serif;color:#b00020;margin:2px 0 0">'
|
||
+ '<br>'.join(escape(n) for n in loc.notes) + '</div>')
|
||
|
||
return (
|
||
f'<h3 style="font:bold 15px Arial,sans-serif;margin:18px 0 4px">{escape(loc.location_name)}</h3>'
|
||
f'<div style="font:11px Arial,sans-serif;color:#666;margin:0 0 6px">{escape(meta)}</div>'
|
||
f'<table style="border-collapse:collapse;border:1px solid #ccc">'
|
||
f'<thead><tr>{top}</tr><tr>{sub_row}</tr></thead>'
|
||
f'<tbody>{body}</tbody></table>{notes}'
|
||
)
|
||
|
||
|
||
def render_html_summary(report: ProjectNightReport) -> str:
|
||
"""Render the full email-body HTML for a project's night report."""
|
||
windows_desc = ", ".join(w.label for w in (report.locations[0].windows if report.locations else []))
|
||
header = (
|
||
f'<h2 style="font:bold 18px Arial,sans-serif;margin:0 0 2px">'
|
||
f'{escape(report.project_name)} — Night Report</h2>'
|
||
f'<div style="font:13px Arial,sans-serif;color:#444;margin:0 0 4px">'
|
||
f'Night of {report.night_date:%a %m/%d/%y} · last night vs. baseline</div>'
|
||
f'<div style="font:11px Arial,sans-serif;color:#888;margin:0 0 10px">'
|
||
f'Windows: {escape(windows_desc)}. '
|
||
f'Δ = last night minus baseline (<span style="color:{_RED}">+ louder</span>, '
|
||
f'<span style="color:{_GREEN}">− quieter</span>). '
|
||
f'LAmax = loudest interval; L-values are arithmetic averages; '
|
||
f'baseline = typical night.</div>'
|
||
)
|
||
|
||
if not report.locations:
|
||
body = ('<div style="font:13px Arial,sans-serif;color:#b00020">'
|
||
'No sound locations found for this project.</div>')
|
||
else:
|
||
body = ''.join(_location_table(loc) for loc in report.locations)
|
||
|
||
footer = ('<div style="font:10px Arial,sans-serif;color:#aaa;margin-top:18px">'
|
||
'Automated report — Terra-View. Full interval data in the attached spreadsheet.</div>')
|
||
|
||
return (f'<!DOCTYPE html><html><body style="margin:0;padding:16px;background:#fff">'
|
||
f'{header}{body}{footer}</body></html>')
|