"""The engine's command line. Run from the eng-lab folder with the venv's Python. python -m hwe report run every unlocked analysis, write out/report/index.html python -m hwe watch rerun the report whenever a design, data, or code file changes python -m hwe tinker KEY=VALUE ... --predict "what you expect" change knobs WITHOUT editing the design file, compare against the baseline, and log your prediction beside the result python -m hwe explain N "why" add your explanation to tinker entry N python -m hwe status which stages run, which wait for your code, which are lockedThe tinker loop (EL01, section 1): predict, run, explain. `tinker` refuses torun without --predict, because a prediction written after seeing the answerteaches nothing."""from__future__importannotationsimportargparseimportcsvimportdatetimeasdtimportsysimporttimefrompathlibimportPathfromhwe.designimportROOT,parse_overrideLOG=ROOT/"logs"/"tinker.csv"LOG_FIELDS=["id","time","change","prediction","result","explanation"]WATCHED=[ROOT/"design",ROOT/"data",ROOT/"hwe"]TRACKED=["mass.predicted","mass.allowable","mass.margin","hover.thrust_to_weight","hover.hover_fraction","hover.max_climb_accel","energy.battery_energy","energy.hover_power","energy.flight_time","arm.mass"]def_utf8():forstreamin(sys.stdout,sys.stderr):try:stream.reconfigure(encoding="utf-8",errors="replace")except(AttributeError,ValueError):passdef_read_log()->list[dict]:ifnotLOG.exists():return[]withopen(LOG,newline="",encoding="utf-8")asf:returnlist(csv.DictReader(f))def_write_log(rows:list[dict])->None:LOG.parent.mkdir(parents=True,exist_ok=True)withopen(LOG,"w",newline="",encoding="utf-8")asf:writer=csv.DictWriter(f,fieldnames=LOG_FIELDS)writer.writeheader()writer.writerows(rows)def_print_summary(run)->None:fromhwe.unitsimportfmtprint(f"\n{run.design.raw.get('meta',{}).get('name','design')}")forkeyinTRACKED:ifkeyinrun.results:print(f" {key:28s}{fmt(run.results[key],4)}")bad=[cforcinrun.cross_checksifnotc["ok"]]print(f" cross-checks: {len(run.cross_checks)-len(bad)} of {len(run.cross_checks)} agree")forsinrun.requirement_status:print(f" {s.requirement.id:10s}{s.state}")forstage,statusinrun.stages.items():ifstatusnotin("ok","locked"):print(f" [{stage}] {status}")defcmd_report(args)->int:fromhweimportpipelinefromhwe.reportimporthtmlrun=pipeline.run()page=html.write(run,_read_log())_print_summary(run)print(f"\nreport: {page}")ifargs.open:importwebbrowserwebbrowser.open(page.as_uri())return0def_snapshot()->dict[Path,float]:stamps={}forfolderinWATCHED:forpathinfolder.rglob("*"):ifpath.is_file()and"__pycache__"notinpath.parts:stamps[path]=path.stat().st_mtimereturnstampsdefcmd_watch(args)->int:importimportlibprint("watching design/, data/, and hwe/ (Ctrl+C to stop)")last=NonewhileTrue:now=_snapshot()ifnow!=last:iflastisnotNone:changed=sorted(p.relative_to(ROOT)forpinnowifnow.get(p)!=last.get(p))print(f"\nchanged: {', '.join(map(str,changed[:5]))}")fornamein[mforminsys.modulesifm.startswith("hwe.")andm!="hwe.__main__"]:importlib.reload(sys.modules[name])# pick up edited code without restartingtry:cmd_report(argparse.Namespace(open=False))exceptExceptionase:# a half-typed file should not kill the watchprint(f"report failed: {type(e).__name__}: {e}")last=nowtime.sleep(1.0)defcmd_tinker(args)->int:fromhweimportpipelinefromhwe.unitsimportfmtifnotargs.predictornotargs.predict.strip():print('Write your prediction first, for example:\n python -m hwe tinker "battery.capacity=8000 mA*h" ''--predict "flight time up, but less than the 60 percent the capacity grew: the pack gets heavier. T/W down"')return2overrides=dict(parse_override(o)foroinargs.changes)base=pipeline.run()new=pipeline.run(overrides)lines,parts=[],[]print(f"\n{'result':28s}{'before':>14s}{'after':>14s}{'change':>9s}")forkeyinTRACKED:a,b=base.results.get(key),new.results.get(key)ifaisNoneorbisNone:continuerel=((b-a)/a).to("dimensionless").mifa.melse0.0print(f"{key:28s}{fmt(a,4):>14s}{fmt(b,4):>14s}{rel*100:+8.1f}%")ifabs(rel)>1e-12:parts.append(f"{key}{fmt(a,3)} -> {fmt(b,3)} ({rel*100:+.1f}%)")fors_base,s_newinzip(base.requirement_status,new.requirement_status):ifs_base.state!=s_new.state:parts.append(f"{s_new.requirement.id}{s_base.state} -> {s_new.state}")print(f"{s_new.requirement.id}: {s_base.state} -> {s_new.state}")forname,stateinnew.stages.items():# a stage that stopped running is a result tooifbase.stages.get(name)!=state:parts.append(f"stage {name}: {state}")print(f"stage {name}: {base.stages.get(name)} -> {state}")agreed={c["name"]:c["ok"]forcinbase.cross_checks}forcinnew.cross_checks:ifnotc["ok"]andagreed.get(c["name"],True):parts.append(f"cross-check now disagrees: {c['name']}")print(f"cross-check now disagrees: {c['name']}")rows=_read_log()entry_id=str(len(rows)+1)rows.append(dict(id=entry_id,time=dt.datetime.now().isoformat(timespec="seconds"),change="; ".join(f"{k}={v}"fork,vinoverrides.items()),prediction=args.predict.strip(),result="; ".join(parts)or"no tracked result changed",explanation=""))_write_log(rows)print(f"\nlogged as entry {entry_id} in {LOG.relative_to(ROOT)}. Now explain the result in your own words:")print(f' python -m hwe explain {entry_id} "..."')return0defcmd_explain(args)->int:rows=_read_log()forrowinrows:ifrow["id"]==str(args.id):row["explanation"]=args.text_write_log(rows)print(f"entry {args.id} explained.")return0print(f"no tinker entry {args.id}")return1defcmd_status(args)->int:fromhweimportpipelinerun=pipeline.run()print(f"unlocked modules: {', '.join(run.unlocked)}")forstage,module,whatinpipeline.STAGES:print(f" {module}{stage:13s}{run.stages.get(stage,'')}")return0defmain(argv=None)->int:_utf8()parser=argparse.ArgumentParser(prog="python -m hwe",description=__doc__.split("\n\n")[0])sub=parser.add_subparsers(dest="command",required=True)p=sub.add_parser("report");p.add_argument("--open",action="store_true");p.set_defaults(func=cmd_report)p=sub.add_parser("watch");p.set_defaults(func=cmd_watch)p=sub.add_parser("tinker");p.add_argument("changes",nargs="+");p.add_argument("--predict",default="")p.set_defaults(func=cmd_tinker)p=sub.add_parser("explain");p.add_argument("id");p.add_argument("text");p.set_defaults(func=cmd_explain)p=sub.add_parser("status");p.set_defaults(func=cmd_status)args=parser.parse_args(argv)returnargs.func(args)if__name__=="__main__":sys.exit(main())