"""Synthetic coupled temperature/moisture controller comparison.""" import json,math,sys from pathlib import Path RV=461.5 def es(t):return 610.8*math.exp(17.27*t/(t+237.3)) def density(t,rh):return es(t)*rh/100/(RV*(t+273.15)) def rh(t,m,v):return 100*(m/v)*RV*(t+273.15)/es(t) def clip(x):return max(0.,min(1.,x)) def validate(c): req=set(json.loads(Path(__file__).with_name('config.json').read_text())) if set(c)!=req:raise ValueError('keys must match contract') for k,v in c.items(): if isinstance(v,bool) or not isinstance(v,(int,float)) or not math.isfinite(v):raise ValueError(k+': finite number required') if c['time_step_seconds']<=0 or c['duration_hours']<=0 or c['room_volume_m3']<=0 or c['thermal_capacitance_j_per_k']<=0:raise ValueError('positive duration, step, volume and capacitance required') if c['lights_off_hour']<=c['lights_on_hour']:raise ValueError('light schedule') n=c['duration_hours']*3600/c['time_step_seconds'] if abs(n-round(n))>1e-9:raise ValueError('integer steps required') return c def run(raw,mode): c=validate(dict(raw));dt=c['time_step_seconds'];v=c['room_volume_m3'];temp=c['initial_temp_c'];mass=density(temp,c['initial_rh_pct'])*v; cool=deh=False;it=ih=0.;prev=(0.,0.);tv=0.;resE=resM=0.;met={'temp_violation_h':0.,'rh_violation_h':0.,'cooling_thermal_kwh':0.,'dehumidified_kg':0.,'condensed_kg':0.,'simultaneous_cooling_reheat_h':0.} for i in range(round(c['duration_hours']*3600/dt)): h=i*dt/3600;outt=c['outside_temp_mean_c']+c['outside_temp_amplitude_c']*math.cos(2*math.pi*(h-15)/24);outrho=density(outt,c['outside_rh_pct']); r=rh(temp,mass,v);lit=c['lights_on_hour']<=h%24=24.5:cool=True if deh and r<=65:deh=False elif not deh and r>=75:deh=True uc,ud=float(cool),float(deh) elif mode=='pi': et=temp-c['temperature_setpoint_c'];eh=r-c['rh_setpoint_pct'];rawc=.28*et+it;rawd=.10*eh+ih;uc,ud=clip(rawc),clip(rawd) if 00) or (rawc>=1 and et<0):it+=.00008*et*dt if 00) or (rawd>=1 and eh<0):ih+=.00003*eh*dt else:raise ValueError('mode must be onoff or pi') trans=(c['transpiration_lit_kg_per_h'] if lit else c['transpiration_dark_kg_per_h'])/3600;ventm=c['ventilation_m3_per_s']*(mass/v-outrho);remove=c['dehumidifier_capacity_kg_per_h']/3600*ud;mb=mass;proposed=mass+(trans-ventm-remove)*dt;sat=density(temp,100)*v;cond=max(0,proposed-sat);mass=proposed-cond q= (c['led_power_w'] if lit else 0)+c['other_heat_w']+c['dehumidifier_reheat_w']*ud-(c['envelope_ua_w_per_k']+1.2*1005*c['ventilation_m3_per_s'])*(temp-outt)-c['cooling_capacity_w']*uc-c['latent_heat_j_per_kg']*trans tb=temp;temp+=q*dt/c['thermal_capacitance_j_per_k'];resE+=abs(c['thermal_capacitance_j_per_k']*(temp-tb)-q*dt);resM+=abs((mass-mb)-((trans-ventm-remove)*dt-cond));r2=rh(temp,mass,v) met['temp_violation_h']+=dt/3600 if not 23<=temp<=25 else 0;met['rh_violation_h']+=dt/3600 if not 60<=r2<=80 else 0;met['cooling_thermal_kwh']+=c['cooling_capacity_w']*uc*dt/3.6e6;met['dehumidified_kg']+=remove*dt;met['condensed_kg']+=cond;met['simultaneous_cooling_reheat_h']+=dt/3600 if uc>0 and ud>0 else 0;tv+=abs(uc-prev[0])+abs(ud-prev[1]);prev=(uc,ud) return {'controller':mode,'final_temp_c':temp,'final_rh_pct':rh(temp,mass,v),'total_variation':tv,'energy_residual_j':resE,'water_residual_kg':resM,**met} def compare(c):return {'scope':'synthetic comparison; not facility performance','onoff':run(c,'onoff'),'pi':run(c,'pi')} if __name__=='__main__': try:p=Path(sys.argv[1]) if len(sys.argv)>1 else Path(__file__).with_name('config.json');print(json.dumps(compare(json.loads(p.read_text())),indent=2,allow_nan=False)) except (OSError,ValueError,json.JSONDecodeError) as e:raise SystemExit('Invalid input: '+str(e)) from None