"""Load the design: every knob of the drone, from design/drone.toml, with units.The design file is the single source of truth. Every analysis reads its inputsfrom the Design object built here, so changing one number in drone.toml (oroverriding it with `python -m hwe tinker`) changes every downstream result onthe next run. That is the design loop of EL01, section 2.1, made mechanical.Rules the loader enforces- Every physical number is a string with a unit ("150 mm", "1300 mA*h"). A bare number is accepted only for counts and pure ratios, which are named explicitly below. Anything else raises an error that names the key.- Overrides use dotted keys: "battery.capacity=2200 mA*h"."""from__future__importannotationsimportcopyimportreimporttomllibfromdataclassesimportdataclass,fieldfrompathlibimportPathfromhwe.unitsimportQ_,Quantity,parseROOT=Path(__file__).resolve().parents[1]# the eng-lab folderDESIGN_FILE=ROOT/"design"/"drone.toml"# A design-file string is a quantity exactly when it starts with a number._NUMBER_FIRST=re.compile(r"^\s*[-+]?(\d|\.\d)")# Keys whose values are plain numbers on purpose (counts and ratios).PLAIN_NUMBER_KEYS={"propulsion.motor_count","battery.cells","battery.usable_fraction","arm.count","requirements.min_thrust_to_weight","tests.phone.sign",}@dataclassclassItem:"""One line of the mass budget: what hwe.core.massbudget receives."""name:strsubsystem:strmass:Quantitymaturity:strsource:str=""@dataclassclassComponentSpec:"""One [[component]] entry as written in the design file. mass is the total for all `count` copies, or None when the mass comes from CAD (mass = "cad" in the file); then `fallback` (optional) is used until the CAD stage has run. """name:strsubsystem:strmass:Quantity|Nonematurity:strsource:str=""count:int=1fallback:Quantity|None=None@dataclassclassDesign:"""The whole design as nested dictionaries of Quantities, plus the parts list."""raw:dict# the TOML as read, after overrides (strings)values:dict# same shape, with units parsed into Quantitiesitems:list[ComponentSpec]=field(default_factory=list)defget(self,dotted:str):"""design.get("battery.capacity") -> Q_(1300, "mA*h")"""node=self.valuesforpartindotted.split("."):node=node[part]returnnodedefsection(self,name:str)->dict:returnself.values.get(name,{})def_parse_value(dotted:str,value):ifisinstance(value,dict):return{k:_parse_value(f"{dotted}.{k}",v)fork,vinvalue.items()}ifisinstance(value,list):returnvalueifisinstance(value,bool):returnvalueifisinstance(value,(int,float)):ifdottedinPLAIN_NUMBER_KEYSordotted.startswith("growth_allowance."):returnvalueraiseTypeError(f"{dotted} = {value!r} has no unit; write it as a string such as \"{value} g\"")ifisinstance(value,str):if_NUMBER_FIRST.match(value):# "150 mm", "-3 degC", ".5 kg": a quantityreturnparse(value)# a typo in the unit raises here, naming the unitreturnvalue# names, paths, notes, maturities stay textreturnvaluedef_set_dotted(tree:dict,dotted:str,text:str)->None:keys=dotted.split(".")node=treeforkinkeys[:-1]:ifknotinnode:raiseKeyError(f"override {dotted}: no section '{k}' in the design file")node=node[k]ifkeys[-1]notinnode:raiseKeyError(f"override {dotted}: no key '{keys[-1]}' in [{'.'.join(keys[:-1])}]")old=node[keys[-1]]ifisinstance(old,(int,float))andnotisinstance(old,bool):node[keys[-1]]=type(old)(text)else:node[keys[-1]]=textdefload(path:Path|str=DESIGN_FILE,overrides:dict[str,str]|None=None)->Design:"""Read the design file, apply overrides such as {"battery.capacity": "2200 mA*h"}."""withopen(path,"rb")asf:raw=tomllib.load(f)raw=copy.deepcopy(raw)fordotted,textin(overridesor{}).items():_set_dotted(raw,dotted,text)components=raw.get("component",[])body={k:vfork,vinraw.items()ifk!="component"}values={k:_parse_value(k,v)fork,vinbody.items()}items=[]forentryincomponents:count=entry.get("count",1)ifisinstance(count,str):# count = "propulsion.motor_count" follows that knobnode=valuesforpartincount.split("."):node=node[part]count=nodecount=int(count)from_cad=entry.get("mass")=="cad"items.append(ComponentSpec(name=entry["name"]ifcount==1elsef"{entry['name']} (x{count})",subsystem=entry["subsystem"],mass=Noneiffrom_cadelseparse(entry["mass"])*count,maturity=entry["maturity"],source=entry.get("source",""),count=count,fallback=parse(entry["fallback"])*countif"fallback"inentryelseNone,))returnDesign(raw=raw,values=values,items=items)defparse_override(text:str)->tuple[str,str]:"""'battery.capacity=2200 mA*h' -> ('battery.capacity', '2200 mA*h')"""if"="notintext:raiseValueError(f"override must look like key=value, got {text!r}")key,value=text.split("=",1)returnkey.strip(),value.strip()