"""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 thedesign 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 thepage, results.json keeps every result in plain numbers with their units; thenext 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__importannotationsimportdatetimeasdtimporthashlibimporthtmlimportjsonfrompathlibimportPathfromhwe.designimportROOTfromhwe.pipelineimportSTAGES,Runfromhwe.unitsimportfmt,is_quantityOUT=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:ifqisNone:return"—"ifis_quantity(q):ifunit=="percent":returnf"{q.to('dimensionless').m*100:.1f}%"ifunitisnotNoneandunit!="":q=q.to(unit)elifunit=="":returnf"{q.to('dimensionless').m:.2f}"returnhtml.escape(fmt(q,4))returnhtml.escape(str(q))def_fraction(x:float)->str:"""A relative difference or tolerance: percent when it is large enough to read that way."""returnf"{x*100:.1f}%"ifx>=1e-3elsef"{x:.1e}"def_fingerprint()->str:h=hashlib.sha256()fornamein("drone.toml","requirements.toml"):p=ROOT/"design"/nameifp.exists():h.update(p.read_bytes())returnh.hexdigest()[:10]def_plain(results:dict)->dict:out={}fork,vinresults.items():ifk.startswith("_"):continueifis_quantity(v):out[k]={"value":float(v.m),"unit":f"{v.units:~}"}returnoutdef_changes(now:dict,before:dict)->list[tuple[str,str,str,str]]:rows=[]forkey,entryinnow.items():old=before.get(key)ifoldisNoneorold["unit"]!=entry["unit"]:continuea,b=old["value"],entry["value"]ifa==b:continuerel=(b-a)/abs(a)ifaelsefloat("inf")rows.append((key,f"{a:.4g}{old['unit']}",f"{b:.4g}{entry['unit']}",f"{rel*100:+.1f}%"))returnrowsdefwrite(run:Run,tinker_rows:list[dict]|None=None)->Path:"""Build index.html and results.json for this run. Returns the page's path."""fromhwe.reportimportchartsOUT.mkdir(parents=True,exist_ok=True)now_plain=_plain(run.results)results_path,previous_path=OUT/"results.json",OUT/"previous.json"before={}ifresults_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} · design fingerprint "f"<code>{_fingerprint()}</code> · unlocked: {', '.join(run.unlocked)} · "f"every number recomputed from <code>design/drone.toml</code></div>")# Key resultsp.append("<div class='tiles'>")forkey,label,unitinKEY_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 changedchanges=_changes(now_plain,before)ifchanges: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>")forkey,a,b,relinchanges: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>")# Requirementsp.append("<h2>Requirements</h2>")problems=r.get("_requirement_problems")or[]ifproblems:p.append("<p class='warn'>Problems in <code>design/requirements.toml</code>: "+"; ".join(html.escape(x)forxinproblems)+"</p>")ifrun.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>")forsinrun.requirement_status:q=s.requirementmargin="—"ifs.marginisNoneelsef"{s.margin*100:+.0f}%"value=_value(s.value,Noneifs.valueisNoneornotis_quantity(q.threshold)elsef"{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>")# Massp.append("<h2>Mass budget</h2>")ifrun.budgetandrun.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>")foriteminrun.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 spiralp.append("<h2>Energy and the mass spiral</h2>")ifrun.sweepandrun.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>")# CADp.append("<h2>The arm (CAD)</h2>")ifrun.partisnotNone:fromhwe.cad.drawingimportISOMETRIC,drawimg=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 testp.append("<h2>Test: the phone accelerometer (EL01)</h2>")ifrun.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-checksp.append("<h2>Cross-checks: each result a second way, labelled by how independent that way is</h2>")ifrun.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>")forcinrun.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'ifc['ok']else'fail'}'>"f"{'agree'ifc['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 logiftinker_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>")forrowintinker_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 aheadp.append("<h2>Stages</h2><table><tr><th>Stage</th><th>Module</th><th>What it does</th><th>Status</th></tr>")forstage,module,whatinSTAGES:status=run.stages.get(stage,"")cls="pass"ifstatus=="ok"else("locked"ifstatus=="locked"else("open"ifstatusin("no data","not built yet")orstatus.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")returnpage