techmannih/nema34-mounted-stepper-controller
A 48 V USB‑C PD 3.1 EPR NEMA‑34 stepper controller PCB combining STM32/TMC5160 control, external MOSFET motor bridges, protected power and regenerative braking, USB data, telemetry, diagnostics, status LEDs, and buzzer alarms.
- Version
- 0.3.92
- License
- unset
- Stars
- 1
scripts/simulate-power-eco.py
#!/usr/bin/env python3
"""Reproducible behavioral ngspice transient screens; Python standard library only.
Run: python3 scripts/simulate-power-eco.py [--library /path/to/libngspice]
Check committed evidence without running ngspice: add --check
Individual decks also run with: ngspice -b -r result.raw engineering/power-eco-simulation/CASE.cir
This does not model or qualify a complete controller, motor or USB-PD source.
"""
import argparse
import csv
import ctypes as ct
import ctypes.util
import hashlib
import json
import math
import os
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
ROOT = REPO / 'engineering' / 'power-eco-simulation'
VERSION = hashlib.sha256((REPO/'index.circuit.tsx').read_bytes()).hexdigest()
class Vector(ct.Structure):
_fields_ = [('name', ct.c_char_p), ('type', ct.c_int), ('flags', ct.c_short),
('real', ct.POINTER(ct.c_double)), ('complex', ct.c_void_p),
('length', ct.c_int)]
class Spice:
def __init__(self, library=None):
candidates = [library, os.environ.get('NGSPICE_LIBRARY'),
'/Applications/KiCad/KiCad.app/Contents/Frameworks/libngspice.0.dylib',
ct.util.find_library('ngspice')]
for candidate in candidates:
if not candidate:
continue
try:
self.lib = ct.CDLL(candidate)
self.library = candidate
break
except OSError:
pass
else:
raise RuntimeError('Install ngspice shared library or pass --library PATH')
self.messages = []
self.callback = ct.CFUNCTYPE(ct.c_int, ct.c_char_p, ct.c_int, ct.c_void_p)(self.log)
self.exit_callback = ct.CFUNCTYPE(ct.c_int,ct.c_int,ct.c_bool,ct.c_bool,ct.c_int,ct.c_void_p)(self.on_exit)
self.lib.ngSpice_Init.argtypes = [ct.c_void_p] * 7
self.lib.ngSpice_Init(self.callback, None, self.exit_callback, None, None, None, None)
self.lib.ngSpice_Command.argtypes = [ct.c_char_p]
self.lib.ngSpice_Circ.argtypes = [ct.POINTER(ct.c_char_p)]
self.lib.ngGet_Vec_Info.argtypes = [ct.c_char_p]
self.lib.ngGet_Vec_Info.restype = ct.POINTER(Vector)
self.command('version')
self.version = '\n'.join(self.messages)
def log(self, text, ident, user):
self.messages.append(text.decode(errors='replace'))
return 0
def on_exit(self, status, immediate, quitexit, ident, user):
self.messages.append(f'CONTROLLED_EXIT status={status}')
return 0
def command(self, text):
return self.lib.ngSpice_Command(text.encode())
def run(self, deck, vectors, duration):
self.command('destroy all')
self.messages = []
lines = deck.splitlines()
array = (ct.c_char_p * (len(lines)+1))(*[s.encode() for s in lines], None)
if self.lib.ngSpice_Circ(array) != 0 or self.command('run') != 0:
raise RuntimeError('\n'.join(self.messages))
data = {}
for name in ['time'] + vectors:
ptr = self.lib.ngGet_Vec_Info(name.encode())
if not ptr or not ptr.contents.real:
raise RuntimeError(f'Missing vector {name}: ' + '\n'.join(self.messages))
v = ptr.contents
data[name] = list(v.real[:v.length])
if abs(data['time'][-1]-duration) > 1e-8:
raise RuntimeError('Incomplete transient: ' + '\n'.join(self.messages))
if any(not math.isfinite(v) for a in data.values() for v in a):
raise RuntimeError('Nonfinite simulation data')
return data, '\n'.join(self.messages)
def integral(t, y):
return sum((t1-t0)*(y1+y0)/2 for t0,t1,y0,y1 in zip(t,t[1:],y,y[1:]))
def crossing(t, y, level, rising=True):
for a,b,x,z in zip(t,t[1:],y,y[1:]):
if (x < level <= z) if rising else (x > level >= z):
return a+(b-a)*(level-x)/(z-x)
return None
def header(title):
return f'''{title}
* BEHAVIORAL SCREEN -- not a manufacturer macro-model or hardware qualification.
* Board source SHA256 {VERSION}
.options reltol=1e-5 abstol=1e-9 vntol=1e-7 method=gear maxord=2
'''
def brake_network(cap, initial, brake=True, on=51.99, off=51.3432, esr=.01, lag=1e-7, resistance=10, initial_sense=None):
# First-order sensing lag is an assumed sensitivity parameter, not a TLV3201 model.
return f'''.param Cbulk={cap:.12g} Vinitial={initial:.12g}
Cbulk cap 0 {{Cbulk}} ic={{Vinitial}}
Resr cap bus {esr}
Rleak bus 0 1e9
Rsense bus sense 1k
Csense sense 0 {lag/1000:.12g} ic={initial if initial_sense is None else initial_sense:.12g}
Vbrake bus rb 0
Rbrake rb drain {resistance if brake else 1e12}
Sbrake drain 0 sense 0 BRK
.model BRK SW(Ron=0.0302 Roff=1e12 Vt={(on+off)/2:.12g} Vh={(on-off)/2:.12g})
'''
def regeneration(p, dt):
cap=p.get('cap',940e-6); initial=p.get('initial',48)
e=p.get('energy',.5*.00014*(2*math.pi*1000/60)**2)
stop=p.get('stop',.020); start=.001; end=start+stop; duration=end+.009
deck=header(p['name'])+brake_network(cap,initial,p.get('brake',True),p.get('on',51.99),p.get('off',51.3432),p.get('esr',.01),p.get('lag',1e-7),p.get('resistance',10))
# Triangular electrical power: integral exactly E; assumes all specified mechanical
# energy reaches the DC bus. It continues after OVP; it is not a torque/speed model.
deck+=f'''Bregen 0 inj I = (time >= {start} && time <= {end:.12g}) ? ({2*e/stop:.12g}*(1-(time-{start})/{stop:.12g})/max(v(bus),1)) : 0
Vregen inj bus 0
.tran {dt:.12g} {duration:.12g} 0 {dt:.12g} uic
.end
'''
return deck,['v(bus)','v(cap)','i(vbrake)','i(vregen)'],duration
def winding(p,dt):
cap=p.get('cap',940e-6); initial=p.get('initial',53.69047619047619)
current=p.get('current',5.5); inductance=p.get('inductance',.0035); duration=.003
deck=header(p['name'])+brake_network(cap,initial,p.get('brake',False),on=p.get('on',51.99),off=p.get('off',51.3432),esr=p.get('esr',.01),lag=p.get('lag',1e-7),initial_sense=p.get('initial_sense_v'))
# Two phases at a 45-degree electrical angle: both initially Irms, total L*Irms^2.
# Two body diodes per phase; simple diode model, without reverse recovery.
for phase in ['a','b']:
deck+=f'''Dlo{phase} 0 lo{phase} BODY
L{phase} lo{phase} w{phase} {inductance} ic={current}
Rw{phase} w{phase} hi{phase} .48
Dhi{phase} hi{phase} ret{phase} BODY
Vret{phase} ret{phase} bus 0
Rfloatlo{phase} lo{phase} 0 1G
Rfloathi{phase} hi{phase} 0 1G
'''
# An imposed 100 us rotor-energy pulse, not a motor torque/deceleration model.
energy=p.get('rotor_energy',0)
deck+=f'''Bregen 0 inj I=(time<=100u) ? ({2*energy/100e-6:.12g}*(1-time/100u)/max(v(bus),1)) : 0
Vregen inj bus 0
.model BODY D(Is=1e-9 N=1.2 Rs=.015 Cjo=330p)
.tran {dt:.12g} {duration} 0 {dt:.12g} uic
.end
'''
return deck,['v(bus)','v(cap)','i(vbrake)','i(la)','i(lb)','i(vreta)','i(vretb)','i(vregen)','v(loa)','v(hia)','v(lob)','v(hib)'],duration
def overload(p,dt):
# Reduced selectable 1x/2x eFuse overload envelope. Timer begins at the prescribed load edge.
# Real PD protocol, source control loop, eFuse thermal behavior and restart are absent.
ilim=p.get('ilim',18000/4020); source_limit=p.get('source_limit',12)
pulse=p.get('pulse',2); cbtime=p.get('cbtime',.0255)
trip=p.get('trip',False); uvlo=p.get('uvlo',False)
start=.005; load_end=start+p.get('load_duration',.040); duration=.060
load=p.get('load',7); rcable=.1; rfuse=.031+.0151
upstream=p.get('upstream',.1)
vin0=48-(2+upstream)*rcable; bus0=vin0-2*rfuse
deck=header(p['name'])
limit=f'((time >= {start} && time < {start+cbtime:.12g}) ? {ilim*pulse:.12g} : {ilim:.12g})'
deck+=f'''* 48 V source; 100 mOhm source/cable R and 2.2 uF input C are assumptions.
* Upstream logic is represented by a constant {upstream*1000:g} mA load above 10 V.
* Initial capacitors are precharged: this is an overload test, not startup.
Videal ideal 0 48
Bsource ideal src I = (v(trip) < .5) ? min({source_limit},max(0,(48-v(vin))/{rcable})) : 0
Vsource src vin 0
Cinput vin 0 2.2u ic={vin0}
Rleakin vin 0 1G
Blogic vin 0 I={upstream:.12g}*min(1,max(v(vin),0)/10)
Bpath vin feed I = (v(uv) > .5) ? 0 : min({limit},max(0,(v(vin)-v(bus))/{rfuse}))
Vfeed feed bus 0
Cbulk cap 0 940u ic={bus0}
Resr cap bus .01
Rleakout bus 0 1G
Bload bus 0 I = (v(uv) > .5) ? 0 : (((time >= {start} && time < {load_end}) ? {load} : 2)*min(1,max(v(bus),0)/10))
* Below 10 V the synthetic load folds back; post-collapse values are illustrative.
'''
if trip:
deck+='''* Hypothetical source trips after 1 ms above 5 A, then stays off.
Btimer 0 timer I = (v(trip) > .5) ? 0 : ((i(Vsource) > 5) ? 1 : -min(max(v(timer),0)*1e7,1e7))
Ctimer timer 0 1 ic=0
Vone one 0 1
Strip one trip timer 0 TRIP
Rtrip trip 0 1k
.model TRIP SW(Ron=.001 Roff=1e12 Vt=.0005 Vh=.0005)
'''
else:
deck+='Vtrip trip 0 0\n'
if uvlo:
deck+='''* Combined successful eFuse disconnect/load-inhibit sensitivity at 44.4 V input.
* This latches off; it is not a model of the actual firmware or eFuse auto-retry.
Buvtimer 0 uvtimer I=(v(vin)<44.4 && time>5m) ? 1 : 0
Cuvtimer uvtimer 0 1 ic=0
Buv uv 0 V=(v(uvtimer)>1u) ? 1 : 0
'''
else:
deck+='Vuv uv 0 0\n'
deck+=f'''.tran {dt:.12g} {duration} 0 {dt:.12g} uic
.end
'''
return deck,['v(bus)','v(cap)','v(vin)','i(vsource)','i(vfeed)','v(trip)','v(uv)'],duration
def summarize(p,d):
t=d['time']; bus=d['v(bus)']; cap=d['v(cap)']; c=p.get('cap',940e-6)
s={'bus_max_v':max(bus),'bus_min_v':min(bus),'bus_final_v':bus[-1],
'first_55v_s':crossing(t,bus,55),'first_60v_s':crossing(t,bus,60),'rows':len(t)}
if p['kind']!='overload':
ib=d['i(vbrake)']; rb=p.get('resistance',10) if p.get('brake',p['kind']=='regen') else 1e12
s.update(brake_peak_a=max(ib), brake_peak_w=max(i*i*rb for i in ib),
brake_energy_j=integral(t,[i*i*rb for i in ib]),
capacitor_energy_change_j=.5*c*(cap[-1]**2-p.get('initial',48 if p['kind']=='regen' else 53.69047619047619)**2))
if p['kind']=='regen':
ir=d['i(vregen)']; s['injected_energy_j']=integral(t,[v*i for v,i in zip(bus,ir)])
s['bank_ripple_rms_a_over_run']=math.sqrt(integral(t,[(i-j)**2 for i,j in zip(ir,ib)])/t[-1])
s['esr_energy_j']=integral(t,[(v-u)**2/p.get('esr',.01) for v,u in zip(bus,cap)])
s['switch_energy_j']=integral(t,[i*i*.0302 for i in ib])
s['energy_residual_j']=s['injected_energy_j']-s['brake_energy_j']-s['capacitor_energy_change_j']-s['esr_energy_j']-s['switch_energy_j']
else:
l=p.get('inductance',.0035); i0=p.get('current',5.5)
s['initial_winding_energy_j']=l*i0*i0
s['returned_energy_j']=integral(t,[v*(a+b) for v,a,b in zip(bus,d['i(vreta)'],d['i(vretb)'])])
s['copper_energy_j']=integral(t,[.48*(a*a+b*b) for a,b in zip(d['i(la)'],d['i(lb)'])])
s['lossless_capacitor_final_v']=math.sqrt(p.get('initial',53.69047619047619)**2+2*l*i0*i0/c)
injected=integral(t,[v*i for v,i in zip(bus,d['i(vregen)'])])
esr=integral(t,[(v-u)**2/p.get('esr',.01) for v,u in zip(bus,cap)])
switch=integral(t,[i*i*.0302 for i in ib])
diode=sum(integral(t,[-lo*il+(hi-v)*ir for lo,il,hi,v,ir in
zip(d['v(lo'+phase+')'],d['i(l'+phase+')'],d['v(hi'+phase+')'],bus,d['i(vret'+phase+')'])]) for phase in ['a','b'])
remaining=.5*l*(d['i(la)'][-1]**2+d['i(lb)'][-1]**2)
residual=s['initial_winding_energy_j']+injected-s['capacitor_energy_change_j']-s['brake_energy_j']-s['copper_energy_j']-esr-switch-diode-remaining
s.update(injected_rotor_energy_j=injected,energy_residual_j=residual,
diode_energy_j=diode,esr_energy_j=esr,switch_energy_j=switch,remaining_winding_energy_j=remaining)
else:
i=d['i(vsource)']; s.update(input_peak_a=max(i),efuse_peak_a=max(d['i(vfeed)']),first_5a_s=crossing(t,i,5),
first_44v_s=crossing(t,bus,44,False),first_40v_s=crossing(t,bus,40,False),
above_5a_ms=1000*integral(t,[float(v>5.0001) for v in i]),
source_trip_s=crossing(t,d['v(trip)'],.5),load_uvlo_s=crossing(t,d['v(uv)'],.5))
return s
def main():
ap=argparse.ArgumentParser(description=__doc__)
ap.add_argument('--library'); ap.add_argument('--csv-dir',type=Path); ap.add_argument('--check',action='store_true')
args=ap.parse_args()
audit=json.loads((REPO/'engineering/power-audit.json').read_text())
if audit['input_sha256']['index.circuit.tsx'] != VERSION:
raise RuntimeError('Refresh power-audit.json from the current source before simulating')
model_hash=hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
audit_hash=hashlib.sha256(json.dumps({k:audit[k] for k in ['calculations','assumptions','errors']},sort_keys=True).encode()).hexdigest()
if args.check:
r=json.loads((ROOT/'results.json').read_text())
assert r['source_sha256']==VERSION and r['model_sha256']==model_hash and r['audit_parameters_sha256']==audit_hash, 'Stale simulation evidence'
assert r['hardware_qualified'] is False and not r['errors'] and all(r['checks'].values()) and len(r['cases'])==14
for name,digest in r['deck_sha256'].items():
assert hashlib.sha256((ROOT/name).read_bytes()).hexdigest()==digest, 'Changed simulation deck: '+name
print('Fourteen behavioral cases: source/model/audit-parameter/deck hashes and numerical checks verified; hardware remains unqualified.')
return
a=audit['calculations']; th=a['thresholds_v']
corner=dict(kind='winding',cap=752e-6,initial=th['motor_cutoff_screen']['max'],
inductance=.0042,current=a['current']['sensitivity_max_rms_a'],
on=th['brake_on_screen']['max'],off=th['brake_off_screen']['max'])
upstream=a['supply']['efuse_limit']['upstream_input_allocation_a']
restricted=a['commissioning_profile']['brake_open_sensitivity']
rotor_energy=restricted['returned_energy_j']-.0042*restricted['each_phase_instantaneous_current_a']**2
cases=[
dict(name='winding_nominal_brake',kind='winding',brake=True,
initial=th['motor_cutoff_screen']['nominal'],current=a['current']['programmed_rms_a'],
on=th['brake_on_screen']['nominal'],off=th['brake_off_screen']['nominal']),
dict(corner,name='winding_corner_brake',brake=True),
dict(corner,name='winding_corner_open',brake=False),
dict(corner,name='commissioning_corner_open',brake=False,current=a['current']['sensitivity_max_rms_a']*7/32),
dict(corner,name='winding_corner_recovery_100us',brake=True,lag=100e-6,initial_sense_v=48),
dict(corner,name='restricted_open_with_rotor',brake=False,current=1.5,rotor_energy=rotor_energy),
dict(corner,name='restricted_supply_loss_open',brake=False,current=1.5,rotor_energy=rotor_energy,initial=50.4),
dict(name='rotor_1000rpm_brake',kind='regen',cap=752e-6,on=th['brake_on_screen']['max'],off=th['brake_off_screen']['max']),
dict(name='rotor_1000rpm_open',kind='regen',cap=752e-6,brake=False),
dict(name='five_joules_20ms_stress',kind='regen',energy=5,cap=752e-6,
on=th['brake_on_screen']['nominal'],off=th['brake_off_screen']['nominal']),
dict(name='input_new_1x_corner',kind='overload',ilim=a['supply']['efuse_limit']['max_a'],pulse=1,upstream=upstream),
dict(name='input_new_1x_nominal_10a',kind='overload',ilim=a['supply']['efuse_limit']['formula_nominal_a'],pulse=1,upstream=upstream,load=10,load_duration=.002),
dict(name='input_new_1x_corner_10a',kind='overload',ilim=a['supply']['efuse_limit']['max_a'],pulse=1,upstream=upstream,load=10,load_duration=.002),
dict(name='input_old_2x_reference',kind='overload',ilim=18000/4020,pulse=2),
]
spice=Spice(args.library); ROOT.mkdir(parents=True,exist_ok=True)
result={'source_sha256':VERSION,'model_type':'behavioral transient screen',
'hardware_qualified':False,'simulator_version':spice.version,'model_sha256':model_hash,
'audit_parameters_sha256':audit_hash,
'assumptions':['100% prescribed energy return; no full motor/bridge model',
'10 mOhm bank ESR assumption; no capacitor ripple or thermal qualification',
'2.2 uF lumped input capacitance, 100 mOhm source/cable resistance; revised input cases use 250 mA upstream, old reference uses 100 mA; not measured',
'Ideal eFuse current envelope omits finite response, thermal shutdown and PD source dynamics',
'Winding corner starts at maximum cutoff; 100 ns sensor lag is assumed; separate 100 us recovery begins at a 48 V sense state',
'Restricted rotor cases impose 15 rpm rotor energy over 100 us; other winding cases exclude mechanical energy',
'Brake cable inductance/clamp transient is a separate screen, not in the bus models'], 'cases':[]}
errors=[]
for p in cases:
summaries=[]
for refinement in [1,2]:
dt=(.1e-6 if p['kind']=='winding' else 1e-6)/refinement
deck,vs,duration={'winding':winding,'regen':regeneration,'overload':overload}[p['kind']](p,dt)
d,log=spice.run(deck,vs,duration); summary=summarize(p,d);summaries.append(summary)
if refinement==2:
(ROOT/(p['name']+'.cir')).write_text(deck)
if args.csv_dir:
args.csv_dir.mkdir(parents=True,exist_ok=True)
with (args.csv_dir/(p['name']+'.csv')).open('w',newline='') as f:
w=csv.writer(f);w.writerow(d);w.writerows(zip(*d.values()))
differences={k:abs(summaries[0][k]-summaries[1][k]) for k in ['bus_max_v','bus_min_v']}
if max(differences.values())>.02: errors.append(p['name']+': timestep sensitivity >20 mV')
if p['kind']=='regen' and abs(summaries[1]['energy_residual_j'])>1e-3:
errors.append(p['name']+': energy residual >1 mJ')
if p['kind']=='winding' and abs(summaries[1]['energy_residual_j'])>max(50e-6,.005*(summaries[1]['initial_winding_energy_j']+summaries[1]['injected_rotor_energy_j'])):
errors.append(p['name']+': winding energy balance outside 50 uJ / 0.5% bound')
if p['kind']=='overload' and abs(summaries[0]['input_peak_a']-summaries[1]['input_peak_a'])>.002:
errors.append(p['name']+': input-current timestep sensitivity >2 mA')
result['cases'].append({'parameters':p,'normal':summaries[0],'fine':summaries[1],'refinement_difference_v':differences})
print(p['name'],json.dumps(summaries[1]),flush=True)
by={c['parameters']['name']:c['fine'] for c in result['cases']}
# These are bounded model assertions, not a hardware acceptance certificate.
checks={
'healthy_winding_corner_plus_0p5v_allocation_below_55v':by['winding_corner_brake']['bus_max_v']+.5<55,
'commissioning_open_winding_plus_0p5v_allocation_below_55v':by['commissioning_corner_open']['bus_max_v']+.5<55,
'new_steady_envelope_below_5a':by['input_new_1x_corner']['input_peak_a']<5,
'new_10a_request_envelope_below_5a':by['input_new_1x_corner_10a']['input_peak_a']<5,
'restricted_rotor_and_winding_plus_0p5v_allocation_below_55v':by['restricted_open_with_rotor']['bus_max_v']+.5<55,
'old_2x_envelope_exceeds_5a':by['input_old_2x_reference']['input_peak_a']>5,
'open_brake_at_1000rpm_remains_unsafe':by['rotor_1000rpm_open']['bus_max_v']>60,
'unapproved_5j_20ms_load_remains_unsafe':by['five_joules_20ms_stress']['bus_max_v']>55,
}
errors += [k for k,v in checks.items() if not v]
result.update(checks=checks,errors=errors,deck_sha256={p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(ROOT.glob('*.cir'))})
(ROOT/'results.json').write_text(json.dumps(result,indent=2)+'\n')
if errors: raise RuntimeError(errors)
if __name__=='__main__':main()