ContentsFoundations to FrontierBezhig

eng-lab/hwe/report/html.py

All lab code. Download this file.

"""Write the design report: one self-contained HTML page, rebuilt from scratch every run.

Nothing in the report is typed by hand. Every number is recomputed from the
design files by the pipeline, so the report can never disagree with the design
(the same reason a CI build beats a hand-maintained spreadsheet). Next to the
page, results.json keeps every result in plain numbers with their units; the
next run compares against it and lists what changed.

    out/report/index.html     open it in any browser
    out/report/results.json   machine-readable results of this run
    out/report/previous.json  the run before, for the "what changed" table
"""
from __future__ import annotations

import datetime as dt
import hashlib
import html
import json
from pathlib import Path

from hwe.design import ROOT
from hwe.pipeline import STAGES, Run
from hwe.units import fmt, is_quantity

OUT = ROOT / "out" / "report"

KEY_RESULTS = [
    ("mass.predicted", "Predicted mass", "g"),
    ("hover.thrust_to_weight", "Thrust-to-weight", ""),
    ("hover.hover_fraction", "Hover uses", "percent"),
    ("hover.max_climb_accel", "Max climb acceleration", "m/s^2"),
    ("energy.battery_energy", "Battery energy", "W*h"),
    ("energy.hover_power", "Hover power", "W"),
    ("energy.flight_time", "Hover flight time", "min"),
]

CSS = """
:root { --surface:#fcfcfb; --card:#ffffff; --ink:#173446; --muted:#52514e; --line:#e4e6e8;
        --blue:#2a78d6; --good:#1a7f37; --bad:#c62828; --warn:#9a6700; --pale:#f2f6f7; }
@media (prefers-color-scheme: dark) {
  :root { --surface:#1a1a19; --card:#232322; --ink:#f2f2f0; --muted:#c3c2b7; --line:#3a3a38;
          --blue:#3987e5; --good:#4caf50; --bad:#ef5350; --warn:#d4a017; --pale:#2b2b29; }
}
* { box-sizing: border-box; }
body { margin:0; background:var(--surface); color:var(--ink); font:15px/1.5 system-ui, "Segoe UI", sans-serif; }
main { max-width: 1040px; margin: 0 auto; padding: 24px 16px 64px; }
h1 { font-size: 26px; margin: 0 0 4px; } h2 { font-size: 19px; margin: 34px 0 10px; border-bottom:1px solid var(--line); padding-bottom:4px; }
.sub { color: var(--muted); font-size: 13px; }
.tiles { display:grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap:10px; margin: 14px 0; }
.tile { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:10px 12px; }
.tile .label { font-size:12px; color:var(--muted); } .tile .value { font-size:22px; font-weight:600; }
table { border-collapse: collapse; width:100%; background:var(--card); font-size:13.5px; }
th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); vertical-align: top; }
th { font-size:12px; color:var(--muted); font-weight:600; }
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
.pill { display:inline-block; padding:1px 8px; border-radius:10px; font-size:12px; font-weight:600; border:1px solid currentColor; }
.pass { color: var(--good); } .fail { color: var(--bad); } .open, .locked, .note { color: var(--muted); } .warn { color: var(--warn); }
.card { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:12px; overflow-x:auto; }
.figure { background:#ffffff; border:1px solid var(--line); border-radius:8px; padding:12px; overflow-x:auto; }
/* Figures stay on white in both themes, like printed plots: their colours are chosen for a light surface. */
.two { display:grid; grid-template-columns: 1fr 1fr; gap: 14px; }
@media (max-width: 900px) { .two { grid-template-columns: 1fr; } }
.drawing img { width:100%; height:auto; }
code { background: var(--pale); padding: 0 4px; border-radius: 4px; }
svg { max-width: 100%; height: auto; }
.figure svg { width: 100%; }
"""


def _value(q, unit: str | None = None) -> str:
    if q is None:
        return "—"
    if is_quantity(q):
        if unit == "percent":
            return f"{q.to('dimensionless').m * 100:.1f}%"
        if unit is not None and unit != "":
            q = q.to(unit)
        elif unit == "":
            return f"{q.to('dimensionless').m:.2f}"
        return html.escape(fmt(q, 4))
    return html.escape(str(q))


def _fraction(x: float) -> str:
    """A relative difference or tolerance: percent when it is large enough to read that way."""
    return f"{x * 100:.1f}%" if x >= 1e-3 else f"{x:.1e}"


def _fingerprint() -> str:
    h = hashlib.sha256()
    for name in ("drone.toml", "requirements.toml"):
        p = ROOT / "design" / name
        if p.exists():
            h.update(p.read_bytes())
    return h.hexdigest()[:10]


def _plain(results: dict) -> dict:
    out = {}
    for k, v in results.items():
        if k.startswith("_"):
            continue
        if is_quantity(v):
            out[k] = {"value": float(v.m), "unit": f"{v.units:~}"}
    return out


def _changes(now: dict, before: dict) -> list[tuple[str, str, str, str]]:
    rows = []
    for key, entry in now.items():
        old = before.get(key)
        if old is None or old["unit"] != entry["unit"]:
            continue
        a, b = old["value"], entry["value"]
        if a == b:
            continue
        rel = (b - a) / abs(a) if a else float("inf")
        rows.append((key, f"{a:.4g} {old['unit']}", f"{b:.4g} {entry['unit']}", f"{rel * 100:+.1f}%"))
    return rows


def write(run: Run, tinker_rows: list[dict] | None = None) -> Path:
    """Build index.html and results.json for this run. Returns the page's path."""
    from hwe.report import charts

    OUT.mkdir(parents=True, exist_ok=True)
    now_plain = _plain(run.results)
    results_path, previous_path = OUT / "results.json", OUT / "previous.json"
    before = {}
    if results_path.exists():
        before = json.loads(results_path.read_text(encoding="utf-8")).get("results", {})
        previous_path.write_text(results_path.read_text(encoding="utf-8"), encoding="utf-8")
    stamp = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    results_path.write_text(json.dumps({"time": stamp, "fingerprint": _fingerprint(), "results": now_plain},
                                       indent=1), encoding="utf-8")

    r, p = run.results, []
    name = html.escape(run.design.raw.get("meta", {}).get("name", "drone"))
    p.append(f"<!doctype html><html lang='en'><head><meta charset='utf-8'>"
             f"<meta name='viewport' content='width=device-width, initial-scale=1'>"
             f"<title>{name}: design report</title><style>{CSS}</style></head><body><main>")
    p.append(f"<h1>{name}</h1><div class='sub'>Design report generated {stamp} &middot; design fingerprint "
             f"<code>{_fingerprint()}</code> &middot; unlocked: {', '.join(run.unlocked)} &middot; "
             f"every number recomputed from <code>design/drone.toml</code></div>")

    # Key results
    p.append("<div class='tiles'>")
    for key, label, unit in KEY_RESULTS:
        p.append(f"<div class='tile'><div class='label'>{label}</div><div class='value'>{_value(r.get(key), unit)}</div></div>")
    p.append("</div>")

    # What changed
    changes = _changes(now_plain, before)
    if changes:
        p.append("<h2>What changed since the last run</h2><table><tr><th>Result</th><th>Before</th><th>Now</th><th>Change</th></tr>")
        for key, a, b, rel in changes:
            p.append(f"<tr><td>{key}</td><td class='num'>{a}</td><td class='num'>{b}</td><td class='num'>{rel}</td></tr>")
        p.append("</table>")

    # Requirements
    p.append("<h2>Requirements</h2>")
    problems = r.get("_requirement_problems") or []
    if problems:
        p.append("<p class='warn'>Problems in <code>design/requirements.toml</code>: " +
                 "; ".join(html.escape(x) for x in problems) + "</p>")
    if run.requirement_status:
        p.append("<table><tr><th>ID</th><th>Requirement</th><th>Method</th><th>Value</th><th>Threshold</th><th>Margin</th><th>Status</th></tr>")
        for s in run.requirement_status:
            q = s.requirement
            margin = "&mdash;" if s.margin is None else f"{s.margin * 100:+.0f}%"
            value = _value(s.value, None if s.value is None or not is_quantity(q.threshold) else f"{q.threshold.units}")
            p.append(f"<tr><td>{html.escape(q.id)}</td><td>{html.escape(q.text)}</td><td>{q.verification}</td>"
                     f"<td class='num'>{value}</td><td class='num'>{html.escape(q.op)} {_value(q.threshold)}</td>"
                     f"<td class='num'>{margin}</td><td><span class='pill {s.state}'>{s.state}</span></td></tr>")
        p.append("</table>")
    else:
        p.append(f"<p class='note'>{html.escape(run.stages.get('requirements', ''))}</p>")

    # Mass
    p.append("<h2>Mass budget</h2>")
    if run.budget and run.by_subsystem:
        allow = run.design.section("growth_allowance")
        p.append("<div class='figure'>" + charts.mass_bars(run.by_subsystem) + "</div><div style='margin-top:12px'>")
        p.append("<table><tr><th>Item</th><th>Maturity</th><th>Mass</th><th>Allowance</th></tr>")
        for item in run.budget:
            p.append(f"<tr><td>{html.escape(item.name)}</td><td>{item.maturity}</td>"
                     f"<td class='num'>{_value(item.mass, 'g')}</td><td class='num'>{allow.get(item.maturity, 0) * 100:.0f}%</td></tr>")
        p.append(f"<tr><th>Basic total</th><td></td><td class='num'>{_value(r.get('mass.basic'), 'g')}</td><td></td></tr>")
        p.append(f"<tr><th>Predicted (with allowance)</th><td></td><td class='num'>{_value(r.get('mass.predicted'), 'g')}</td><td></td></tr>")
        p.append(f"<tr><th>Allowable (from T/W requirement)</th><td></td><td class='num'>{_value(r.get('mass.allowable'), 'g')}</td>"
                 f"<td class='num'>margin {_value(r.get('mass.margin'), 'percent')}</td></tr></table></div>")
    else:
        p.append(f"<p class='note'>{html.escape(run.stages.get('mass', ''))}</p>")

    # Energy and the mass spiral
    p.append("<h2>Energy and the mass spiral</h2>")
    if run.sweep and run.sweep.get("flight_time"):
        p.append("<div class='figure'>" + charts.mass_spiral(run.sweep) + "</div>")
        p.append(f"<p class='sub'>Battery specific energy held at {_value(run.sweep['specific_energy'], 'W*h/kg')}; "
                 f"the hover efficiency is read from the thrust table at each new hover thrust.</p>")
    else:
        p.append(f"<p class='note'>{html.escape(run.stages.get('sweep', ''))}</p>")

    # CAD
    p.append("<h2>The arm (CAD)</h2>")
    if run.part is not None:
        from hwe.cad.drawing import ISOMETRIC, draw
        img = draw(run.part, OUT / "arm.png", ISOMETRIC)
        p.append(f"<div class='two'><div class='figure drawing'><img alt='Isometric drawing of the arm' src='{img.name}'></div>"
                 f"<div><table><tr><th>Volume (CAD)</th><td class='num'>{_value(r.get('arm.volume'), 'mm^3')}</td></tr>"
                 f"<tr><th>Volume (by hand)</th><td class='num'>{_value(r.get('arm.volume_by_hand'), 'mm^3')}</td></tr>"
                 f"<tr><th>Mass, one arm</th><td class='num'>{_value(r.get('arm.mass'), 'g')}</td></tr></table></div></div>")
    else:
        p.append(f"<p class='note'>{html.escape(run.stages.get('cad', ''))}</p>")

    # Phone test
    p.append("<h2>Test: the phone accelerometer (EL01)</h2>")
    if run.phone:
        p.append("<div class='figure'>" + charts.phone_traces(run.phone) + "</div>")
        p.append(f"<table><tr><th>Reading at rest</th><td class='num'>{_value(r.get('phone.rest_reading'), 'm/s^2')}</td></tr>"
                 f"<tr><th>Peak reading</th><td class='num'>{_value(r.get('phone.peak_accel_reading'), 'm/s^2')}</td></tr>"
                 f"<tr><th>Final height from double integration</th><td class='num'>{_value(r.get('phone.final_height'), 'cm')}</td></tr></table>")
    else:
        p.append(f"<p class='note'>{html.escape(run.stages.get('phone', ''))}</p>")

    # Cross-checks
    p.append("<h2>Cross-checks: each result a second way, labelled by how independent that way is</h2>")
    if run.cross_checks:
        p.append("<table><tr><th>Check</th><th>Kind</th><th>Method A</th><th>Method B</th><th>Difference</th>"
                 "<th>Tolerance</th><th></th></tr>")
        for c in run.cross_checks:
            p.append(f"<tr><td>{html.escape(c['name'])}</td><td>{html.escape(c.get('kind', ''))}</td>"
                     f"<td class='num'>{_value(c['a'])}</td><td class='num'>{_value(c['b'])}</td>"
                     f"<td class='num'>{_fraction(c['rel'])}</td><td class='num'>{_fraction(c['tol'])}</td>"
                     f"<td><span class='pill {'pass' if c['ok'] else 'fail'}'>"
                     f"{'agree' if c['ok'] else 'disagree'}</span></td></tr>")
        p.append("</table>")
        p.append("<p class='note'><b>independent</b>: a different method or different data; only these can "
                 "catch a wrong model. <b>recomputed</b>: the same formula and inputs, worked separately; it "
                 "catches slips in code. <b>identity</b>: must agree by algebra; it catches arithmetic slips only. "
                 "<b>assumption</b>: checks that an input suits the model.</p>")

    # Tinker log
    if tinker_rows:
        p.append("<h2>Tinker log: predictions against results</h2><table><tr><th>#</th><th>Change</th><th>Your prediction</th>"
                 "<th>Result</th><th>Your explanation</th></tr>")
        for row in tinker_rows[-12:]:
            p.append(f"<tr><td>{row['id']}</td><td><code>{html.escape(row['change'])}</code></td><td>{html.escape(row['prediction'])}</td>"
                     f"<td>{html.escape(row['result'])}</td><td>{html.escape(row.get('explanation', ''))}</td></tr>")
        p.append("</table>")

    # Stages and the road ahead
    p.append("<h2>Stages</h2><table><tr><th>Stage</th><th>Module</th><th>What it does</th><th>Status</th></tr>")
    for stage, module, what in STAGES:
        status = run.stages.get(stage, "")
        cls = "pass" if status == "ok" else ("locked" if status == "locked" else ("open" if status in ("no data", "not built yet") or status.startswith("not written") else "fail"))
        p.append(f"<tr><td>{stage}</td><td>{module}</td><td>{what}</td><td><span class='pill {cls}'>{html.escape(status)}</span></td></tr>")
    p.append("</table>")
    p.append("</main></body></html>")

    page = OUT / "index.html"
    page.write_text("\n".join(p), encoding="utf-8")
    return page