"""Couple the implemented controller to a synthetic reservoir and motor contacts.
Units: litres, seconds. All plant properties are declared reference assumptions.
"""
from dataclasses import asdict
import json, math
from controller import Controller, Inputs
from completion_support import *
from support import plot_style,save_figure

CASES={
 'normal_cycles':dict(duration=600,inflow=1.0),
 'assist_demand':dict(duration=600,inflow=6.0),
 'lead_start_failure':dict(duration=240,inflow=1.0,fail_start_a=True),
 'one_pump_unavailable':dict(duration=360,inflow=1.0,unavailable_a=True),
 'capacity_exceeded':dict(duration=240,inflow=9.0),
 'power_loss_restart_inhibit':dict(duration=240,inflow=1.0,power_loss=True),
 'attended_power_recovery':dict(duration=360,inflow=1.0,power_loss=True,attended_recovery=True),
 'invalid_level_switches':dict(duration=150,inflow=1.0,bad_switch=True),
 'running_feedback_loss':dict(duration=240,inflow=1.0,drop_feedback_a=True),
 'short_feedback_dropout':dict(duration=240,inflow=1.0,brief_drop=True),
 'manual_dry_interlock':dict(duration=5,inflow=0.0,dry_manual=True),
}

def reservoir_case(name,dt=.1):
 if not math.isfinite(dt) or dt<=0:raise ValueError('Finite positive scan period required')
 cfg=CASES[name];ctl=Controller();volume=0.;overflow=0.;drain_total=0.;inflow_total=0.;commands=[False,False];command_since=[None,None];rows=[];cycles=0;prior_lead='A';dry_command=False
 for k in range(round(cfg['duration']/dt)+1):
  t=k*dt
  power=not(cfg.get('power_loss') and 70<=t<75)
  attended=cfg.get('attended_recovery',False)
  inflow=0. if attended and 120<=t<134 else cfg['inflow']
  external_drain=10. if attended and 120<=t<133 else 0.
  avail=[not cfg.get('unavailable_a',False),True]
  actual=[commands[i] and command_since[i] is not None and t-command_since[i]>=.2-1e-9 and avail[i] and power for i in range(2)]
  if cfg.get('fail_start_a'):actual[0]=False
  feedback=actual.copy()
  if cfg.get('drop_feedback_a') and t>=90:feedback[0]=False
  if cfg.get('brief_drop') and 90<=t<90.4:feedback[0]=False
  wet=volume>=20.;start=volume>=80.;assist=volume>=120.
  if cfg.get('bad_switch') and t>=90:assist=True;start=False
  inp=Inputs(wet_low=wet,start=start,assist=assist,feedback_a=feedback[0],feedback_b=feedback[1],available_a=avail[0],available_b=avail[1],power=power,reset=k==0 or (attended and 133<=t<133+dt*.5),manual_a=bool(cfg.get('dry_manual') and k>0),manual_b=bool(cfg.get('dry_manual') and k>0))
  out=ctl.step(t,inp);new=[out['command_a'],out['command_b']]
  if not wet and any(new):dry_command=True
  if out['next_lead']!=prior_lead:cycles+=1;prior_lead=out['next_lead']
  for i in range(2):
   if new[i] and not commands[i]:command_since[i]=t
   elif not new[i]:command_since[i]=None
  # Stop is immediate in this simplified motor model. Start has a 0.2 s proof delay.
  actual=[actual[i] and new[i] for i in range(2)]
  rows.append(dict(time_s=round(t,6),volume_l=volume,inflow_lps=inflow,outflow_lps=4*sum(actual),external_drain_lps=external_drain,overflow_l=overflow,power=power,wet_low=wet,start=start,assist=assist,**{k:v for k,v in out.items() if k!='time_s'}))
  if k==round(cfg['duration']/dt):break
  incoming=inflow*dt;removed=min(volume+incoming,(4*sum(actual)+external_drain)*dt)
  volume+=incoming-removed;spilled=max(0,volume-200.);volume=min(volume,200.)
  inflow_total+=incoming;drain_total+=removed;overflow+=spilled;commands=new
 summary=dict(case=name,scan_s=dt,max_volume_l=max(r['volume_l'] for r in rows),overflow_l=overflow,lead_rotations=cycles,latched_fault_a=ctl.pumps[0].fault,sensor_fault=ctl.sensor_fault,restart_inhibited=ctl.boot_inhibit,dry_command=dry_command,volume_balance_error_l=inflow_total-drain_total-overflow-volume,high_level_reached=any(r['volume_l']>=180 for r in rows))
 return summary,rows

def timer_bounds(dt):
 ctl=Controller();ctl.step(0,Inputs(reset=True));ctl.step(dt,Inputs(wet_low=True,start=True));trip=None
 for k in range(2,math.ceil(5/dt)+1):
  out=ctl.step(k*dt,Inputs(wet_low=True,start=True))
  if out['fault_a']=='START_TIMEOUT':trip=k*dt-dt;break
 return trip

def main():
 summaries=[];trace=[]
 for name in CASES:
  s,r=reservoir_case(name);summaries.append(s);trace.extend(dict(case=name,**x) for x in r)
 write_csv('results/virtual-reservoir-traces.csv',trace);write_csv('results/virtual-reservoir-summary.csv',summaries)
 by={r['case']:r for r in summaries};checks=[]
 for r in summaries:
  checks.append(check('balance-'+r['case'],'Reservoir volume balance', 'Absolute residual < 1e-8 L', r['volume_balance_error_l'],abs(r['volume_balance_error_l'])<1e-8,'Integrate incoming, pumped, spilled and retained volume independently'))
  checks.append(check('dry-'+r['case'],'Low level overrides both commands',False,r['dry_command'],not r['dry_command'],'Check every scan of the coupled reservoir/controller run'))
 checks += [
  check('normal-cycles','Automatic cycles alternate the lead','At least 5 rotations and no spill',{'rotations':by['normal_cycles']['lead_rotations'],'overflow_l':by['normal_cycles']['overflow_l']},by['normal_cycles']['lead_rotations']>=5 and by['normal_cycles']['overflow_l']==0,'600 s closed-loop run, 1 L/s inlet'),
  check('assist-capacity','Two-pump demand stays within tank capacity','No spill at 6 L/s',by['assist_demand']['overflow_l'],by['assist_demand']['overflow_l']==0,'Each proved pump removes 4 L/s'),
  check('failed-lead','Failed start latches and standby clears demand','START_TIMEOUT and no spill',by['lead_start_failure']['latched_fault_a'],by['lead_start_failure']['latched_fault_a']=='START_TIMEOUT' and by['lead_start_failure']['overflow_l']==0,'Suppress motor A start/feedback'),
  check('maintenance','Available standby carries declared demand','No spill',by['one_pump_unavailable']['overflow_l'],by['one_pump_unavailable']['overflow_l']==0,'Motor A unavailable, 1 L/s inlet'),
  check('overload','Insufficient installed capacity is reported as adverse','Positive spill at 9 L/s',by['capacity_exceeded']['overflow_l'],by['capacity_exceeded']['overflow_l']>0,'Demand exceeds both pumps combined; no automatic design acceptance'),
  check('restart','Restored power does not cause an automatic restart','Inhibited with positive spill',{'inhibited':by['power_loss_restart_inhibit']['restart_inhibited'],'spill':by['power_loss_restart_inhibit']['overflow_l']},by['power_loss_restart_inhibit']['restart_inhibited'] and by['power_loss_restart_inhibit']['overflow_l']>0,'5 s interruption; no operator drain or reset follows'),
  check('attended-recovery','Selected operating response restores service without spill','No spill and inhibit cleared',by['attended_power_recovery'],by['attended_power_recovery']['overflow_l']==0 and not by['attended_power_recovery']['restart_inhibited'],'At 120 s isolate inlet and drain externally, reset at 133 s, reopen inlet at 134 s'),
  check('sensor-fault','Impossible level ordering latches alarm',True,by['invalid_level_switches']['sensor_fault'],by['invalid_level_switches']['sensor_fault'],'Force assist true while start false at 90 s'),
  check('feedback-latch','Sustained feedback loss trips motor','RUN_FEEDBACK_LOST',by['running_feedback_loss']['latched_fault_a'],by['running_feedback_loss']['latched_fault_a']=='RUN_FEEDBACK_LOST','Remove feedback after 90 s while flow can persist until command removal'),
  check('feedback-debounce','Short feedback interruption does not trip','No latched fault',by['short_feedback_dropout']['latched_fault_a'],by['short_feedback_dropout']['latched_fault_a']=='','0.4 s drop vs 1 s timer'),
 ]
 timing=[]
 for dt in [.05,.1,.25]:
  delay=timer_bounds(dt);timing.append(dict(scan_s=dt,timeout_s=3.0,observed_s=delay,upper_bound_s=3+dt))
  checks.append(check('timer-'+str(dt),'Start timeout includes scan quantization','3 <= delay <= 3 + scan',delay,delay is not None and 3-1e-9<=delay<=3+dt+1e-9,'Discrete scan-period sweep'))
 write_csv('results/virtual-scan-timing.csv',timing)
 fine,coarse=reservoir_case('normal_cycles',.05)[0],by['normal_cycles']
 checks.append(check('refinement','Reservoir peak is stable when scan is halved','Peak difference <= 0.5 L',abs(fine['max_volume_l']-coarse['max_volume_l']),abs(fine['max_volume_l']-coarse['max_volume_l'])<=.5,'Compare 50 and 100 ms scans; deterministic event model'))
 write_json('selected-operating-envelope.json',dict(evidence_type='synthetic/theoretical',normal_inflow_max_lps=1.0,per_pump_flow_lps=4.,reservoir_l=200.,single_motor_failure_covered=True,normal_case='normal_cycles',power_recovery_case='attended_power_recovery',restoration_policy='Attended: isolate inlet at t=120 s after loss at t=70 s; drain at 10 L/s until dry; quiet reset at 133 s; restore inlet at 134 s.',rejected_conditions=['9 L/s exceeds installed capacity','6 L/s assist operation is not single-pump-failure tolerant','Unattended power restoration remains inhibited and eventually spills'],physical_validation=False))
 plt=plot_style();fig,axes=plt.subplots(2,2,figsize=(10,7))
 for ax,name in zip(axes.flat,['normal_cycles','lead_start_failure','power_loss_restart_inhibit','capacity_exceeded']):
  rows=[r for r in trace if r['case']==name];ax.plot([r['time_s'] for r in rows],[r['volume_l'] for r in rows]);ax.axhline(200,color='#e7a387',ls='--');ax.axhline(180,color='#9bbcc5',ls=':',alpha=.8);ax.set(xlabel='Time (s)',ylabel='Retained volume (L)',title=name.replace('_',' '));ax.grid()
 fig.tight_layout();save_figure(fig,'virtual-reservoir');plt.close(fig)
 display=[{k:r[k] for k in ['case','max_volume_l','overflow_l','lead_rotations','latched_fault_a','sensor_fault','restart_inhibited']} for r in summaries]
 finish('Pump controller virtual commissioning','Executed the actual controller against a synthetic reservoir and motor-feedback model. Selected reference service is 1 L/s with one-pump standby capacity and an attended power-restoration procedure. Tests include normal cycles, capacity loss, invalid sensors and power interruption.',[
 'Reservoir capacity 200 L; dry stop below 20 L, start at 80 L, assist at 120 L and reported high level at 180 L. The mass balance is V[k+1] = V[k] + (inflow - delivered outflow) * dt, with spill recorded separately. These are reference inputs, not plant dimensions.',
 'Each available pump delivers a constant 4 L/s once proved, with 0.2 s start delay and instantaneous stop. The controller uses its actual 3 s start, 1 s feedback loss and 10 s minimum-run timers. The standard scan is 100 ms.',
 'The power-loss case intentionally remains inhibited after restoration because no quiet reset occurs. Its spill is a design consequence of the existing restart policy, not a successful capacity result. An operating process requiring unattended recovery needs a separately approved restart policy or independent storage/alarm response.',
 'The selected attended recovery case isolates the inlet 50 s after the modeled power loss, drains externally at 10 L/s, resets with quiet inputs, then restores inflow. It clears inhibition without spill. The original unacknowledged-restart case remains a rejected unattended operating condition.',
 'Checks pass when observed behavior matches the specified expectation. This includes expected adverse conditions: capacity overrun and process overflow while restart is inhibited.'] ,display,checks,[
 'This completes a virtual commissioning exercise. It does not assert PLC timing, switch debounce, contactor feedback, motor hydraulics, electrical protection, or field reliability.',
 'Constant flow and instantaneous stopping omit pump curves, inertia, pressure transients and stuck-on physical outputs. Independent electrical and process protections are not implemented by this supervisory state machine.',
 'High-level indication is recorded by the plant harness. It is not a new hardware alarm output or a claim of safe unattended operation.'
 ], [('selected-operating-envelope.json','Selected plant envelope and restoration policy'),('figures/virtual-reservoir.svg','Synthetic reservoir traces: 200 L capacity and 180 L high-level reference'),('results/virtual-reservoir-summary.csv','Reservoir case summary'),('results/virtual-reservoir-traces.csv','All coupled reservoir and command traces'),('results/virtual-scan-timing.csv','Scan timing sweep')], [dict(label='Virtual plant cases',value=len(CASES)),dict(label='Reference 1 L/s cycles',value=by['normal_cycles']['lead_rotations'])])
if __name__=='__main__':main()
