Skip to content

magna-ev-digital-twin

The magna-ev-digital-twin worked example.

Run it from sema/:

Terminal window
sema check examples/magna-ev-digital-twin
SEMA_STRICT=1 sema run examples/magna-ev-digital-twin
sema assure examples/magna-ev-digital-twin --grade silver
"""CircuitFrame Lab automotive digital-twin entrypoint."""
from magna_ev_digital_twin.aero import aero_solve
from magna_ev_digital_twin.live import serve_live
from magna_ev_digital_twin.physics import physics_context
from magna_ev_digital_twin.vehicle import completeness_report, vehicle_identity
assure silver
args CircuitFrameCli:
live: bool = flag("--live")
port: int = option("--port", default=8794)
def main() !{net.listen}:
identity = vehicle_identity()
report = completeness_report("circuitframe")
ensure not report.major_assembly_complete and not report.production_complete
if CircuitFrameCli.live:
serve_live(CircuitFrameCli.port)
return
context = physics_context()
head_on = aero_solve(context.basis, 27.8, 0.0)
yawed = aero_solve(context.basis, 27.8, 0.34906585039886593)
print(
"configuration=" + identity.id
+ " revision=" + str(identity.revision)
+ " profile=" + report.profile
+ " parts=" + str(report.declared_parts)
+ " instances=" + str(report.declared_instances)
+ " nets=" + str(report.declared_nets)
+ " operations=" + str(report.declared_operations)
+ " major_assembly_complete=" + str(report.major_assembly_complete)
+ " production_complete=" + str(report.production_complete)
)
print(
"aero panels=" + str(context.basis.panel_count)
+ " cd0=" + str(head_on.cd)
+ " cd20=" + str(yawed.cd)
+ " cy20=" + str(yawed.cy)
+ " drag_n=" + str(head_on.drag_n)
+ " solver_residual=" + str(head_on.solver_residual)
+ " closure_residual=" + str(head_on.force_closure_residual)
)
print(
"surrogate calm_rms=" + str(context.calm.fit_rms_cd)
+ " crosswind_rms=" + str(context.crosswind.fit_rms_cd)
+ " active_validation=" + str(context.holdout.active_validation_error)
+ " candidate_validation=" + str(context.holdout.candidate_validation_error)
+ " mass_kg=" + str(context.parameters.mass_kg)
+ " izz=" + str(context.parameters.yaw_inertia_kg_m2)
+ " cg_height_m=" + str(context.parameters.cg_height_m)
)
"""Automotive adaptive-aerodynamics lifecycle over Sema standard-library gates."""
from std.adaptive_dynamics import ActivationDecision, ModelDescriptor, ModelLifecycle, RegimeStatus, ResidualDetector, SelectionPolicy, activate_validated, make_detector, update_detector, validate_candidate
from std.epistemic import Assumption, Evidence, ValidityRegion
assure silver
pub struct AeroAdaptationSummary:
active_model_id: str
candidate_model_id: str
selected_model_id: str
lifecycle: str
regime: str
mode: str
validation_status: str
detector_score: f64
training_error: f64
validation_error: f64
invariant_violations: int
dwell_steps: int
hysteresis_margin: f64
activated: bool
reason: str
evidence_ids: list[str]
invariant len(active_model_id) > 0
invariant len(candidate_model_id) > 0
invariant len(selected_model_id) > 0
invariant detector_score >= 0.0
invariant training_error >= 0.0
invariant validation_error >= 0.0
invariant invariant_violations >= 0
invariant dwell_steps >= 0
invariant hysteresis_margin >= 0.0
invariant len(reason) > 0
invariant len(evidence_ids) <= 32
pub def initial_aero_detector() -> ResidualDetector !{}:
return make_detector(32, 4, 1.5, 3, 0.01, 0.01)
pub def update_aero_detector(detector: ResidualDetector, residual: f64, observed_at: f64) -> ResidualDetector !{}:
return update_detector(detector, residual, observed_at)
pub def regime_label(detector: ResidualDetector) -> str !{}:
if detector.status == RegimeStatus.stable:
return "stable"
if detector.status == RegimeStatus.suspected:
return "suspected"
if detector.status == RegimeStatus.shifted:
return "shifted"
return "unknown"
pub def retained_adaptation(detector: ResidualDetector, active_model_id: str) -> AeroAdaptationSummary !{}:
mode = "safe_unknown" if detector.status == RegimeStatus.unknown or detector.status == RegimeStatus.suspected else "retain"
reason = "residual history is not yet decisive" if mode == "safe_unknown" else "active aerodynamic model remains inside the observed regime"
return AeroAdaptationSummary(
active_model_id=active_model_id,
candidate_model_id="aero-rom-crosswind-v2",
selected_model_id=active_model_id,
lifecycle="active",
regime=regime_label(detector),
mode=mode,
validation_status="pending" if mode == "safe_unknown" else "not_required",
detector_score=detector.score,
training_error=0.0,
validation_error=0.0,
invariant_violations=0,
dwell_steps=12,
hysteresis_margin=0.01,
activated=false,
reason=reason,
evidence_ids=[],
)
pub def evaluate_yaw_candidate(detector: ResidualDetector, step: int, observed_at: f64, speed_mps: f64, sideslip_deg: f64, holdout: dict[str, f64]) -> AeroAdaptationSummary !{}:
require detector.status == RegimeStatus.shifted
require step >= 1 and observed_at >= 0.0 and speed_mps >= 0.0
evidence_id = "panel-solve-yaw-holdout-v1"
policy = SelectionPolicy(min_validation_improvement=0.25, max_validation_error=0.04, max_invariant_violations=0, min_dwell_steps=12, hysteresis_margin=0.01, required_horizon_steps=12)
if speed_mps > 55.0 or abs(sideslip_deg) > 35.0:
return AeroAdaptationSummary(active_model_id="aero-rom-calm-v1", candidate_model_id="aero-rom-crosswind-v2", selected_model_id="aero-rom-calm-v1", lifecycle="proposed", regime="shifted", mode="safe_unknown", validation_status="blocked_out_of_scope", detector_score=detector.score, training_error=holdout["candidate_training_error"], validation_error=holdout["candidate_validation_error"], invariant_violations=int(holdout["invariant_violations"]), dwell_steps=policy.min_dwell_steps, hysteresis_margin=policy.hysteresis_margin, activated=false, reason="operating point is outside the declared speed and sideslip validity region", evidence_ids=[])
distance_to_boundary = min(55.0 - speed_mps, 35.0 - abs(sideslip_deg))
validity = ValidityRegion(label="speed 0-55 m/s; absolute apparent-wind sideslip <= 35 deg", in_scope=true, distance_to_boundary=distance_to_boundary, checked_at=observed_at)
assumption = Assumption(id="bounded-yaw-envelope", statement="Both reduced-order surrogates are fitted to and scored against the live source-panel solve inside the declared sideslip window; the panel solve itself carries an empirical viscous and base-pressure closure, not a governing CFD field", active=true, checked_at=observed_at, evidence_ids=[evidence_id])
active = ModelDescriptor(id="aero-rom-calm-v1", family="vehicle-aero-reduced", version=1, lifecycle=ModelLifecycle.active, parameter_names=["C_d0", "c_2"], parameters=[0.23, 0.48], structure_signature="quadratic-in-sideslip drag fitted over 0-8 deg", assumptions=[assumption], validity=validity, fit_error=holdout["active_training_error"], validation_error=holdout["active_validation_error"], created_at=0.0, activated_step=0)
candidate = ModelDescriptor(id="aero-rom-crosswind-v2", family="vehicle-aero-reduced", version=2, lifecycle=ModelLifecycle.proposed, parameter_names=["C_d0", "c_2", "c_4"], parameters=[0.23, 0.90, -2.19], structure_signature="quartic-in-sideslip drag and cubic side force fitted over 0-35 deg", assumptions=[assumption], validity=validity, fit_error=holdout["candidate_training_error"], validation_error=holdout["candidate_validation_error"], created_at=observed_at, activated_step=0)
evidence = Evidence(id=evidence_id, source="CircuitFrame source-panel solve swept over sideslip", observed_at=observed_at, reliability=0.72, summary="Training stations at 4 and 8 deg and held-out stations at 22 and 32 deg of apparent-wind sideslip, scored against the live panel solve; this is reduced-model evidence, not governing CFD", provenance=["aero.sema", "physics.sema", evidence_id])
validation = validate_candidate(active, candidate, holdout["candidate_training_error"], holdout["candidate_validation_error"], int(holdout["invariant_violations"]), [evidence], observed_at, policy)
decision = activate_validated(active, validation.model, step, step, [evidence], policy)
match decision:
case ActivationDecision.activated(previous, current, transition):
return AeroAdaptationSummary(active_model_id=previous.id, candidate_model_id=candidate.id, selected_model_id=current.id, lifecycle="active", regime="shifted", mode="structural_switch", validation_status="passed_reduced_analytic", detector_score=detector.score, training_error=validation.training_error, validation_error=validation.validation_error, invariant_violations=validation.invariant_violations, dwell_steps=policy.min_dwell_steps, hysteresis_margin=policy.hysteresis_margin, activated=true, reason=transition.reason, evidence_ids=[evidence_id])
case ActivationDecision.blocked(reason, transition):
return AeroAdaptationSummary(active_model_id=active.id, candidate_model_id=candidate.id, selected_model_id=active.id, lifecycle="validated_not_active", regime="shifted", mode="safe_unknown", validation_status="passed_reduced_analytic", detector_score=detector.score, training_error=validation.training_error, validation_error=validation.validation_error, invariant_violations=validation.invariant_violations, dwell_steps=policy.min_dwell_steps, hysteresis_margin=policy.hysteresis_margin, activated=false, reason=reason, evidence_ids=[evidence_id])
"""Vehicle aerodynamics by a 3D constant-strength source-panel method with an image ground plane.
Method: Hess-Smith constant-strength source panels over a closed lofted body, with every panel
mirrored through the road surface (z = 0) so the ground boundary condition is satisfied exactly.
The Neumann condition (zero normal velocity on every panel) is closed by two dense linear solves,
one per unit freestream direction; the boundary-value problem is linear in the freestream, so
sigma(V, beta) = V (cos beta sigma_x + sin beta sigma_y) is exact for every wind speed and yaw and
no matrix work is needed per frame.
Honesty: this is NOT a CFD run and no Navier-Stokes equation is integrated. Potential flow alone
yields exactly zero drag and zero side force on a closed body (d'Alembert's paradox), so every
drag and side-force number here comes from the empirical viscous and base-pressure closure layered
on top of the solved pressure field. Each published quantity is tagged `solved` (out of the linear
system), `correlated` (empirical closure with a cited form), or `numerical` (a discretisation
diagnostic). See `aero_equations()` for the per-quantity provenance.
"""
import math
assure silver
NOSE_X_M = 2.31
TAIL_X_M = -2.44
LENGTH_M = 4.751
HALF_WIDTH_M = 0.9605
HEIGHT_M = 1.624
AXLE_X_M = 1.445
RIDE_HEIGHT_M = 0.167
FRONTAL_AREA_M2 = 2.48
SIDE_AREA_M2 = 4.32
PLANFORM_AREA_M2 = 7.95
WETTED_AREA_M2 = 19.6
AIR_DENSITY_KG_M3 = 1.225
AIR_VISCOSITY_PA_S = 0.0000181
RING_COUNT = 21
AROUND = 12
PANEL_COUNT = 240
HALF_PANEL_COUNT = 120
HALF_AROUND = 6
SECTION_EXPONENT = 3.2
REGULARISATION_FACTOR = 0.5
FOUR_PI = 12.566370614359172
NOSE_APEX_Z_M = 0.46
HOOD_Z_M = 0.97
HOOD_END_U = 0.28
ROOF_START_U = 0.46
FASTBACK_START_U = 0.54
TAIL_TOP_Z_M = 0.98
DIFFUSER_Z_M = 0.50
WIDTH_FULL_U = 0.38
TAPER_START_U = 0.48
TAPER_DEPTH = 0.26
NOSE_FULLNESS = 2.2
TUMBLEHOME = 0.30
SILL_TUCK = 0.20
STRATFORD_LONGITUDINAL = 0.24
STRATFORD_CROSSFLOW = 0.60
SIDE_LINE_NORMAL_Z = -0.6
BASE_PRESSURE_CP = 0.20
BASE_PRESSURE_YAW_GAIN = 0.80
CROSSFLOW_BASE_CP = -1.00
WHEEL_CD = 0.036947
WHEEL_CD_YAW_GAIN = 0.60
CALIBRATION_SPEED_MPS = 27.8
CALIBRATION_TARGET_CD = 0.23
STROUHAL = 0.20
CP_FLOOR = -4.0
pub struct AeroPanel:
index: int
station: int
ring_index: int
center_x: f64
center_y: f64
center_z: f64
normal_x: f64
normal_y: f64
normal_z: f64
area_m2: f64
invariant index >= 0 and index < 4096
invariant station >= 0 and station < 64
invariant ring_index >= 0 and ring_index < 64
invariant area_m2 > 0.0
invariant center_z > 0.0 and center_z < 3.0
invariant center_x >= -3.0 and center_x <= 3.0
invariant abs(normal_x * normal_x + normal_y * normal_y + normal_z * normal_z - 1.0) < 0.000001
pub struct AeroBasis:
panels: list[AeroPanel]
source_x: list[f64]
source_y: list[f64]
mode_x: list[f64]
mode_y: list[f64]
wake_forward: list[f64]
wake_forward_cp: list[f64]
wake_reverse: list[f64]
wake_reverse_cp: list[f64]
wake_crossflow: list[f64]
wake_crossflow_cp: list[f64]
base_face: list[f64]
panel_count: int
wetted_area_m2: f64
frontal_area_m2: f64
side_area_m2: f64
planform_area_m2: f64
closure_residual_m2: f64
residual_x: f64
residual_y: f64
separation_x_m: f64
calibrated_wheel_cd: f64
calibration_speed_mps: f64
calibration_target_cd: f64
invariant panel_count == PANEL_COUNT
invariant len(panels) == PANEL_COUNT
invariant len(source_x) == PANEL_COUNT and len(source_y) == PANEL_COUNT
invariant len(mode_x) == 3 * PANEL_COUNT and len(mode_y) == 3 * PANEL_COUNT
invariant len(wake_forward) == PANEL_COUNT and len(wake_crossflow) == PANEL_COUNT
invariant len(base_face) == PANEL_COUNT
invariant wetted_area_m2 > 0.0 and frontal_area_m2 > 0.0
invariant closure_residual_m2 >= 0.0 and closure_residual_m2 < 0.000001
invariant residual_x >= 0.0 and residual_x < 0.00000001
invariant residual_y >= 0.0 and residual_y < 0.00000001
invariant separation_x_m >= TAIL_X_M and separation_x_m <= NOSE_X_M
invariant calibrated_wheel_cd > 0.0
invariant calibration_speed_mps > 0.0
invariant calibration_target_cd > 0.0
pub struct AeroForces:
air_speed_mps: f64
yaw_rad: f64
dynamic_pressure_pa: f64
reynolds_number: f64
cd: f64
cd_pressure: f64
cd_base: f64
cd_friction: f64
cd_wheels: f64
cy: f64
cl: f64
cmz: f64
drag_n: f64
side_n: f64
lift_n: f64
yaw_moment_nm: f64
front_lift_n: f64
rear_lift_n: f64
pressure_drag_n: f64
cp_min: f64
cp_max: f64
base_pressure_coefficient: f64
separation_x_m: f64
wake_width_m: f64
wake_deficit_fraction: f64
wake_shedding_hz: f64
turbulence_intensity: f64
wake_recirculation_length_m: f64
dalembert_residual_cd: f64
solver_residual: f64
force_closure_residual: f64
surface_cp: list[f64]
invariant air_speed_mps >= 0.0
invariant dynamic_pressure_pa >= 0.0
invariant reynolds_number >= 0.0
invariant cd_friction > 0.0
invariant cd_wheels > 0.0
invariant abs(cd - (cd_pressure + cd_base + cd_friction + cd_wheels)) < 0.000000001
invariant cp_min >= CP_FLOOR and cp_min <= 1.0
invariant cp_max >= CP_FLOOR and cp_max <= 1.0
invariant cp_min <= cp_max
invariant base_pressure_coefficient < 0.0
invariant separation_x_m >= TAIL_X_M and separation_x_m <= NOSE_X_M
invariant wake_width_m >= 0.0
invariant wake_deficit_fraction >= 0.0 and wake_deficit_fraction <= 1.0
invariant wake_shedding_hz >= 0.0
invariant turbulence_intensity >= 0.0 and turbulence_intensity <= 1.0
invariant wake_recirculation_length_m > 0.0
invariant solver_residual >= 0.0
invariant force_closure_residual >= 0.0
invariant len(surface_cp) == PANEL_COUNT
def smooth_step(low: f64, high: f64, value: f64):
"""Cubic Hermite ramp, clamped to [0, 1] outside the interval."""
span = max(0.000001, high - low)
u = min(1.0, max(0.0, (value - low) / span))
return u * u * (3.0 - 2.0 * u)
def roof_height(u: f64):
"""Upper silhouette: low pointed nose, hood, raked windscreen, roof peak, fastback taper."""
return (NOSE_APEX_Z_M
+ (HOOD_Z_M - NOSE_APEX_Z_M) * smooth_step(0.0, HOOD_END_U, u)
+ (HEIGHT_M - HOOD_Z_M) * smooth_step(HOOD_END_U, ROOF_START_U, u)
- (HEIGHT_M - TAIL_TOP_Z_M) * smooth_step(FASTBACK_START_U, 1.0, u))
def floor_height(u: f64):
"""Lower silhouette: approach ramp, flat underbody at ride height, rear diffuser."""
return (RIDE_HEIGHT_M
+ (NOSE_APEX_Z_M - RIDE_HEIGHT_M) * (1.0 - smooth_step(0.0, 0.09, u))
+ (DIFFUSER_Z_M - RIDE_HEIGHT_M) * smooth_step(0.86, 1.0, u))
def half_width(u: f64):
"""Plan-view half width: blunt nose growth to full width, mild fastback taper aft."""
grow = 1.0 - min(1.0, u / WIDTH_FULL_U)
front = math.pow(max(0.0, 1.0 - grow * grow), 1.0 / NOSE_FULLNESS)
aft = max(0.0, u - TAPER_START_U) / (1.0 - TAPER_START_U)
return HALF_WIDTH_M * front * (1.0 - TAPER_DEPTH * math.pow(aft, 2.4))
def station_ring(u: f64, x_m: f64):
"""One superelliptic cross-section as AROUND flattened x,y,z points, mirror symmetric in y."""
width = half_width(u)
low = floor_height(u)
high = roof_height(u)
middle = 0.5 * (low + high)
reach = 0.5 * (high - low)
mut points = []
for k in range(0, AROUND):
angle = 0.5 * math.pi - 2.0 * math.pi * float(k) / float(AROUND)
across = math.cos(angle)
upward = math.sin(angle)
height_shape = math.pow(abs(upward), 2.0 / SECTION_EXPONENT)
width_shape = math.pow(abs(across), 2.0 / SECTION_EXPONENT)
if upward >= 0.0:
edge = max(0.0, (height_shape - 0.35) / 0.65)
narrow = 1.0 - TUMBLEHOME * edge * edge
else:
edge = max(0.0, (height_shape - 0.30) / 0.70)
narrow = 1.0 - SILL_TUCK * edge * edge
y = width * narrow * width_shape
if across < 0.0:
y = 0.0 - y
z = middle + reach * height_shape
if upward < 0.0:
z = middle - reach * height_shape
points = points + [x_m, y, z]
return points
def panel_mesh():
"""Closed lofted body: two degenerate end caps plus RING_COUNT - 3 superelliptic stations.
Panels are ordered index = station * AROUND + ring_index, and ring_index k mirrors to
AROUND - 1 - k through the y = 0 plane, which the symmetry-reduced solve relies on.
"""
span = NOSE_X_M - TAIL_X_M
middle_x = 0.5 * (NOSE_X_M + TAIL_X_M)
mut rings = []
for r in range(0, RING_COUNT):
x = middle_x + 0.5 * span * math.cos(math.pi * float(r) / float(RING_COUNT - 1))
u = (NOSE_X_M - x) / span
if r == 0:
mut apex = []
for k in range(0, AROUND):
apex = apex + [NOSE_X_M, 0.0, NOSE_APEX_Z_M]
rings = rings + [apex]
elif r == RING_COUNT - 1:
centre = 0.5 * (floor_height(1.0) + roof_height(1.0))
mut tail = []
for k in range(0, AROUND):
tail = tail + [TAIL_X_M, 0.0, centre]
rings = rings + [tail]
else:
rings = rings + [station_ring(u, x)]
mut panels = []
for r in range(0, RING_COUNT - 1):
near = rings[r]
far = rings[r + 1]
for k in range(0, AROUND):
j = 3 * k
m = 3 * ((k + 1) % AROUND)
ax = far[m] - near[j]
ay = far[m + 1] - near[j + 1]
az = far[m + 2] - near[j + 2]
bx = far[j] - near[m]
by = far[j + 1] - near[m + 1]
bz = far[j + 2] - near[m + 2]
vx = 0.5 * (ay * bz - az * by)
vy = 0.5 * (az * bx - ax * bz)
vz = 0.5 * (ax * by - ay * bx)
area = math.sqrt(vx * vx + vy * vy + vz * vz)
panels = panels + [AeroPanel(
index=r * AROUND + k,
station=r,
ring_index=k,
center_x=0.25 * (near[j] + near[m] + far[m] + far[j]),
center_y=0.25 * (near[j + 1] + near[m + 1] + far[m + 1] + far[j + 1]),
center_z=0.25 * (near[j + 2] + near[m + 2] + far[m + 2] + far[j + 2]),
normal_x=vx / area,
normal_y=vy / area,
normal_z=vz / area,
area_m2=area,
)]
return panels
def influence_blocks(tx: list[f64], ty: list[f64], tz: list[f64], sx: list[f64], sy: list[f64],
sz: list[f64], area: list[f64], epsilon: list[f64]) -> any:
"""Velocity induced at every target by unit constant sources plus their z = 0 ground images.
Returns the three dense component matrices [Kx, Ky, Kz]; the near field is regularised with
r_eps = REGULARISATION_FACTOR * sqrt(area_j) exactly as the browser-side field evaluator does.
"""
target_ones = [1.0] * len(tx)
source_ones = [1.0] * len(sx)
equation:
tgx := outer(tx, source_ones)
tgy := outer(ty, source_ones)
tgz := outer(tz, source_ones)
srx := outer(target_ones, sx)
sry := outer(target_ones, sy)
srz := outer(target_ones, sz)
strength := outer(target_ones, area)
guard := outer(target_ones, epsilon)
dx = tgx - srx
dy = tgy - sry
dz = tgz - srz
dzi = tgz + srz
flat = dx * dx + dy * dy
direct = math.sqrt(flat + dz * dz)
image = math.sqrt(flat + dzi * dzi)
rd = where(direct > guard, direct, guard)
ri = where(image > guard, image, guard)
sd = strength / (FOUR_PI * rd * rd * rd)
si = strength / (FOUR_PI * ri * ri * ri)
return [(sd + si) * dx, (sd + si) * dy, sd * dz + si * dzi]
def normal_projection(blocks: any, nx: list[f64], ny: list[f64], nz: list[f64], columns: int):
"""Contract the three influence blocks against the target normals into one dense matrix."""
ones = [1.0] * columns
equation:
gx := outer(nx, ones)
gy := outer(ny, ones)
gz := outer(nz, ones)
return gx * blocks[0] + gy * blocks[1] + gz * blocks[2]
def identity_matrix(n: int):
mut rows = []
for i in range(0, n):
mut row = [0.0] * n
row[i] = 1.0
rows = rows + [row]
return tensor(rows)
def dense_solve(matrix: any, rhs: list[f64]):
equation:
answer := solve(matrix, rhs)
mut out = []
for i in range(0, len(rhs)):
out = out + [float(answer[i])]
return out
def dense_apply(matrix: any, vector: list[f64], rows: int):
equation:
product := matvec(matrix, vector)
mut out = []
for i in range(0, rows):
out = out + [float(product[i])]
return out
def stratford_limit(recovery: f64, run_m: f64, gradient: f64, constant: f64):
"""Stratford (1959) turbulent separation: Cbar (xi dCbar/dxi)^1/2 >= k (1e-6 Re_xi)^1/10.
Re_xi is taken at the calibration speed so the separation topology stays geometry locked, which
is what road vehicles show above Re_L ~ 4e6 where separation sits on fixed edges. `correlated`.
"""
run = max(run_m, 0.0001)
reynolds = AIR_DENSITY_KG_M3 * CALIBRATION_SPEED_MPS * run / AIR_VISCOSITY_PA_S
return recovery * math.sqrt(max(0.0, run * gradient)) >= constant * math.pow(0.000001 * reynolds, 0.1)
def line_walk(panels: list[AeroPanel], cp: list[f64], side_line: list[f64], forward: bool):
"""March each longitudinal surface line and freeze the pressure downstream of separation.
Returns 2 * PANEL_COUNT values: a 0/1 separation flag per panel followed by the frozen
separation pressure coefficient carried into the wake.
"""
stations = RING_COUNT - 1
mut flag = [0.0] * PANEL_COUNT
mut frozen = [0.0] * PANEL_COUNT
for k in range(0, AROUND):
if side_line[k] < 0.5:
continue
mut order = []
for s in range(0, stations):
if forward:
order = order + [s * AROUND + k]
else:
order = order + [(stations - 1 - s) * AROUND + k]
mut lowest = cp[order[0]]
mut run = 0.0
mut previous = 0.0
for j in range(1, stations):
here = panels[order[j]]
back = panels[order[j - 1]]
value = cp[order[j]]
if value <= lowest:
lowest = value
run = 0.0
previous = 0.0
continue
dx = here.center_x - back.center_x
dy = here.center_y - back.center_y
dz = here.center_z - back.center_z
step = max(0.000001, math.sqrt(dx * dx + dy * dy + dz * dz))
run = run + step
recovery = (value - lowest) / (1.0 - lowest)
gradient = max(0.0, (recovery - previous) / step)
previous = recovery
if stratford_limit(recovery, run, gradient, STRATFORD_LONGITUDINAL):
wake_cp = cp[order[j - 1]]
for m in range(j, stations):
flag[order[m]] = 1.0
frozen[order[m]] = wake_cp
break
return flag + frozen
def ring_walk(panels: list[AeroPanel], cp: list[f64]):
"""March each cross-section from its crossflow stagnation point and mark the leeward wake.
Crossflow-analogy separation (Allen & Perkins): the pressure field driving it is the unit
lateral mode alone, so the topology is yaw independent and can be precomputed once.
"""
mut flag = [0.0] * PANEL_COUNT
mut frozen = [0.0] * PANEL_COUNT
for s in range(1, RING_COUNT - 2):
mut start = 0
for k in range(1, AROUND):
if cp[s * AROUND + k] > cp[s * AROUND + start]:
start = k
for direction in range(0, 2):
step_sign = 1
if direction == 1:
step_sign = -1
mut lowest = cp[s * AROUND + start]
mut run = 0.0
mut previous = 0.0
mut wake_cp = 0.0
mut separated = false
for d in range(1, AROUND):
k = (start + step_sign * d + 2 * AROUND) % AROUND
index = s * AROUND + k
if separated:
flag[index] = 1.0
frozen[index] = wake_cp
continue
previous_k = (k - step_sign + 2 * AROUND) % AROUND
here = panels[index]
back = panels[s * AROUND + previous_k]
value = cp[index]
if value <= lowest:
lowest = value
run = 0.0
previous = 0.0
continue
dx = here.center_x - back.center_x
dy = here.center_y - back.center_y
dz = here.center_z - back.center_z
step = max(0.000001, math.sqrt(dx * dx + dy * dy + dz * dz))
run = run + step
recovery = (value - lowest) / (1.0 - lowest)
gradient = max(0.0, (recovery - previous) / step)
previous = recovery
if stratford_limit(recovery, run, gradient, STRATFORD_CROSSFLOW):
separated = true
wake_cp = min(cp[s * AROUND + previous_k], CROSSFLOW_BASE_CP)
flag[index] = 1.0
frozen[index] = wake_cp
for s in range(0, RING_COUNT - 1):
for k in range(0, HALF_AROUND):
a = s * AROUND + k
b = s * AROUND + (AROUND - 1 - k)
if flag[a] > 0.5 or flag[b] > 0.5:
mut wake = frozen[a]
if flag[a] < 0.5:
wake = frozen[b]
elif flag[b] > 0.5:
wake = min(frozen[a], frozen[b])
flag[a] = 1.0
flag[b] = 1.0
frozen[a] = wake
frozen[b] = wake
return flag + frozen
pub def aero_basis() -> AeroBasis !{}:
"""Build the panel mesh, run both dense source solves once, and freeze the wake topology."""
panels = panel_mesh()
half = HALF_PANEL_COUNT
mut hx = []
mut hy = []
mut hz = []
mut hnx = []
mut hny = []
mut hnz = []
mut harea = []
mut heps = []
mut mx = []
mut my = []
mut mz = []
mut marea = []
mut meps = []
mut ax = []
mut ay = []
mut az = []
mut anx = []
mut any_y = []
mut anz = []
mut aarea = []
mut aeps = []
for i in range(0, PANEL_COUNT):
p = panels[i]
radius = REGULARISATION_FACTOR * math.sqrt(p.area_m2)
ax = ax + [p.center_x]
ay = ay + [p.center_y]
az = az + [p.center_z]
anx = anx + [p.normal_x]
any_y = any_y + [p.normal_y]
anz = anz + [p.normal_z]
aarea = aarea + [p.area_m2]
aeps = aeps + [radius]
if p.ring_index < HALF_AROUND:
hx = hx + [p.center_x]
hy = hy + [p.center_y]
hz = hz + [p.center_z]
hnx = hnx + [p.normal_x]
hny = hny + [p.normal_y]
hnz = hnz + [p.normal_z]
harea = harea + [p.area_m2]
heps = heps + [radius]
mx = mx + [p.center_x]
my = my + [0.0 - p.center_y]
mz = mz + [p.center_z]
marea = marea + [p.area_m2]
meps = meps + [radius]
near = influence_blocks(hx, hy, hz, hx, hy, hz, harea, heps)
across = influence_blocks(hx, hy, hz, mx, my, mz, marea, meps)
plus = [near[0] + across[0], near[1] + across[1], near[2] + across[2]]
minus = [near[0] - across[0], near[1] - across[1], near[2] - across[2]]
unit = identity_matrix(half) * 0.5
symmetric = normal_projection(plus, hnx, hny, hnz, half) + unit
antisymmetric = normal_projection(minus, hnx, hny, hnz, half) + unit
mut rhs_x = []
mut rhs_y = []
for h in range(0, half):
rhs_x = rhs_x + [0.0 - hnx[h]]
rhs_y = rhs_y + [0.0 - hny[h]]
half_sx = dense_solve(symmetric, rhs_x)
half_sy = dense_solve(antisymmetric, rhs_y)
plus_x = dense_apply(plus[0], half_sx, half)
plus_y = dense_apply(plus[1], half_sx, half)
plus_z = dense_apply(plus[2], half_sx, half)
minus_x = dense_apply(minus[0], half_sy, half)
minus_y = dense_apply(minus[1], half_sy, half)
minus_z = dense_apply(minus[2], half_sy, half)
mut source_x = [0.0] * PANEL_COUNT
mut source_y = [0.0] * PANEL_COUNT
mut mode_x = [0.0] * (3 * PANEL_COUNT)
mut mode_y = [0.0] * (3 * PANEL_COUNT)
for h in range(0, half):
station = h // HALF_AROUND
k = h % HALF_AROUND
i = station * AROUND + k
mirror = station * AROUND + (AROUND - 1 - k)
source_x[i] = half_sx[h]
source_x[mirror] = half_sx[h]
source_y[i] = half_sy[h]
source_y[mirror] = 0.0 - half_sy[h]
vxx = 1.0 + 0.5 * half_sx[h] * hnx[h] + plus_x[h]
vxy = 0.5 * half_sx[h] * hny[h] + plus_y[h]
vxz = 0.5 * half_sx[h] * hnz[h] + plus_z[h]
vyx = 0.5 * half_sy[h] * hnx[h] + minus_x[h]
vyy = 1.0 + 0.5 * half_sy[h] * hny[h] + minus_y[h]
vyz = 0.5 * half_sy[h] * hnz[h] + minus_z[h]
mode_x[3 * i] = vxx
mode_x[3 * i + 1] = vxy
mode_x[3 * i + 2] = vxz
mode_x[3 * mirror] = vxx
mode_x[3 * mirror + 1] = 0.0 - vxy
mode_x[3 * mirror + 2] = vxz
mode_y[3 * i] = vyx
mode_y[3 * i + 1] = vyy
mode_y[3 * i + 2] = vyz
mode_y[3 * mirror] = 0.0 - vyx
mode_y[3 * mirror + 1] = vyy
mode_y[3 * mirror + 2] = 0.0 - vyz
full = influence_blocks(ax, ay, az, ax, ay, az, aarea, aeps)
operator = normal_projection(full, anx, any_y, anz, PANEL_COUNT) + identity_matrix(PANEL_COUNT) * 0.5
check_x = dense_apply(operator, source_x, PANEL_COUNT)
check_y = dense_apply(operator, source_y, PANEL_COUNT)
mut residual_x = 0.0
mut residual_y = 0.0
mut wetted = 0.0
mut frontal = 0.0
mut lateral = 0.0
mut planform = 0.0
mut sum_ax = 0.0
mut sum_ay = 0.0
mut sum_az = 0.0
mut cp_axial = [0.0] * PANEL_COUNT
mut cp_crossflow = [0.0] * PANEL_COUNT
mut base_face = [0.0] * PANEL_COUNT
for i in range(0, PANEL_COUNT):
p = panels[i]
residual_x = max(residual_x, abs(check_x[i] + p.normal_x))
residual_y = max(residual_y, abs(check_y[i] + p.normal_y))
wetted = wetted + p.area_m2
frontal = frontal + p.area_m2 * max(0.0, p.normal_x)
lateral = lateral + p.area_m2 * max(0.0, p.normal_y)
planform = planform + p.area_m2 * max(0.0, p.normal_z)
sum_ax = sum_ax + p.area_m2 * p.normal_x
sum_ay = sum_ay + p.area_m2 * p.normal_y
sum_az = sum_az + p.area_m2 * p.normal_z
vx = mode_x[3 * i]
vy = mode_x[3 * i + 1]
vz = mode_x[3 * i + 2]
vn = vx * p.normal_x + vy * p.normal_y + vz * p.normal_z
tx = vx - vn * p.normal_x
ty = vy - vn * p.normal_y
tz = vz - vn * p.normal_z
cp_axial[i] = max(CP_FLOOR, min(1.0, 1.0 - (tx * tx + ty * ty + tz * tz)))
wy = mode_y[3 * i + 1]
wz = mode_y[3 * i + 2]
cp_crossflow[i] = max(CP_FLOOR, min(1.0, 1.0 - (wy * wy + wz * wz)))
if p.station == 0 or p.station == RING_COUNT - 2:
base_face[i] = 1.0
mut side_line = [0.0] * AROUND
for k in range(0, AROUND):
mut mean_nz = 0.0
for s in range(0, RING_COUNT - 1):
mean_nz = mean_nz + panels[s * AROUND + k].normal_z
if mean_nz / float(RING_COUNT - 1) > SIDE_LINE_NORMAL_Z:
side_line[k] = 1.0
forward = line_walk(panels, cp_axial, side_line, true)
reverse = line_walk(panels, cp_axial, side_line, false)
crossflow = ring_walk(panels, cp_crossflow)
mut wake_forward = [0.0] * PANEL_COUNT
mut wake_forward_cp = [0.0] * PANEL_COUNT
mut wake_reverse = [0.0] * PANEL_COUNT
mut wake_reverse_cp = [0.0] * PANEL_COUNT
mut wake_crossflow = [0.0] * PANEL_COUNT
mut wake_crossflow_cp = [0.0] * PANEL_COUNT
mut separated_area = 0.0
mut separated_moment = 0.0
for i in range(0, PANEL_COUNT):
p = panels[i]
wake_forward[i] = forward[i]
wake_forward_cp[i] = forward[PANEL_COUNT + i]
wake_reverse[i] = reverse[i]
wake_reverse_cp[i] = reverse[PANEL_COUNT + i]
wake_crossflow[i] = crossflow[i]
wake_crossflow_cp[i] = crossflow[PANEL_COUNT + i]
if p.station == RING_COUNT - 2:
wake_forward[i] = 1.0
wake_forward_cp[i] = 1.0
if p.station == 0:
wake_reverse[i] = 1.0
wake_reverse_cp[i] = 1.0
if wake_forward[i] > 0.5:
separated_area = separated_area + p.area_m2
separated_moment = separated_moment + p.area_m2 * p.center_x
return AeroBasis(
panels=panels,
source_x=source_x,
source_y=source_y,
mode_x=mode_x,
mode_y=mode_y,
wake_forward=wake_forward,
wake_forward_cp=wake_forward_cp,
wake_reverse=wake_reverse,
wake_reverse_cp=wake_reverse_cp,
wake_crossflow=wake_crossflow,
wake_crossflow_cp=wake_crossflow_cp,
base_face=base_face,
panel_count=PANEL_COUNT,
wetted_area_m2=wetted,
frontal_area_m2=frontal,
side_area_m2=lateral,
planform_area_m2=planform,
closure_residual_m2=math.sqrt(sum_ax * sum_ax + sum_ay * sum_ay + sum_az * sum_az),
residual_x=residual_x,
residual_y=residual_y,
separation_x_m=separated_moment / separated_area,
calibrated_wheel_cd=WHEEL_CD,
calibration_speed_mps=CALIBRATION_SPEED_MPS,
calibration_target_cd=CALIBRATION_TARGET_CD,
)
pub def aero_solve(basis: AeroBasis, air_speed_mps: f64, yaw_rad: f64) -> AeroForces !{}:
"""Integrate the surface pressure for one wind speed and yaw. O(panels), no matrix work.
The apparent wind in the vehicle frame is U = -V (cos beta, sin beta, 0): air arrives at the
nose and leaves past the tail at zero yaw, and beta > 0 puts the wind on the vehicle's left.
The solved Cp field is invariant under freestream reversal, so it is evaluated straight from
the exported +x and +y modes; only the viscous closure needs the physical flow direction.
"""
speed = max(0.0, air_speed_mps)
cb = math.cos(yaw_rad)
sb = math.sin(yaw_rad)
q = 0.5 * AIR_DENSITY_KG_M3 * speed * speed
base_cp = 0.0 - (BASE_PRESSURE_CP + BASE_PRESSURE_YAW_GAIN * abs(sb))
weight_forward = max(0.0, cb)
weight_reverse = max(0.0, 0.0 - cb)
weight_cross = abs(sb)
lee = 0.0
if sb > 0.0:
lee = 1.0
if sb < 0.0:
lee = -1.0
across_x = 0.0 - sb
across_y = cb
panels = basis.panels
mode_x = basis.mode_x
mode_y = basis.mode_y
forward_flag = basis.wake_forward
forward_cp = basis.wake_forward_cp
reverse_flag = basis.wake_reverse
reverse_cp = basis.wake_reverse_cp
cross_flag = basis.wake_crossflow
cross_cp = basis.wake_crossflow_cp
base_flag = basis.base_face
mut surface_cp = [0.0] * basis.panel_count
mut potential_x = 0.0
mut potential_y = 0.0
mut potential_z = 0.0
mut face_x = 0.0
mut face_y = 0.0
mut face_z = 0.0
mut shear_x = 0.0
mut shear_y = 0.0
mut shear_z = 0.0
mut closure_x = 0.0
mut lift_moment = 0.0
mut yaw_moment = 0.0
mut cp_min = 1.0
mut cp_max = CP_FLOOR
mut wake_area = 0.0
mut wake_moment = 0.0
mut wake_low = 1000.0
mut wake_high = -1000.0
mut wake_floor = 1000.0
mut wake_ceiling = -1000.0
for i in range(0, basis.panel_count):
p = panels[i]
nx = p.normal_x
ny = p.normal_y
nz = p.normal_z
px = p.center_x
py = p.center_y
b = 3 * i
ux = cb * mode_x[b] + sb * mode_y[b]
uy = cb * mode_x[b + 1] + sb * mode_y[b + 1]
uz = cb * mode_x[b + 2] + sb * mode_y[b + 2]
un = ux * nx + uy * ny + uz * nz
tx = ux - un * nx
ty = uy - un * ny
tz = uz - un * nz
potential = max(CP_FLOOR, min(1.0, 1.0 - (tx * tx + ty * ty + tz * tz)))
mut cp = potential
mut active = 0.0
if forward_flag[i] > 0.5 and weight_forward > 0.0:
cp = cp + weight_forward * (min(forward_cp[i], base_cp) - cp)
active = weight_forward
if reverse_flag[i] > 0.5 and weight_reverse > 0.0:
cp = cp + weight_reverse * (min(reverse_cp[i], base_cp) - cp)
active = max(active, weight_reverse)
if cross_flag[i] > 0.5 and lee * py < 0.0:
cp = cp + weight_cross * (cross_cp[i] - cp)
active = max(active, weight_cross)
cp = max(CP_FLOOR, min(1.0, cp))
surface_cp[i] = cp
cp_min = min(cp_min, cp)
cp_max = max(cp_max, cp)
wx = 0.0 - p.area_m2 * nx
wy = 0.0 - p.area_m2 * ny
wz = 0.0 - p.area_m2 * nz
potential_x = potential_x + potential * wx
potential_y = potential_y + potential * wy
potential_z = potential_z + potential * wz
delta = cp - potential
dx = delta * wx
dy = delta * wy
dz = delta * wz
closure_x = closure_x + dx
if base_flag[i] > 0.5:
face_x = face_x + dx
face_y = face_y + dy
face_z = face_z + dz
else:
shear_x = shear_x + dx
shear_y = shear_y + dy
shear_z = shear_z + dz
lift_moment = lift_moment + cp * wz * px
yaw_moment = yaw_moment + cp * (px * wy - py * wx)
if active > 0.0:
reach = across_x * px + across_y * py
weighted = p.area_m2 * active
wake_area = wake_area + weighted
wake_moment = wake_moment + weighted * px
wake_low = min(wake_low, reach)
wake_high = max(wake_high, reach)
wake_floor = min(wake_floor, p.center_z)
wake_ceiling = max(wake_ceiling, p.center_z)
cd_pressure = 0.0 - shear_x / FRONTAL_AREA_M2
cd_base = 0.0 - face_x / FRONTAL_AREA_M2
reynolds = AIR_DENSITY_KG_M3 * speed * LENGTH_M / AIR_VISCOSITY_PA_S
effective = max(reynolds, AIR_DENSITY_KG_M3 * 1.0 * LENGTH_M / AIR_VISCOSITY_PA_S)
skin_friction = 0.455 / math.pow(math.log(effective) / math.log(10.0), 2.58)
cd_friction = skin_friction * WETTED_AREA_M2 / FRONTAL_AREA_M2
cd_wheels = WHEEL_CD * (1.0 + WHEEL_CD_YAW_GAIN * abs(sb))
cd = cd_pressure + cd_base + cd_friction + cd_wheels
cy = 0.0 - (face_y + shear_y) / SIDE_AREA_M2
cl = (potential_z + face_z + shear_z) / PLANFORM_AREA_M2
cmz = 0.0 - yaw_moment / (SIDE_AREA_M2 * LENGTH_M)
lift_unit = potential_z + face_z + shear_z
front_unit = (lift_moment + lift_unit * AXLE_X_M) / (2.0 * AXLE_X_M)
drag_n = cd * q * FRONTAL_AREA_M2
pressure_drag_n = 0.0 - closure_x * q
recomposed = pressure_drag_n + (cd_friction + cd_wheels) * q * FRONTAL_AREA_M2
closure_residual = abs(drag_n - recomposed) / max(1.0, abs(drag_n))
separation_x = basis.separation_x_m
if wake_area > 0.0:
separation_x = wake_moment / wake_area
width = max(0.0, wake_high - wake_low)
frontal_wake = max(0.05, width * max(0.05, wake_ceiling - wake_floor))
deficit = min(1.0, max(0.0, (cd_pressure + cd_base) * FRONTAL_AREA_M2 / (2.0 * frontal_wake)))
recirculation = HEIGHT_M * max(1.2, min(2.5, 2.5 - 1.3 * min(1.0, abs(base_cp) / 0.5)))
return AeroForces(
air_speed_mps=speed,
yaw_rad=yaw_rad,
dynamic_pressure_pa=q,
reynolds_number=reynolds,
cd=cd,
cd_pressure=cd_pressure,
cd_base=cd_base,
cd_friction=cd_friction,
cd_wheels=cd_wheels,
cy=cy,
cl=cl,
cmz=cmz,
drag_n=drag_n,
side_n=cy * q * SIDE_AREA_M2,
lift_n=lift_unit * q,
yaw_moment_nm=cmz * q * SIDE_AREA_M2 * LENGTH_M,
front_lift_n=front_unit * q,
rear_lift_n=(lift_unit - front_unit) * q,
pressure_drag_n=pressure_drag_n,
cp_min=cp_min,
cp_max=cp_max,
base_pressure_coefficient=base_cp,
separation_x_m=separation_x,
wake_width_m=width,
wake_deficit_fraction=deficit,
wake_shedding_hz=STROUHAL * speed / HEIGHT_M,
turbulence_intensity=min(1.0, max(0.0, 0.14 + 0.45 * deficit)),
wake_recirculation_length_m=recirculation,
dalembert_residual_cd=0.0 - potential_x / FRONTAL_AREA_M2,
solver_residual=max(basis.residual_x, basis.residual_y),
force_closure_residual=closure_residual,
surface_cp=surface_cp,
)
def rounded(value: f64):
"""Half-up rounding to five decimals so the exported panel payload stays compact."""
if value < 0.0:
return 0.0 - float(int(0.0 - value * 100000.0 + 0.5)) / 100000.0
return float(int(value * 100000.0 + 0.5)) / 100000.0
pub def aero_basis_export(basis: AeroBasis) -> dict[str, any] !{}:
"""`sema.circuitframe-aero-basis/v1`: geometry, source strengths and the solver provenance."""
mut rows = []
for i in range(0, basis.panel_count):
p = basis.panels[i]
rows = rows + [{
"i": p.index,
"c": [rounded(p.center_x), rounded(p.center_y), rounded(p.center_z)],
"n": [rounded(p.normal_x), rounded(p.normal_y), rounded(p.normal_z)],
"a": rounded(p.area_m2),
"sx": rounded(basis.source_x[i]),
"sy": rounded(basis.source_y[i]),
}]
calibration = aero_solve(basis, basis.calibration_speed_mps, 0.0)
return {
"schema": "sema.circuitframe-aero-basis/v1",
"method": "constant-source-panel + image ground plane",
"panel_count": basis.panel_count,
"reference": {
"frontal_area_m2": FRONTAL_AREA_M2,
"planform_area_m2": PLANFORM_AREA_M2,
"side_area_m2": SIDE_AREA_M2,
"wetted_area_m2": WETTED_AREA_M2,
"length_m": LENGTH_M,
"width_m": 2.0 * HALF_WIDTH_M,
"height_m": HEIGHT_M,
"wheelbase_m": 2.0 * AXLE_X_M,
"ride_height_m": RIDE_HEIGHT_M,
},
"mesh": {
"wetted_area_m2": rounded(basis.wetted_area_m2),
"frontal_area_m2": rounded(basis.frontal_area_m2),
"side_area_m2": rounded(basis.side_area_m2),
"planform_area_m2": rounded(basis.planform_area_m2),
"closure_residual_m2": basis.closure_residual_m2,
"stations": RING_COUNT - 1,
"around": AROUND,
},
"panels": rows,
"solver": {
"residual_max": max(basis.residual_x, basis.residual_y),
"modes": 2,
"ground_image": true,
"regularisation": "r_eps = 0.5 sqrt(a_i)",
"reduction": "mirror-symmetry split: two 120x120 dense solves span the 240-panel system",
"freestream_convention": "U_inf = V (cos yaw, sin yaw, 0); render -u for nose-to-tail smoke",
"render_sign": -1.0,
},
"calibration": {
"cd_pressure": calibration.cd_pressure,
"cd_friction": calibration.cd_friction,
"cd_base": calibration.cd_base,
"cd_wheels": calibration.cd_wheels,
"cd_total": calibration.cd,
"speed_mps": basis.calibration_speed_mps,
"target_cd": basis.calibration_target_cd,
"free_parameter": "wheel and underbody increment",
"wheel_increment_zero_yaw": basis.calibrated_wheel_cd,
},
}
pub def aero_equations() -> list[dict[str, str]] !{}:
"""Equation manifest rows; every owner states solved, correlated or numerical provenance."""
return [
{"id": "panel-neumann", "source": "aero_basis", "expression": "sum_j A_ij sigma_j = -n_i . e_inf, A_ii = 1/2", "unit": "m/s", "owner": "Sema source-panel solve (solved)"},
{"id": "panel-influence", "source": "influence_blocks", "expression": "A_ij = a_j n_i . (c_i - c_j) / (4 pi max(r, r_eps)^3) + ground image", "unit": "1/s", "owner": "Sema source-panel solve (solved)"},
{"id": "panel-ground-image", "source": "influence_blocks", "expression": "every source mirrored through z = 0 so u_z(z=0) = 0 exactly", "unit": "1", "owner": "Sema source-panel solve (solved)"},
{"id": "panel-superposition", "source": "aero_solve", "expression": "sigma(V, beta) = V (cos beta sigma_x + sin beta sigma_y)", "unit": "m/s", "owner": "Sema source-panel solve (solved, exact in the freestream)"},
{"id": "surface-pressure", "source": "aero_solve", "expression": "Cp_i = 1 - |v_i|^2 / V^2, v_i tangential", "unit": "1", "owner": "Sema source-panel solve (solved)"},
{"id": "pressure-integral", "source": "aero_solve", "expression": "F = -q sum_i Cp_i a_i n_i, q = 1/2 rho V^2", "unit": "N", "owner": "Sema surface integration (solved)"},
{"id": "dalembert", "source": "aero_solve", "expression": "closed-body potential flow gives F_x = F_y = 0; the residual is discretisation error", "unit": "1", "owner": "Sema solver diagnostic (numerical)"},
{"id": "stratford-separation", "source": "line_walk", "expression": "Cbar (xi dCbar/dxi)^1/2 >= k (1e-6 Re_xi)^1/10", "unit": "1", "owner": "Stratford 1959 turbulent recovery (correlated)"},
{"id": "crossflow-separation", "source": "ring_walk", "expression": "same criterion walked around each section on the unit crossflow mode", "unit": "1", "owner": "Allen-Perkins crossflow analogy (correlated)"},
{"id": "base-pressure", "source": "aero_solve", "expression": "Cp_b = -(0.20 + 0.80 |sin beta|) held over the separated wake", "unit": "1", "owner": "Sema bluff-body base closure (correlated)"},
{"id": "free-streamline", "source": "aero_solve", "expression": "separated panels hold min(Cp at separation, Cp_b)", "unit": "1", "owner": "Kirchhoff constant-pressure wake (correlated)"},
{"id": "skin-friction", "source": "aero_solve", "expression": "C_f = 0.455 / (log10 Re_L)^2.58, cd_f = C_f A_wet / A_front", "unit": "1", "owner": "Schlichting turbulent flat plate (correlated)"},
{"id": "wheel-increment", "source": "aero_solve", "expression": "cd_w = 0.036947 (1 + 0.60 |sin beta|)", "unit": "1", "owner": "Sema wheel and underbody increment (correlated, calibrated)"},
{"id": "drag-buildup", "source": "aero_solve", "expression": "C_D = cd_pressure + cd_base + cd_friction + cd_wheels", "unit": "1", "owner": "Sema drag build-up (solved + correlated)"},
{"id": "axle-lift-split", "source": "aero_solve", "expression": "F_zf = (M_y + F_z a) / 2a about the mid-wheelbase origin", "unit": "N", "owner": "Sema surface integration (solved + correlated)"},
{"id": "yaw-moment", "source": "aero_solve", "expression": "M_z = sum_i (x_i dF_y,i - y_i dF_x,i), C_Mz = -M_z / (q A_side L)", "unit": "N.m", "owner": "Sema surface integration (solved + correlated)"},
{"id": "wake-shedding", "source": "aero_solve", "expression": "f_s = St V / H, St = 0.20", "unit": "Hz", "owner": "Sema bluff-body wake scalar (correlated)"},
{"id": "wake-deficit", "source": "aero_solve", "expression": "delta_u = C_D,pressure A_front / (2 A_wake), bounded to [0, 1]", "unit": "fraction", "owner": "Sema momentum-deficit estimate (correlated)"},
{"id": "wake-recirculation", "source": "aero_solve", "expression": "L_r = H (2.5 - 1.3 min(1, |Cp_b|/0.5)) bounded to 1.2..2.5 H", "unit": "m", "owner": "Sema bluff-body wake scalar (correlated)"},
{"id": "separation-front", "source": "aero_solve", "expression": "x_sep = area-weighted centroid of the panels whose wake closure is active", "unit": "m", "owner": "Sema wake geometry (solved topology, correlated weighting)"},
{"id": "force-closure", "source": "aero_solve", "expression": "|C_D q A - (F_pressure + (cd_f + cd_w) q A)| / max(1, |C_D q A|)", "unit": "1", "owner": "Sema bookkeeping check (numerical)"},
]
def yaw_forces(basis: AeroBasis, degrees: f64):
"""Evaluate one yaw station at the calibration wind speed. Used by the tests below."""
return aero_solve(basis, CALIBRATION_SPEED_MPS, degrees * math.pi / 180.0)
test "panel mesh closes and matches the declared reference areas":
basis = aero_basis()
ensure basis.panel_count == 240
ensure len(basis.panels) == 240
ensure basis.closure_residual_m2 < 0.000000001
ensure abs(basis.wetted_area_m2 / WETTED_AREA_M2 - 1.0) < 0.15
ensure abs(basis.frontal_area_m2 - FRONTAL_AREA_M2) < 0.15
ensure basis.separation_x_m < 0.0
test "both dense source solves satisfy the Neumann condition on every panel":
basis = aero_basis()
ensure basis.residual_x < 0.00000001
ensure basis.residual_y < 0.00000001
mut worst = 0.0
for i in range(0, basis.panel_count):
p = basis.panels[i]
vn = (basis.mode_x[3 * i] * p.normal_x + basis.mode_x[3 * i + 1] * p.normal_y
+ basis.mode_x[3 * i + 2] * p.normal_z)
worst = max(worst, abs(vn))
ensure worst < 0.00000001
test "zero yaw is symmetric and lands on the calibrated drag coefficient":
basis = aero_basis()
forces = yaw_forces(basis, 0.0)
ensure abs(forces.cd - 0.23) < 0.005
ensure abs(forces.cy) < 0.000001 * forces.cd
ensure abs(forces.cmz) < 0.000001 * forces.cd
ensure abs(forces.side_n) < 0.000001 * forces.drag_n
ensure abs(forces.yaw_moment_nm) < 0.000001 * forces.drag_n
test "yaw sweep raises drag and grows side force nearly linearly":
basis = aero_basis()
calm = yaw_forces(basis, 0.0)
five = yaw_forces(basis, 5.0)
ten = yaw_forces(basis, 10.0)
fifteen = yaw_forces(basis, 15.0)
twenty = yaw_forces(basis, 20.0)
ensure fifteen.cd > calm.cd
ensure twenty.cd > fifteen.cd
ensure ten.cy > 0.0
ensure abs(ten.cy / five.cy - 2.0) < 0.5
ensure yaw_forces(basis, -10.0).cy < 0.0
ensure abs(yaw_forces(basis, -10.0).cd - ten.cd) < 0.000000001
test "pressure forces scale with the square of the wind speed":
basis = aero_basis()
low = aero_solve(basis, 12.0, 0.2)
high = aero_solve(basis, 24.0, 0.2)
ensure low.pressure_drag_n > 0.0
ensure abs(high.pressure_drag_n / low.pressure_drag_n - 4.0) < 0.000000001
ensure abs(high.dynamic_pressure_pa / low.dynamic_pressure_pa - 4.0) < 0.000000001
test "a stationary car in moving air still reports drag":
basis = aero_basis()
forces = aero_solve(basis, 27.8, 0.0)
ensure forces.drag_n > 100.0
ensure forces.dynamic_pressure_pa > 0.0
ensure aero_solve(basis, 0.0, 0.0).drag_n == 0.0
test "coefficient build-up reproduces the integrated pressure force":
basis = aero_basis()
for step in range(0, 7):
forces = yaw_forces(basis, float(step) * 6.0)
ensure forces.force_closure_residual < 0.000000001
ensure len(forces.surface_cp) == 240
ensure forces.cp_max <= 1.0 and forces.cp_min >= -4.0
test "exported basis carries the panel field the browser recombines":
basis = aero_basis()
payload = aero_basis_export(basis)
ensure payload["schema"] == "sema.circuitframe-aero-basis/v1"
ensure payload["panel_count"] == 240
ensure len(payload["panels"]) == 240
ensure len(aero_equations()) == 21
"""Physical assembly model: ports, mates, joints, mass properties, load paths, and the coupled thermal network."""
import math
from magna_ev_digital_twin.domain import PartGroup, PortDefinition, PortKind
from magna_ev_digital_twin.vehicle import vehicle_parts
assure silver
enum JointKind:
fixed | revolute | prismatic | spherical | cylindrical | gear | rigid_bond
enum PlacementPattern:
single | distributed | lateral_pair | longitudinal_pair | corner_quad
enum LoadPathKind:
vertical | longitudinal | lateral | structural | crash | restraint
struct MateDefinition:
id: str
port_a_id: str
port_b_id: str
kind: PortKind
preload_n: f64
sealed: bool
serviceable: bool
robot_accessible: bool
invariant len(id) > 0
invariant len(port_a_id) > 0
invariant len(port_b_id) > 0
invariant port_a_id != port_b_id
invariant preload_n >= 0.0
struct JointDefinition:
id: str
name: str
kind: JointKind
parent_part_id: str
child_part_id: str
parent_link: str
child_link: str
axis_x: f64
axis_y: f64
axis_z: f64
origin_x: f64
origin_y: f64
origin_z: f64
limit_lower: f64
limit_upper: f64
ratio: f64
dof: int
idle_dof: int
corner: str
loop_closing: bool
invariant len(id) > 0
invariant len(name) > 0
invariant len(parent_part_id) > 0
invariant len(child_part_id) > 0
invariant len(parent_link) > 0
invariant len(child_link) > 0
invariant parent_link != child_link
invariant limit_upper >= limit_lower
invariant ratio != 0.0
invariant dof >= 0 and dof <= 6
invariant idle_dof >= 0 and idle_dof <= dof
struct PartPlacement:
part_id: str
pattern: PlacementPattern
pattern_instances: int
mass_kg: f64
centre_x: f64
centre_y: f64
centre_z: f64
extent_x: f64
extent_y: f64
extent_z: f64
span_x: f64
span_y: f64
unsprung_fraction: f64
invariant len(part_id) > 0
invariant pattern_instances >= 1 and pattern_instances <= 4
invariant mass_kg >= 0.0
invariant extent_x > 0.0 and extent_y > 0.0 and extent_z > 0.0
invariant span_x >= 0.0 and span_y >= 0.0
invariant unsprung_fraction >= 0.0 and unsprung_fraction <= 1.0
pub struct MassProperties:
total_mass_kg: f64
sprung_mass_kg: f64
unsprung_mass_kg: f64
cg_x_m: f64
cg_y_m: f64
cg_z_m: f64
ixx_kg_m2: f64
iyy_kg_m2: f64
izz_kg_m2: f64
front_axle_load_kg: f64
rear_axle_load_kg: f64
front_mass_fraction: f64
wheelbase_m: f64
cg_height_m: f64
part_count: int
instance_count: int
closure_residual_kg: f64
invariant total_mass_kg > 0.0
invariant sprung_mass_kg >= 0.0
invariant unsprung_mass_kg >= 0.0
invariant ixx_kg_m2 > 0.0 and iyy_kg_m2 > 0.0 and izz_kg_m2 > 0.0
invariant front_axle_load_kg >= 0.0 and rear_axle_load_kg >= 0.0
invariant front_mass_fraction > 0.0 and front_mass_fraction < 1.0
invariant wheelbase_m > 0.0
invariant cg_height_m > 0.0
invariant part_count > 0
invariant instance_count >= part_count
struct LoadPath:
id: str
name: str
kind: LoadPathKind
part_ids: list[str]
rated_load_n: f64
utilisation: f64
invariant len(id) > 0
invariant len(name) > 0
invariant len(part_ids) >= 2 and len(part_ids) <= 32
invariant rated_load_n > 0.0
invariant utilisation >= 0.0
struct LoadPathReaction:
path_id: str
element_index: int
part_id: str
role: str
force_n: f64
moment_nm: f64
invariant len(path_id) > 0
invariant element_index >= 0
invariant len(part_id) > 0
invariant len(role) > 0
struct LoadPathResult:
path_id: str
name: str
kind: LoadPathKind
applied_n: f64
reaction_n: f64
residual_n: f64
peak_force_n: f64
rated_load_n: f64
utilisation: f64
reactions: list[LoadPathReaction]
invariant len(path_id) > 0
invariant rated_load_n > 0.0
invariant peak_force_n >= 0.0
invariant utilisation >= 0.0
invariant len(reactions) >= 2
struct LoadCase:
vertical_n: f64
longitudinal_n: f64
lateral_n: f64
results: list[LoadPathResult]
equilibrium_residual_n: f64
moment_residual_nm: f64
worst_path_id: str
worst_utilisation: f64
invariant len(results) > 0
invariant equilibrium_residual_n >= 0.0
invariant moment_residual_nm >= 0.0
invariant worst_utilisation >= 0.0
pub struct SuspensionState:
corner_id: str
vertical_load_n: f64
arm_rotation_rad: f64
wheel_travel_m: f64
spring_stroke_m: f64
damper_length_m: f64
spring_force_n: f64
wheel_rate_n_m: f64
motion_ratio: f64
camber_deg: f64
camber_change_deg: f64
toe_deg: f64
toe_change_deg: f64
kingpin_rotation_rad: f64
jounce_limit_m: f64
rebound_limit_m: f64
travel_utilisation: f64
at_limit: bool
invariant len(corner_id) > 0
invariant vertical_load_n >= 0.0
invariant damper_length_m > 0.0
invariant motion_ratio > 0.0
invariant jounce_limit_m > 0.0
invariant rebound_limit_m < 0.0
invariant travel_utilisation >= 0.0 and travel_utilisation <= 1.0
struct ThermalNode:
id: str
part_id: str
capacity_kj_k: f64
ambient_coupling_w_k: f64
initial_temp_c: f64
invariant len(id) > 0
invariant len(part_id) > 0
invariant capacity_kj_k > 0.0
invariant ambient_coupling_w_k >= 0.0
invariant initial_temp_c >= -60.0 and initial_temp_c <= 200.0
struct ThermalLink:
id: str
from_node_id: str
to_node_id: str
conductance_w_k: f64
medium: str
invariant len(id) > 0
invariant len(from_node_id) > 0
invariant len(to_node_id) > 0
invariant from_node_id != to_node_id
invariant conductance_w_k > 0.0
invariant len(medium) > 0
struct ThermalNetwork:
id: str
nodes: list[ThermalNode]
links: list[ThermalLink]
invariant len(id) > 0
invariant len(nodes) >= 2
invariant len(links) >= 1
struct MobilityReport:
corner_id: str
link_count: int
joint_count: int
joint_dof_sum: int
gross_mobility: int
idle_dof: int
effective_mobility: int
expected_mobility: int
independent_loops: int
loop_closing_joint_ids: list[str]
ok: bool
invariant len(corner_id) > 0
invariant link_count >= 2
invariant joint_count >= 1
invariant joint_dof_sum >= joint_count
invariant independent_loops >= 0
CONFIGURATION_VARIANT_DEFAULT = "circuitframe"
ASSEMBLY_SCHEMA = "sema.circuitframe-assembly/v1"
ASSEMBLY_AUTHORITY = "bounded engineered reconstruction: hard points, mates, joints, and mass properties are declared assumptions derived from the published exterior envelope, not OEM CAD or measured inertia data"
GRAVITY_MPS2 = 9.80665
WHEELBASE_M = 2.890
HALF_TRACK_M = 0.8175
FRONT_AXLE_X_M = 1.445
REAR_AXLE_X_M = -1.445
WHEEL_RADIUS_M = 0.3695
RIDE_HEIGHT_M = 0.167
ASSEMBLY_MASS_KG = 2108.2
HP_LCA_IN_Y = 0.320
HP_LCA_IN_Z = 0.215
HP_LCA_BUSH_DX = 0.140
HP_UCA_IN_Y = 0.400
HP_UCA_IN_Z = 0.470
HP_LBJ_Y = 0.735
HP_LBJ_Z = 0.185
HP_UBJ_Y = 0.700
HP_UBJ_Z = 0.545
HP_WC_Y = 0.8175
HP_WC_Z = 0.3695
HP_TIE_DX = -0.125
HP_TIE_IN_Y = 0.360
HP_TIE_IN_Z = 0.2885
HP_TIE_OUT_Y = 0.720
HP_TIE_OUT_Z = 0.300
HP_KINGPIN_DX = -0.047
HP_DAMPER_LO_Y = 0.620
HP_DAMPER_LO_Z = 0.19331
HP_DAMPER_UP_Y = 0.560
HP_DAMPER_UP_Z = 0.760
HP_ARB_BAR_DX = -0.230
HP_ARB_BAR_Y = 0.480
HP_ARB_BAR_Z = 0.250
HP_ARB_LINK_Y = 0.660
HP_ARB_LINK_Z = 0.205
AIR_SPRING_AREA_M2 = 0.01539
AIR_SPRING_VOLUME_M3 = 0.00232
AIR_SPRING_POLYTROPIC_N = 1.38
ATMOSPHERIC_PRESSURE_PA = 101325.0
JOUNCE_LIMIT_M = 0.090
REBOUND_LIMIT_M = -0.080
STATIC_CAMBER_DEG = -0.5
STATIC_TOE_DEG = 0.08
TRAVEL_SOLVE_ITERATIONS = 22
STEER_SOLVE_ITERATIONS = 6
REFERENCE_BRAKING_N = 9000.0
REFERENCE_CORNERING_N = 6000.0
GEAR_STAGE_ONE_RATIO = 3.25
GEAR_STAGE_TWO_RATIO = 2.8
equation linear_solve_3(matrix, rhs) -> any:
"""Dense 3x3 static-equilibrium solve; returns the reaction vector."""
return solve(matrix, rhs)
equation grubler_mobility(links, joints, joint_dof_sum) -> any:
"""Grubler-Kutzbach spatial mobility of a closed kinematic loop."""
return 6 * (links - 1 - joints) + joint_dof_sum
equation polytropic_pressure(reference_pressure, reference_volume, volume, exponent) -> any:
"""Polytropic gas state of an air spring bellows."""
return reference_pressure * (reference_volume / volume)^exponent
equation box_inertia_term(mass, edge_a, edge_b) -> any:
"""Second moment of a uniform rectangular prism about a centroidal axis."""
return mass * (edge_a^2 + edge_b^2) / 12.0
equation parallel_axis_term(mass, offset_a, offset_b) -> any:
"""Parallel-axis transfer of a body inertia to the vehicle centre of mass."""
return mass * (offset_a^2 + offset_b^2)
def interface_specs() -> list[dict[str, any]]:
"""Declared part-to-part interfaces. Every row expands into two ports and one terminating mate."""
return [
{"mate": "mate-front-subframe-body-rail", "part_a": "front-subframe", "port_a": "front-subframe.mech.body-rail", "part_b": "body-shell", "port_b": "body-shell.mech.front-subframe-rail", "kind": PortKind.mechanical, "preload_n": 42000.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-rear-subframe-body-rail", "part_a": "rear-subframe", "port_a": "rear-subframe.mech.body-rail", "part_b": "body-shell", "port_b": "body-shell.mech.rear-subframe-rail", "kind": PortKind.mechanical, "preload_n": 38000.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-floor-pan-body-bond", "part_a": "floor-pan", "port_a": "floor-pan.mech.body-bond", "part_b": "body-shell", "port_b": "body-shell.mech.floor-flange", "kind": PortKind.mechanical, "preload_n": 0.0, "sealed": true, "serviceable": false, "robot_accessible": true},
{"mate": "mate-sill-panels-floor-flange", "part_a": "sill-panels", "port_a": "sill-panels.mech.floor-flange", "part_b": "floor-pan", "port_b": "floor-pan.mech.sill-flange", "kind": PortKind.mechanical, "preload_n": 26000.0, "sealed": true, "serviceable": false, "robot_accessible": true},
{"mate": "mate-sill-panels-body-flange", "part_a": "sill-panels", "port_a": "sill-panels.mech.body-flange", "part_b": "body-shell", "port_b": "body-shell.mech.sill-flange", "kind": PortKind.mechanical, "preload_n": 24000.0, "sealed": true, "serviceable": false, "robot_accessible": true},
{"mate": "mate-battery-enclosure-floor-mount", "part_a": "battery-enclosure", "port_a": "battery-enclosure.mech.floor-mount", "part_b": "floor-pan", "port_b": "floor-pan.mech.pack-mount", "kind": PortKind.mechanical, "preload_n": 96000.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-battery-enclosure-sill-mount", "part_a": "battery-enclosure", "port_a": "battery-enclosure.mech.sill-mount", "part_b": "sill-panels", "port_b": "sill-panels.mech.pack-mount", "kind": PortKind.mechanical, "preload_n": 54000.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-battery-modules-enclosure-rail", "part_a": "battery-modules", "port_a": "battery-modules.mech.enclosure-rail", "part_b": "battery-enclosure", "port_b": "battery-enclosure.mech.module-rail", "kind": PortKind.mechanical, "preload_n": 18000.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-battery-management-enclosure-bracket", "part_a": "battery-management", "port_a": "battery-management.mech.enclosure-bracket", "part_b": "battery-enclosure", "port_b": "battery-enclosure.mech.bms-bracket", "kind": PortKind.mechanical, "preload_n": 900.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-front-motor-subframe-mount", "part_a": "front-motor", "port_a": "front-motor.mech.subframe-mount", "part_b": "front-subframe", "port_b": "front-subframe.mech.motor-mount", "kind": PortKind.mechanical, "preload_n": 34000.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-rear-motor-subframe-mount", "part_a": "rear-motor", "port_a": "rear-motor.mech.subframe-mount", "part_b": "rear-subframe", "port_b": "rear-subframe.mech.motor-mount", "kind": PortKind.mechanical, "preload_n": 37000.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-reduction-gears-front-motor-flange", "part_a": "reduction-gears", "port_a": "reduction-gears.mech.front-motor-flange", "part_b": "front-motor", "port_b": "front-motor.mech.gearbox-flange", "kind": PortKind.mechanical, "preload_n": 21000.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-reduction-gears-rear-motor-flange", "part_a": "reduction-gears", "port_a": "reduction-gears.mech.rear-motor-flange", "part_b": "rear-motor", "port_b": "rear-motor.mech.gearbox-flange", "kind": PortKind.mechanical, "preload_n": 23000.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-front-inverter-motor-mount", "part_a": "front-inverter", "port_a": "front-inverter.mech.motor-mount", "part_b": "front-motor", "port_b": "front-motor.mech.inverter-pad", "kind": PortKind.mechanical, "preload_n": 5200.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-rear-inverter-motor-mount", "part_a": "rear-inverter", "port_a": "rear-inverter.mech.motor-mount", "part_b": "rear-motor", "port_b": "rear-motor.mech.inverter-pad", "kind": PortKind.mechanical, "preload_n": 5600.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-suspension-front-subframe-pivot", "part_a": "suspension", "port_a": "suspension.mech.front-subframe-pivot", "part_b": "front-subframe", "port_b": "front-subframe.mech.control-arm-pivot", "kind": PortKind.mechanical, "preload_n": 28000.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-suspension-rear-subframe-pivot", "part_a": "suspension", "port_a": "suspension.mech.rear-subframe-pivot", "part_b": "rear-subframe", "port_b": "rear-subframe.mech.control-arm-pivot", "kind": PortKind.mechanical, "preload_n": 29000.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-suspension-damper-top", "part_a": "suspension", "port_a": "suspension.mech.damper-top", "part_b": "body-shell", "port_b": "body-shell.mech.damper-tower", "kind": PortKind.mechanical, "preload_n": 16000.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-brakes-knuckle-mount", "part_a": "brakes", "port_a": "brakes.mech.knuckle-mount", "part_b": "suspension", "port_b": "suspension.mech.caliper-pad", "kind": PortKind.mechanical, "preload_n": 12500.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-wheels-hub-face", "part_a": "wheels", "port_a": "wheels.mech.hub-face", "part_b": "brakes", "port_b": "brakes.mech.hub-face", "kind": PortKind.mechanical, "preload_n": 11800.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-tires-bead-seat", "part_a": "tires", "port_a": "tires.mech.bead-seat", "part_b": "wheels", "port_b": "wheels.mech.rim-flange", "kind": PortKind.mechanical, "preload_n": 0.0, "sealed": true, "serviceable": true, "robot_accessible": false},
{"mate": "mate-steering-subframe-mount", "part_a": "steering", "port_a": "steering.mech.subframe-mount", "part_b": "front-subframe", "port_b": "front-subframe.mech.rack-mount", "kind": PortKind.mechanical, "preload_n": 14000.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-steering-tie-rod-end", "part_a": "steering", "port_a": "steering.mech.tie-rod-end", "part_b": "suspension", "port_b": "suspension.mech.steering-arm", "kind": PortKind.mechanical, "preload_n": 3800.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-closures-hinge-pin", "part_a": "closures", "port_a": "closures.mech.hinge-pin", "part_b": "body-shell", "port_b": "body-shell.mech.hinge-boss", "kind": PortKind.mechanical, "preload_n": 7400.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-glazing-urethane-bead", "part_a": "glazing", "port_a": "glazing.mech.urethane-bead", "part_b": "body-shell", "port_b": "body-shell.mech.glazing-flange", "kind": PortKind.mechanical, "preload_n": 0.0, "sealed": true, "serviceable": false, "robot_accessible": true},
{"mate": "mate-glazing-door-channel", "part_a": "glazing", "port_a": "glazing.mech.door-channel", "part_b": "closures", "port_b": "closures.mech.glazing-channel", "kind": PortKind.mechanical, "preload_n": 0.0, "sealed": true, "serviceable": false, "robot_accessible": false},
{"mate": "mate-seats-floor-rail", "part_a": "seats", "port_a": "seats.mech.floor-rail", "part_b": "floor-pan", "port_b": "floor-pan.mech.seat-rail", "kind": PortKind.mechanical, "preload_n": 9600.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-interior-body-clip", "part_a": "interior", "port_a": "interior.mech.body-clip", "part_b": "body-shell", "port_b": "body-shell.mech.trim-boss", "kind": PortKind.mechanical, "preload_n": 1400.0, "sealed": false, "serviceable": true, "robot_accessible": false},
{"mate": "mate-restraints-pillar-anchor", "part_a": "restraints", "port_a": "restraints.mech.pillar-anchor", "part_b": "body-shell", "port_b": "body-shell.mech.belt-anchor", "kind": PortKind.mechanical, "preload_n": 13500.0, "sealed": false, "serviceable": true, "robot_accessible": false},
{"mate": "mate-restraints-seat-buckle", "part_a": "restraints", "port_a": "restraints.mech.seat-buckle", "part_b": "seats", "port_b": "seats.mech.buckle-anchor", "kind": PortKind.mechanical, "preload_n": 13500.0, "sealed": false, "serviceable": true, "robot_accessible": false},
{"mate": "mate-lamps-body-socket", "part_a": "lamps", "port_a": "lamps.mech.body-socket", "part_b": "body-shell", "port_b": "body-shell.mech.lamp-aperture", "kind": PortKind.mechanical, "preload_n": 900.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-heat-pump-subframe-bracket", "part_a": "heat-pump", "port_a": "heat-pump.mech.subframe-bracket", "part_b": "front-subframe", "port_b": "front-subframe.mech.hvac-bracket", "kind": PortKind.mechanical, "preload_n": 3200.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-thermal-loop-body-clip", "part_a": "thermal-loop", "port_a": "thermal-loop.mech.body-clip", "part_b": "body-shell", "port_b": "body-shell.mech.coolant-clip", "kind": PortKind.mechanical, "preload_n": 700.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-charge-port-body-aperture", "part_a": "charge-port", "port_a": "charge-port.mech.body-aperture", "part_b": "body-shell", "port_b": "body-shell.mech.charge-aperture", "kind": PortKind.mechanical, "preload_n": 800.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-onboard-charger-floor-bracket", "part_a": "onboard-charger", "port_a": "onboard-charger.mech.floor-bracket", "part_b": "floor-pan", "port_b": "floor-pan.mech.charger-bracket", "kind": PortKind.mechanical, "preload_n": 2600.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-hv-junction-enclosure-mount", "part_a": "hv-junction", "port_a": "hv-junction.mech.enclosure-mount", "part_b": "battery-enclosure", "port_b": "battery-enclosure.mech.junction-pad", "kind": PortKind.mechanical, "preload_n": 4200.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-dc-dc-body-bracket", "part_a": "dc-dc", "port_a": "dc-dc.mech.body-bracket", "part_b": "body-shell", "port_b": "body-shell.mech.converter-bracket", "kind": PortKind.mechanical, "preload_n": 2100.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-central-compute-body-bracket", "part_a": "central-compute", "port_a": "central-compute.mech.body-bracket", "part_b": "body-shell", "port_b": "body-shell.mech.compute-bracket", "kind": PortKind.mechanical, "preload_n": 1500.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-lv-battery-body-tray", "part_a": "lv-battery", "port_a": "lv-battery.mech.body-tray", "part_b": "body-shell", "port_b": "body-shell.mech.lv-tray", "kind": PortKind.mechanical, "preload_n": 2300.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-front-zone-controller-body-bracket", "part_a": "front-zone-controller", "port_a": "front-zone-controller.mech.body-bracket", "part_b": "body-shell", "port_b": "body-shell.mech.front-zone-bracket", "kind": PortKind.mechanical, "preload_n": 800.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-rear-zone-controller-body-bracket", "part_a": "rear-zone-controller", "port_a": "rear-zone-controller.mech.body-bracket", "part_b": "body-shell", "port_b": "body-shell.mech.rear-zone-bracket", "kind": PortKind.mechanical, "preload_n": 800.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-cabin-zone-controller-interior-bracket", "part_a": "cabin-zone-controller", "port_a": "cabin-zone-controller.mech.interior-bracket", "part_b": "interior", "port_b": "interior.mech.zone-bracket", "kind": PortKind.mechanical, "preload_n": 600.0, "sealed": false, "serviceable": true, "robot_accessible": false},
{"mate": "mate-sensors-body-boss", "part_a": "sensors", "port_a": "sensors.mech.body-boss", "part_b": "body-shell", "port_b": "body-shell.mech.sensor-boss", "kind": PortKind.mechanical, "preload_n": 400.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-fasteners-seals-closure-seal", "part_a": "fasteners-seals", "port_a": "fasteners-seals.mech.closure-seal", "part_b": "closures", "port_b": "closures.mech.seal-groove", "kind": PortKind.mechanical, "preload_n": 0.0, "sealed": true, "serviceable": false, "robot_accessible": false},
{"mate": "mate-battery-modules-hv-positive", "part_a": "battery-modules", "port_a": "battery-modules.hv.hv-positive", "part_b": "hv-junction", "port_b": "hv-junction.hv.pack-positive", "kind": PortKind.high_voltage, "preload_n": 320.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-battery-modules-hv-negative", "part_a": "battery-modules", "port_a": "battery-modules.hv.hv-negative", "part_b": "hv-junction", "port_b": "hv-junction.hv.pack-negative", "kind": PortKind.high_voltage, "preload_n": 320.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-hv-junction-front-drive", "part_a": "hv-junction", "port_a": "hv-junction.hv.front-drive", "part_b": "front-inverter", "port_b": "front-inverter.hv.hv-input", "kind": PortKind.high_voltage, "preload_n": 260.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-hv-junction-rear-drive", "part_a": "hv-junction", "port_a": "hv-junction.hv.rear-drive", "part_b": "rear-inverter", "port_b": "rear-inverter.hv.hv-input", "kind": PortKind.high_voltage, "preload_n": 260.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-hv-junction-dcdc-feed", "part_a": "hv-junction", "port_a": "hv-junction.hv.dcdc-feed", "part_b": "dc-dc", "port_b": "dc-dc.hv.hv-input", "kind": PortKind.high_voltage, "preload_n": 180.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-hv-junction-charger-feed", "part_a": "hv-junction", "port_a": "hv-junction.hv.charger-feed", "part_b": "onboard-charger", "port_b": "onboard-charger.hv.hv-output", "kind": PortKind.high_voltage, "preload_n": 180.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-charge-port-dc-inlet", "part_a": "charge-port", "port_a": "charge-port.hv.dc-inlet", "part_b": "hv-junction", "port_b": "hv-junction.hv.dc-fast-input", "kind": PortKind.high_voltage, "preload_n": 240.0, "sealed": true, "serviceable": true, "robot_accessible": false},
{"mate": "mate-hv-junction-thermal-feed", "part_a": "hv-junction", "port_a": "hv-junction.hv.thermal-feed", "part_b": "heat-pump", "port_b": "heat-pump.hv.hv-input", "kind": PortKind.high_voltage, "preload_n": 160.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-front-inverter-phase-output", "part_a": "front-inverter", "port_a": "front-inverter.hv.phase-output", "part_b": "front-motor", "port_b": "front-motor.hv.phase-terminal", "kind": PortKind.high_voltage, "preload_n": 210.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-rear-inverter-phase-output", "part_a": "rear-inverter", "port_a": "rear-inverter.hv.phase-output", "part_b": "rear-motor", "port_b": "rear-motor.hv.phase-terminal", "kind": PortKind.high_voltage, "preload_n": 210.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-sill-panels-hv-busbar", "part_a": "sill-panels", "port_a": "sill-panels.hv.hv-busbar", "part_b": "hv-junction", "port_b": "hv-junction.hv.sill-busbar", "kind": PortKind.high_voltage, "preload_n": 140.0, "sealed": true, "serviceable": false, "robot_accessible": true},
{"mate": "mate-dc-dc-lv-output", "part_a": "dc-dc", "port_a": "dc-dc.lv.lv-output", "part_b": "lv-battery", "port_b": "lv-battery.lv.lv-terminal", "kind": PortKind.low_voltage, "preload_n": 60.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-dc-dc-front-zone-feed", "part_a": "dc-dc", "port_a": "dc-dc.lv.front-zone-feed", "part_b": "front-zone-controller", "port_b": "front-zone-controller.lv.lv-input", "kind": PortKind.low_voltage, "preload_n": 40.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-dc-dc-rear-zone-feed", "part_a": "dc-dc", "port_a": "dc-dc.lv.rear-zone-feed", "part_b": "rear-zone-controller", "port_b": "rear-zone-controller.lv.lv-input", "kind": PortKind.low_voltage, "preload_n": 40.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-dc-dc-cabin-zone-feed", "part_a": "dc-dc", "port_a": "dc-dc.lv.cabin-zone-feed", "part_b": "cabin-zone-controller", "port_b": "cabin-zone-controller.lv.lv-input", "kind": PortKind.low_voltage, "preload_n": 40.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-dc-dc-compute-feed", "part_a": "dc-dc", "port_a": "dc-dc.lv.compute-feed", "part_b": "central-compute", "port_b": "central-compute.lv.lv-input", "kind": PortKind.low_voltage, "preload_n": 40.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-lv-battery-contactor-supply", "part_a": "lv-battery", "port_a": "lv-battery.lv.contactor-supply", "part_b": "hv-junction", "port_b": "hv-junction.lv.contactor-coil", "kind": PortKind.low_voltage, "preload_n": 25.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-front-zone-controller-lamp-feed", "part_a": "front-zone-controller", "port_a": "front-zone-controller.lv.lamp-feed", "part_b": "lamps", "port_b": "lamps.lv.lv-input", "kind": PortKind.low_voltage, "preload_n": 20.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-front-zone-controller-steering-feed", "part_a": "front-zone-controller", "port_a": "front-zone-controller.lv.steering-feed", "part_b": "steering", "port_b": "steering.lv.lv-input", "kind": PortKind.low_voltage, "preload_n": 55.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-rear-zone-controller-brake-feed", "part_a": "rear-zone-controller", "port_a": "rear-zone-controller.lv.brake-feed", "part_b": "brakes", "port_b": "brakes.lv.lv-input", "kind": PortKind.low_voltage, "preload_n": 45.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-cabin-zone-controller-seat-feed", "part_a": "cabin-zone-controller", "port_a": "cabin-zone-controller.lv.seat-feed", "part_b": "seats", "port_b": "seats.lv.lv-input", "kind": PortKind.low_voltage, "preload_n": 20.0, "sealed": false, "serviceable": true, "robot_accessible": false},
{"mate": "mate-cabin-zone-controller-squib-feed", "part_a": "cabin-zone-controller", "port_a": "cabin-zone-controller.lv.squib-feed", "part_b": "restraints", "port_b": "restraints.lv.lv-input", "kind": PortKind.low_voltage, "preload_n": 15.0, "sealed": false, "serviceable": false, "robot_accessible": false},
{"mate": "mate-central-compute-front-zone-link", "part_a": "central-compute", "port_a": "central-compute.data.front-zone-link", "part_b": "front-zone-controller", "port_b": "front-zone-controller.data.backbone-a", "kind": PortKind.data, "preload_n": 12.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-central-compute-rear-zone-link", "part_a": "central-compute", "port_a": "central-compute.data.rear-zone-link", "part_b": "rear-zone-controller", "port_b": "rear-zone-controller.data.backbone-a", "kind": PortKind.data, "preload_n": 12.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-central-compute-cabin-zone-link", "part_a": "central-compute", "port_a": "central-compute.data.cabin-zone-link", "part_b": "cabin-zone-controller", "port_b": "cabin-zone-controller.data.backbone-a", "kind": PortKind.data, "preload_n": 12.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-front-zone-controller-inverter-link", "part_a": "front-zone-controller", "port_a": "front-zone-controller.data.inverter-link", "part_b": "front-inverter", "port_b": "front-inverter.data.control-port", "kind": PortKind.data, "preload_n": 10.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-rear-zone-controller-inverter-link", "part_a": "rear-zone-controller", "port_a": "rear-zone-controller.data.inverter-link", "part_b": "rear-inverter", "port_b": "rear-inverter.data.control-port", "kind": PortKind.data, "preload_n": 10.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-front-zone-controller-brake-link", "part_a": "front-zone-controller", "port_a": "front-zone-controller.data.brake-link", "part_b": "brakes", "port_b": "brakes.data.control-port", "kind": PortKind.data, "preload_n": 10.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-front-zone-controller-steer-link", "part_a": "front-zone-controller", "port_a": "front-zone-controller.data.steer-link", "part_b": "steering", "port_b": "steering.data.control-port", "kind": PortKind.data, "preload_n": 10.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-battery-management-cell-sense", "part_a": "battery-management", "port_a": "battery-management.data.cell-sense", "part_b": "battery-modules", "port_b": "battery-modules.data.sense-header", "kind": PortKind.data, "preload_n": 8.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-battery-management-thermal-link", "part_a": "battery-management", "port_a": "battery-management.data.thermal-link", "part_b": "cabin-zone-controller", "port_b": "cabin-zone-controller.data.battery-port", "kind": PortKind.data, "preload_n": 8.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-sensors-compute-link", "part_a": "sensors", "port_a": "sensors.data.compute-link", "part_b": "central-compute", "port_b": "central-compute.data.sensor-port", "kind": PortKind.data, "preload_n": 8.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-cabin-zone-controller-loop-link", "part_a": "cabin-zone-controller", "port_a": "cabin-zone-controller.data.loop-link", "part_b": "thermal-loop", "port_b": "thermal-loop.data.sensor-port", "kind": PortKind.data, "preload_n": 8.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-cabin-zone-controller-heatpump-link", "part_a": "cabin-zone-controller", "port_a": "cabin-zone-controller.data.heatpump-link", "part_b": "heat-pump", "port_b": "heat-pump.data.control-port", "kind": PortKind.data, "preload_n": 8.0, "sealed": false, "serviceable": true, "robot_accessible": true},
{"mate": "mate-thermal-loop-pack-supply", "part_a": "thermal-loop", "port_a": "thermal-loop.fluid.pack-supply", "part_b": "battery-enclosure", "port_b": "battery-enclosure.fluid.cold-plate-inlet", "kind": PortKind.fluid, "preload_n": 1200.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-thermal-loop-front-drive-supply", "part_a": "thermal-loop", "port_a": "thermal-loop.fluid.front-drive-supply", "part_b": "front-motor", "port_b": "front-motor.fluid.jacket-inlet", "kind": PortKind.fluid, "preload_n": 900.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-thermal-loop-rear-drive-supply", "part_a": "thermal-loop", "port_a": "thermal-loop.fluid.rear-drive-supply", "part_b": "rear-motor", "port_b": "rear-motor.fluid.jacket-inlet", "kind": PortKind.fluid, "preload_n": 900.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-thermal-loop-front-inverter-supply", "part_a": "thermal-loop", "port_a": "thermal-loop.fluid.front-inverter-supply", "part_b": "front-inverter", "port_b": "front-inverter.fluid.cold-plate-inlet", "kind": PortKind.fluid, "preload_n": 700.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-thermal-loop-rear-inverter-supply", "part_a": "thermal-loop", "port_a": "thermal-loop.fluid.rear-inverter-supply", "part_b": "rear-inverter", "port_b": "rear-inverter.fluid.cold-plate-inlet", "kind": PortKind.fluid, "preload_n": 700.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-thermal-loop-chiller-port", "part_a": "thermal-loop", "port_a": "thermal-loop.fluid.chiller-port", "part_b": "heat-pump", "port_b": "heat-pump.fluid.chiller-inlet", "kind": PortKind.fluid, "preload_n": 1000.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-thermal-loop-charger-supply", "part_a": "thermal-loop", "port_a": "thermal-loop.fluid.charger-supply", "part_b": "onboard-charger", "port_b": "onboard-charger.fluid.cold-plate-inlet", "kind": PortKind.fluid, "preload_n": 600.0, "sealed": true, "serviceable": true, "robot_accessible": true},
{"mate": "mate-heat-pump-cabin-duct", "part_a": "heat-pump", "port_a": "heat-pump.therm.cabin-duct", "part_b": "interior", "port_b": "interior.therm.hvac-duct", "kind": PortKind.thermal, "preload_n": 0.0, "sealed": false, "serviceable": false, "robot_accessible": false},
{"mate": "mate-battery-modules-pad-interface", "part_a": "battery-modules", "port_a": "battery-modules.therm.pad-interface", "part_b": "battery-enclosure", "port_b": "battery-enclosure.therm.thermal-pad", "kind": PortKind.thermal, "preload_n": 0.0, "sealed": false, "serviceable": false, "robot_accessible": true},
{"mate": "mate-front-inverter-housing-interface", "part_a": "front-inverter", "port_a": "front-inverter.therm.housing-interface", "part_b": "front-motor", "port_b": "front-motor.therm.housing-pad", "kind": PortKind.thermal, "preload_n": 0.0, "sealed": false, "serviceable": false, "robot_accessible": true},
{"mate": "mate-rear-inverter-housing-interface", "part_a": "rear-inverter", "port_a": "rear-inverter.therm.housing-interface", "part_b": "rear-motor", "port_b": "rear-motor.therm.housing-pad", "kind": PortKind.thermal, "preload_n": 0.0, "sealed": false, "serviceable": false, "robot_accessible": true},
{"mate": "mate-service-links-hv-disconnect", "part_a": "service-links", "port_a": "service-links.svc.hv-disconnect", "part_b": "hv-junction", "port_b": "hv-junction.svc.service-disconnect", "kind": PortKind.service, "preload_n": 300.0, "sealed": true, "serviceable": true, "robot_accessible": false},
{"mate": "mate-service-links-pack-service", "part_a": "service-links", "port_a": "service-links.svc.pack-service", "part_b": "battery-enclosure", "port_b": "battery-enclosure.svc.service-port", "kind": PortKind.service, "preload_n": 250.0, "sealed": true, "serviceable": true, "robot_accessible": false},
{"mate": "mate-service-links-front-zone-service", "part_a": "service-links", "port_a": "service-links.svc.front-zone-service", "part_b": "front-zone-controller", "port_b": "front-zone-controller.svc.service-port", "kind": PortKind.service, "preload_n": 60.0, "sealed": false, "serviceable": true, "robot_accessible": false},
{"mate": "mate-service-links-rear-zone-service", "part_a": "service-links", "port_a": "service-links.svc.rear-zone-service", "part_b": "rear-zone-controller", "port_b": "rear-zone-controller.svc.service-port", "kind": PortKind.service, "preload_n": 60.0, "sealed": false, "serviceable": true, "robot_accessible": false},
{"mate": "mate-service-links-charge-service", "part_a": "service-links", "port_a": "service-links.svc.charge-service", "part_b": "charge-port", "port_b": "charge-port.svc.service-port", "kind": PortKind.service, "preload_n": 80.0, "sealed": false, "serviceable": true, "robot_accessible": false},
]
def open_port_specs() -> list[dict[str, any]]:
"""Declared but intentionally unmated interfaces. None of them is a required port."""
return [
{"part": "body-shell", "port": "body-shell.mech.tow-eye-front", "kind": PortKind.mechanical},
{"part": "body-shell", "port": "body-shell.mech.tow-eye-rear", "kind": PortKind.mechanical},
{"part": "body-shell", "port": "body-shell.mech.roof-rail-left", "kind": PortKind.mechanical},
{"part": "body-shell", "port": "body-shell.mech.roof-rail-right", "kind": PortKind.mechanical},
{"part": "central-compute", "port": "central-compute.svc.obd-diagnostic", "kind": PortKind.service},
{"part": "central-compute", "port": "central-compute.data.telemetry-expansion", "kind": PortKind.data},
{"part": "hv-junction", "port": "hv-junction.hv.v2l-outlet", "kind": PortKind.high_voltage},
{"part": "thermal-loop", "port": "thermal-loop.fluid.auxiliary-heater-port", "kind": PortKind.fluid},
]
def vehicle_ports():
"""Full port inventory: two terminated ports per declared interface plus the open optional ports."""
mut ports = []
for spec in interface_specs():
ports.append(PortDefinition(id=spec["port_a"], owner_part_id=spec["part_a"], kind=spec["kind"], mate_port_id=spec["port_b"], required=true, terminated=true))
ports.append(PortDefinition(id=spec["port_b"], owner_part_id=spec["part_b"], kind=spec["kind"], mate_port_id=spec["port_a"], required=true, terminated=true))
for spec in open_port_specs():
ports.append(PortDefinition(id=spec["port"], owner_part_id=spec["part"], kind=spec["kind"], mate_port_id="", required=false, terminated=false))
return ports
def vehicle_mates():
"""One mate per declared interface, carrying its preload, sealing, and service classification."""
mut mates = []
for spec in interface_specs():
mates.append(MateDefinition(id=spec["mate"], port_a_id=spec["port_a"], port_b_id=spec["port_b"], kind=spec["kind"], preload_n=spec["preload_n"], sealed=spec["sealed"], serviceable=spec["serviceable"], robot_accessible=spec["robot_accessible"]))
return mates
def port_termination_report():
"""Counts required, terminated, open, and multiply-mated ports by scanning the inventory."""
ports = vehicle_ports()
mates = vehicle_mates()
mut required_open = 0
mut open_optional = 0
mut mismated = 0
for port in ports:
if port.required and not port.terminated:
required_open = required_open + 1
if not port.terminated:
open_optional = open_optional + 1
if port.terminated:
mut hits = 0
for mate in mates:
if mate.port_a_id == port.id or mate.port_b_id == port.id:
hits = hits + 1
if hits != 1:
mismated = mismated + 1
return {
"ports": len(ports),
"mates": len(mates),
"unterminated_required_ports": required_open,
"open_optional_ports": open_optional,
"ports_mated_exactly_once": len(ports) - open_optional - mismated,
"mismated_ports": mismated,
}
def part_placements():
"""Placement of every declared part in vehicle coordinates, in the same order as vehicle_parts()."""
return [
PartPlacement(part_id="body-shell", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=236.0, centre_x=0.0, centre_y=0.0, centre_z=0.98, extent_x=4.4, extent_y=1.86, extent_z=1.18, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="floor-pan", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=82.0, centre_x=-0.05, centre_y=0.0, centre_z=0.235, extent_x=3.15, extent_y=1.52, extent_z=0.13, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="front-subframe", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=31.0, centre_x=1.445, centre_y=0.0, centre_z=0.3, extent_x=0.64, extent_y=1.4, extent_z=0.23, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="rear-subframe", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=34.0, centre_x=-1.48, centre_y=0.0, centre_z=0.345, extent_x=0.7, extent_y=1.42, extent_z=0.26, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="sill-panels", pattern=PlacementPattern.lateral_pair, pattern_instances=2, mass_kg=26.0, centre_x=0.0, centre_y=0.0, centre_z=0.29, extent_x=2.9, extent_y=0.135, extent_z=0.24, span_x=0.0, span_y=1.545, unsprung_fraction=0.0),
PartPlacement(part_id="battery-enclosure", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=118.0, centre_x=-0.02, centre_y=0.0, centre_z=0.23, extent_x=2.96, extent_y=1.56, extent_z=0.17, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="battery-modules", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=438.0, centre_x=-0.02, centre_y=0.0, centre_z=0.25, extent_x=2.82, extent_y=1.44, extent_z=0.115, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="battery-management", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=6.0, centre_x=-0.62, centre_y=0.3, centre_z=0.31, extent_x=0.24, extent_y=0.18, extent_z=0.055, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="hv-junction", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=13.0, centre_x=-0.98, centre_y=0.42, centre_z=0.36, extent_x=0.32, extent_y=0.24, extent_z=0.15, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="charge-port", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=4.0, centre_x=-2.18, centre_y=0.76, centre_z=0.76, extent_x=0.19, extent_y=0.13, extent_z=0.17, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="onboard-charger", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=14.0, centre_x=-1.7, centre_y=-0.43, centre_z=0.42, extent_x=0.34, extent_y=0.26, extent_z=0.12, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="dc-dc", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=8.0, centre_x=1.32, centre_y=-0.4, centre_z=0.52, extent_x=0.26, extent_y=0.21, extent_z=0.11, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="front-inverter", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=11.0, centre_x=1.64, centre_y=0.18, centre_z=0.52, extent_x=0.28, extent_y=0.26, extent_z=0.18, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="rear-inverter", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=12.0, centre_x=-1.64, centre_y=-0.18, centre_z=0.54, extent_x=0.29, extent_y=0.27, extent_z=0.19, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="front-motor", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=58.0, centre_x=1.56, centre_y=0.0, centre_z=0.33, extent_x=0.4, extent_y=0.56, extent_z=0.34, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="rear-motor", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=71.0, centre_x=-1.56, centre_y=0.0, centre_z=0.345, extent_x=0.43, extent_y=0.6, extent_z=0.36, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="reduction-gears", pattern=PlacementPattern.longitudinal_pair, pattern_instances=2, mass_kg=42.0, centre_x=0.0, centre_y=0.0, centre_z=0.335, extent_x=0.24, extent_y=0.42, extent_z=0.33, span_x=3.06, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="wheels", pattern=PlacementPattern.corner_quad, pattern_instances=4, mass_kg=52.0, centre_x=0.0, centre_y=0.0, centre_z=0.3695, extent_x=0.235, extent_y=0.235, extent_z=0.739, span_x=2.89, span_y=1.635, unsprung_fraction=1.0),
PartPlacement(part_id="tires", pattern=PlacementPattern.corner_quad, pattern_instances=4, mass_kg=48.0, centre_x=0.0, centre_y=0.0, centre_z=0.3695, extent_x=0.285, extent_y=0.285, extent_z=0.739, span_x=2.89, span_y=1.635, unsprung_fraction=1.0),
PartPlacement(part_id="suspension", pattern=PlacementPattern.corner_quad, pattern_instances=4, mass_kg=104.0, centre_x=0.0, centre_y=0.0, centre_z=0.38, extent_x=0.48, extent_y=0.52, extent_z=0.56, span_x=2.89, span_y=1.3, unsprung_fraction=0.45),
PartPlacement(part_id="steering", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=23.0, centre_x=1.3, centre_y=0.0, centre_z=0.33, extent_x=0.16, extent_y=1.18, extent_z=0.17, span_x=0.0, span_y=0.0, unsprung_fraction=0.1),
PartPlacement(part_id="brakes", pattern=PlacementPattern.corner_quad, pattern_instances=4, mass_kg=76.0, centre_x=0.0, centre_y=0.0, centre_z=0.3695, extent_x=0.19, extent_y=0.23, extent_z=0.4, span_x=2.89, span_y=1.5, unsprung_fraction=0.85),
PartPlacement(part_id="thermal-loop", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=31.0, centre_x=0.15, centre_y=0.0, centre_z=0.43, extent_x=3.9, extent_y=1.32, extent_z=0.42, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="heat-pump", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=24.0, centre_x=1.82, centre_y=0.24, centre_z=0.56, extent_x=0.42, extent_y=0.38, extent_z=0.33, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="front-zone-controller", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=2.2, centre_x=1.4, centre_y=0.56, centre_z=0.64, extent_x=0.2, extent_y=0.16, extent_z=0.06, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="rear-zone-controller", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=2.2, centre_x=-1.52, centre_y=-0.52, centre_z=0.62, extent_x=0.2, extent_y=0.16, extent_z=0.06, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="cabin-zone-controller", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=1.8, centre_x=0.56, centre_y=0.38, centre_z=0.62, extent_x=0.18, extent_y=0.15, extent_z=0.055, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="central-compute", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=5.0, centre_x=0.88, centre_y=0.0, centre_z=0.64, extent_x=0.3, extent_y=0.24, extent_z=0.09, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="lv-battery", pattern=PlacementPattern.single, pattern_instances=1, mass_kg=12.0, centre_x=1.68, centre_y=-0.56, centre_z=0.44, extent_x=0.26, extent_y=0.18, extent_z=0.19, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="sensors", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=18.0, centre_x=0.2, centre_y=0.0, centre_z=0.98, extent_x=4.5, extent_y=1.86, extent_z=1.24, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="lamps", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=16.0, centre_x=0.05, centre_y=0.0, centre_z=0.83, extent_x=4.56, extent_y=1.84, extent_z=0.56, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="glazing", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=47.0, centre_x=-0.15, centre_y=0.0, centre_z=1.19, extent_x=3.5, extent_y=1.7, extent_z=0.98, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="closures", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=112.0, centre_x=-0.06, centre_y=0.0, centre_z=0.93, extent_x=3.3, extent_y=1.88, extent_z=1.06, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="seats", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=96.0, centre_x=-0.23, centre_y=0.0, centre_z=0.76, extent_x=1.9, extent_y=1.44, extent_z=1.02, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="interior", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=118.0, centre_x=-0.12, centre_y=0.0, centre_z=0.79, extent_x=2.7, extent_y=1.68, extent_z=1.06, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="restraints", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=34.0, centre_x=0.15, centre_y=0.0, centre_z=1.02, extent_x=2.9, extent_y=1.76, extent_z=0.9, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="fasteners-seals", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=63.0, centre_x=0.0, centre_y=0.0, centre_z=0.82, extent_x=4.5, extent_y=1.88, extent_z=1.4, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
PartPlacement(part_id="service-links", pattern=PlacementPattern.distributed, pattern_instances=1, mass_kg=19.0, centre_x=0.05, centre_y=0.0, centre_z=0.56, extent_x=4.2, extent_y=1.74, extent_z=0.9, span_x=0.0, span_y=0.0, unsprung_fraction=0.0),
]
def placement_instances(placement: PartPlacement):
"""Expands a placement pattern into its instance centres. Each row is [mass, x, y, z]."""
share = placement.mass_kg / float(placement.pattern_instances)
half_x = placement.span_x * 0.5
half_y = placement.span_y * 0.5
if placement.pattern == PlacementPattern.corner_quad:
return [
[share, placement.centre_x + half_x, placement.centre_y + half_y, placement.centre_z],
[share, placement.centre_x + half_x, placement.centre_y - half_y, placement.centre_z],
[share, placement.centre_x - half_x, placement.centre_y + half_y, placement.centre_z],
[share, placement.centre_x - half_x, placement.centre_y - half_y, placement.centre_z],
]
if placement.pattern == PlacementPattern.lateral_pair:
return [
[share, placement.centre_x, placement.centre_y + half_y, placement.centre_z],
[share, placement.centre_x, placement.centre_y - half_y, placement.centre_z],
]
if placement.pattern == PlacementPattern.longitudinal_pair:
return [
[share, placement.centre_x + half_x, placement.centre_y, placement.centre_z],
[share, placement.centre_x - half_x, placement.centre_y, placement.centre_z],
]
return [[share, placement.centre_x, placement.centre_y, placement.centre_z]]
pub def mass_properties() -> MassProperties !{}:
"""Total mass, centre of gravity, and inertia tensor summed from the part placements.
Every part is treated as a uniform rectangular prism of its declared extent, transferred to the
vehicle centre of mass by the parallel-axis theorem. The closure residual compares the declared
part masses against the placement masses and must be exactly zero.
"""
parts = vehicle_parts()
placements = part_placements()
mut aligned = len(parts) == len(placements)
mut declared_mass = 0.0
mut placed_mass = 0.0
mut instances = 0
mut unsprung = 0.0
mut moment_x = 0.0
mut moment_y = 0.0
mut moment_z = 0.0
for index in range(0, len(parts)):
part = parts[index]
placement = placements[index]
if part.id != placement.part_id:
aligned = false
declared_mass = declared_mass + part.mass_kg
placed_mass = placed_mass + placement.mass_kg
instances = instances + part.count
unsprung = unsprung + placement.mass_kg * placement.unsprung_fraction
for row in placement_instances(placement):
moment_x = moment_x + row[0] * row[1]
moment_y = moment_y + row[0] * row[2]
moment_z = moment_z + row[0] * row[3]
ensure aligned
cg_x = moment_x / placed_mass
cg_y = moment_y / placed_mass
cg_z = moment_z / placed_mass
mut ixx = 0.0
mut iyy = 0.0
mut izz = 0.0
for placement in placements:
for row in placement_instances(placement):
ixx = ixx + box_inertia_term(row[0], placement.extent_y, placement.extent_z) + parallel_axis_term(row[0], row[2] - cg_y, row[3] - cg_z)
iyy = iyy + box_inertia_term(row[0], placement.extent_x, placement.extent_z) + parallel_axis_term(row[0], row[1] - cg_x, row[3] - cg_z)
izz = izz + box_inertia_term(row[0], placement.extent_x, placement.extent_y) + parallel_axis_term(row[0], row[1] - cg_x, row[2] - cg_y)
front_fraction = (cg_x - REAR_AXLE_X_M) / WHEELBASE_M
return MassProperties(
total_mass_kg=placed_mass,
sprung_mass_kg=placed_mass - unsprung,
unsprung_mass_kg=unsprung,
cg_x_m=cg_x,
cg_y_m=cg_y,
cg_z_m=cg_z,
ixx_kg_m2=ixx,
iyy_kg_m2=iyy,
izz_kg_m2=izz,
front_axle_load_kg=placed_mass * front_fraction,
rear_axle_load_kg=placed_mass * (1.0 - front_fraction),
front_mass_fraction=front_fraction,
wheelbase_m=WHEELBASE_M,
cg_height_m=cg_z,
part_count=len(parts),
instance_count=instances,
closure_residual_kg=declared_mass - placed_mass,
)
def corner_table():
"""The four suspension corners: mirror signs, steering capability, and the subframe they hang from."""
return [
{"corner": "front-left", "prefix": "fl", "axle": "front", "sign_x": 1.0, "sign_y": 1.0, "steerable": true, "subframe": "front-subframe"},
{"corner": "front-right", "prefix": "fr", "axle": "front", "sign_x": 1.0, "sign_y": -1.0, "steerable": true, "subframe": "front-subframe"},
{"corner": "rear-left", "prefix": "rl", "axle": "rear", "sign_x": -1.0, "sign_y": 1.0, "steerable": false, "subframe": "rear-subframe"},
{"corner": "rear-right", "prefix": "rr", "axle": "rear", "sign_x": -1.0, "sign_y": -1.0, "steerable": false, "subframe": "rear-subframe"},
]
def joint(id: str, name: str, kind: JointKind, parent_part_id: str, child_part_id: str, parent_link: str, child_link: str, axis: list[f64], origin: list[f64], limits: list[f64], ratio: f64, dof: int, idle_dof: int, corner: str, loop_closing: bool):
"""Constructs a joint from packed axis, origin, and limit triples."""
return JointDefinition(
id=id,
name=name,
kind=kind,
parent_part_id=parent_part_id,
child_part_id=child_part_id,
parent_link=parent_link,
child_link=child_link,
axis_x=axis[0],
axis_y=axis[1],
axis_z=axis[2],
origin_x=origin[0],
origin_y=origin[1],
origin_z=origin[2],
limit_lower=limits[0],
limit_upper=limits[1],
ratio=ratio,
dof=dof,
idle_dof=idle_dof,
corner=corner,
loop_closing=loop_closing,
)
def corner_joints(entry: dict[str, any]):
"""Every joint of one suspension corner, mirrored from the reference hard points."""
prefix = entry["prefix"]
corner = entry["corner"]
axle = entry["axle"]
subframe = entry["subframe"]
sign_x = entry["sign_x"]
sign_y = entry["sign_y"]
axle_x = sign_x * FRONT_AXLE_X_M
knuckle = prefix + "-knuckle"
lower_arm = prefix + "-lower-arm"
upper_arm = prefix + "-upper-arm"
hub = prefix + "-hub"
damper_dy = sign_y * (HP_DAMPER_UP_Y - HP_DAMPER_LO_Y)
damper_dz = HP_DAMPER_UP_Z - HP_DAMPER_LO_Z
damper_axis_length = math.sqrt(damper_dy * damper_dy + damper_dz * damper_dz)
steer_x = axle_x + sign_x * HP_TIE_DX
steer_link = prefix + "-tie-rod" if entry["steerable"] else prefix + "-toe-link"
steer_root = "steering-rack" if entry["steerable"] else "chassis"
steer_root_part = "steering" if entry["steerable"] else subframe
steer_name = "tie rod" if entry["steerable"] else "toe link"
steer_slug = "tie-rod" if entry["steerable"] else "toe-link"
return [
joint(prefix + "-lca-inboard", corner + " lower control arm inboard pivot", JointKind.revolute, subframe, "suspension", "chassis", lower_arm, [1.0, 0.0, 0.0], [axle_x, sign_y * HP_LCA_IN_Y, HP_LCA_IN_Z], [-0.35, 0.35], 1.0, 1, 0, corner, false),
joint(prefix + "-uca-inboard", corner + " upper control arm inboard pivot", JointKind.revolute, subframe, "suspension", "chassis", upper_arm, [1.0, 0.0, 0.0], [axle_x, sign_y * HP_UCA_IN_Y, HP_UCA_IN_Z], [-0.35, 0.35], 1.0, 1, 0, corner, false),
joint(prefix + "-lca-outboard", corner + " lower ball joint", JointKind.spherical, "suspension", "suspension", lower_arm, knuckle, [0.0, 0.0, 1.0], [axle_x, sign_y * HP_LBJ_Y, HP_LBJ_Z], [-0.45, 0.45], 1.0, 3, 0, corner, false),
joint(prefix + "-uca-outboard", corner + " upper ball joint", JointKind.spherical, "suspension", "suspension", upper_arm, knuckle, [0.0, 0.0, 1.0], [axle_x, sign_y * HP_UBJ_Y, HP_UBJ_Z], [-0.45, 0.45], 1.0, 3, 0, corner, true),
joint(prefix + "-damper-top", corner + " damper upper mount", JointKind.spherical, "body-shell", "suspension", "chassis", prefix + "-damper-body", [0.0, damper_dy / damper_axis_length, damper_dz / damper_axis_length], [axle_x, sign_y * HP_DAMPER_UP_Y, HP_DAMPER_UP_Z], [-0.20, 0.20], 1.0, 3, 0, corner, false),
joint(prefix + "-damper-slide", corner + " air spring and damper stroke", JointKind.prismatic, "suspension", "suspension", prefix + "-damper-body", prefix + "-damper-rod", [0.0, damper_dy / damper_axis_length, damper_dz / damper_axis_length], [axle_x, sign_y * (HP_DAMPER_LO_Y + HP_DAMPER_UP_Y) * 0.5, (HP_DAMPER_LO_Z + HP_DAMPER_UP_Z) * 0.5], [-0.05296641106708366, 0.0603908906802505], 1.0, 1, 1, corner, false),
joint(prefix + "-damper-lower", corner + " damper lower mount", JointKind.spherical, "suspension", "suspension", prefix + "-damper-rod", lower_arm, [0.0, 0.0, 1.0], [axle_x, sign_y * HP_DAMPER_LO_Y, HP_DAMPER_LO_Z], [-0.20, 0.20], 1.0, 3, 0, corner, true),
joint(prefix + "-" + steer_slug + "-inner", corner + " " + steer_name + " inner joint", JointKind.spherical, steer_root_part, "suspension", steer_root, steer_link, [0.0, 0.0, 1.0], [steer_x, sign_y * HP_TIE_IN_Y, HP_TIE_IN_Z], [-0.60, 0.60], 1.0, 3, 1, corner, false),
joint(prefix + "-" + steer_slug + "-outer", corner + " " + steer_name + " outer joint", JointKind.spherical, "suspension", "suspension", steer_link, knuckle, [0.0, 0.0, 1.0], [steer_x, sign_y * HP_TIE_OUT_Y, HP_TIE_OUT_Z], [-0.60, 0.60], 1.0, 3, 0, corner, true),
joint(prefix + "-arb-bearing", corner + " anti-roll bar bearing", JointKind.cylindrical, subframe, "suspension", "chassis", axle + "-anti-roll-bar", [0.0, 1.0, 0.0], [axle_x + sign_x * HP_ARB_BAR_DX, sign_y * HP_ARB_BAR_Y, HP_ARB_BAR_Z], [-0.30, 0.30], 1.0, 2, 0, corner, false),
joint(prefix + "-arb-link", corner + " anti-roll bar drop link", JointKind.revolute, "suspension", "suspension", axle + "-anti-roll-bar", lower_arm, [1.0, 0.0, 0.0], [axle_x + sign_x * HP_ARB_BAR_DX, sign_y * HP_ARB_LINK_Y, HP_ARB_LINK_Z], [-0.25, 0.25], 1.0, 1, 0, corner, true),
joint(prefix + "-halfshaft-inboard", corner + " inboard constant-velocity joint", JointKind.spherical, "reduction-gears", "suspension", axle + "-final-drive", prefix + "-halfshaft", [0.0, sign_y, 0.0], [axle_x, sign_y * 0.190, HP_WC_Z], [-0.40, 0.40], 1.0, 3, 1, corner, false),
joint(prefix + "-halfshaft-outboard", corner + " outboard constant-velocity joint", JointKind.spherical, "suspension", "suspension", prefix + "-halfshaft", hub, [0.0, sign_y, 0.0], [axle_x, sign_y * 0.700, HP_WC_Z], [-0.85, 0.85], 1.0, 3, 0, corner, false),
joint(prefix + "-hub", corner + " wheel bearing", JointKind.revolute, "suspension", "suspension", knuckle, hub, [0.0, sign_y, 0.0], [axle_x, sign_y * HP_WC_Y, HP_WC_Z], [-6.283185307179586, 6.283185307179586], 1.0, 1, 0, corner, false),
joint(prefix + "-brake-disc", corner + " brake disc mount", JointKind.fixed, "suspension", "brakes", hub, prefix + "-brake-disc", [0.0, sign_y, 0.0], [axle_x, sign_y * 0.760, HP_WC_Z], [0.0, 0.0], 1.0, 0, 0, corner, false),
joint(prefix + "-brake-caliper", corner + " brake caliper mount", JointKind.fixed, "suspension", "brakes", knuckle, prefix + "-brake-caliper", [0.0, sign_y, 0.0], [axle_x + sign_x * 0.075, sign_y * 0.735, HP_WC_Z + 0.055], [0.0, 0.0], 1.0, 0, 0, corner, false),
joint(prefix + "-wheel-mount", corner + " wheel bolt circle", JointKind.fixed, "suspension", "wheels", hub, prefix + "-wheel", [0.0, sign_y, 0.0], [axle_x, sign_y * 0.790, HP_WC_Z], [0.0, 0.0], 1.0, 0, 0, corner, false),
joint(prefix + "-tire-bead", corner + " tire bead seat", JointKind.fixed, "wheels", "tires", prefix + "-wheel", prefix + "-tire", [0.0, sign_y, 0.0], [axle_x, sign_y * HP_WC_Y, HP_WC_Z], [0.0, 0.0], 1.0, 0, 0, corner, false),
]
def variant_joint_kind(configuration_variant: str):
"""Subframe and pack mounts are elastomeric bolted joints conventionally and bonded structural joints in the CircuitFrame variant."""
require configuration_variant == "conventional" or configuration_variant == "circuitframe"
return JointKind.fixed if configuration_variant == "conventional" else JointKind.rigid_bond
def structural_joints(configuration_variant: str):
"""Driveline, body, pack, and equipment joints outside the four suspension corners."""
mount_kind = variant_joint_kind(configuration_variant)
return [
joint("steering-rack-slide", "Steering rack translation", JointKind.prismatic, "steering", "steering", "chassis", "steering-rack", [0.0, 1.0, 0.0], [1.32, 0.0, 0.29], [-0.078, 0.078], 1.0, 1, 0, "vehicle", false),
joint("front-rotor", "Front motor rotor bearing", JointKind.revolute, "front-motor", "front-motor", "front-motor-housing", "front-motor-rotor", [0.0, 1.0, 0.0], [1.56, 0.0, 0.33], [-6.283185307179586, 6.283185307179586], 1.0, 1, 0, "vehicle", false),
joint("rear-rotor", "Rear motor rotor bearing", JointKind.revolute, "rear-motor", "rear-motor", "rear-motor-housing", "rear-motor-rotor", [0.0, 1.0, 0.0], [-1.56, 0.0, 0.345], [-6.283185307179586, 6.283185307179586], 1.0, 1, 0, "vehicle", false),
joint("front-gear-stage-1", "Front reduction first stage", JointKind.gear, "front-motor", "reduction-gears", "front-motor-rotor", "front-intermediate-shaft", [0.0, 1.0, 0.0], [1.5, 0.14, 0.33], [-6.283185307179586, 6.283185307179586], 3.25, 1, 0, "vehicle", false),
joint("front-gear-stage-2", "Front reduction second stage", JointKind.gear, "reduction-gears", "reduction-gears", "front-intermediate-shaft", "front-final-drive", [0.0, 1.0, 0.0], [1.47, 0.07, 0.335], [-6.283185307179586, 6.283185307179586], 2.8, 1, 0, "vehicle", false),
joint("rear-gear-stage-1", "Rear reduction first stage", JointKind.gear, "rear-motor", "reduction-gears", "rear-motor-rotor", "rear-intermediate-shaft", [0.0, 1.0, 0.0], [-1.5, 0.14, 0.345], [-6.283185307179586, 6.283185307179586], 3.25, 1, 0, "vehicle", false),
joint("rear-gear-stage-2", "Rear reduction second stage", JointKind.gear, "reduction-gears", "reduction-gears", "rear-intermediate-shaft", "rear-final-drive", [0.0, 1.0, 0.0], [-1.47, 0.07, 0.34], [-6.283185307179586, 6.283185307179586], 2.8, 1, 0, "vehicle", false),
joint("front-subframe-mount", "Front subframe to body rails", mount_kind, "body-shell", "front-subframe", "body-structure", "front-subframe", [0.0, 0.0, 1.0], [1.445, 0.0, 0.3], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("rear-subframe-mount", "Rear subframe to body rails", mount_kind, "body-shell", "rear-subframe", "body-structure", "rear-subframe", [0.0, 0.0, 1.0], [-1.48, 0.0, 0.345], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("floor-pan-bond", "Structural floor pan to body", JointKind.rigid_bond, "body-shell", "floor-pan", "body-structure", "floor-pan", [0.0, 0.0, 1.0], [-0.05, 0.0, 0.235], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("sill-left-bond", "Left structural sill to floor and body", JointKind.rigid_bond, "floor-pan", "sill-panels", "floor-pan", "sill-left", [0.0, 0.0, 1.0], [0.0, 0.7725, 0.29], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("sill-right-bond", "Right structural sill to floor and body", JointKind.rigid_bond, "floor-pan", "sill-panels", "floor-pan", "sill-right", [0.0, 0.0, 1.0], [0.0, -0.7725, 0.29], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("pack-enclosure-mount", "Battery enclosure to floor and sills", mount_kind, "floor-pan", "battery-enclosure", "floor-pan", "battery-enclosure", [0.0, 0.0, 1.0], [-0.02, 0.0, 0.23], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("pack-module-mount", "Battery modules to enclosure rails", JointKind.fixed, "battery-enclosure", "battery-modules", "battery-enclosure", "battery-modules", [0.0, 0.0, 1.0], [-0.02, 0.0, 0.25], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("bms-mount", "Battery management to enclosure", JointKind.fixed, "battery-enclosure", "battery-management", "battery-enclosure", "battery-management", [0.0, 0.0, 1.0], [-0.62, 0.3, 0.31], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("front-motor-mount", "Front drive unit to subframe", JointKind.fixed, "front-subframe", "front-motor", "front-subframe", "front-motor-housing", [0.0, 0.0, 1.0], [1.56, 0.0, 0.33], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("rear-motor-mount", "Rear drive unit to subframe", JointKind.fixed, "rear-subframe", "rear-motor", "rear-subframe", "rear-motor-housing", [0.0, 0.0, 1.0], [-1.56, 0.0, 0.345], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("front-gearbox-mount", "Front gearbox housing to motor", JointKind.fixed, "front-motor", "reduction-gears", "front-motor-housing", "front-gearbox-housing", [0.0, 0.0, 1.0], [1.48, 0.13, 0.33], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("rear-gearbox-mount", "Rear gearbox housing to motor", JointKind.fixed, "rear-motor", "reduction-gears", "rear-motor-housing", "rear-gearbox-housing", [0.0, 0.0, 1.0], [-1.48, 0.13, 0.345], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("steering-rack-mount", "Steering rack housing to subframe", JointKind.fixed, "front-subframe", "steering", "front-subframe", "steering-rack-housing", [0.0, 0.0, 1.0], [1.3, 0.0, 0.33], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("front-inverter-mount", "Front inverter to drive unit", JointKind.fixed, "front-motor", "front-inverter", "front-motor-housing", "front-inverter", [0.0, 0.0, 1.0], [1.64, 0.18, 0.52], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("rear-inverter-mount", "Rear inverter to drive unit", JointKind.fixed, "rear-motor", "rear-inverter", "rear-motor-housing", "rear-inverter", [0.0, 0.0, 1.0], [-1.64, -0.18, 0.54], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("hood-hinge", "Hood hinge", JointKind.revolute, "body-shell", "closures", "body-structure", "hood", [0.0, 1.0, 0.0], [1.02, 0.0, 1.06], [0.0, 1.2], 1.0, 1, 0, "vehicle", false),
joint("liftgate-hinge", "Liftgate hinge", JointKind.revolute, "body-shell", "closures", "body-structure", "liftgate", [0.0, 1.0, 0.0], [-1.93, 0.0, 1.56], [0.0, 1.45], 1.0, 1, 0, "vehicle", false),
joint("door-hinge-front-left", "Front left door hinge", JointKind.revolute, "body-shell", "closures", "body-structure", "door-front-left", [0.0, 0.0, 1.0], [0.76, 0.86, 0.9], [0.0, 1.28], 1.0, 1, 0, "vehicle", false),
joint("door-hinge-front-right", "Front right door hinge", JointKind.revolute, "body-shell", "closures", "body-structure", "door-front-right", [0.0, 0.0, 1.0], [0.76, -0.86, 0.9], [0.0, 1.28], 1.0, 1, 0, "vehicle", false),
joint("door-hinge-rear-left", "Rear left door hinge", JointKind.revolute, "body-shell", "closures", "body-structure", "door-rear-left", [0.0, 0.0, 1.0], [-0.42, 0.86, 0.9], [0.0, 1.22], 1.0, 1, 0, "vehicle", false),
joint("door-hinge-rear-right", "Rear right door hinge", JointKind.revolute, "body-shell", "closures", "body-structure", "door-rear-right", [0.0, 0.0, 1.0], [-0.42, -0.86, 0.9], [0.0, 1.22], 1.0, 1, 0, "vehicle", false),
joint("seat-rail-slide", "Front seat longitudinal rail", JointKind.prismatic, "floor-pan", "seats", "floor-pan", "front-seat-frame", [1.0, 0.0, 0.0], [0.18, 0.38, 0.42], [-0.12, 0.12], 1.0, 1, 0, "vehicle", false),
joint("glazing-bond", "Laminated glazing urethane bond", JointKind.rigid_bond, "body-shell", "glazing", "body-structure", "glazing", [0.0, 0.0, 1.0], [-0.15, 0.0, 1.19], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("interior-mount", "Interior and trim to body", JointKind.fixed, "body-shell", "interior", "body-structure", "interior", [0.0, 0.0, 1.0], [-0.12, 0.0, 0.79], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("restraints-mount", "Restraint anchorages to body", JointKind.fixed, "body-shell", "restraints", "body-structure", "restraints", [0.0, 0.0, 1.0], [0.15, 0.0, 1.02], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("lamps-mount", "Exterior lighting to body apertures", JointKind.fixed, "body-shell", "lamps", "body-structure", "lamps", [0.0, 0.0, 1.0], [0.05, 0.0, 0.83], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("sensors-mount", "Sensing suite to body bosses", JointKind.fixed, "body-shell", "sensors", "body-structure", "sensors", [0.0, 0.0, 1.0], [0.2, 0.0, 0.98], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("hv-junction-mount", "HV junction unit to pack", JointKind.fixed, "battery-enclosure", "hv-junction", "battery-enclosure", "hv-junction", [0.0, 0.0, 1.0], [-0.98, 0.42, 0.36], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("charge-port-mount", "Charge port to body aperture", JointKind.fixed, "body-shell", "charge-port", "body-structure", "charge-port", [0.0, 0.0, 1.0], [-2.18, 0.76, 0.76], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("onboard-charger-mount", "On-board charger to floor", JointKind.fixed, "floor-pan", "onboard-charger", "floor-pan", "onboard-charger", [0.0, 0.0, 1.0], [-1.7, -0.43, 0.42], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("dc-dc-mount", "DC/DC converter to body bracket", JointKind.fixed, "body-shell", "dc-dc", "body-structure", "dc-dc", [0.0, 0.0, 1.0], [1.32, -0.4, 0.52], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("lv-battery-mount", "Low-voltage battery tray", JointKind.fixed, "body-shell", "lv-battery", "body-structure", "lv-battery", [0.0, 0.0, 1.0], [1.68, -0.56, 0.44], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("central-compute-mount", "Central compute to body bracket", JointKind.fixed, "body-shell", "central-compute", "body-structure", "central-compute", [0.0, 0.0, 1.0], [0.88, 0.0, 0.64], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("front-zone-controller-mount", "Front zone controller bracket", JointKind.fixed, "body-shell", "front-zone-controller", "body-structure", "front-zone-controller", [0.0, 0.0, 1.0], [1.4, 0.56, 0.64], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("rear-zone-controller-mount", "Rear zone controller bracket", JointKind.fixed, "body-shell", "rear-zone-controller", "body-structure", "rear-zone-controller", [0.0, 0.0, 1.0], [-1.52, -0.52, 0.62], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("cabin-zone-controller-mount", "Cabin zone controller bracket", JointKind.fixed, "interior", "cabin-zone-controller", "interior", "cabin-zone-controller", [0.0, 0.0, 1.0], [0.56, 0.38, 0.62], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("heat-pump-mount", "Heat pump to front subframe", JointKind.fixed, "front-subframe", "heat-pump", "front-subframe", "heat-pump", [0.0, 0.0, 1.0], [1.82, 0.24, 0.56], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("thermal-loop-mount", "Coolant loop routing to body", JointKind.fixed, "body-shell", "thermal-loop", "body-structure", "thermal-loop", [0.0, 0.0, 1.0], [0.15, 0.0, 0.43], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("service-links-mount", "Serviceable harness set to body", JointKind.fixed, "body-shell", "service-links", "body-structure", "service-links", [0.0, 0.0, 1.0], [0.05, 0.0, 0.56], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
joint("fasteners-seals-bond", "Closure seal and joining family", JointKind.rigid_bond, "closures", "fasteners-seals", "door-front-left", "closure-seal-set", [0.0, 0.0, 1.0], [0.76, 0.86, 0.9], [0.0, 0.0], 1.0, 0, 0, "vehicle", false),
]
def vehicle_joints_for(configuration_variant: str):
"""Complete kinematic joint set for one configuration variant."""
require configuration_variant == "conventional" or configuration_variant == "circuitframe"
mut joints = []
for entry in corner_table():
for corner_joint in corner_joints(entry):
joints.append(corner_joint)
for structural in structural_joints(configuration_variant):
joints.append(structural)
return joints
def vehicle_joints():
"""Complete kinematic joint set for the default CircuitFrame configuration."""
return vehicle_joints_for(CONFIGURATION_VARIANT_DEFAULT)
def corner_loop_joint_ids(entry: dict[str, any]):
"""The joints that form the closed kinematic loop of one corner.
Wheel bearing, brake, wheel, tire, halfshaft, and anti-roll-bar joints are excluded: they extend
the corner as an open chain or couple the two corners of an axle, and are analysed separately.
"""
prefix = entry["prefix"]
steer_slug = "tie-rod" if entry["steerable"] else "toe-link"
mut ids = [
prefix + "-lca-inboard",
prefix + "-uca-inboard",
prefix + "-lca-outboard",
prefix + "-uca-outboard",
prefix + "-damper-top",
prefix + "-damper-slide",
prefix + "-damper-lower",
prefix + "-" + steer_slug + "-inner",
prefix + "-" + steer_slug + "-outer",
]
if entry["steerable"]:
ids = ids + ["steering-rack-slide"]
return ids
def corner_mobility(entry: dict[str, any]) !{}:
"""Grubler-Kutzbach mobility of one suspension corner loop.
Gross mobility counts every joint freedom; the idle freedoms are the spins of the
spherical-spherical steering link and of the spherical-prismatic-spherical damper unit, which
move no wheel state. A steered corner must retain one wheel-travel and one steer freedom; a
rear corner must retain wheel travel only.
"""
loop_ids = corner_loop_joint_ids(entry)
joints = vehicle_joints()
mut links = []
mut dof_sum = 0
mut idle_sum = 0
mut loop_closing = []
mut found = 0
for candidate in joints:
mut selected = false
for loop_id in loop_ids:
if candidate.id == loop_id:
selected = true
if selected:
found = found + 1
dof_sum = dof_sum + candidate.dof
idle_sum = idle_sum + candidate.idle_dof
if candidate.loop_closing:
loop_closing = loop_closing + [candidate.id]
for link in [candidate.parent_link, candidate.child_link]:
mut seen = false
for known in links:
if known == link:
seen = true
if not seen:
links = links + [link]
gross = grubler_mobility(len(links), found, dof_sum)
expected = 2 if entry["steerable"] else 1
effective = gross - idle_sum
return MobilityReport(
corner_id=entry["corner"],
link_count=len(links),
joint_count=found,
joint_dof_sum=dof_sum,
gross_mobility=gross,
idle_dof=idle_sum,
effective_mobility=effective,
expected_mobility=expected,
independent_loops=found - len(links) + 1,
loop_closing_joint_ids=loop_closing,
ok=effective == expected and found == len(loop_ids),
)
def mobility_reports() !{}:
"""Mobility of all four suspension corner loops."""
mut reports = []
for entry in corner_table():
reports.append(corner_mobility(entry))
return reports
pub def arm_rotation_limits() -> list[f64] !{}:
"""Lower control arm rotation at the rebound and jounce stops, from the declared wheel travel."""
return [-0.18232180232086856, 0.2017847836373893]
pub def corner_pose(arm_rotation_rad: f64) -> list[f64] !{}:
"""Closes the planar double-wishbone four-bar for a given lower-arm rotation.
Returns [lbj_y, lbj_z, ubj_y, ubj_z, knuckle_rotation_rad, wheel_y, wheel_z, wheel_travel_m].
The upper ball joint is the circle-circle intersection of the upper arm sweep and the rigid
knuckle, so wheel travel and camber are consequences of the joint set, not of a fitted curve.
"""
lower_dy = HP_LBJ_Y - HP_LCA_IN_Y
lower_dz = HP_LBJ_Z - HP_LCA_IN_Z
upper_dy = HP_UBJ_Y - HP_UCA_IN_Y
upper_dz = HP_UBJ_Z - HP_UCA_IN_Z
knuckle_dy = HP_UBJ_Y - HP_LBJ_Y
knuckle_dz = HP_UBJ_Z - HP_LBJ_Z
lower_length = math.sqrt(lower_dy * lower_dy + lower_dz * lower_dz)
upper_length = math.sqrt(upper_dy * upper_dy + upper_dz * upper_dz)
knuckle_span = math.sqrt(knuckle_dy * knuckle_dy + knuckle_dz * knuckle_dz)
angle = math.atan2(lower_dz, lower_dy) + arm_rotation_rad
lbj_y = HP_LCA_IN_Y + lower_length * math.cos(angle)
lbj_z = HP_LCA_IN_Z + lower_length * math.sin(angle)
span_y = lbj_y - HP_UCA_IN_Y
span_z = lbj_z - HP_UCA_IN_Z
span = math.sqrt(span_y * span_y + span_z * span_z)
projection = (upper_length * upper_length - knuckle_span * knuckle_span + span * span) / (2.0 * span)
offset = math.sqrt(max(0.0, upper_length * upper_length - projection * projection))
mid_y = HP_UCA_IN_Y + projection * span_y / span
mid_z = HP_UCA_IN_Z + projection * span_z / span
first_y = mid_y - offset * span_z / span
first_z = mid_z + offset * span_y / span
second_y = mid_y + offset * span_z / span
second_z = mid_z - offset * span_y / span
first_error = (first_y - HP_UBJ_Y) * (first_y - HP_UBJ_Y) + (first_z - HP_UBJ_Z) * (first_z - HP_UBJ_Z)
second_error = (second_y - HP_UBJ_Y) * (second_y - HP_UBJ_Y) + (second_z - HP_UBJ_Z) * (second_z - HP_UBJ_Z)
ubj_y = first_y if first_error <= second_error else second_y
ubj_z = first_z if first_error <= second_error else second_z
knuckle_rotation = math.atan2(ubj_z - lbj_z, ubj_y - lbj_y) - math.atan2(knuckle_dz, knuckle_dy)
arm_y = HP_WC_Y - HP_LBJ_Y
arm_z = HP_WC_Z - HP_LBJ_Z
cosine = math.cos(knuckle_rotation)
sine = math.sin(knuckle_rotation)
wheel_y = lbj_y + cosine * arm_y - sine * arm_z
wheel_z = lbj_z + sine * arm_y + cosine * arm_z
return [lbj_y, lbj_z, ubj_y, ubj_z, knuckle_rotation, wheel_y, wheel_z, wheel_z - HP_WC_Z]
def damper_length(arm_rotation_rad: f64):
"""Installed length of the air spring and damper unit for a given lower-arm rotation."""
mount_dy = HP_DAMPER_LO_Y - HP_LCA_IN_Y
mount_dz = HP_DAMPER_LO_Z - HP_LCA_IN_Z
radius = math.sqrt(mount_dy * mount_dy + mount_dz * mount_dz)
angle = math.atan2(mount_dz, mount_dy) + arm_rotation_rad
mount_y = HP_LCA_IN_Y + radius * math.cos(angle)
mount_z = HP_LCA_IN_Z + radius * math.sin(angle)
return math.sqrt((HP_DAMPER_UP_Y - mount_y) * (HP_DAMPER_UP_Y - mount_y) + (HP_DAMPER_UP_Z - mount_z) * (HP_DAMPER_UP_Z - mount_z))
def motion_ratio(arm_rotation_rad: f64):
"""Spring shortening per unit wheel rise, differentiated through the linkage."""
step = 0.00001
length_change = damper_length(arm_rotation_rad + step) - damper_length(arm_rotation_rad - step)
travel_change = corner_pose(arm_rotation_rad + step)[7] - corner_pose(arm_rotation_rad - step)[7]
return -length_change / travel_change
def static_corner_load_n():
"""Static vertical load at one wheel with the vehicle at its declared kerb mass."""
return ASSEMBLY_MASS_KG * GRAVITY_MPS2 / 4.0
pub def air_spring_reference_pressure() -> f64 !{}:
"""Absolute bellows pressure that balances the static corner load at design height."""
return static_corner_load_n() / (motion_ratio(0.0) * AIR_SPRING_AREA_M2) + ATMOSPHERIC_PRESSURE_PA
def air_spring_force(arm_rotation_rad: f64, reference_pressure: f64) !{}:
"""Axial air spring force from the polytropic gas state of the bellows."""
stroke = damper_length(0.0) - damper_length(arm_rotation_rad)
volume = AIR_SPRING_VOLUME_M3 - AIR_SPRING_AREA_M2 * stroke
pressure = polytropic_pressure(reference_pressure, AIR_SPRING_VOLUME_M3, volume, AIR_SPRING_POLYTROPIC_N)
return AIR_SPRING_AREA_M2 * (pressure - ATMOSPHERIC_PRESSURE_PA)
pub def wheel_force(arm_rotation_rad: f64, reference_pressure: f64) -> f64 !{}:
"""Vertical wheel force supported by the corner, by virtual work through the motion ratio."""
return air_spring_force(arm_rotation_rad, reference_pressure) * motion_ratio(arm_rotation_rad)
def vector_rotate(vector: list[f64], axis: list[f64], angle: f64):
"""Rodrigues rotation of a vector about a unit axis."""
cosine = math.cos(angle)
sine = math.sin(angle)
projection = axis[0] * vector[0] + axis[1] * vector[1] + axis[2] * vector[2]
return [
vector[0] * cosine + (axis[1] * vector[2] - axis[2] * vector[1]) * sine + axis[0] * projection * (1.0 - cosine),
vector[1] * cosine + (axis[2] * vector[0] - axis[0] * vector[2]) * sine + axis[1] * projection * (1.0 - cosine),
vector[2] * cosine + (axis[0] * vector[1] - axis[1] * vector[0]) * sine + axis[2] * projection * (1.0 - cosine),
]
def tie_rod_residual(pose: list[f64], steer_rotation_rad: f64):
"""Length error of the steering link when the knuckle is rotated about its kingpin axis."""
kingpin_y = pose[2] - pose[0]
kingpin_z = pose[3] - pose[1]
kingpin_length = math.sqrt(HP_KINGPIN_DX * HP_KINGPIN_DX + kingpin_y * kingpin_y + kingpin_z * kingpin_z)
axis = [HP_KINGPIN_DX / kingpin_length, kingpin_y / kingpin_length, kingpin_z / kingpin_length]
cosine = math.cos(pose[4])
sine = math.sin(pose[4])
offset_y = HP_TIE_OUT_Y - HP_LBJ_Y
offset_z = HP_TIE_OUT_Z - HP_LBJ_Z
carried = [HP_TIE_DX, cosine * offset_y - sine * offset_z, sine * offset_y + cosine * offset_z]
turned = vector_rotate(carried, axis, steer_rotation_rad)
delta_x = turned[0] - HP_TIE_DX
delta_y = pose[0] + turned[1] - HP_TIE_IN_Y
delta_z = pose[1] + turned[2] - HP_TIE_IN_Z
reference_y = HP_TIE_OUT_Y - HP_TIE_IN_Y
reference_z = HP_TIE_OUT_Z - HP_TIE_IN_Z
return math.sqrt(delta_x * delta_x + delta_y * delta_y + delta_z * delta_z) - math.sqrt(reference_y * reference_y + reference_z * reference_z)
def bump_steer(pose: list[f64]):
"""Kingpin rotation forced by the fixed-length steering link, and the toe change it produces.
Returns [kingpin_rotation_rad, toe_change_deg]. Solved with a damped Newton iteration seeded at
the design position, so it tracks the physically near root of the length constraint.
"""
kingpin_y = pose[2] - pose[0]
kingpin_z = pose[3] - pose[1]
kingpin_length = math.sqrt(HP_KINGPIN_DX * HP_KINGPIN_DX + kingpin_y * kingpin_y + kingpin_z * kingpin_z)
vertical_component = kingpin_z / kingpin_length
mut rotation = 0.0
mut index = 0
while index < STEER_SOLVE_ITERATIONS:
step = 0.000001
slope = (tie_rod_residual(pose, rotation + step) - tie_rod_residual(pose, rotation - step)) / (2.0 * step)
if abs(slope) < 0.000000000001:
index = STEER_SOLVE_ITERATIONS
else:
correction = tie_rod_residual(pose, rotation) / slope
rotation = rotation - max(-0.05, min(0.05, correction))
index = index + 1
return [rotation, rotation * vertical_component * 180.0 / math.pi]
pub def suspension_travel(vertical_load_n: f64) -> SuspensionState !{}:
"""Solves the corner for a vertical wheel load and reports the resulting wheel motion.
The load is balanced against the polytropic air spring through the linkage motion ratio; the
lower-arm rotation that satisfies it is bisected between the rebound and jounce stops. Camber and
toe are then read out of the four-bar closure and the steering-link length constraint, so the
wheel pose is a consequence of the declared joints.
"""
require vertical_load_n >= 0.0 and vertical_load_n <= 40000.0
limits = arm_rotation_limits()
reference_pressure = air_spring_reference_pressure()
mut low = limits[0]
mut high = limits[1]
if vertical_load_n <= wheel_force(low, reference_pressure):
high = low
elif vertical_load_n >= wheel_force(high, reference_pressure):
low = high
else:
mut index = 0
while index < TRAVEL_SOLVE_ITERATIONS:
middle = (low + high) * 0.5
if wheel_force(middle, reference_pressure) < vertical_load_n:
low = middle
else:
high = middle
index = index + 1
rotation = (low + high) * 0.5
pose = corner_pose(rotation)
steer = bump_steer(pose)
travel = pose[7]
step = 0.00001
rate = (wheel_force(rotation + step, reference_pressure) - wheel_force(rotation - step, reference_pressure)) / (corner_pose(rotation + step)[7] - corner_pose(rotation - step)[7])
camber_change = -pose[4] * 180.0 / math.pi
utilisation = travel / JOUNCE_LIMIT_M if travel >= 0.0 else travel / REBOUND_LIMIT_M
return SuspensionState(
corner_id="front-left",
vertical_load_n=vertical_load_n,
arm_rotation_rad=rotation,
wheel_travel_m=travel,
spring_stroke_m=damper_length(0.0) - damper_length(rotation),
damper_length_m=damper_length(rotation),
spring_force_n=air_spring_force(rotation, reference_pressure),
wheel_rate_n_m=rate,
motion_ratio=motion_ratio(rotation),
camber_deg=STATIC_CAMBER_DEG + camber_change,
camber_change_deg=camber_change,
toe_deg=STATIC_TOE_DEG + steer[1],
toe_change_deg=steer[1],
kingpin_rotation_rad=steer[0],
jounce_limit_m=JOUNCE_LIMIT_M,
rebound_limit_m=REBOUND_LIMIT_M,
travel_utilisation=min(1.0, max(0.0, utilisation)),
at_limit=travel >= JOUNCE_LIMIT_M or travel <= REBOUND_LIMIT_M,
)
def load_path_declarations():
"""Declared load paths from load introduction to structural reaction, in transmission order.
Utilisation is left at zero here; load_paths() fills it from the reference static load case.
"""
return [
LoadPath(id="lp-vertical-corner", name="Vertical wheel load to body and pack rails", kind=LoadPathKind.vertical, part_ids=["tires", "wheels", "brakes", "suspension", "front-subframe", "body-shell", "battery-enclosure"], rated_load_n=16000.0, utilisation=0.0),
LoadPath(id="lp-braking-longitudinal", name="Braking reaction to subframe and floor", kind=LoadPathKind.longitudinal, part_ids=["tires", "wheels", "brakes", "suspension", "front-subframe", "floor-pan", "body-shell"], rated_load_n=26000.0, utilisation=0.0),
LoadPath(id="lp-cornering-lateral", name="Cornering side force to sills and pack", kind=LoadPathKind.lateral, part_ids=["tires", "wheels", "suspension", "front-subframe", "sill-panels", "battery-enclosure", "body-shell"], rated_load_n=16000.0, utilisation=0.0),
LoadPath(id="lp-pack-stressed-member", name="Battery pack as a stressed structural member", kind=LoadPathKind.structural, part_ids=["sill-panels", "battery-enclosure", "battery-modules", "floor-pan", "body-shell"], rated_load_n=90000.0, utilisation=0.0),
LoadPath(id="lp-front-crash-rail", name="Front crash rail crush path", kind=LoadPathKind.crash, part_ids=["body-shell", "front-subframe", "sill-panels", "floor-pan", "battery-enclosure"], rated_load_n=420000.0, utilisation=0.0),
LoadPath(id="lp-rear-crash-rail", name="Rear crash rail crush path", kind=LoadPathKind.crash, part_ids=["body-shell", "rear-subframe", "sill-panels", "floor-pan"], rated_load_n=300000.0, utilisation=0.0),
LoadPath(id="lp-belt-anchorage", name="Seat belt anchorage reaction", kind=LoadPathKind.restraint, part_ids=["restraints", "seats", "floor-pan", "sill-panels", "body-shell"], rated_load_n=13500.0, utilisation=0.0),
]
def corner_statics(lateral_n: f64, vertical_n: f64) !{}:
"""Static equilibrium of one corner at the design position.
Two 3x3 solves: the knuckle against the lower ball joint reaction and the axial upper-arm force,
then the lower arm against the damper force and its inboard pivot reaction. Returns
[lower_ball_y, lower_ball_z, upper_arm_axial, damper_axial, pivot_y, pivot_z,
chassis_y, chassis_z, force_residual, moment_residual].
"""
upper_dy = HP_UBJ_Y - HP_UCA_IN_Y
upper_dz = HP_UBJ_Z - HP_UCA_IN_Z
upper_length = math.sqrt(upper_dy * upper_dy + upper_dz * upper_dz)
upper_unit_y = upper_dy / upper_length
upper_unit_z = upper_dz / upper_length
damper_dy = HP_DAMPER_UP_Y - HP_DAMPER_LO_Y
damper_dz = HP_DAMPER_UP_Z - HP_DAMPER_LO_Z
damper_length_static = math.sqrt(damper_dy * damper_dy + damper_dz * damper_dz)
damper_unit_y = damper_dy / damper_length_static
damper_unit_z = damper_dz / damper_length_static
knuckle_matrix = [
[1.0, 0.0, upper_unit_y],
[0.0, 1.0, upper_unit_z],
[0.0, 0.0, (HP_UBJ_Y - HP_LBJ_Y) * upper_unit_z - (HP_UBJ_Z - HP_LBJ_Z) * upper_unit_y],
]
knuckle_rhs = [
-lateral_n,
-vertical_n,
-((HP_WC_Y - HP_LBJ_Y) * vertical_n - (0.0 - HP_LBJ_Z) * lateral_n),
]
knuckle = linear_solve_3(knuckle_matrix, knuckle_rhs)
lower_ball_y = knuckle[0]
lower_ball_z = knuckle[1]
upper_axial = knuckle[2]
arm_matrix = [
[damper_unit_y, 1.0, 0.0],
[damper_unit_z, 0.0, 1.0],
[(HP_DAMPER_LO_Y - HP_LCA_IN_Y) * damper_unit_z - (HP_DAMPER_LO_Z - HP_LCA_IN_Z) * damper_unit_y, 0.0, 0.0],
]
arm_rhs = [
lower_ball_y,
lower_ball_z,
(HP_LBJ_Y - HP_LCA_IN_Y) * lower_ball_z - (HP_LBJ_Z - HP_LCA_IN_Z) * lower_ball_y,
]
arm = linear_solve_3(arm_matrix, arm_rhs)
damper_axial = arm[0]
pivot_y = arm[1]
pivot_z = arm[2]
chassis_y = -pivot_y - upper_axial * upper_unit_y - damper_axial * damper_unit_y
chassis_z = -pivot_z - upper_axial * upper_unit_z - damper_axial * damper_unit_z
applied_moment = (HP_WC_Y - HP_LCA_IN_Y) * vertical_n - (0.0 - HP_LCA_IN_Z) * lateral_n
reaction_moment = (HP_UCA_IN_Y - HP_LCA_IN_Y) * (-upper_axial * upper_unit_z) - (HP_UCA_IN_Z - HP_LCA_IN_Z) * (-upper_axial * upper_unit_y) + (HP_DAMPER_UP_Y - HP_LCA_IN_Y) * (-damper_axial * damper_unit_z) - (HP_DAMPER_UP_Z - HP_LCA_IN_Z) * (-damper_axial * damper_unit_y)
return [
lower_ball_y,
lower_ball_z,
upper_axial,
damper_axial,
pivot_y,
pivot_z,
chassis_y,
chassis_z,
math.sqrt((chassis_y - lateral_n) * (chassis_y - lateral_n) + (chassis_z - vertical_n) * (chassis_z - vertical_n)),
abs(applied_moment - reaction_moment),
]
def load_paths() !{}:
"""Declared load paths with the utilisation each reaches under the reference static load case."""
evaluated = load_path_reactions(static_corner_load_n(), REFERENCE_BRAKING_N, REFERENCE_CORNERING_N)
declarations = load_path_declarations()
mut paths = []
for index in range(0, len(declarations)):
declaration = declarations[index]
paths.append(LoadPath(id=declaration.id, name=declaration.name, kind=declaration.kind, part_ids=declaration.part_ids, rated_load_n=declaration.rated_load_n, utilisation=evaluated.results[index].utilisation))
return paths
def reaction(path_id: str, index: int, part_id: str, role: str, force_n: f64, moment_nm: f64):
"""One element of a load path chain."""
return LoadPathReaction(path_id=path_id, element_index=index, part_id=part_id, role=role, force_n=force_n, moment_nm=moment_nm)
def shared_path_result(path: LoadPath, applied_n: f64, shares: list[f64], roles: list[str]):
"""Distributes an applied load over a branching path whose shares sum to one."""
require len(shares) == len(path.part_ids)
require len(roles) == len(path.part_ids)
mut reactions = []
mut total = 0.0
mut peak = 0.0
for index in range(0, len(shares)):
element = applied_n * shares[index]
total = total + element
peak = max(peak, abs(element))
reactions.append(reaction(path.id, index, path.part_ids[index], roles[index], element, 0.0))
return LoadPathResult(
path_id=path.id,
name=path.name,
kind=path.kind,
applied_n=applied_n,
reaction_n=total,
residual_n=abs(applied_n - total),
peak_force_n=peak,
rated_load_n=path.rated_load_n,
utilisation=peak / path.rated_load_n,
reactions=reactions,
)
def load_path_reactions(vertical_n: f64, longitudinal_n: f64, lateral_n: f64) !{}:
"""Reactions along every declared load path for one static load case.
The vertical and lateral corner paths are closed by the two 3x3 equilibrium solves in
corner_statics; the remaining paths distribute their input over branch shares that sum to one.
Every path reports the residual between the applied load and the summed reaction.
"""
require vertical_n >= 0.0 and vertical_n <= 40000.0
require longitudinal_n >= -60000.0 and longitudinal_n <= 60000.0
require lateral_n >= -60000.0 and lateral_n <= 60000.0
paths = load_path_declarations()
braking = abs(longitudinal_n)
cornering = abs(lateral_n)
vertical_solve = corner_statics(0.0, vertical_n)
lateral_solve = corner_statics(lateral_n, vertical_n)
lower_ball = math.sqrt(vertical_solve[0] * vertical_solve[0] + vertical_solve[1] * vertical_solve[1])
pivot = math.sqrt(vertical_solve[4] * vertical_solve[4] + vertical_solve[5] * vertical_solve[5])
chassis = math.sqrt(vertical_solve[6] * vertical_solve[6] + vertical_solve[7] * vertical_solve[7])
lateral_ball = math.sqrt(lateral_solve[0] * lateral_solve[0] + lateral_solve[1] * lateral_solve[1])
lateral_pivot = math.sqrt(lateral_solve[4] * lateral_solve[4] + lateral_solve[5] * lateral_solve[5])
vertical_path = paths[0]
vertical_reactions = [
reaction(vertical_path.id, 0, "tires", "contact patch", vertical_n, 0.0),
reaction(vertical_path.id, 1, "wheels", "rim to hub face", vertical_n, 0.0),
reaction(vertical_path.id, 2, "brakes", "hub bearing", vertical_n, 0.0),
reaction(vertical_path.id, 3, "suspension", "lower ball joint and upper arm", max(lower_ball, abs(vertical_solve[2])), 0.0),
reaction(vertical_path.id, 4, "front-subframe", "control arm pivot", pivot, 0.0),
reaction(vertical_path.id, 5, "body-shell", "damper tower", abs(vertical_solve[3]), 0.0),
reaction(vertical_path.id, 6, "battery-enclosure", "pack rail reaction", chassis, 0.0),
]
mut vertical_peak = 0.0
for entry in vertical_reactions:
vertical_peak = max(vertical_peak, abs(entry.force_n))
vertical_result = LoadPathResult(path_id=vertical_path.id, name=vertical_path.name, kind=vertical_path.kind, applied_n=vertical_n, reaction_n=chassis, residual_n=vertical_solve[8], peak_force_n=vertical_peak, rated_load_n=vertical_path.rated_load_n, utilisation=vertical_peak / vertical_path.rated_load_n, reactions=vertical_reactions)
braking_path = paths[1]
anti_dive_couple = braking * WHEEL_RADIUS_M / (HP_UBJ_Z - HP_LBJ_Z)
braking_reactions = [
reaction(braking_path.id, 0, "tires", "contact patch", braking, 0.0),
reaction(braking_path.id, 1, "wheels", "rim to hub face", braking, 0.0),
reaction(braking_path.id, 2, "brakes", "caliper torque reaction", braking * WHEEL_RADIUS_M / 0.150, braking * WHEEL_RADIUS_M),
reaction(braking_path.id, 3, "suspension", "arm couple against wheel torque", anti_dive_couple, braking * WHEEL_RADIUS_M),
reaction(braking_path.id, 4, "front-subframe", "fore-aft bush reaction", braking, 0.0),
reaction(braking_path.id, 5, "floor-pan", "front rail shear", braking * 0.5, 0.0),
reaction(braking_path.id, 6, "body-shell", "front rail shear", braking * 0.5, 0.0),
]
mut braking_peak = 0.0
for entry in braking_reactions:
braking_peak = max(braking_peak, abs(entry.force_n))
braking_result = LoadPathResult(path_id=braking_path.id, name=braking_path.name, kind=braking_path.kind, applied_n=braking, reaction_n=braking * 0.5 + braking * 0.5, residual_n=abs(braking - (braking * 0.5 + braking * 0.5)), peak_force_n=braking_peak, rated_load_n=braking_path.rated_load_n, utilisation=braking_peak / braking_path.rated_load_n, reactions=braking_reactions)
lateral_path = paths[2]
lateral_reactions = [
reaction(lateral_path.id, 0, "tires", "contact patch side force", cornering, 0.0),
reaction(lateral_path.id, 1, "wheels", "bead seat shear", cornering, 0.0),
reaction(lateral_path.id, 2, "suspension", "ball joint resultant", lateral_ball, 0.0),
reaction(lateral_path.id, 3, "front-subframe", "control arm pivot", lateral_pivot, 0.0),
reaction(lateral_path.id, 4, "sill-panels", "sill shear panel", cornering * 0.5, 0.0),
reaction(lateral_path.id, 5, "battery-enclosure", "pack cross member", cornering * 0.25, 0.0),
reaction(lateral_path.id, 6, "body-shell", "floor cross member", cornering * 0.25, 0.0),
]
mut lateral_peak = 0.0
for entry in lateral_reactions:
lateral_peak = max(lateral_peak, abs(entry.force_n))
lateral_total = cornering * 0.5 + cornering * 0.25 + cornering * 0.25
lateral_result = LoadPathResult(path_id=lateral_path.id, name=lateral_path.name, kind=lateral_path.kind, applied_n=cornering, reaction_n=lateral_total, residual_n=abs(cornering - lateral_total), peak_force_n=lateral_peak, rated_load_n=lateral_path.rated_load_n, utilisation=lateral_peak / lateral_path.rated_load_n, reactions=lateral_reactions)
pack_result = shared_path_result(paths[3], vertical_n * 4.0 * 0.5, [0.5, 0.25, 0.125, 0.0625, 0.0625], ["sill shear panel", "enclosure top and bottom skin", "module stack shear", "floor tunnel", "body ring"])
front_crash_result = shared_path_result(paths[4], braking, [0.375, 0.1875, 0.25, 0.125, 0.0625], ["upper rail", "subframe crush can", "sill front horn", "floor front rail", "pack front bumper beam"])
rear_crash_result = shared_path_result(paths[5], braking, [0.4375, 0.1875, 0.25, 0.125], ["upper rail", "subframe crush can", "sill rear horn", "floor rear rail"])
belt_result = shared_path_result(paths[6], paths[6].rated_load_n, [0.5, 0.25, 0.125, 0.0625, 0.0625], ["upper anchorage", "buckle anchorage", "floor anchor plate", "sill reinforcement", "pillar ring"])
results = [vertical_result, braking_result, lateral_result, pack_result, front_crash_result, rear_crash_result, belt_result]
mut residual = 0.0
mut worst_id = results[0].path_id
mut worst = 0.0
for path_result in results:
residual = max(residual, path_result.residual_n)
if path_result.utilisation > worst:
worst = path_result.utilisation
worst_id = path_result.path_id
return LoadCase(
vertical_n=vertical_n,
longitudinal_n=longitudinal_n,
lateral_n=lateral_n,
results=results,
equilibrium_residual_n=residual,
moment_residual_nm=max(vertical_solve[9], lateral_solve[9]),
worst_path_id=worst_id,
worst_utilisation=worst,
)
def thermal_network():
"""Coupled lumped thermal network: the coolant loop is a shared node, not a fixed sink.
Heat leaves a component into the glycol loop, travels to the chiller and the cabin through the
heat pump, and only then reaches ambient through the radiator and the body skin.
"""
return ThermalNetwork(
id="circuitframe-thermal-v1",
nodes=[
ThermalNode(id="tn-battery", part_id="battery-modules", capacity_kj_k=540.0, ambient_coupling_w_k=6.0, initial_temp_c=24.0),
ThermalNode(id="tn-front-motor", part_id="front-motor", capacity_kj_k=27.0, ambient_coupling_w_k=3.5, initial_temp_c=26.0),
ThermalNode(id="tn-rear-motor", part_id="rear-motor", capacity_kj_k=33.0, ambient_coupling_w_k=3.8, initial_temp_c=26.0),
ThermalNode(id="tn-front-inverter", part_id="front-inverter", capacity_kj_k=7.7, ambient_coupling_w_k=1.6, initial_temp_c=25.0),
ThermalNode(id="tn-rear-inverter", part_id="rear-inverter", capacity_kj_k=8.4, ambient_coupling_w_k=1.7, initial_temp_c=25.0),
ThermalNode(id="tn-cabin", part_id="interior", capacity_kj_k=160.0, ambient_coupling_w_k=42.0, initial_temp_c=22.0),
ThermalNode(id="tn-coolant", part_id="thermal-loop", capacity_kj_k=60.0, ambient_coupling_w_k=260.0, initial_temp_c=24.0),
ThermalNode(id="tn-heat-pump", part_id="heat-pump", capacity_kj_k=22.0, ambient_coupling_w_k=4.0, initial_temp_c=24.0),
],
links=[
ThermalLink(id="tl-battery-coolant", from_node_id="tn-battery", to_node_id="tn-coolant", conductance_w_k=900.0, medium="glycol cold plate"),
ThermalLink(id="tl-front-motor-coolant", from_node_id="tn-front-motor", to_node_id="tn-coolant", conductance_w_k=420.0, medium="stator jacket"),
ThermalLink(id="tl-rear-motor-coolant", from_node_id="tn-rear-motor", to_node_id="tn-coolant", conductance_w_k=480.0, medium="stator jacket"),
ThermalLink(id="tl-front-inverter-coolant", from_node_id="tn-front-inverter", to_node_id="tn-coolant", conductance_w_k=260.0, medium="power module cold plate"),
ThermalLink(id="tl-rear-inverter-coolant", from_node_id="tn-rear-inverter", to_node_id="tn-coolant", conductance_w_k=280.0, medium="power module cold plate"),
ThermalLink(id="tl-coolant-heat-pump", from_node_id="tn-coolant", to_node_id="tn-heat-pump", conductance_w_k=700.0, medium="chiller plate heat exchanger"),
ThermalLink(id="tl-heat-pump-cabin", from_node_id="tn-heat-pump", to_node_id="tn-cabin", conductance_w_k=520.0, medium="cabin condenser and evaporator"),
ThermalLink(id="tl-cabin-battery", from_node_id="tn-cabin", to_node_id="tn-battery", conductance_w_k=55.0, medium="floor conduction"),
ThermalLink(id="tl-front-inverter-motor", from_node_id="tn-front-inverter", to_node_id="tn-front-motor", conductance_w_k=34.0, medium="shared drive unit housing"),
ThermalLink(id="tl-rear-inverter-motor", from_node_id="tn-rear-inverter", to_node_id="tn-rear-motor", conductance_w_k=36.0, medium="shared drive unit housing"),
],
)
def isolated_thermal_network():
"""The same graph with every ambient coupling removed, for closed-system energy checks."""
source = thermal_network()
mut nodes = []
for node in source.nodes:
nodes.append(ThermalNode(id=node.id, part_id=node.part_id, capacity_kj_k=node.capacity_kj_k, ambient_coupling_w_k=0.0, initial_temp_c=node.initial_temp_c))
return ThermalNetwork(id=source.id + "-isolated", nodes=nodes, links=source.links)
def node_index(network: ThermalNetwork, node_id: str):
"""Position of a node in the network ordering, or -1 when it is absent."""
for index in range(0, len(network.nodes)):
if network.nodes[index].id == node_id:
return index
return -1
def thermal_step_network(network: ThermalNetwork, temps: list[f64], heat_w: list[f64], ambient_c: f64, dt_s: f64):
"""Advances an explicit lumped-capacitance network one step.
C_i dT_i/dt = Q_i + sum_j k_ij (T_j - T_i) - h_i (T_i - T_amb). The link term is antisymmetric, so
with the ambient couplings removed the total stored energy changes by exactly the injected heat.
"""
require len(temps) == len(network.nodes)
require len(heat_w) == len(network.nodes)
require dt_s > 0.0 and dt_s <= 5.0
mut flux = []
for index in range(0, len(temps)):
flux = flux + [heat_w[index] - network.nodes[index].ambient_coupling_w_k * (temps[index] - ambient_c)]
for link in network.links:
source = node_index(network, link.from_node_id)
target = node_index(network, link.to_node_id)
transfer = link.conductance_w_k * (temps[source] - temps[target])
flux[source] = flux[source] - transfer
flux[target] = flux[target] + transfer
mut next_temps = []
for index in range(0, len(temps)):
next_temps = next_temps + [temps[index] + flux[index] * dt_s / (network.nodes[index].capacity_kj_k * 1000.0)]
return next_temps
pub def thermal_step(temps: list[f64], heat_w: list[f64], ambient_c: f64, dt_s: f64) -> list[f64] !{}:
"""Advances the vehicle thermal network one step."""
return thermal_step_network(thermal_network(), temps, heat_w, ambient_c, dt_s)
pub def thermal_initial_temps() -> list[f64] !{}:
"""Declared soak temperatures of the network, in node order."""
mut temps = []
for node in thermal_network().nodes:
temps = temps + [node.initial_temp_c]
return temps
def thermal_stability_limit_s():
"""Largest explicit step the network tolerates: min over nodes of 2 C / (sum k + h)."""
network = thermal_network()
mut limit = 1000000.0
for index in range(0, len(network.nodes)):
mut conductance = network.nodes[index].ambient_coupling_w_k
for link in network.links:
if link.from_node_id == network.nodes[index].id or link.to_node_id == network.nodes[index].id:
conductance = conductance + link.conductance_w_k
limit = min(limit, 2.0 * network.nodes[index].capacity_kj_k * 1000.0 / conductance)
return limit
pub def assembly_export() -> dict[str, any] !{}:
"""Full assembly payload: placements, interfaces, kinematics, load paths, and the thermal graph."""
properties = mass_properties()
parts = vehicle_parts()
placements = part_placements()
termination = port_termination_report()
reports = mobility_reports()
load_case = load_path_reactions(static_corner_load_n(), REFERENCE_BRAKING_N, REFERENCE_CORNERING_N)
mut placement_rows = []
for index in range(0, len(placements)):
placement = placements[index]
mut centres = []
for row in placement_instances(placement):
centres = centres + [[row[1], row[2], row[3]]]
placement_rows = placement_rows + [{
"part_id": placement.part_id,
"name": parts[index].name,
"group": parts[index].group,
"pattern": placement.pattern,
"pattern_instances": placement.pattern_instances,
"declared_count": parts[index].count,
"mass_kg": placement.mass_kg,
"centre": [placement.centre_x, placement.centre_y, placement.centre_z],
"extent": [placement.extent_x, placement.extent_y, placement.extent_z],
"span": [placement.span_x, placement.span_y, 0.0],
"instance_centres": centres,
"unsprung_fraction": placement.unsprung_fraction,
}]
mut port_rows = []
for port in vehicle_ports():
port_rows = port_rows + [{"id": port.id, "part": port.owner_part_id, "kind": port.kind, "mate_port": port.mate_port_id, "required": port.required, "terminated": port.terminated}]
mut mate_rows = []
for mate in vehicle_mates():
mate_rows = mate_rows + [{"id": mate.id, "port_a": mate.port_a_id, "port_b": mate.port_b_id, "kind": mate.kind, "preload_n": mate.preload_n, "sealed": mate.sealed, "serviceable": mate.serviceable, "robot_accessible": mate.robot_accessible}]
mut joint_rows = []
for item in vehicle_joints():
joint_rows = joint_rows + [{
"id": item.id,
"name": item.name,
"kind": item.kind,
"parent_part": item.parent_part_id,
"child_part": item.child_part_id,
"parent_link": item.parent_link,
"child_link": item.child_link,
"axis": [item.axis_x, item.axis_y, item.axis_z],
"origin": [item.origin_x, item.origin_y, item.origin_z],
"limit_lower": item.limit_lower,
"limit_upper": item.limit_upper,
"ratio": item.ratio,
"dof": item.dof,
"idle_dof": item.idle_dof,
"corner": item.corner,
"loop_closing": item.loop_closing,
}]
mut load_path_rows = []
for path_result in load_case.results:
mut entries = []
for entry in path_result.reactions:
entries = entries + [{"index": entry.element_index, "part_id": entry.part_id, "role": entry.role, "force_n": entry.force_n, "moment_nm": entry.moment_nm}]
load_path_rows = load_path_rows + [{
"id": path_result.path_id,
"name": path_result.name,
"kind": path_result.kind,
"part_ids": [entry.part_id for entry in path_result.reactions],
"applied_n": path_result.applied_n,
"reaction_n": path_result.reaction_n,
"residual_n": path_result.residual_n,
"peak_force_n": path_result.peak_force_n,
"rated_load_n": path_result.rated_load_n,
"utilisation": path_result.utilisation,
"reactions": entries,
}]
network = thermal_network()
mut node_rows = []
for node in network.nodes:
node_rows = node_rows + [{"id": node.id, "part_id": node.part_id, "capacity_kj_k": node.capacity_kj_k, "ambient_coupling_w_k": node.ambient_coupling_w_k, "initial_temp_c": node.initial_temp_c}]
mut link_rows = []
for link in network.links:
link_rows = link_rows + [{"id": link.id, "from": link.from_node_id, "to": link.to_node_id, "conductance_w_k": link.conductance_w_k, "medium": link.medium}]
mut mobility_rows = []
mut mobility_ok = true
for report in reports:
mobility_rows = mobility_rows + [{
"corner": report.corner_id,
"links": report.link_count,
"joints": report.joint_count,
"joint_dof_sum": report.joint_dof_sum,
"gross_mobility": report.gross_mobility,
"idle_dof": report.idle_dof,
"effective_mobility": report.effective_mobility,
"expected_mobility": report.expected_mobility,
"independent_loops": report.independent_loops,
"loop_closing_joints": report.loop_closing_joint_ids,
"ok": report.ok,
}]
if not report.ok:
mobility_ok = false
mut suspension_rows = []
for load in [3800.0, static_corner_load_n(), 6500.0, 8500.0]:
state = suspension_travel(load)
suspension_rows = suspension_rows + [{
"load_n": state.vertical_load_n,
"travel_m": state.wheel_travel_m,
"spring_stroke_m": state.spring_stroke_m,
"wheel_rate_n_m": state.wheel_rate_n_m,
"motion_ratio": state.motion_ratio,
"camber_deg": state.camber_deg,
"toe_deg": state.toe_deg,
"travel_utilisation": state.travel_utilisation,
}]
return {
"schema": ASSEMBLY_SCHEMA,
"configuration_variant": CONFIGURATION_VARIANT_DEFAULT,
"authority": ASSEMBLY_AUTHORITY,
"frame": {
"origin": "ground plane at mid-wheelbase",
"x": "forward",
"y": "left",
"z": "up",
"front_axle_x_m": FRONT_AXLE_X_M,
"rear_axle_x_m": REAR_AXLE_X_M,
"half_track_m": HALF_TRACK_M,
"wheel_radius_m": WHEEL_RADIUS_M,
"ride_height_m": RIDE_HEIGHT_M,
"wheelbase_m": WHEELBASE_M,
},
"mass_properties": {
"total_mass_kg": properties.total_mass_kg,
"sprung_mass_kg": properties.sprung_mass_kg,
"unsprung_mass_kg": properties.unsprung_mass_kg,
"cg_x_m": properties.cg_x_m,
"cg_y_m": properties.cg_y_m,
"cg_z_m": properties.cg_z_m,
"ixx_kg_m2": properties.ixx_kg_m2,
"iyy_kg_m2": properties.iyy_kg_m2,
"izz_kg_m2": properties.izz_kg_m2,
"front_axle_load_kg": properties.front_axle_load_kg,
"rear_axle_load_kg": properties.rear_axle_load_kg,
"front_mass_fraction": properties.front_mass_fraction,
"wheelbase_m": properties.wheelbase_m,
"cg_height_m": properties.cg_height_m,
"part_count": properties.part_count,
"instance_count": properties.instance_count,
"closure_residual_kg": properties.closure_residual_kg,
},
"ports": port_rows,
"mates": mate_rows,
"joints": joint_rows,
"placements": placement_rows,
"load_paths": load_path_rows,
"thermal": {"nodes": node_rows, "links": link_rows, "stability_limit_s": thermal_stability_limit_s()},
"mobility": mobility_rows,
"suspension": {
"static_corner_load_n": static_corner_load_n(),
"travel_jounce_m": JOUNCE_LIMIT_M,
"travel_rebound_m": REBOUND_LIMIT_M,
"motion_ratio": motion_ratio(0.0),
"static_camber_deg": STATIC_CAMBER_DEG,
"static_toe_deg": STATIC_TOE_DEG,
"samples": suspension_rows,
},
"checks": {
"mobility_ok": mobility_ok,
"ports_terminated": termination["unterminated_required_ports"] == 0 and termination["mismated_ports"] == 0,
"equilibrium_residual_n": load_case.equilibrium_residual_n,
"moment_residual_nm": load_case.moment_residual_nm,
"mass_closure_residual_kg": properties.closure_residual_kg,
"unterminated_required_ports": termination["unterminated_required_ports"],
"open_optional_ports": termination["open_optional_ports"],
"mismated_ports": termination["mismated_ports"],
"port_count": termination["ports"],
"mate_count": termination["mates"],
"joint_count": len(joint_rows),
"placement_count": len(placement_rows),
"load_path_count": len(load_path_rows),
"thermal_node_count": len(node_rows),
"thermal_link_count": len(link_rows),
},
}
pub def assembly_equations() -> list[dict[str, str]] !{}:
"""Manifest rows for the assembly model, in the same shape as physics.sema:dynamics_equations()."""
return [
{"id": "centre-of-mass", "source": "mass_properties", "expression": "r_cg = sum(m_i r_i) / sum(m_i)", "unit": "m", "owner": "Sema assembly mass model"},
{"id": "parallel-axis", "source": "parallel_axis_term", "expression": "I_zz = sum(m_i (a_i^2 + b_i^2)/12 + m_i ((x_i - x_cg)^2 + (y_i - y_cg)^2))", "unit": "kg m^2", "owner": "Sema assembly mass model"},
{"id": "axle-load-split", "source": "mass_properties", "expression": "f_front = (x_cg - x_rear) / L", "unit": "fraction", "owner": "Sema assembly mass model"},
{"id": "grubler-mobility", "source": "grubler_mobility", "expression": "M = 6 (n - 1 - j) + sum f_i - f_idle", "unit": "1", "owner": "Sema kinematic assembly"},
{"id": "four-bar-closure", "source": "corner_pose", "expression": "|p_ubj - p_uca| = L_upper and |p_ubj - p_lbj| = L_knuckle", "unit": "m", "owner": "Sema suspension kinematics"},
{"id": "motion-ratio", "source": "motion_ratio", "expression": "MR = -d(l_damper)/d(z_wheel)", "unit": "1", "owner": "Sema suspension kinematics"},
{"id": "air-spring", "source": "polytropic_pressure", "expression": "p = p_0 (V_0 / (V_0 - A s))^n; F_wheel = MR A (p - p_atm)", "unit": "N", "owner": "Sema suspension compliance"},
{"id": "bump-steer", "source": "bump_steer", "expression": "|R(u, delta) (p_tie - p_lbj) + p_lbj - p_rack| = L_tie", "unit": "rad", "owner": "Sema suspension kinematics"},
{"id": "static-equilibrium", "source": "corner_statics", "expression": "sum F = 0 and sum M = 0 solved per free body", "unit": "N", "owner": "Sema load-path statics"},
{"id": "load-path-closure", "source": "load_path_reactions", "expression": "R_total - F_applied = 0", "unit": "N", "owner": "Sema load-path statics"},
{"id": "thermal-network", "source": "thermal_step_network", "expression": "C_i dT_i/dt = Q_i + sum_j k_ij (T_j - T_i) - h_i (T_i - T_amb)", "unit": "degC", "owner": "Sema coupled thermal network"},
{"id": "thermal-stability", "source": "thermal_stability_limit_s", "expression": "dt_max = min_i 2 C_i / (sum_j k_ij + h_i)", "unit": "s", "owner": "Sema coupled thermal network"},
]
test "part placements close the declared mass exactly":
properties = mass_properties()
ensure properties.closure_residual_kg == 0.0
ensure properties.total_mass_kg == ASSEMBLY_MASS_KG
ensure properties.part_count == 38
ensure properties.instance_count > properties.part_count
ensure abs(properties.sprung_mass_kg + properties.unsprung_mass_kg - properties.total_mass_kg) < 0.000000001
ensure properties.unsprung_mass_kg > 150.0 and properties.unsprung_mass_kg < 280.0
test "centre of gravity and inertia stay inside the crossover envelope":
properties = mass_properties()
ensure properties.cg_height_m >= 0.45 and properties.cg_height_m <= 0.60
ensure properties.front_mass_fraction >= 0.47 and properties.front_mass_fraction <= 0.53
ensure properties.izz_kg_m2 >= 2800.0 and properties.izz_kg_m2 <= 4500.0
ensure properties.ixx_kg_m2 >= 500.0 and properties.ixx_kg_m2 <= 1400.0
ensure properties.iyy_kg_m2 >= 2400.0 and properties.iyy_kg_m2 <= 4200.0
ensure abs(properties.cg_y_m) < 0.02
ensure abs(properties.front_axle_load_kg + properties.rear_axle_load_kg - properties.total_mass_kg) < 0.000000001
test "every required port is terminated by exactly one mate":
report = port_termination_report()
ensure report["unterminated_required_ports"] == 0
ensure report["mismated_ports"] == 0
ensure report["ports"] >= 90
ensure report["mates"] >= 45
ensure report["open_optional_ports"] > 0
ensure report["ports_mated_exactly_once"] == report["mates"] * 2
test "suspension corner loops have the mobility their joints imply":
reports = mobility_reports()
ensure len(reports) == 4
mut steered = 0
for report in reports:
ensure report.ok
ensure report.effective_mobility == report.expected_mobility
ensure report.independent_loops == 3
ensure len(report.loop_closing_joint_ids) == 3
if report.expected_mobility == 2:
steered = steered + 1
ensure steered == 2
test "the declared joint set covers the real chains":
joints = vehicle_joints()
ensure len(joints) >= 100
mut gears = 0
mut spherical = 0
mut prismatic = 0
mut ratio_product = 1.0
for item in joints:
if item.kind == JointKind.gear:
gears = gears + 1
if item.parent_link == "front-motor-rotor" or item.parent_link == "front-intermediate-shaft":
ratio_product = ratio_product * item.ratio
if item.kind == JointKind.spherical:
spherical = spherical + 1
if item.kind == JointKind.prismatic:
prismatic = prismatic + 1
ensure gears == 4
ensure abs(ratio_product - 9.1) < 0.000000001
ensure spherical >= 24
ensure prismatic >= 5
test "load path reactions close against the applied load":
load_case = load_path_reactions(5200.0, 9000.0, 6000.0)
ensure len(load_case.results) == 7
ensure load_case.equilibrium_residual_n < 0.000000001
ensure load_case.moment_residual_nm < 0.000000001
for path_result in load_case.results:
ensure path_result.residual_n < 0.000000001
ensure path_result.peak_force_n > 0.0
ensure load_case.worst_utilisation > 0.0
test "an isolated thermal network conserves energy exactly":
network = isolated_thermal_network()
temps = [31.0, 58.0, 64.0, 47.0, 52.0, 21.0, 35.0, 27.0]
heat = [1800.0, 2400.0, 3100.0, 900.0, 1100.0, 0.0, 0.0, 600.0]
dt = 0.05
next_temps = thermal_step_network(network, temps, heat, 20.0, dt)
mut stored = 0.0
mut injected = 0.0
for index in range(0, len(temps)):
stored = stored + network.nodes[index].capacity_kj_k * 1000.0 * (next_temps[index] - temps[index])
injected = injected + heat[index] * dt
ensure abs(stored - injected) < 0.000000001 * abs(injected)
test "the coupled network moves heat between components":
temps = thermal_initial_temps()
heat = [0.0, 9000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
next_temps = thermal_step(temps, heat, 24.0, 0.05)
ensure next_temps[1] > temps[1]
ensure next_temps[6] > temps[6]
ensure thermal_stability_limit_s() > 1.0
ensure len(thermal_network().nodes) == 8
ensure len(thermal_network().links) == 10
test "suspension travel rises with load and keeps camber bounded":
light = suspension_travel(3800.0)
static = suspension_travel(static_corner_load_n())
heavy = suspension_travel(7200.0)
limit = suspension_travel(20000.0)
ensure light.wheel_travel_m < static.wheel_travel_m
ensure static.wheel_travel_m < heavy.wheel_travel_m
ensure heavy.wheel_travel_m <= limit.wheel_travel_m
ensure abs(static.wheel_travel_m) < 0.001
ensure abs(static.camber_change_deg) < 0.05
ensure abs(light.camber_change_deg) < 6.0 and abs(limit.camber_change_deg) < 6.0
ensure abs(limit.toe_change_deg) < 1.0
ensure limit.at_limit and not static.at_limit
ensure static.wheel_rate_n_m > 20000.0 and static.wheel_rate_n_m < 60000.0
ensure light.spring_stroke_m < 0.0 and heavy.spring_stroke_m > 0.0
test "declared damper stroke limits match the solved linkage":
limits = arm_rotation_limits()
joints = vehicle_joints()
mut checked = 0
for item in joints:
if item.id == "fl-damper-slide":
ensure abs(item.limit_lower - (damper_length(0.0) - damper_length(limits[0]))) < 0.001
ensure abs(item.limit_upper - (damper_length(0.0) - damper_length(limits[1]))) < 0.001
checked = checked + 1
ensure checked == 1
ensure abs(corner_pose(limits[0])[7] - REBOUND_LIMIT_M) < 0.0001
ensure abs(corner_pose(limits[1])[7] - JOUNCE_LIMIT_M) < 0.0001
test "the assembly export publishes a complete and self-consistent payload":
payload = assembly_export()
checks = payload["checks"]
ensure payload["schema"] == "sema.circuitframe-assembly/v1"
ensure checks["mass_closure_residual_kg"] == 0.0
ensure checks["unterminated_required_ports"] == 0
ensure checks["mobility_ok"]
ensure checks["ports_terminated"]
ensure checks["equilibrium_residual_n"] < 0.000000001
ensure checks["port_count"] >= 90
ensure checks["mate_count"] >= 45
ensure checks["joint_count"] >= 100
ensure checks["placement_count"] == 38
ensure checks["load_path_count"] == 7
ensure len(assembly_equations()) >= 10
test "declared load paths report a real utilisation under the reference load case":
paths = load_paths()
ensure len(paths) == 7
mut covered = 0
for declared in paths:
ensure declared.utilisation > 0.0 and declared.utilisation < 1.0
ensure len(declared.part_ids) >= 4
covered = covered + 1
ensure covered == len(load_path_declarations())
ensure paths[0].id == "lp-vertical-corner"
ensure paths[6].id == "lp-belt-anchorage"
"""Bounded natural-language circuit authoring compiled into typed Sema net definitions."""
from magna_ev_digital_twin.domain import Fidelity, NetDefinition
assure silver
pub enum AuthoringIntent:
connect | constrain | unknown
pub struct CircuitAuthoringResult:
intent: AuthoringIntent
accepted: bool
natural_description: str
formal_sema: str
net: NetDefinition
requirements: list[str]
assumptions: list[str]
diagnostics: list[str]
invariant len(natural_description) > 0 and len(natural_description) <= 2048
invariant len(requirements) <= 16 and len(assumptions) <= 16 and len(diagnostics) <= 16
def formal_hv_drive_net():
sem "A typed formal Sema definition of the protected 800 V source-to-inverter relation"
return NetDefinition(
id="authoring-hv-drive",
name="Authored pack-to-inverter protected power relation",
domain="HV",
source_part_id="hv-junction",
target_part_ids=["front-inverter", "rear-inverter"],
nominal_voltage_v=800.0,
maximum_current_a=520.0,
route="protected CircuitFrame sill conductors",
fidelity=Fidelity.derived,
evidence_ids=["circuitframe-electrical-v1"],
)
def formal_zonal_supply_net():
sem "A typed formal Sema definition of the protected 48 V zonal supply relation"
return NetDefinition(
id="authoring-lv-zonal",
name="Authored 48 V zonal supply relation",
domain="LV",
source_part_id="dc-dc",
target_part_ids=["front-zone-controller", "rear-zone-controller", "cabin-zone-controller"],
nominal_voltage_v=48.0,
maximum_current_a=180.0,
route="dual protected structural conductors",
fidelity=Fidelity.derived,
evidence_ids=["circuitframe-electrical-v1"],
)
def formal_data_backbone_net():
sem "A typed formal Sema definition of the redundant vehicle data relation"
return NetDefinition(
id="authoring-data-backbone",
name="Authored redundant Ethernet backbone",
domain="DATA",
source_part_id="central-compute",
target_part_ids=["front-zone-controller", "rear-zone-controller", "cabin-zone-controller"],
nominal_voltage_v=1.0,
maximum_current_a=0.2,
route="shielded differential structural links",
fidelity=Fidelity.derived,
evidence_ids=["circuitframe-electrical-v1"],
)
def unsupported_net():
return NetDefinition(
id="unsupported",
name="Unsupported authoring request",
domain="UNKNOWN",
source_part_id="unknown",
target_part_ids=["unknown"],
nominal_voltage_v=0.0,
maximum_current_a=0.0,
route="none",
fidelity=Fidelity.unknown,
evidence_ids=[],
)
pub def compile_circuit_description(description: str) -> CircuitAuthoringResult !{}:
sem "Interpret bounded plain automotive circuit language and emit a typed formal Sema relation or fail visibly"
require len(description) > 0 and len(description) <= 2048
text = description.lower()
if text.contains("800") and (text.contains("battery") or text.contains("pack")) and text.contains("inverter"):
net = formal_hv_drive_net()
return CircuitAuthoringResult(
intent=AuthoringIntent.connect,
accepted=true,
natural_description=description,
formal_sema="NetDefinition(id=\"authoring-hv-drive\", domain=\"HV\", source_part_id=\"hv-junction\", target_part_ids=[\"front-inverter\", \"rear-inverter\"], nominal_voltage_v=800.0, maximum_current_a=520.0, route=\"protected CircuitFrame sill conductors\", fidelity=Fidelity.derived, evidence_ids=[\"circuitframe-electrical-v1\"])",
net=net,
requirements=["800 V nominal class", "protected source-to-load path", "front and rear inverter targets", "current limit not above 520 A"],
assumptions=["battery source terminates at the canonical HV junction", "protected panel conductors use the active section profile"],
diagnostics=[],
)
if text.contains("48") and (text.contains("zone") or text.contains("controller")):
net = formal_zonal_supply_net()
return CircuitAuthoringResult(
intent=AuthoringIntent.connect,
accepted=true,
natural_description=description,
formal_sema="NetDefinition(id=\"authoring-lv-zonal\", domain=\"LV\", source_part_id=\"dc-dc\", target_part_ids=[\"front-zone-controller\", \"rear-zone-controller\", \"cabin-zone-controller\"], nominal_voltage_v=48.0, maximum_current_a=180.0, route=\"dual protected structural conductors\", fidelity=Fidelity.derived, evidence_ids=[\"circuitframe-electrical-v1\"])",
net=net,
requirements=["48 V nominal class", "front, rear, and cabin zone targets", "dual protected paths"],
assumptions=["the canonical DC/DC is the low-voltage source"],
diagnostics=[],
)
if text.contains("ethernet") or (text.contains("data") and text.contains("zone")):
net = formal_data_backbone_net()
return CircuitAuthoringResult(
intent=AuthoringIntent.connect,
accepted=true,
natural_description=description,
formal_sema="NetDefinition(id=\"authoring-data-backbone\", domain=\"DATA\", source_part_id=\"central-compute\", target_part_ids=[\"front-zone-controller\", \"rear-zone-controller\", \"cabin-zone-controller\"], nominal_voltage_v=1.0, maximum_current_a=0.2, route=\"shielded differential structural links\", fidelity=Fidelity.derived, evidence_ids=[\"circuitframe-electrical-v1\"])",
net=net,
requirements=["redundant Ethernet relation", "shielded differential route", "all zone controllers addressed"],
assumptions=["central compute is the logical source"],
diagnostics=[],
)
return CircuitAuthoringResult(
intent=AuthoringIntent.unknown,
accepted=false,
natural_description=description,
formal_sema="",
net=unsupported_net(),
requirements=[],
assumptions=[],
diagnostics=["Unsupported bounded intent. Name voltage/domain plus known source and target roles.", "Supported profiles: 800 V battery-to-inverters, 48 V zonal supply, redundant Ethernet data backbone."],
)
test "natural 800 V request compiles to a typed protected drive relation":
result = compile_circuit_description("Connect the 800 V battery to both inverters through protected CircuitFrame conductors")
ensure result.accepted and result.intent == AuthoringIntent.connect
ensure result.net.domain == "HV" and result.net.nominal_voltage_v == 800.0
ensure result.net.source_part_id == "hv-junction" and len(result.net.target_part_ids) == 2
ensure result.formal_sema.contains("NetDefinition")
test "natural 48 V request compiles to the three zonal controllers":
result = compile_circuit_description("Supply every zone controller over dual protected 48 V conductors")
ensure result.accepted and result.net.domain == "LV"
ensure len(result.net.target_part_ids) == 3 and result.net.maximum_current_a == 180.0
test "unknown natural request fails visibly rather than inventing topology":
result = compile_circuit_description("Make the circuit magical and perfect")
ensure not result.accepted and result.intent == AuthoringIntent.unknown
ensure len(result.formal_sema) == 0 and len(result.diagnostics) == 2
"""Bounded structural-conductor candidate search with immutable gate results."""
assure silver
struct PanelCandidate:
id: str
base_revision: int
width_mm: f64
thickness_mm: f64
length_m: f64
mass_kg: f64
current_density_a_mm2: f64
voltage_drop_v: f64
loss_kw: f64
estimated_temp_c: f64
mass_reduction_fraction: f64
passed: bool
reason: str
invariant len(id) > 0
invariant base_revision >= 1
invariant width_mm > 0.0
invariant thickness_mm > 0.0
invariant length_m > 0.0
invariant mass_kg > 0.0
invariant current_density_a_mm2 >= 0.0
invariant voltage_drop_v >= 0.0
invariant loss_kw >= 0.0
invariant estimated_temp_c >= -40.0
invariant mass_reduction_fraction >= 0.0 and mass_reduction_fraction <= 1.0
invariant len(reason) > 0
pub equation conductor_resistance(resistivity_ohm_m, length_m, width_mm, thickness_mm) -> any:
return resistivity_ohm_m * length_m / (width_mm * thickness_mm * 0.000001)
pub equation conductor_mass(density_kg_m3, length_m, width_mm, thickness_mm) -> any:
return density_kg_m3 * length_m * width_mm * thickness_mm * 0.000001
def evaluate_panel_candidate(width_mm: f64, base_revision: int) !{}:
thickness_mm = 1.2
length_m = 2.6
current_a = 420.0
area_mm2 = width_mm * thickness_mm
resistance = conductor_resistance(0.0000000168, length_m, width_mm, thickness_mm)
voltage_drop = current_a * resistance
loss_kw = current_a * current_a * resistance / 1000.0
mass = conductor_mass(8960.0, length_m, width_mm, thickness_mm)
baseline_mass = conductor_mass(8960.0, length_m, 90.0, thickness_mm)
current_density = current_a / area_mm2
estimated_temp = 24.0 + loss_kw * 18.0
passed = current_density <= 6.5 and voltage_drop <= 1.8 and estimated_temp <= 90.0 and width_mm >= 40.0
reason = "all current-density, voltage-drop, thermal, manufacturability and service-width gates passed" if passed else "one or more electrical, thermal or manufacturability gates failed"
return PanelCandidate(
id="panel-width-" + str(int(width_mm)) + "-r" + str(base_revision),
base_revision=base_revision,
width_mm=width_mm,
thickness_mm=thickness_mm,
length_m=length_m,
mass_kg=mass,
current_density_a_mm2=current_density,
voltage_drop_v=voltage_drop,
loss_kw=loss_kw,
estimated_temp_c=estimated_temp,
mass_reduction_fraction=max(0.0, (baseline_mass - mass) / baseline_mass),
passed=passed,
reason=reason,
)
pub def optimize_panel(base_revision: int) -> dict[str, any] !{}:
require base_revision >= 1
widths = [80.0, 70.0, 60.0, 50.0, 40.0]
mut evaluated = []
mut selected = evaluate_panel_candidate(90.0, base_revision)
for width in widths:
candidate = evaluate_panel_candidate(width, base_revision)
evaluated.append(candidate)
if candidate.passed and candidate.mass_kg < selected.mass_kg:
selected = candidate
ensure selected.passed
return {
"schema": "sema.circuitframe-candidate-preview/v1",
"objective": "reduce structural-conductor mass",
"protected_constraints": ["current density <= 6.5 A/mm2", "voltage drop <= 1.8 V", "estimated temperature <= 90 C", "manufacturable width >= 40 mm"],
"evaluated": evaluated,
"selected": selected,
"commit_authorized": selected.passed,
"decision_authority": false,
"evidence": ["Sema deterministic bounded enumeration", "electrical and thermal equations", "immutable base revision"],
}
test "bounded panel search selects the lightest candidate that passes every gate":
preview = optimize_panel(1)
selected = preview["selected"]
ensure preview["commit_authorized"]
ensure selected.passed
ensure selected.width_mm == 60.0
ensure selected.mass_reduction_fraction > 0.3
ensure selected.current_density_a_mm2 <= 6.5
ensure selected.voltage_drop_v <= 1.8
ensure selected.estimated_temp_c <= 90.0
"""Canonical automotive identities, completeness, evidence, and interface contracts."""
assure silver
pub enum Fidelity:
canonical | derived | reduced | illustrative | unknown | excluded
pub enum PartGroup:
body | chassis | energy | drive | motion | thermal | electrical | control | cabin | safety | manufacturing
pub enum PortKind:
mechanical | high_voltage | low_voltage | data | thermal | fluid | service
pub enum AutomationClass:
robot_validated | assisted | manual | failed | unknown
pub enum QualificationStatus:
executable_reduced | planned_external | reference_only | blocked_missing_evidence
pub enum EvidenceClass:
computational | structural | direct_parity | reduced_model | manufacturing | negative | provenance
pub enum SourceAuthority:
customer_oem | licensed_benchmark | open_research | engineered_assumption | unqualified_fixture
pub struct SourceRecord:
id: str
authority: SourceAuthority
provider: str
vehicle_model: str
source_revision: str
artifact_kind: str
rights_scope: str
digest: str
fidelity: Fidelity
render_allowed: bool
simulation_allowed: bool
export_allowed: bool
invariant len(id) > 0
invariant len(provider) > 0
invariant len(vehicle_model) > 0
invariant len(source_revision) > 0
invariant len(artifact_kind) > 0
invariant len(rights_scope) > 0
invariant len(digest) > 0
pub struct SourceBinding:
entity_id: str
source_record_id: str
source_entity_id: str
fidelity: Fidelity
invariant len(entity_id) > 0
invariant len(source_record_id) > 0
invariant len(source_entity_id) > 0
struct ConfigurationDelta:
id: str
base_configuration: str
target_configuration: str
unchanged_part_ids: list[str]
removed_route_ids: list[str]
added_feature_ids: list[str]
changed_operation_ids: list[str]
invariant len(id) > 0
invariant len(base_configuration) > 0
invariant len(target_configuration) > 0
invariant base_configuration != target_configuration
invariant len(unchanged_part_ids) <= 100000
invariant len(removed_route_ids) <= 100000
invariant len(added_feature_ids) <= 100000
invariant len(changed_operation_ids) <= 100000
pub struct VehicleIdentity:
id: str
revision: int
name: str
category: str
seats: int
fidelity: Fidelity
invariant len(id) > 0
invariant revision >= 1
invariant len(name) > 0
invariant len(category) > 0
invariant seats > 0 and seats <= 9
pub struct PartDefinition:
id: str
name: str
group: PartGroup
count: int
material: str
mass_kg: f64
fidelity: Fidelity
opaque: bool
evidence_ids: list[str]
invariant len(id) > 0
invariant len(name) > 0
invariant count > 0 and count <= 10000
invariant len(material) > 0
invariant mass_kg >= 0.0
invariant len(evidence_ids) <= 64
pub struct PortDefinition:
id: str
owner_part_id: str
kind: PortKind
mate_port_id: str
required: bool
terminated: bool
invariant len(id) > 0
invariant len(owner_part_id) > 0
invariant len(mate_port_id) > 0 or not required
pub struct NetDefinition:
id: str
name: str
domain: str
source_part_id: str
target_part_ids: list[str]
nominal_voltage_v: f64
maximum_current_a: f64
route: str
fidelity: Fidelity
evidence_ids: list[str]
invariant len(id) > 0
invariant len(name) > 0
invariant len(domain) > 0
invariant len(source_part_id) > 0
invariant len(target_part_ids) > 0 and len(target_part_ids) <= 128
invariant nominal_voltage_v >= 0.0
invariant maximum_current_a >= 0.0
invariant len(route) > 0
invariant len(evidence_ids) <= 64
pub struct OperationDefinition:
id: str
name: str
station: str
resource: str
consumed_part_ids: list[str]
created_features: list[str]
automation: AutomationClass
cycle_time_s: f64
evidence_ids: list[str]
invariant len(id) > 0
invariant len(name) > 0
invariant len(station) > 0
invariant len(resource) > 0
invariant len(consumed_part_ids) <= 128
invariant len(created_features) <= 128
invariant cycle_time_s >= 0.0
invariant len(evidence_ids) <= 64
pub struct ValidationCaseDefinition:
id: str
name: str
domain: str
authority: str
method: str
status: QualificationStatus
fidelity: Fidelity
evidence_ids: list[str]
invariant len(id) > 0
invariant len(name) > 0
invariant len(domain) > 0
invariant len(authority) > 0
invariant len(method) > 0
invariant len(evidence_ids) <= 64
pub struct EvidenceRecord:
id: str
kind: EvidenceClass
source: str
summary: str
fidelity: Fidelity
accepted: bool
invariant len(id) > 0
invariant len(source) > 0
invariant len(summary) > 0
pub struct CompletenessReport:
profile: str
declared_parts: int
declared_instances: int
declared_nets: int
declared_operations: int
unterminated_required_ports: int
required_port_inventory_complete: bool
unknown_operations: int
major_assembly_complete: bool
production_complete: bool
exclusions: list[str]
invariant len(profile) > 0
invariant declared_parts > 0
invariant declared_instances >= declared_parts
invariant declared_nets > 0
invariant declared_operations > 0
invariant unterminated_required_ports >= 0
invariant unknown_operations >= 0
invariant len(exclusions) <= 128
"""Engineering specification of the declared drive, brake, tyre, pack and gear hardware.
The vehicle's mass is summed from the declared part placements in `assembly.sema`, but until now
nothing that accelerated or stopped that mass was declared: peak torque, drive power, brake force,
regenerative force, pack energy, pack resistance, tyre grip, cornering stiffness and rolling
resistance were all bare literals in `physics.sema`. This module declares the engineering
parameters of the components `vehicle.sema` already lists and derives those limits from them, so
the performance follows from the hardware rather than sitting beside it.
Every specification is keyed to the part id it represents, and `part_reconciliation()` checks that
each id resolves in `vehicle_parts()`, that the declared instance counts agree, and that the mass
the specification implies matches the mass the part already declares. `hardware_limits()` fails
loudly on any mismatch, so a specification cannot quietly drift away from the part it describes.
Honesty: these are engineered assumptions for a reconstruction, not OEM data. No motor was
dynamometer tested, no caliper was pressure tested and no cell was cycled. Each declared parameter
carries a `solved`, `correlated` or `assumed` tag in `hardware_parameters()`, using the same
vocabulary as the aerodynamic and assembly modules: `assumed` for a bounded engineering choice,
`correlated` for an empirical closure with a cited form, `solved` for a value that falls out of a
closed derivation over the declared inputs.
"""
import math
from magna_ev_digital_twin.domain import PartDefinition
from magna_ev_digital_twin.vehicle import vehicle_nets, vehicle_parts
assure silver
SOURCE_SOLVED = "solved"
SOURCE_CORRELATED = "correlated"
SOURCE_ASSUMED = "assumed"
GRAVITY_MPS2 = 9.80665
ROOT_THREE = 1.7320508075688772
ROOT_TWO = 1.4142135623730951
INCH_M = 0.0254
# Shared inverter design choices: one silicon generation and one DC-link ripple budget for both
# units, so only the current rating, the on-state resistance and the housing differ per axle.
INVERTER_SWITCHING_FREQUENCY_HZ = 12000.0
INVERTER_DC_LINK_RIPPLE_V = 8.0
INVERTER_SWITCHING_ENERGY_MJ_PER_A = 0.060
INVERTER_CAPACITOR_KG_PER_MF = 3.0
INVERTER_MODULE_KG_PER_100A = 0.16
INVERTER_SWITCH_COUNT = 6.0
# Shared machine build: mean density of the active stack (laminations, copper, magnets, slot air)
# and the assembled-to-active mass ratio that covers housing, shaft, bearings and end windings.
MOTOR_ACTIVE_DENSITY_KG_M3 = 4400.0
MOTOR_ASSEMBLED_MASS_FACTOR = 1.34
MOTOR_EFFICIENCY = 0.95
# Shared friction-brake material and hydraulic limits, common to both axles.
PAD_FRICTION_COEFFICIENT = 0.42
MAX_LINE_PRESSURE_PA = 16000000.0
DISC_DENSITY_KG_M3 = 7200.0
DISC_SPECIFIC_HEAT_J_KG_K = 460.0
FADE_ONSET_TEMP_C = 340.0
FADE_RATE_PER_K = 0.0012
FADE_FLOOR_FRACTION = 0.45
DISC_COOLING_BASE_W_PER_K = 1.6
DISC_COOLING_SPEED_W_PER_K_MPS = 0.75
STEEL_DENSITY_KG_M3 = 7850.0
GEAR_MESH_EFFICIENCY = 0.985
GEAR_BLANK_WEB_FACTOR = 0.62
GEARBOX_HOUSING_MASS_FACTOR = 1.16
MASS_TOLERANCE_FRACTION = 0.03
struct TractionMotorSpec:
"""One interior-permanent-magnet traction machine, keyed to its declared drive part."""
part_id: str
pole_pairs: int
rotor_radius_m: f64
stack_length_m: f64
stator_outer_radius_m: f64
magnet_flux_linkage_wb: f64
d_axis_inductance_h: f64
q_axis_inductance_h: f64
peak_phase_current_a: f64
continuous_phase_current_a: f64
current_angle_deg: f64
max_speed_rpm: f64
invariant len(part_id) > 0
invariant pole_pairs >= 1
invariant rotor_radius_m > 0.0 and rotor_radius_m < stator_outer_radius_m
invariant stack_length_m > 0.0
invariant magnet_flux_linkage_wb > 0.0
invariant q_axis_inductance_h >= d_axis_inductance_h and d_axis_inductance_h > 0.0
invariant peak_phase_current_a >= continuous_phase_current_a and continuous_phase_current_a > 0.0
invariant current_angle_deg >= 0.0 and current_angle_deg < 90.0
invariant max_speed_rpm > 0.0
struct InverterSpec:
"""One traction inverter, keyed to its declared power-electronics part."""
part_id: str
dc_link_voltage_v: f64
peak_phase_current_a: f64
on_state_resistance_ohm: f64
housing_mass_kg: f64
net_id: str
invariant len(part_id) > 0
invariant dc_link_voltage_v > 0.0
invariant peak_phase_current_a > 0.0
invariant on_state_resistance_ohm > 0.0
invariant housing_mass_kg > 0.0
invariant len(net_id) > 0
struct BrakeCornerSpec:
"""One brake-by-wire corner module. Both axles are instances of the declared `brakes` part."""
part_id: str
axle: str
disc_outer_radius_m: f64
disc_inner_radius_m: f64
hat_radius_m: f64
disc_thickness_m: f64
vent_solid_fraction: f64
piston_count: int
piston_bore_m: f64
caliper_mass_kg: f64
pad_mass_kg: f64
actuator_mass_kg: f64
invariant len(part_id) > 0
invariant disc_outer_radius_m > disc_inner_radius_m and disc_inner_radius_m > hat_radius_m
invariant disc_thickness_m > 0.0
invariant vent_solid_fraction > 0.0 and vent_solid_fraction <= 1.0
invariant piston_count >= 1
invariant piston_bore_m > 0.0
invariant caliper_mass_kg > 0.0 and pad_mass_kg > 0.0 and actuator_mass_kg > 0.0
struct TireSpec:
"""The declared road tyre, in the size notation moulded on its sidewall."""
part_id: str
section_width_m: f64
aspect_ratio: f64
rim_diameter_in: f64
rolling_radius_factor: f64
load_index_kg: f64
peak_friction: f64
slide_friction: f64
load_sensitivity: f64
friction_reference_load_fraction: f64
cornering_stiffness_per_n: f64
rolling_coefficient: f64
rolling_speed_coefficient: f64
carcass_thickness_m: f64
carcass_density_kg_m3: f64
invariant len(part_id) > 0
invariant section_width_m > 0.0 and aspect_ratio > 0.0 and rim_diameter_in > 0.0
invariant rolling_radius_factor > 0.0 and rolling_radius_factor <= 1.0
invariant load_index_kg > 0.0
invariant peak_friction > slide_friction and slide_friction > 0.0
invariant load_sensitivity >= 0.0
invariant friction_reference_load_fraction > 0.0 and friction_reference_load_fraction <= 1.0
invariant cornering_stiffness_per_n > 0.0
invariant rolling_coefficient > 0.0 and rolling_speed_coefficient >= 0.0
invariant carcass_thickness_m > 0.0 and carcass_density_kg_m3 > 0.0
struct PackSpec:
"""The declared battery modules, described at the cell that fills them."""
part_id: str
module_count: int
series_per_module: int
parallel_per_module: int
cell_nominal_voltage_v: f64
cell_capacity_ah: f64
cell_resistance_ohm: f64
cell_specific_energy_wh_kg: f64
module_packaging_factor: f64
module_interconnect_resistance_ohm: f64
pack_bus_resistance_ohm: f64
cell_pulse_charge_c_rate: f64
cell_pulse_discharge_c_rate: f64
invariant len(part_id) > 0
invariant module_count >= 1 and series_per_module >= 1 and parallel_per_module >= 1
invariant cell_nominal_voltage_v > 0.0 and cell_capacity_ah > 0.0
invariant cell_resistance_ohm > 0.0 and cell_specific_energy_wh_kg > 0.0
invariant module_packaging_factor >= 1.0
invariant module_interconnect_resistance_ohm >= 0.0 and pack_bus_resistance_ohm >= 0.0
invariant cell_pulse_charge_c_rate > 0.0 and cell_pulse_discharge_c_rate > 0.0
struct GearStageSpec:
"""One helical reduction stage, described by the two pitch diameters that set its ratio."""
pinion_pitch_diameter_m: f64
wheel_pitch_diameter_m: f64
face_width_m: f64
invariant wheel_pitch_diameter_m > pinion_pitch_diameter_m and pinion_pitch_diameter_m > 0.0
invariant face_width_m > 0.0
struct GearboxSpec:
"""The declared single-speed reduction gearset, one unit per drive axle."""
part_id: str
unit_count: int
first: GearStageSpec
second: GearStageSpec
invariant len(part_id) > 0
invariant unit_count >= 1
pub struct MotorLimits:
"""What one traction machine can actually deliver through its own inverter."""
part_id: str
inverter_part_id: str
effective_peak_current_a: f64
current_limited_by: str
peak_torque_nm: f64
continuous_torque_nm: f64
base_speed_rpm: f64
max_speed_rpm: f64
constant_power_ratio: f64
corner_power_kw: f64
stator_flux_linkage_wb: f64
power_factor: f64
inverter_apparent_power_kva: f64
inverter_peak_power_kw: f64
inverter_loss_kw: f64
dc_link_peak_current_a: f64
airgap_shear_stress_kpa: f64
invariant len(part_id) > 0
invariant effective_peak_current_a > 0.0
invariant peak_torque_nm > continuous_torque_nm and continuous_torque_nm > 0.0
invariant base_speed_rpm > 0.0 and max_speed_rpm > base_speed_rpm
invariant corner_power_kw > 0.0
invariant power_factor > 0.0 and power_factor <= 1.0
invariant inverter_peak_power_kw > 0.0
invariant airgap_shear_stress_kpa > 0.0
pub struct BrakeAxleLimits:
"""Both corner modules of one axle, reduced to the numbers the dynamics needs."""
part_id: str
axle: str
effective_radius_m: f64
piston_area_m2: f64
clamp_force_n: f64
corner_torque_nm: f64
axle_force_n: f64
disc_mass_kg: f64
disc_thermal_capacity_j_k: f64
invariant len(part_id) > 0
invariant effective_radius_m > 0.0
invariant piston_area_m2 > 0.0
invariant clamp_force_n > 0.0
invariant corner_torque_nm > 0.0
invariant axle_force_n > 0.0
invariant disc_thermal_capacity_j_k > 0.0
pub struct TireLimits:
"""The contact-patch model the dynamics reads, evaluated against live corner loads."""
part_id: str
unloaded_radius_m: f64
rolling_radius_m: f64
rated_load_n: f64
friction_reference_load_n: f64
peak_friction: f64
slide_friction: f64
load_sensitivity: f64
cornering_stiffness_per_n: f64
rolling_coefficient: f64
rolling_speed_coefficient: f64
invariant len(part_id) > 0
invariant rolling_radius_m > 0.0 and rolling_radius_m < unloaded_radius_m
invariant rated_load_n > 0.0 and friction_reference_load_n > 0.0
invariant peak_friction > slide_friction and slide_friction > 0.0
pub struct PackLimits:
"""The traction pack, summed from the declared cell out to the terminals."""
part_id: str
cell_count: int
series_count: int
parallel_count: int
energy_kwh: f64
nominal_voltage_v: f64
resistance_ohm: f64
charge_power_kw: f64
discharge_power_kw: f64
mass_kg: f64
invariant len(part_id) > 0
invariant cell_count >= 1
invariant energy_kwh > 0.0
invariant nominal_voltage_v > 0.0
invariant resistance_ohm > 0.0
invariant charge_power_kw > 0.0 and discharge_power_kw > 0.0
invariant mass_kg > 0.0
struct PartReconciliation:
"""One declared part, checked against the specification that claims to describe it."""
part_id: str
resolved: bool
declared_count: int
expected_count: int
declared_mass_kg: f64
derived_mass_kg: f64
mass_error_fraction: f64
tolerance_fraction: f64
derivation: str
passed: bool
invariant len(part_id) > 0
invariant expected_count >= 1
invariant mass_error_fraction >= 0.0
invariant tolerance_fraction >= 0.0
invariant len(derivation) > 0
pub struct AxleHardware:
"""Everything one axle's 20 Hz longitudinal solve needs, pre-solved into flat scalars.
Nothing on this struct depends on the live vehicle state, so it is built once with the rest of
the hardware. The loop is then left with only the arithmetic that genuinely varies: the friction
circle on this axle's live vertical load, the fade factor at its live disc temperature, and the
comparison that decides which of the two is binding. Keeping it flat also keeps it cheap to pass
around: the interpreter copies a struct argument by value, so the per-step helpers must never be
handed anything that carries the panel basis or a pre-sampled table.
"""
axle: str
brake_axle_force_n: f64
drive_force_per_throttle_n: f64
power_force_numerator_w: f64
power_constraint: str
peak_friction: f64
slide_friction: f64
load_sensitivity: f64
friction_reference_load_n: f64
cornering_stiffness_per_n: f64
fade_onset_temp_c: f64
fade_rate_per_k: f64
fade_floor_fraction: f64
disc_thermal_capacity_j_k: f64
invariant len(axle) > 0 and len(power_constraint) > 0
invariant brake_axle_force_n > 0.0
invariant drive_force_per_throttle_n > 0.0
invariant power_force_numerator_w > 0.0
invariant peak_friction > slide_friction and slide_friction > 0.0
invariant load_sensitivity >= 0.0
invariant friction_reference_load_n > 0.0
invariant cornering_stiffness_per_n > 0.0
invariant fade_onset_temp_c > 0.0
invariant fade_rate_per_k >= 0.0
invariant fade_floor_fraction > 0.0 and fade_floor_fraction <= 1.0
invariant disc_thermal_capacity_j_k > 0.0
pub struct HardwareLimits:
"""Everything `physics.sema` used to hardcode, derived from the declared components."""
front_motor: MotorLimits
rear_motor: MotorLimits
front_brake: BrakeAxleLimits
rear_brake: BrakeAxleLimits
tire: TireLimits
pack: PackLimits
front_axle: AxleHardware
rear_axle: AxleHardware
final_drive_ratio: f64
gearbox_efficiency: f64
motor_efficiency: f64
inverter_efficiency: f64
max_motor_torque_nm: f64
max_drive_power_kw: f64
max_brake_force_n: f64
max_regenerative_force_n: f64
max_regenerative_power_kw: f64
regenerative_front_share: f64
max_line_pressure_pa: f64
pad_friction: f64
fade_onset_temp_c: f64
fade_rate_per_k: f64
fade_floor_fraction: f64
disc_cooling_base_w_per_k: f64
disc_cooling_speed_w_per_k_mps: f64
driveline_top_speed_mps: f64
invariant final_drive_ratio > 1.0
invariant gearbox_efficiency > 0.0 and gearbox_efficiency <= 1.0
invariant max_motor_torque_nm > 0.0
invariant max_drive_power_kw > 0.0
invariant motor_efficiency > 0.0 and motor_efficiency <= 1.0
invariant inverter_efficiency > 0.0 and inverter_efficiency <= 1.0
invariant max_brake_force_n > 0.0
invariant max_regenerative_force_n > 0.0
invariant max_regenerative_power_kw > 0.0
invariant regenerative_front_share > 0.0 and regenerative_front_share < 1.0
invariant fade_onset_temp_c > 0.0
invariant fade_rate_per_k >= 0.0
invariant fade_floor_fraction > 0.0 and fade_floor_fraction <= 1.0
invariant driveline_top_speed_mps > 0.0
invariant max_line_pressure_pa > 0.0
equation ipm_shaft_torque(pole_pairs, flux_linkage, d_inductance, q_inductance, current, angle_rad) -> any:
return 1.5 * pole_pairs * current * cos(angle_rad) * (flux_linkage + (q_inductance - d_inductance) * current * sin(angle_rad))
equation stator_flux_linkage(flux_linkage, d_inductance, q_inductance, current, angle_rad) -> any:
return sqrt((flux_linkage - d_inductance * current * sin(angle_rad))^2 + (q_inductance * current * cos(angle_rad))^2)
equation voltage_limited_speed(dc_link_voltage, stator_flux, pole_pairs) -> any:
return dc_link_voltage / (sqrt(3.0) * stator_flux * pole_pairs)
equation airgap_shear_stress(torque, rotor_radius, stack_length) -> any:
return torque / (2.0 * 3.141592653589793 * rotor_radius^2 * stack_length)
equation uniform_pressure_radius(outer_radius, inner_radius) -> any:
return (2.0 / 3.0) * (outer_radius^3 - inner_radius^3) / (outer_radius^2 - inner_radius^2)
equation caliper_clamp_force(line_pressure, piston_count, piston_bore) -> any:
return line_pressure * piston_count * 3.141592653589793 * (piston_bore / 2.0)^2
equation corner_brake_torque(pad_friction, clamp_force, effective_radius) -> any:
return 2.0 * pad_friction * clamp_force * effective_radius
pub equation brake_fade_factor(disc_temp_c, onset_c, rate_per_k, floor_fraction) -> any:
return max(floor_fraction, 1.0 - rate_per_k * max(0.0, disc_temp_c - onset_c))
pub equation tire_friction(peak, slide, sensitivity, reference_load, corner_load) -> any:
return max(slide, min(peak, peak * (1.0 - sensitivity * (corner_load / reference_load - 1.0))))
pub equation rolling_coefficient(base, speed_coefficient, speed) -> any:
return base + speed_coefficient * speed^2
def front_motor_spec():
"""Front machine: smaller rotor, more turns, sized for the lighter front axle duty."""
return TractionMotorSpec(
part_id="front-motor",
pole_pairs=4,
rotor_radius_m=0.078,
stack_length_m=0.160,
stator_outer_radius_m=0.140,
magnet_flux_linkage_wb=0.260,
d_axis_inductance_h=0.00070,
q_axis_inductance_h=0.00095,
peak_phase_current_a=145.0,
continuous_phase_current_a=65.0,
current_angle_deg=15.0,
max_speed_rpm=17000.0,
)
def rear_motor_spec():
"""Rear machine: longer, wider rotor and roughly twice the current for the primary drive axle."""
return TractionMotorSpec(
part_id="rear-motor",
pole_pairs=4,
rotor_radius_m=0.093,
stack_length_m=0.170,
stator_outer_radius_m=0.150,
magnet_flux_linkage_wb=0.212,
d_axis_inductance_h=0.00030,
q_axis_inductance_h=0.00044,
peak_phase_current_a=355.0,
continuous_phase_current_a=145.0,
current_angle_deg=15.0,
max_speed_rpm=16000.0,
)
def front_inverter_spec():
"""Front inverter: standalone subframe housing, rated above the machine it feeds."""
return InverterSpec(part_id="front-inverter", dc_link_voltage_v=800.0, peak_phase_current_a=160.0, on_state_resistance_ohm=0.0045, housing_mass_kg=8.20, net_id="hv-pack-front")
def rear_inverter_spec():
"""Rear inverter: shares the drive-unit casting, and is the binding current limit of that axle."""
return InverterSpec(part_id="rear-inverter", dc_link_voltage_v=800.0, peak_phase_current_a=335.0, on_state_resistance_ohm=0.0028, housing_mass_kg=6.20, net_id="hv-pack-rear")
def front_brake_spec():
"""Front corner: 355 mm vented disc, four-piston fixed caliper."""
return BrakeCornerSpec(part_id="brakes", axle="front", disc_outer_radius_m=0.1775, disc_inner_radius_m=0.1175, hat_radius_m=0.090, disc_thickness_m=0.032, vent_solid_fraction=0.60, piston_count=4, piston_bore_m=0.024, caliper_mass_kg=6.60, pad_mass_kg=1.55, actuator_mass_kg=2.90)
def rear_brake_spec():
"""Rear corner: 330 mm vented disc, two-piston caliper carrying the park-brake actuator."""
return BrakeCornerSpec(part_id="brakes", axle="rear", disc_outer_radius_m=0.1650, disc_inner_radius_m=0.1120, hat_radius_m=0.085, disc_thickness_m=0.022, vent_solid_fraction=0.65, piston_count=2, piston_bore_m=0.022, caliper_mass_kg=5.20, pad_mass_kg=1.15, actuator_mass_kg=4.15)
def tire_spec():
"""255/45 R21 summer tyre on all four corners."""
return TireSpec(
part_id="tires",
section_width_m=0.255,
aspect_ratio=0.45,
rim_diameter_in=21.0,
rolling_radius_factor=0.97,
load_index_kg=950.0,
peak_friction=1.04,
slide_friction=0.86,
load_sensitivity=0.09,
friction_reference_load_fraction=0.55,
cornering_stiffness_per_n=8.2,
rolling_coefficient=0.0088,
rolling_speed_coefficient=0.0000038,
carcass_thickness_m=0.0098,
carcass_density_kg_m3=1140.0,
)
def pack_spec():
"""Twenty-four 9s2p prismatic NMC modules wired in series into one 216s2p traction pack."""
return PackSpec(
part_id="battery-modules",
module_count=24,
series_per_module=9,
parallel_per_module=2,
cell_nominal_voltage_v=3.68,
cell_capacity_ah=57.9,
cell_resistance_ohm=0.00072,
cell_specific_energy_wh_kg=240.0,
module_packaging_factor=1.142,
module_interconnect_resistance_ohm=0.00022,
pack_bus_resistance_ohm=0.0040,
cell_pulse_charge_c_rate=2.0,
cell_pulse_discharge_c_rate=5.0,
)
def gearbox_spec():
"""Two helical stages per drive unit; their pitch diameters are what make the 9.1:1 final drive."""
return GearboxSpec(
part_id="reduction-gears",
unit_count=2,
first=GearStageSpec(pinion_pitch_diameter_m=0.062, wheel_pitch_diameter_m=0.217, face_width_m=0.045),
second=GearStageSpec(pinion_pitch_diameter_m=0.078, wheel_pitch_diameter_m=0.2028, face_width_m=0.052),
)
def motor_limits(motor: TractionMotorSpec, inverter: InverterSpec) !{}:
"""Solve one machine at its own current limit, then at whichever limit its inverter imposes.
Torque is the interior-permanent-magnet expression: magnet torque plus the reluctance term the
declared saliency provides at the declared current angle. The base speed is where the stator
flux linkage the same operating point produces can no longer be driven at the space-vector
voltage ceiling, and the constant-power region runs from there to the declared maximum speed.
Power factor comes out of the corner-point voltage and current phasors rather than being
declared, so the inverter's apparent-power rating and the machine's shaft power stay consistent.
"""
current = min(motor.peak_phase_current_a, inverter.peak_phase_current_a)
angle = motor.current_angle_deg * math.pi / 180.0
torque = ipm_shaft_torque(f64(motor.pole_pairs), motor.magnet_flux_linkage_wb, motor.d_axis_inductance_h, motor.q_axis_inductance_h, current, angle)
flux = stator_flux_linkage(motor.magnet_flux_linkage_wb, motor.d_axis_inductance_h, motor.q_axis_inductance_h, current, angle)
mechanical_rps = voltage_limited_speed(inverter.dc_link_voltage_v, flux, f64(motor.pole_pairs))
electrical_rps = mechanical_rps * f64(motor.pole_pairs)
direct_current = 0.0 - current * math.sin(angle)
quadrature_current = current * math.cos(angle)
direct_voltage = 0.0 - electrical_rps * motor.q_axis_inductance_h * quadrature_current
quadrature_voltage = electrical_rps * (motor.magnet_flux_linkage_wb + motor.d_axis_inductance_h * direct_current)
real_power = 1.5 * (direct_voltage * direct_current + quadrature_voltage * quadrature_current)
apparent_power = 1.5 * inverter.dc_link_voltage_v / ROOT_THREE * current
switching_loss = INVERTER_SWITCH_COUNT * INVERTER_SWITCHING_FREQUENCY_HZ * INVERTER_SWITCHING_ENERGY_MJ_PER_A * current / 1000.0
conduction_loss = 3.0 * (current / ROOT_TWO) * (current / ROOT_TWO) * inverter.on_state_resistance_ohm
inverter_loss = (switching_loss + conduction_loss) / 1000.0
corner_power = torque * mechanical_rps / 1000.0
return MotorLimits(
part_id=motor.part_id,
inverter_part_id=inverter.part_id,
effective_peak_current_a=current,
current_limited_by="motor-winding" if motor.peak_phase_current_a <= inverter.peak_phase_current_a else "inverter-rating",
peak_torque_nm=torque,
continuous_torque_nm=ipm_shaft_torque(f64(motor.pole_pairs), motor.magnet_flux_linkage_wb, motor.d_axis_inductance_h, motor.q_axis_inductance_h, motor.continuous_phase_current_a, angle),
base_speed_rpm=mechanical_rps * 60.0 / (2.0 * math.pi),
max_speed_rpm=motor.max_speed_rpm,
constant_power_ratio=motor.max_speed_rpm / (mechanical_rps * 60.0 / (2.0 * math.pi)),
corner_power_kw=corner_power,
stator_flux_linkage_wb=flux,
power_factor=real_power / apparent_power,
inverter_apparent_power_kva=apparent_power / 1000.0,
inverter_peak_power_kw=real_power / 1000.0,
inverter_loss_kw=inverter_loss,
dc_link_peak_current_a=(real_power / 1000.0 + inverter_loss) * 1000.0 / inverter.dc_link_voltage_v,
airgap_shear_stress_kpa=airgap_shear_stress(torque, motor.rotor_radius_m, motor.stack_length_m) / 1000.0,
)
def brake_axle_limits(corner: BrakeCornerSpec, rolling_radius_m: f64) !{}:
"""Pressure, clamp force, corner torque and the wheel force both corners of one axle can make.
The effective radius is the uniform-pressure integral over the swept annulus, and the disc
thermal mass is the vented swept volume in cast iron. That thermal mass is what turns half m v
squared into a disc temperature, so a heavier car heats the same disc further on the same stop.
"""
effective_radius = uniform_pressure_radius(corner.disc_outer_radius_m, corner.disc_inner_radius_m)
clamp_force = caliper_clamp_force(MAX_LINE_PRESSURE_PA, f64(corner.piston_count), corner.piston_bore_m)
torque = corner_brake_torque(PAD_FRICTION_COEFFICIENT, clamp_force, effective_radius)
swept_volume = math.pi * (corner.disc_outer_radius_m * corner.disc_outer_radius_m - corner.hat_radius_m * corner.hat_radius_m) * corner.disc_thickness_m * corner.vent_solid_fraction
disc_mass = DISC_DENSITY_KG_M3 * swept_volume
return BrakeAxleLimits(
part_id=corner.part_id,
axle=corner.axle,
effective_radius_m=effective_radius,
piston_area_m2=clamp_force / MAX_LINE_PRESSURE_PA,
clamp_force_n=clamp_force,
corner_torque_nm=torque,
axle_force_n=2.0 * torque / rolling_radius_m,
disc_mass_kg=disc_mass,
disc_thermal_capacity_j_k=disc_mass * DISC_SPECIFIC_HEAT_J_KG_K,
)
def tire_limits():
"""Turn the moulded tyre size into the radius, rated load and grip model the dynamics reads."""
spec = tire_spec()
rim_radius = spec.rim_diameter_in * INCH_M / 2.0
unloaded_radius = rim_radius + spec.section_width_m * spec.aspect_ratio
rated_load = spec.load_index_kg * GRAVITY_MPS2
return TireLimits(
part_id=spec.part_id,
unloaded_radius_m=unloaded_radius,
rolling_radius_m=unloaded_radius * spec.rolling_radius_factor,
rated_load_n=rated_load,
friction_reference_load_n=rated_load * spec.friction_reference_load_fraction,
peak_friction=spec.peak_friction,
slide_friction=spec.slide_friction,
load_sensitivity=spec.load_sensitivity,
cornering_stiffness_per_n=spec.cornering_stiffness_per_n,
rolling_coefficient=spec.rolling_coefficient,
rolling_speed_coefficient=spec.rolling_speed_coefficient,
)
def pack_limits():
"""Sum 24 declared modules of cells into pack energy, voltage, resistance, power and mass."""
spec = pack_spec()
series = spec.module_count * spec.series_per_module
strings = spec.parallel_per_module
cells = series * strings
capacity_ah = f64(strings) * spec.cell_capacity_ah
nominal_voltage = f64(series) * spec.cell_nominal_voltage_v
cell_mass = spec.cell_nominal_voltage_v * spec.cell_capacity_ah / spec.cell_specific_energy_wh_kg
return PackLimits(
part_id=spec.part_id,
cell_count=cells,
series_count=series,
parallel_count=strings,
energy_kwh=nominal_voltage * capacity_ah / 1000.0,
nominal_voltage_v=nominal_voltage,
resistance_ohm=f64(series) * spec.cell_resistance_ohm / f64(strings) + f64(spec.module_count) * spec.module_interconnect_resistance_ohm + spec.pack_bus_resistance_ohm,
charge_power_kw=nominal_voltage * spec.cell_pulse_charge_c_rate * capacity_ah / 1000.0,
discharge_power_kw=nominal_voltage * spec.cell_pulse_discharge_c_rate * capacity_ah / 1000.0,
mass_kg=f64(cells) * cell_mass * spec.module_packaging_factor,
)
def final_drive_ratio():
"""The 9.1:1 reduction is the product of the two declared stage pitch-diameter ratios."""
gearbox = gearbox_spec()
return gearbox.first.wheel_pitch_diameter_m / gearbox.first.pinion_pitch_diameter_m * gearbox.second.wheel_pitch_diameter_m / gearbox.second.pinion_pitch_diameter_m
def gear_blank_mass(stage: GearStageSpec):
"""Both gear blanks of one stage as webbed steel discs of their pitch diameter and face width."""
area = math.pi / 4.0 * (stage.pinion_pitch_diameter_m * stage.pinion_pitch_diameter_m + stage.wheel_pitch_diameter_m * stage.wheel_pitch_diameter_m)
return area * stage.face_width_m * STEEL_DENSITY_KG_M3 * GEAR_BLANK_WEB_FACTOR
def derived_part_mass(part_id: str) !{}:
"""Mass the declared specification implies for one part, summed over its declared instances."""
if part_id == "front-motor" or part_id == "rear-motor":
motor = front_motor_spec() if part_id == "front-motor" else rear_motor_spec()
return math.pi * motor.stator_outer_radius_m * motor.stator_outer_radius_m * motor.stack_length_m * MOTOR_ACTIVE_DENSITY_KG_M3 * MOTOR_ASSEMBLED_MASS_FACTOR
if part_id == "front-inverter" or part_id == "rear-inverter":
inverter = front_inverter_spec() if part_id == "front-inverter" else rear_inverter_spec()
capacitance_mf = 0.5 * inverter.peak_phase_current_a / (2.0 * INVERTER_SWITCHING_FREQUENCY_HZ * INVERTER_DC_LINK_RIPPLE_V) * 1000.0
return capacitance_mf * INVERTER_CAPACITOR_KG_PER_MF + INVERTER_SWITCH_COUNT * inverter.peak_phase_current_a / 100.0 * INVERTER_MODULE_KG_PER_100A + inverter.housing_mass_kg
if part_id == "brakes":
radius = tire_limits().rolling_radius_m
front = brake_axle_limits(front_brake_spec(), radius)
rear = brake_axle_limits(rear_brake_spec(), radius)
front_spec = front_brake_spec()
rear_spec = rear_brake_spec()
return 2.0 * (front.disc_mass_kg + front_spec.caliper_mass_kg + front_spec.pad_mass_kg + front_spec.actuator_mass_kg) + 2.0 * (rear.disc_mass_kg + rear_spec.caliper_mass_kg + rear_spec.pad_mass_kg + rear_spec.actuator_mass_kg)
if part_id == "tires":
spec = tire_spec()
limits = tire_limits()
rim_radius = spec.rim_diameter_in * INCH_M / 2.0
tread_area = 2.0 * math.pi * limits.unloaded_radius_m * spec.section_width_m
sidewall_area = 2.0 * math.pi * (limits.unloaded_radius_m * limits.unloaded_radius_m - rim_radius * rim_radius)
return 4.0 * (tread_area + sidewall_area) * spec.carcass_thickness_m * spec.carcass_density_kg_m3
if part_id == "battery-modules":
return pack_limits().mass_kg
gearbox = gearbox_spec()
return f64(gearbox.unit_count) * (gear_blank_mass(gearbox.first) + gear_blank_mass(gearbox.second)) * GEARBOX_HOUSING_MASS_FACTOR
def declared_part(part_id: str):
"""The declared part this specification claims to describe. Fails loudly on an unknown id."""
for part in vehicle_parts():
if part.id == part_id:
return part
ensure false
return vehicle_parts()[0]
def part_derivation(part_id: str):
"""One sentence naming what the derived mass for this part is actually built from."""
if part_id == "front-motor" or part_id == "rear-motor":
return "active stack volume from the declared stator outer radius and stack length, at the shared active density and assembled-to-active mass ratio"
if part_id == "front-inverter" or part_id == "rear-inverter":
return "DC-link capacitance sized from the declared peak phase current and ripple budget, plus six power modules at the declared current rating, plus the declared housing"
if part_id == "brakes":
return "four vented cast-iron discs from the declared swept annulus, plus the declared caliper, pad and actuator masses per corner"
if part_id == "tires":
return "four carcasses as a tread band and two sidewall annuli at the declared thickness and compound density"
if part_id == "battery-modules":
return "432 cells at the declared cell specific energy, times the declared module packaging factor"
return "four webbed steel gear blanks per unit at their declared pitch diameters and face widths, times the declared housing mass factor"
def reconcile_part(part_id: str, expected_count: int) !{}:
"""Check one specification against the part it claims to describe: identity, count and mass."""
part = declared_part(part_id)
derived = derived_part_mass(part_id)
error = abs(derived - part.mass_kg) / part.mass_kg
return PartReconciliation(
part_id=part_id,
resolved=true,
declared_count=part.count,
expected_count=expected_count,
declared_mass_kg=part.mass_kg,
derived_mass_kg=derived,
mass_error_fraction=error,
tolerance_fraction=MASS_TOLERANCE_FRACTION,
derivation=part_derivation(part_id),
passed=part.count == expected_count and error <= MASS_TOLERANCE_FRACTION,
)
def part_reconciliation() !{}:
"""Every specification checked against `vehicle_parts()`, in the order the drive chain runs."""
return [
reconcile_part("front-motor", 1),
reconcile_part("rear-motor", 1),
reconcile_part("front-inverter", 1),
reconcile_part("rear-inverter", 1),
reconcile_part("reduction-gears", 2),
reconcile_part("brakes", 4),
reconcile_part("tires", 4),
reconcile_part("battery-modules", 24),
]
def net_current_limit(net_id: str):
"""Declared maximum current of one high-voltage net, read out of `vehicle_nets()`."""
for net in vehicle_nets("circuitframe"):
if net.id == net_id:
return net.maximum_current_a
ensure false
return 0.0
def generating_power(motor: MotorLimits):
"""Electrical power one machine can regenerate: its continuous torque at its own base speed.
A braking burst is short, but the machines still recover at their continuous rating rather than
their peak: peak current is a traction-side thermal allowance the pad-and-disc side never needs.
"""
return motor.corner_power_kw * motor.continuous_torque_nm / motor.peak_torque_nm
def axle_hardware(axle: str, motor: MotorLimits, brake: BrakeAxleLimits, tire: TireLimits, ratio: f64, efficiency: f64):
"""Collapse one axle's declared machine, inverter, calipers and tyres into per-step scalars."""
return AxleHardware(
axle=axle,
brake_axle_force_n=brake.axle_force_n,
drive_force_per_throttle_n=motor.peak_torque_nm * ratio * efficiency / tire.rolling_radius_m,
power_force_numerator_w=motor.corner_power_kw * 1000.0 * efficiency,
power_constraint="inverter-power" if motor.current_limited_by == "inverter-rating" else "motor-power",
peak_friction=tire.peak_friction,
slide_friction=tire.slide_friction,
load_sensitivity=tire.load_sensitivity,
friction_reference_load_n=tire.friction_reference_load_n,
cornering_stiffness_per_n=tire.cornering_stiffness_per_n,
fade_onset_temp_c=FADE_ONSET_TEMP_C,
fade_rate_per_k=FADE_RATE_PER_K,
fade_floor_fraction=FADE_FLOOR_FRACTION,
disc_thermal_capacity_j_k=brake.disc_thermal_capacity_j_k,
)
pub def hardware_limits() -> HardwareLimits !{}:
"""Derive every performance limit `physics.sema` needs, and refuse to return a drifted set.
The reconciliation runs first. A specification whose part id no longer resolves, whose instance
count no longer matches the declared count, or whose derived mass has drifted outside the stated
tolerance stops the build here rather than quietly feeding a wrong limit into the dynamics.
"""
reconciliation = part_reconciliation()
for row in reconciliation:
ensure row.resolved and row.passed
front = motor_limits(front_motor_spec(), front_inverter_spec())
rear = motor_limits(rear_motor_spec(), rear_inverter_spec())
ensure front.dc_link_peak_current_a <= net_current_limit(front_inverter_spec().net_id)
ensure rear.dc_link_peak_current_a <= net_current_limit(rear_inverter_spec().net_id)
tire = tire_limits()
pack = pack_limits()
ratio = final_drive_ratio()
efficiency = GEAR_MESH_EFFICIENCY * GEAR_MESH_EFFICIENCY
front_brake = brake_axle_limits(front_brake_spec(), tire.rolling_radius_m)
rear_brake = brake_axle_limits(rear_brake_spec(), tire.rolling_radius_m)
regenerative_torque = front.continuous_torque_nm + rear.continuous_torque_nm
driveline = MOTOR_EFFICIENCY * (1.0 - (front.inverter_loss_kw + rear.inverter_loss_kw) / (front.corner_power_kw + rear.corner_power_kw)) * efficiency
return HardwareLimits(
front_motor=front,
rear_motor=rear,
front_brake=front_brake,
rear_brake=rear_brake,
tire=tire,
pack=pack,
front_axle=axle_hardware("front", front, front_brake, tire, ratio, driveline),
rear_axle=axle_hardware("rear", rear, rear_brake, tire, ratio, driveline),
final_drive_ratio=ratio,
gearbox_efficiency=efficiency,
motor_efficiency=MOTOR_EFFICIENCY,
inverter_efficiency=1.0 - (front.inverter_loss_kw + rear.inverter_loss_kw) / (front.corner_power_kw + rear.corner_power_kw),
max_motor_torque_nm=front.peak_torque_nm + rear.peak_torque_nm,
max_brake_force_n=front_brake.axle_force_n + rear_brake.axle_force_n,
max_drive_power_kw=front.corner_power_kw + rear.corner_power_kw,
max_regenerative_force_n=regenerative_torque * ratio * efficiency / tire.rolling_radius_m,
max_regenerative_power_kw=min(pack.charge_power_kw, generating_power(front) + generating_power(rear)),
regenerative_front_share=front.continuous_torque_nm / regenerative_torque,
max_line_pressure_pa=MAX_LINE_PRESSURE_PA,
pad_friction=PAD_FRICTION_COEFFICIENT,
fade_onset_temp_c=FADE_ONSET_TEMP_C,
fade_rate_per_k=FADE_RATE_PER_K,
fade_floor_fraction=FADE_FLOOR_FRACTION,
disc_cooling_base_w_per_k=DISC_COOLING_BASE_W_PER_K,
disc_cooling_speed_w_per_k_mps=DISC_COOLING_SPEED_W_PER_K_MPS,
driveline_top_speed_mps=min(front.max_speed_rpm, rear.max_speed_rpm) / 60.0 / ratio * 2.0 * math.pi * tire.rolling_radius_m,
)
def parameter_row(part_id: str, key: str, label: str, value: f64, unit: str, source: str, note: str):
"""One declared scalar with the part it belongs to and how it was arrived at."""
return {"id": part_id + "." + key, "part_id": part_id, "label": label, "value": value, "unit": unit, "source": source, "note": note}
def motor_parameters(motor: TractionMotorSpec):
"""Provenance rows for one machine, read straight off the specification struct."""
return [
parameter_row(motor.part_id, "pole-pairs", "Pole pairs", f64(motor.pole_pairs), "1", SOURCE_ASSUMED, "eight-pole rotor, the usual choice for this speed and frequency class"),
parameter_row(motor.part_id, "rotor-radius", "Rotor radius", motor.rotor_radius_m, "m", SOURCE_ASSUMED, "sized so the derived peak torque sits near 47 kPa of airgap shear stress"),
parameter_row(motor.part_id, "stack-length", "Active stack length", motor.stack_length_m, "m", SOURCE_ASSUMED, "sets the torque per unit shear stress and, with the stator radius, the machine mass"),
parameter_row(motor.part_id, "stator-outer-radius", "Stator outer radius", motor.stator_outer_radius_m, "m", SOURCE_ASSUMED, "outer envelope of the active stack; drives the reconciled machine mass"),
parameter_row(motor.part_id, "magnet-flux-linkage", "Magnet flux linkage", motor.magnet_flux_linkage_wb, "Wb", SOURCE_ASSUMED, "800 V winding, so roughly twice the flux linkage and half the current of a 400 V machine"),
parameter_row(motor.part_id, "d-axis-inductance", "d-axis inductance", motor.d_axis_inductance_h, "H", SOURCE_ASSUMED, "saturated value at peak current; no current-dependent inductance map is claimed"),
parameter_row(motor.part_id, "q-axis-inductance", "q-axis inductance", motor.q_axis_inductance_h, "H", SOURCE_ASSUMED, "the declared saliency is what produces the reluctance share of the derived torque"),
parameter_row(motor.part_id, "peak-phase-current", "Peak phase current", motor.peak_phase_current_a, "A", SOURCE_ASSUMED, "winding thermal and demagnetisation limit for a bounded peak-power burst"),
parameter_row(motor.part_id, "continuous-phase-current", "Continuous phase current", motor.continuous_phase_current_a, "A", SOURCE_ASSUMED, "liquid-cooled continuous rating; sets the derived regenerative torque"),
parameter_row(motor.part_id, "current-angle", "MTPA current angle", motor.current_angle_deg, "deg", SOURCE_CORRELATED, "fixed maximum-torque-per-amp angle; a real controller would walk this with speed and temperature"),
parameter_row(motor.part_id, "max-speed", "Maximum speed", motor.max_speed_rpm, "rpm", SOURCE_ASSUMED, "rotor burst and bearing limit; with the base speed it sets the constant-power region"),
]
def inverter_parameters(inverter: InverterSpec):
"""Provenance rows for one inverter, read straight off the specification struct."""
return [
parameter_row(inverter.part_id, "dc-link-voltage", "DC link voltage", inverter.dc_link_voltage_v, "V", SOURCE_ASSUMED, "800 V class, consistent with the declared HV net voltage and the derived pack nominal"),
parameter_row(inverter.part_id, "peak-phase-current", "Peak phase current", inverter.peak_phase_current_a, "A", SOURCE_ASSUMED, "silicon carbide module rating; caps the machine when it is below the winding limit"),
parameter_row(inverter.part_id, "on-state-resistance", "On-state resistance", inverter.on_state_resistance_ohm, "ohm", SOURCE_ASSUMED, "per-phase conduction path through the paralleled dies"),
parameter_row(inverter.part_id, "housing-mass", "Housing and coldplate mass", inverter.housing_mass_kg, "kg", SOURCE_ASSUMED, "front unit is standalone in the subframe, rear shares the drive-unit casting"),
]
def brake_parameters(corner: BrakeCornerSpec):
"""Provenance rows for one brake corner, read straight off the specification struct."""
return [
parameter_row(corner.part_id, corner.axle + ".disc-outer-radius", corner.axle + " disc outer radius", corner.disc_outer_radius_m, "m", SOURCE_ASSUMED, "swept annulus outer edge; with the inner edge it fixes the effective radius"),
parameter_row(corner.part_id, corner.axle + ".disc-inner-radius", corner.axle + " disc inner radius", corner.disc_inner_radius_m, "m", SOURCE_ASSUMED, "inner edge of the pad sweep"),
parameter_row(corner.part_id, corner.axle + ".hat-radius", corner.axle + " disc hat radius", corner.hat_radius_m, "m", SOURCE_ASSUMED, "mounting hat; excluded from the thermal mass because it is not swept"),
parameter_row(corner.part_id, corner.axle + ".disc-thickness", corner.axle + " disc thickness", corner.disc_thickness_m, "m", SOURCE_ASSUMED, "vented disc overall thickness"),
parameter_row(corner.part_id, corner.axle + ".vent-solid-fraction", corner.axle + " vented solid fraction", corner.vent_solid_fraction, "1", SOURCE_CORRELATED, "solid fraction of a vaned vented disc; sets both the disc mass and its thermal capacity"),
parameter_row(corner.part_id, corner.axle + ".piston-count", corner.axle + " piston count", f64(corner.piston_count), "1", SOURCE_ASSUMED, "fixed-caliper pistons per side"),
parameter_row(corner.part_id, corner.axle + ".piston-bore", corner.axle + " piston bore", corner.piston_bore_m, "m", SOURCE_ASSUMED, "with the line pressure this is the whole clamp force"),
parameter_row(corner.part_id, corner.axle + ".caliper-mass", corner.axle + " caliper mass", corner.caliper_mass_kg, "kg", SOURCE_ASSUMED, "aluminium fixed caliper body"),
parameter_row(corner.part_id, corner.axle + ".pad-mass", corner.axle + " pad set mass", corner.pad_mass_kg, "kg", SOURCE_ASSUMED, "both pads including backing plates"),
parameter_row(corner.part_id, corner.axle + ".actuator-mass", corner.axle + " actuator mass", corner.actuator_mass_kg, "kg", SOURCE_ASSUMED, "brake-by-wire actuator and local electronics; the rear also carries the park-brake motor"),
]
def shared_parameters():
"""Provenance rows for the constants shared across specifications and reconciliations."""
spec = tire_spec()
pack = pack_spec()
gearbox = gearbox_spec()
return [
parameter_row("front-motor", "active-density", "Active stack mean density", MOTOR_ACTIVE_DENSITY_KG_M3, "kg/m^3", SOURCE_CORRELATED, "volume-mean of laminations, copper, magnets and slot air; shared by both machines"),
parameter_row("front-motor", "assembled-mass-factor", "Assembled to active mass ratio", MOTOR_ASSEMBLED_MASS_FACTOR, "1", SOURCE_ASSUMED, "housing, shaft, bearings and end windings over the active stack; shared by both machines"),
parameter_row("front-motor", "efficiency", "Shaft efficiency at the corner point", MOTOR_EFFICIENCY, "1", SOURCE_CORRELATED, "lumped copper, iron, magnet and windage loss for a liquid-cooled traction machine; no loss map is claimed, so this stays declared rather than derived"),
parameter_row("front-inverter", "switching-frequency", "Switching frequency", INVERTER_SWITCHING_FREQUENCY_HZ, "Hz", SOURCE_ASSUMED, "shared silicon carbide switching frequency"),
parameter_row("front-inverter", "switching-energy", "Switching energy coefficient", INVERTER_SWITCHING_ENERGY_MJ_PER_A, "mJ/A", SOURCE_CORRELATED, "turn-on, turn-off and reverse-recovery energy per amp at the declared DC link voltage"),
parameter_row("front-inverter", "dc-link-ripple", "DC link ripple budget", INVERTER_DC_LINK_RIPPLE_V, "V", SOURCE_ASSUMED, "sizes the film capacitor, and through it the reconciled inverter mass"),
parameter_row("front-inverter", "capacitor-specific-mass", "Capacitor specific mass", INVERTER_CAPACITOR_KG_PER_MF, "kg/mF", SOURCE_CORRELATED, "900 V film capacitor mass per millifarad"),
parameter_row("front-inverter", "module-specific-mass", "Power module specific mass", INVERTER_MODULE_KG_PER_100A, "kg/100A", SOURCE_CORRELATED, "module, substrate and busbar mass per hundred amps per switch"),
parameter_row("brakes", "pad-friction", "Pad friction coefficient", PAD_FRICTION_COEFFICIENT, "1", SOURCE_CORRELATED, "cold organic pad against cast iron; the fade curve degrades it with disc temperature"),
parameter_row("brakes", "max-line-pressure", "Maximum line pressure", MAX_LINE_PRESSURE_PA, "Pa", SOURCE_ASSUMED, "160 bar at full pedal demand; the pedal-to-pressure map is taken as linear"),
parameter_row("brakes", "disc-density", "Disc material density", DISC_DENSITY_KG_M3, "kg/m^3", SOURCE_ASSUMED, "grey cast iron"),
parameter_row("brakes", "disc-specific-heat", "Disc specific heat", DISC_SPECIFIC_HEAT_J_KG_K, "J/(kg.K)", SOURCE_CORRELATED, "grey cast iron; with the swept mass this is the whole disc thermal capacity"),
parameter_row("brakes", "fade-onset", "Fade onset temperature", FADE_ONSET_TEMP_C, "degC", SOURCE_CORRELATED, "temperature above which the declared pad compound starts losing friction"),
parameter_row("brakes", "fade-rate", "Fade rate", FADE_RATE_PER_K, "1/K", SOURCE_CORRELATED, "fractional friction loss per kelvin above onset"),
parameter_row("brakes", "fade-floor", "Fade floor", FADE_FLOOR_FRACTION, "1", SOURCE_CORRELATED, "residual friction fraction the compound never falls below"),
parameter_row("brakes", "disc-cooling-base", "Disc cooling at rest", DISC_COOLING_BASE_W_PER_K, "W/K", SOURCE_CORRELATED, "natural convection and radiation from one disc"),
parameter_row("brakes", "disc-cooling-speed", "Disc cooling per unit speed", DISC_COOLING_SPEED_W_PER_K_MPS, "W/(K.m/s)", SOURCE_CORRELATED, "forced convection through the vanes, linear in road speed"),
parameter_row(spec.part_id, "section-width", "Section width", spec.section_width_m, "m", SOURCE_ASSUMED, "moulded tyre size 255/45 R21"),
parameter_row(spec.part_id, "aspect-ratio", "Aspect ratio", spec.aspect_ratio, "1", SOURCE_ASSUMED, "sidewall height as a fraction of section width"),
parameter_row(spec.part_id, "rim-diameter", "Rim diameter", spec.rim_diameter_in, "in", SOURCE_ASSUMED, "matches the declared alloy wheel"),
parameter_row(spec.part_id, "rolling-radius-factor", "Rolling radius factor", spec.rolling_radius_factor, "1", SOURCE_CORRELATED, "loaded rolling radius over unloaded radius for a laden radial tyre"),
parameter_row(spec.part_id, "load-index", "Load index mass", spec.load_index_kg, "kg", SOURCE_ASSUMED, "load index 106; four corners must cover the laden vehicle with margin"),
parameter_row(spec.part_id, "peak-friction", "Peak friction coefficient", spec.peak_friction, "1", SOURCE_CORRELATED, "dry asphalt summer compound at the reference load"),
parameter_row(spec.part_id, "slide-friction", "Sliding friction coefficient", spec.slide_friction, "1", SOURCE_CORRELATED, "past the peak of the slip curve; also the floor of the load-sensitivity model"),
parameter_row(spec.part_id, "load-sensitivity", "Friction load sensitivity", spec.load_sensitivity, "1", SOURCE_CORRELATED, "fractional friction loss per unit of relative overload; this is how added mass costs grip"),
parameter_row(spec.part_id, "friction-reference-load", "Friction reference load fraction", spec.friction_reference_load_fraction, "1", SOURCE_CORRELATED, "fraction of the load-index maximum at which the friction figures are quoted"),
parameter_row(spec.part_id, "cornering-stiffness", "Cornering stiffness per unit load", spec.cornering_stiffness_per_n, "1/rad", SOURCE_CORRELATED, "linear-range slip stiffness per newton of vertical load, so an axle stiffens as it is loaded"),
parameter_row(spec.part_id, "rolling-coefficient", "Rolling resistance coefficient", spec.rolling_coefficient, "1", SOURCE_CORRELATED, "standing-start value; ISO 28580 class for a low-rolling-resistance EV tyre"),
parameter_row(spec.part_id, "rolling-speed-coefficient", "Rolling resistance speed term", spec.rolling_speed_coefficient, "s^2/m^2", SOURCE_CORRELATED, "quadratic speed growth from carcass hysteresis"),
parameter_row(spec.part_id, "carcass-thickness", "Carcass thickness", spec.carcass_thickness_m, "m", SOURCE_ASSUMED, "mean rubber and belt thickness used for the reconciled tyre mass"),
parameter_row(spec.part_id, "carcass-density", "Carcass density", spec.carcass_density_kg_m3, "kg/m^3", SOURCE_ASSUMED, "rubber, steel belt and textile composite"),
parameter_row(pack.part_id, "module-count", "Module count", f64(pack.module_count), "1", SOURCE_ASSUMED, "checked against the declared part instance count"),
parameter_row(pack.part_id, "series-per-module", "Cells in series per module", f64(pack.series_per_module), "1", SOURCE_ASSUMED, "24 modules of 9 in series make the 216s string"),
parameter_row(pack.part_id, "parallel-per-module", "Cells in parallel per module", f64(pack.parallel_per_module), "1", SOURCE_ASSUMED, "sets pack capacity and divides the cell resistance"),
parameter_row(pack.part_id, "cell-nominal-voltage", "Cell nominal voltage", pack.cell_nominal_voltage_v, "V", SOURCE_ASSUMED, "prismatic nickel-manganese-cobalt chemistry"),
parameter_row(pack.part_id, "cell-capacity", "Cell capacity", pack.cell_capacity_ah, "Ah", SOURCE_ASSUMED, "with the series and parallel counts this is the whole pack energy"),
parameter_row(pack.part_id, "cell-resistance", "Cell internal resistance", pack.cell_resistance_ohm, "ohm", SOURCE_CORRELATED, "DC internal resistance of a power-oriented prismatic cell at 25 degrees"),
parameter_row(pack.part_id, "cell-specific-energy", "Cell specific energy", pack.cell_specific_energy_wh_kg, "Wh/kg", SOURCE_CORRELATED, "cell-level energy density; with the packaging factor it is the reconciled module mass"),
parameter_row(pack.part_id, "module-packaging-factor", "Module packaging factor", pack.module_packaging_factor, "1", SOURCE_ASSUMED, "busbars, frame, cooling plate and sensing over bare cell mass"),
parameter_row(pack.part_id, "module-interconnect-resistance", "Module interconnect resistance", pack.module_interconnect_resistance_ohm, "ohm", SOURCE_ASSUMED, "inter-module busbar and joint resistance"),
parameter_row(pack.part_id, "pack-bus-resistance", "Pack bus resistance", pack.pack_bus_resistance_ohm, "ohm", SOURCE_ASSUMED, "contactors, fuse and pack terminals"),
parameter_row(pack.part_id, "charge-c-rate", "Cell pulse charge rate", pack.cell_pulse_charge_c_rate, "1/h", SOURCE_CORRELATED, "regenerative charge acceptance for a bounded pulse; caps the derived regenerative power"),
parameter_row(pack.part_id, "discharge-c-rate", "Cell pulse discharge rate", pack.cell_pulse_discharge_c_rate, "1/h", SOURCE_CORRELATED, "peak discharge pulse the cell will take"),
parameter_row(gearbox.part_id, "stage-1-pinion", "First stage pinion pitch diameter", gearbox.first.pinion_pitch_diameter_m, "m", SOURCE_ASSUMED, "with the wheel diameter this is the 3.5:1 first stage"),
parameter_row(gearbox.part_id, "stage-1-wheel", "First stage wheel pitch diameter", gearbox.first.wheel_pitch_diameter_m, "m", SOURCE_ASSUMED, "first reduction wheel"),
parameter_row(gearbox.part_id, "stage-1-face", "First stage face width", gearbox.first.face_width_m, "m", SOURCE_ASSUMED, "helical face width; enters the reconciled gearbox mass only"),
parameter_row(gearbox.part_id, "stage-2-pinion", "Second stage pinion pitch diameter", gearbox.second.pinion_pitch_diameter_m, "m", SOURCE_ASSUMED, "with the wheel diameter this is the 2.6:1 second stage"),
parameter_row(gearbox.part_id, "stage-2-wheel", "Second stage wheel pitch diameter", gearbox.second.wheel_pitch_diameter_m, "m", SOURCE_ASSUMED, "output wheel on the differential carrier"),
parameter_row(gearbox.part_id, "stage-2-face", "Second stage face width", gearbox.second.face_width_m, "m", SOURCE_ASSUMED, "helical face width; enters the reconciled gearbox mass only"),
parameter_row(gearbox.part_id, "mesh-efficiency", "Mesh efficiency per stage", GEAR_MESH_EFFICIENCY, "1", SOURCE_CORRELATED, "loaded helical mesh, churning and bearing drag; applied once per stage"),
parameter_row(gearbox.part_id, "blank-web-factor", "Gear blank web factor", GEAR_BLANK_WEB_FACTOR, "1", SOURCE_ASSUMED, "webbed blank mass over the solid disc of the same pitch diameter"),
parameter_row(gearbox.part_id, "housing-mass-factor", "Gearbox housing mass factor", GEARBOX_HOUSING_MASS_FACTOR, "1", SOURCE_ASSUMED, "casing, shafts, bearings, oil and differential over the gear blanks"),
]
def hardware_parameters():
"""The whole provenance ledger, built from the specification structs so it cannot drift."""
return (motor_parameters(front_motor_spec())
+ motor_parameters(rear_motor_spec())
+ inverter_parameters(front_inverter_spec())
+ inverter_parameters(rear_inverter_spec())
+ brake_parameters(front_brake_spec())
+ brake_parameters(rear_brake_spec())
+ shared_parameters())
def motor_export(limits: MotorLimits):
return {
"part_id": limits.part_id,
"inverter_part_id": limits.inverter_part_id,
"effective_peak_current_a": limits.effective_peak_current_a,
"current_limited_by": limits.current_limited_by,
"peak_torque_nm": limits.peak_torque_nm,
"continuous_torque_nm": limits.continuous_torque_nm,
"base_speed_rpm": limits.base_speed_rpm,
"max_speed_rpm": limits.max_speed_rpm,
"constant_power_ratio": limits.constant_power_ratio,
"corner_power_kw": limits.corner_power_kw,
"power_factor": limits.power_factor,
"inverter_apparent_power_kva": limits.inverter_apparent_power_kva,
"inverter_peak_power_kw": limits.inverter_peak_power_kw,
"inverter_loss_kw": limits.inverter_loss_kw,
"dc_link_peak_current_a": limits.dc_link_peak_current_a,
"airgap_shear_stress_kpa": limits.airgap_shear_stress_kpa,
}
def brake_export(limits: BrakeAxleLimits):
return {
"part_id": limits.part_id,
"axle": limits.axle,
"effective_radius_m": limits.effective_radius_m,
"piston_area_m2": limits.piston_area_m2,
"clamp_force_n": limits.clamp_force_n,
"corner_torque_nm": limits.corner_torque_nm,
"axle_force_n": limits.axle_force_n,
"disc_mass_kg": limits.disc_mass_kg,
"disc_thermal_capacity_j_k": limits.disc_thermal_capacity_j_k,
}
def reconciliation_export(row: PartReconciliation):
return {
"part_id": row.part_id,
"resolved": row.resolved,
"declared_count": row.declared_count,
"expected_count": row.expected_count,
"declared_mass_kg": row.declared_mass_kg,
"derived_mass_kg": row.derived_mass_kg,
"mass_error_fraction": row.mass_error_fraction,
"tolerance_fraction": row.tolerance_fraction,
"derivation": row.derivation,
"passed": row.passed,
}
pub def hardware_export() -> dict[str, any] !{}:
"""API payload: the derived limits, the declared parameters behind them, and the reconciliation."""
limits = hardware_limits()
tire = limits.tire
pack = limits.pack
return {
"schema": "sema.circuitframe-hardware/v1",
"authority": "engineered component assumptions for a reconstruction; no dynamometer, pressure-rig, tyre-rig or cell-cycling evidence exists for any value here",
"method": "every performance limit is derived from the declared parameters of a part that already exists in vehicle.sema, and every specification is reconciled against that part's declared instance count and mass",
"front_motor": motor_export(limits.front_motor),
"rear_motor": motor_export(limits.rear_motor),
"front_brake": brake_export(limits.front_brake),
"rear_brake": brake_export(limits.rear_brake),
"tire": {
"part_id": tire.part_id,
"size": "255/45 R21",
"unloaded_radius_m": tire.unloaded_radius_m,
"rolling_radius_m": tire.rolling_radius_m,
"rated_load_n": tire.rated_load_n,
"friction_reference_load_n": tire.friction_reference_load_n,
"peak_friction": tire.peak_friction,
"slide_friction": tire.slide_friction,
"load_sensitivity": tire.load_sensitivity,
"cornering_stiffness_per_n": tire.cornering_stiffness_per_n,
"rolling_coefficient": tire.rolling_coefficient,
"rolling_speed_coefficient": tire.rolling_speed_coefficient,
},
"pack": {
"part_id": pack.part_id,
"cell_count": pack.cell_count,
"series_count": pack.series_count,
"parallel_count": pack.parallel_count,
"energy_kwh": pack.energy_kwh,
"nominal_voltage_v": pack.nominal_voltage_v,
"resistance_ohm": pack.resistance_ohm,
"charge_power_kw": pack.charge_power_kw,
"discharge_power_kw": pack.discharge_power_kw,
"mass_kg": pack.mass_kg,
},
"driveline": {
"part_id": "reduction-gears",
"final_drive_ratio": limits.final_drive_ratio,
"gearbox_efficiency": limits.gearbox_efficiency,
"motor_efficiency": limits.motor_efficiency,
"inverter_efficiency": limits.inverter_efficiency,
"top_speed_mps": limits.driveline_top_speed_mps,
},
"limits": {
"max_motor_torque_nm": limits.max_motor_torque_nm,
"max_drive_power_kw": limits.max_drive_power_kw,
"max_brake_force_n": limits.max_brake_force_n,
"max_regenerative_force_n": limits.max_regenerative_force_n,
"max_regenerative_power_kw": limits.max_regenerative_power_kw,
"regenerative_front_share": limits.regenerative_front_share,
"max_line_pressure_pa": limits.max_line_pressure_pa,
"pad_friction": limits.pad_friction,
"fade_onset_temp_c": limits.fade_onset_temp_c,
"fade_rate_per_k": limits.fade_rate_per_k,
"fade_floor_fraction": limits.fade_floor_fraction,
},
"chain": {
"drive": "front-motor + rear-motor peak shaft torque at the effective peak current -> reduction-gears final drive and mesh efficiency -> tires rolling radius -> wheel force, held above the machine's own base speed to its corner shaft power and bounded by that axle's tyre friction circle. Where the inverter's current rating is below the winding rating it is the inverter that set the torque, and `current_limited_by` names which one did",
"brake": "pedal demand -> brakes line pressure -> caliper clamp force -> corner torque at the derived effective radius -> tires rolling radius -> axle force, then bounded by the tyre friction circle on that axle's live vertical load",
"energy": "battery-modules cells -> pack energy, nominal voltage and internal resistance -> terminal power and regenerative charge acceptance",
},
"parameters": hardware_parameters(),
"reconciliation": [reconciliation_export(row) for row in part_reconciliation()],
"scientific_validated": false,
}
pub def hardware_equations() -> list[dict[str, str]] !{}:
"""Manifest rows for the hardware model, in the same shape as physics.sema:dynamics_equations()."""
return [
{"id": "ipm-torque", "source": "ipm_shaft_torque", "expression": "T = 3/2 p I cos(gamma) (lambda_m + (L_q - L_d) I sin(gamma))", "unit": "N.m", "owner": "Sema interior-permanent-magnet machine on declared winding parameters (solved on assumed inputs)"},
{"id": "stator-flux", "source": "stator_flux_linkage", "expression": "psi_s = sqrt((lambda_m - L_d I sin gamma)^2 + (L_q I cos gamma)^2)", "unit": "Wb", "owner": "Sema machine flux at the peak-torque operating point (solved)"},
{"id": "base-speed", "source": "voltage_limited_speed", "expression": "omega_m = V_dc / (sqrt(3) psi_s p)", "unit": "rad/s", "owner": "Sema space-vector voltage ceiling; start of the constant-power region (solved)"},
{"id": "inverter-power", "source": "motor_limits", "expression": "P_inv = 3/2 (V_dc/sqrt3) I cos(phi) - 6 f_sw k_sw I - 3 (I/sqrt2)^2 R_on", "unit": "kW", "owner": "Sema inverter apparent power at the derived corner power factor, less switching and conduction loss (solved on correlated loss coefficients)"},
{"id": "airgap-shear", "source": "airgap_shear_stress", "expression": "sigma = T / (2 pi r_rotor^2 l_stack)", "unit": "Pa", "owner": "Sema machine sizing check; a credible liquid-cooled peak is 40-55 kPa (correlated)"},
{"id": "brake-effective-radius", "source": "uniform_pressure_radius", "expression": "r_eff = 2/3 (r_o^3 - r_i^3) / (r_o^2 - r_i^2)", "unit": "m", "owner": "Sema uniform-pressure pad sweep (solved)"},
{"id": "brake-clamp", "source": "caliper_clamp_force", "expression": "F_clamp = p_line n_piston pi (d_bore/2)^2", "unit": "N", "owner": "Sema hydraulic caliper (exact)"},
{"id": "brake-torque", "source": "corner_brake_torque", "expression": "T_corner = 2 mu_pad F_clamp r_eff", "unit": "N.m", "owner": "Sema friction brake, two pads per disc (exact on a correlated pad friction)"},
{"id": "brake-fade", "source": "brake_fade_factor", "expression": "mu(T) = max(f_floor, 1 - k_fade max(0, T_disc - T_onset)) mu_pad", "unit": "1", "owner": "Sema pad fade curve above the declared onset temperature (correlated)"},
{"id": "disc-thermal-mass", "source": "brake_axle_limits", "expression": "C_disc = rho_iron pi (r_o^2 - r_hat^2) t phi_vent c_iron", "unit": "J/K", "owner": "Sema vented swept-volume disc thermal mass (solved on assumed geometry)"},
{"id": "tire-load-sensitivity", "source": "tire_friction", "expression": "mu(F_z) = clamp(mu_peak (1 - k_load (F_z/F_ref - 1)), mu_slide, mu_peak)", "unit": "1", "owner": "Sema tyre load sensitivity; this is why added mass buys less grip than it costs (correlated)"},
{"id": "tire-rolling", "source": "rolling_coefficient", "expression": "f_r(v) = f_0 + f_2 v^2", "unit": "1", "owner": "Sema rolling resistance with a quadratic speed term (correlated)"},
{"id": "tire-radius", "source": "tire_limits", "expression": "r_roll = k_roll (d_rim/2 + w_section a_ratio)", "unit": "m", "owner": "Sema moulded tyre size to loaded rolling radius (solved on a correlated deflection factor)"},
{"id": "pack-topology", "source": "pack_limits", "expression": "E = n_s n_p V_cell C_cell; V = n_s V_cell; R = n_s R_cell / n_p + n_mod R_link + R_bus", "unit": "kWh, V, ohm", "owner": "Sema series-parallel cell summation over the declared modules (solved)"},
{"id": "pack-mass-closure", "source": "reconcile_part", "expression": "|m_derived - m_declared| / m_declared <= tol", "unit": "1", "owner": "Sema specification-to-part reconciliation; fails the build on drift (solved)"},
{"id": "final-drive", "source": "final_drive_ratio", "expression": "i = (d_w1/d_p1) (d_w2/d_p2)", "unit": "1", "owner": "Sema two-stage helical reduction from the declared pitch diameters (exact)"},
]
test "every hardware specification resolves to a declared part with the declared count and mass":
rows = part_reconciliation()
ensure len(rows) == 8
for row in rows:
ensure row.resolved
ensure row.declared_count == row.expected_count
ensure row.mass_error_fraction <= row.tolerance_fraction
ensure row.passed
ensure declared_part("battery-modules").count == 24
ensure declared_part("brakes").count == 4
ensure declared_part("tires").count == 4
ensure declared_part("reduction-gears").count == 2
test "derived pack energy, nominal voltage and mass land on the declared pack":
pack = pack_limits()
ensure pack.cell_count == 432 and pack.series_count == 216 and pack.parallel_count == 2
ensure pack.energy_kwh > 91.0 and pack.energy_kwh < 93.0
ensure pack.nominal_voltage_v > 760.0 and pack.nominal_voltage_v < 840.0
ensure abs(pack.mass_kg - 438.0) < 0.02 * 438.0
ensure pack.resistance_ohm > 0.0 and pack.resistance_ohm < 0.15
ensure pack.charge_power_kw > 150.0 and pack.charge_power_kw < 220.0
ensure pack.discharge_power_kw > pack.charge_power_kw
test "the declared reduction stages make the 9.1 final drive and a credible tyre radius":
limits = hardware_limits()
ensure abs(limits.final_drive_ratio - 9.1) < 0.000000001
ensure abs(limits.tire.rolling_radius_m - 0.3695) < 0.005
ensure limits.tire.rolling_radius_m < limits.tire.unloaded_radius_m
ensure limits.driveline_top_speed_mps > 55.0 and limits.driveline_top_speed_mps < 90.0
test "each machine runs at the lower of its own winding limit and its inverter rating":
limits = hardware_limits()
ensure limits.front_motor.current_limited_by == "motor-winding"
ensure limits.rear_motor.current_limited_by == "inverter-rating"
ensure limits.rear_motor.effective_peak_current_a == rear_inverter_spec().peak_phase_current_a
ensure limits.front_motor.effective_peak_current_a == front_motor_spec().peak_phase_current_a
ensure limits.rear_motor.peak_torque_nm > limits.front_motor.peak_torque_nm
for motor in [limits.front_motor, limits.rear_motor]:
ensure motor.airgap_shear_stress_kpa > 30.0 and motor.airgap_shear_stress_kpa < 60.0
ensure motor.constant_power_ratio > 2.5 and motor.constant_power_ratio < 5.0
ensure motor.base_speed_rpm > 3000.0 and motor.base_speed_rpm < 6500.0
ensure motor.power_factor > 0.8
ensure motor.dc_link_peak_current_a < 520.0
test "the derived limits reproduce the literals they replace":
limits = hardware_limits()
ensure abs(limits.max_motor_torque_nm - 660.0) < 0.02 * 660.0
ensure abs(limits.max_drive_power_kw - 320.0) < 0.06 * 320.0
ensure abs(limits.pack.energy_kwh - 92.0) < 0.02 * 92.0
ensure limits.max_regenerative_force_n > 5000.0 and limits.max_regenerative_force_n < 7000.0
# The 180 kW regenerative literal is what the declared cells will accept, and the pack lands on
# it. The machines cannot deliver it: two liquid-cooled stators generate 133 kW at their
# continuous rating, so the recovery limit is the drive units, not the pack.
ensure abs(limits.pack.charge_power_kw - 180.0) < 0.06 * 180.0
ensure limits.max_regenerative_power_kw < limits.pack.charge_power_kw
ensure limits.max_regenerative_power_kw > 110.0 and limits.max_regenerative_power_kw < 160.0
# The brake force is the one limit that must not reproduce its literal: 14.5 kN was below the
# tyre limit at kerb mass, which would make every stop brake-bound. A real system is sized above
# the contact patch it feeds.
ensure limits.max_brake_force_n > 25000.0
test "the provenance ledger covers every specification and carries only known source tags":
rows = hardware_parameters()
ensure len(rows) >= 80
mut seen = []
for row in rows:
ensure row["source"] == SOURCE_SOLVED or row["source"] == SOURCE_CORRELATED or row["source"] == SOURCE_ASSUMED
ensure len(str(row["note"])) > 0
ensure not contains_id(seen, str(row["id"]))
seen = seen + [str(row["id"])]
for part_id in ["front-motor", "rear-motor", "front-inverter", "rear-inverter", "brakes", "tires", "battery-modules", "reduction-gears"]:
ensure ledger_covers(rows, part_id)
def contains_id(seen: list[str], value: str):
for item in seen:
if item == value:
return true
return false
def ledger_covers(rows: list[dict[str, any]], part_id: str):
for row in rows:
if row["part_id"] == part_id:
return true
return false
test "the ledger reports the same numbers the specification structs carry":
rows = hardware_parameters()
motor = rear_motor_spec()
pack = pack_spec()
mut checked = 0
for row in rows:
if row["id"] == "rear-motor.magnet-flux-linkage":
ensure row["value"] == motor.magnet_flux_linkage_wb
checked = checked + 1
if row["id"] == "rear-motor.peak-phase-current":
ensure row["value"] == motor.peak_phase_current_a
checked = checked + 1
if row["id"] == "battery-modules.cell-capacity":
ensure row["value"] == pack.cell_capacity_ah
checked = checked + 1
if row["id"] == "brakes.front.piston-bore":
ensure row["value"] == front_brake_spec().piston_bore_m
checked = checked + 1
ensure checked == 4
test "the export carries the derived limits, the parameters and the reconciliation":
payload = hardware_export()
ensure payload["schema"] == "sema.circuitframe-hardware/v1"
ensure len(payload["reconciliation"]) == 8
ensure len(payload["parameters"]) >= 80
ensure payload["limits"]["max_motor_torque_nm"] > 600.0
ensure payload["front_motor"]["current_limited_by"] == "motor-winding"
ensure payload["rear_motor"]["current_limited_by"] == "inverter-rating"
for row in payload["reconciliation"]:
ensure row["passed"] and row["resolved"]
rows = hardware_equations()
ensure len(rows) >= 12
for row in rows:
ensure len(row["id"]) > 0 and len(row["source"]) > 0 and len(row["expression"]) > 0 and len(row["unit"]) > 0 and len(row["owner"]) > 0
test "tyre friction falls as the corner it carries is loaded":
tire = tire_limits()
light = tire_friction(tire.peak_friction, tire.slide_friction, tire.load_sensitivity, tire.friction_reference_load_n, 0.5 * tire.friction_reference_load_n)
reference = tire_friction(tire.peak_friction, tire.slide_friction, tire.load_sensitivity, tire.friction_reference_load_n, tire.friction_reference_load_n)
heavy = tire_friction(tire.peak_friction, tire.slide_friction, tire.load_sensitivity, tire.friction_reference_load_n, 2.0 * tire.friction_reference_load_n)
ensure abs(reference - tire.peak_friction) < 0.000000001
ensure light == tire.peak_friction
ensure heavy < reference
ensure heavy >= tire.slide_friction
ensure tire_friction(tire.peak_friction, tire.slide_friction, tire.load_sensitivity, tire.friction_reference_load_n, 20.0 * tire.friction_reference_load_n) == tire.slide_friction
test "pad friction is unaffected below the fade onset and degrades above it":
limits = hardware_limits()
cold = brake_fade_factor(limits.fade_onset_temp_c - 100.0, limits.fade_onset_temp_c, limits.fade_rate_per_k, limits.fade_floor_fraction)
warm = brake_fade_factor(limits.fade_onset_temp_c + 200.0, limits.fade_onset_temp_c, limits.fade_rate_per_k, limits.fade_floor_fraction)
cooked = brake_fade_factor(limits.fade_onset_temp_c + 2000.0, limits.fade_onset_temp_c, limits.fade_rate_per_k, limits.fade_floor_fraction)
ensure cold == 1.0
ensure warm < 1.0 and warm > limits.fade_floor_fraction
ensure cooked == limits.fade_floor_fraction
"""Automotive-native Sema HTTP service for CircuitFrame Lab.
Everything expensive is solved once when the service starts: the source-panel aerodynamic basis, both
reduced-order surrogates, the mass and joint budget, and the four printed-circuit boards with their DC
nodal solutions. The 20 Hz drive loop and the on-demand wind-tunnel measurement then reuse those caches.
"""
import http
import math
from std.adaptive_dynamics import RegimeStatus, append_bounded
from std.json import decode as decode_json, encode as encode_json
from magna_ev_digital_twin.adaptation import evaluate_yaw_candidate, initial_aero_detector, regime_label, retained_adaptation, update_aero_detector
from magna_ev_digital_twin.aero import aero_basis_export, aero_equations, aero_solve
from magna_ev_digital_twin.assembly import assembly_equations, assembly_export
from magna_ev_digital_twin.hardware import hardware_equations, hardware_export
from magna_ev_digital_twin.authoring import compile_circuit_description
from magna_ev_digital_twin.candidate import optimize_panel
from magna_ev_digital_twin.pcb import pcb_equations, pcb_export
from magna_ev_digital_twin.physics import ControlInput, PhysicsContext, aero_holdout_metrics, dynamics_equations, initial_vehicle_state, physics_context, step_vehicle
from magna_ev_digital_twin.vehicle import source_gate, vehicle_manifest, vehicle_net_comparison
assure silver
MAX_WIND_MPS = 60.0
AERO_PROVENANCE = {
"cd": "solved+correlated",
"cl": "solved+correlated",
"cy": "solved+correlated",
"cmz": "solved+correlated",
"cd_pressure": "solved topology + correlated base pressure",
"cd_base": "solved topology + correlated base pressure",
"cd_friction": "correlated",
"cd_wheels": "correlated",
"drag_n": "solved+correlated",
"lift_n": "solved+correlated",
"side_n": "solved+correlated",
"yaw_moment_nm": "solved+correlated",
"front_lift_n": "solved+correlated",
"rear_lift_n": "solved+correlated",
"dynamic_pressure_pa": "exact",
"reynolds_number": "exact",
"separation_x_m": "solved",
"cp_min": "solved",
"cp_max": "solved",
"base_pressure_coefficient": "correlated",
"wake_width_m": "solved",
"wake_deficit_fraction": "correlated",
"wake_shedding_hz": "correlated",
"turbulence_intensity": "correlated",
"wake_recirculation_length_m": "correlated",
"dalembert_residual_cd": "numerical",
"solver_residual": "numerical",
"force_closure_residual": "numerical",
}
AERO_METHOD = "3D constant-strength source-panel solve with an image ground plane, closed by an empirical viscous and base-pressure model; not a governing CFD field"
RESIDUAL_HISTORY_CAPACITY = 120
SWEEP_STEPS = 200
SWEEP_RAMP_STEPS = 40
SWEEP_TARGET_SIDESLIP_DEG = 25.0
SWEEP_MIN_WIND_MPS = 6.0
SWEEP_MAX_WIND_MPS = 30.0
# The candidate's declared validity region, mirrored from `evaluate_yaw_candidate` in adaptation.sema
# so the viewer can state the limits before a demonstration runs instead of hard-coding them.
# `the declared validity region is published and enforced` pins the two together.
ENVELOPE_MAX_SPEED_MPS = 55.0
ENVELOPE_MAX_SIDESLIP_DEG = 35.0
CALM_MODEL_ID = "aero-rom-calm-v1"
SAMPLED_EQUATION_IDS = ["surrogate-fit", "mass-budget", "panel-neumann", "panel-influence", "panel-ground-image", "centre-of-mass", "parallel-axis", "axle-load-split", "four-bar-closure", "motion-ratio", "air-spring", "bump-steer", "ipm-torque", "stator-flux", "base-speed", "inverter-power", "airgap-shear", "brake-effective-radius", "brake-clamp", "brake-torque", "disc-thermal-mass", "tire-radius", "pack-topology", "pack-mass-closure", "final-drive"]
PER_STEP_EQUATION_IDS = ["thermal-network"]
THERMAL_EQUATION_IDS = ["thermal-network", "thermal-tire", "thermal-stability"]
def response(status: int, body: any) !{}:
return {
"status": status,
"content_type": "application/json",
"headers": {"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
"body": encode_json(body),
}
def error_response(status: int, code: str, detail: str) !{}:
return response(status, {"schema": "sema.circuitframe-error/v1", "error": code, "detail": detail})
def physical_actions_allowed():
return source_gate()["physical_completeness"]
def evidence_export_allowed():
return source_gate()["presentation_qualified"]
def initial_session(context: PhysicsContext):
detector = initial_aero_detector()
return {
"generation": 0,
"sequence": 0,
"state_version": 0,
"state": initial_vehicle_state(),
"frame": None,
"detector": detector,
"model_id": "aero-rom-calm-v1",
"adaptation": retained_adaptation(detector, "aero-rom-calm-v1"),
"candidate": None,
"panel_width_mm": 90.0,
"vehicle_revision": 1,
"assembly_step": 11,
"configuration_variant": "circuitframe",
"context": context,
"aero_basis_payload": None,
"pcb_payload": None,
"assembly_payload": None,
"hardware_payload": None,
"residual_history": initial_residual_history(),
"sweep": idle_sweep(),
}
def cached_aero_basis(session: dict[str, any]):
if session["aero_basis_payload"] is None:
session["aero_basis_payload"] = aero_basis_export(session["context"].basis)
return session["aero_basis_payload"]
def cached_pcb(session: dict[str, any]):
if session["pcb_payload"] is None:
session["pcb_payload"] = pcb_export()
return session["pcb_payload"]
def cached_assembly(session: dict[str, any]):
if session["assembly_payload"] is None:
session["assembly_payload"] = assembly_export()
return session["assembly_payload"]
def cached_hardware(session: dict[str, any]):
"""The derived component limits and their reconciliation. Solved once with the context."""
if session["hardware_payload"] is None:
session["hardware_payload"] = hardware_export()
return session["hardware_payload"]
def progress_report():
return [
{"id": "foundation", "label": "Sema automotive core", "percent": 100, "state": "implemented"},
{"id": "vehicle", "label": "Evidence-aware EV reconstruction", "percent": 88, "state": "attributed exterior with separated wheel surfaces plus 737-feature FreeCAD/OCCT mechanical assembly, source-measured wheel/body clearances, wheelhouse-cut chassis, axle-bounded battery packaging, mounted controllers, wheel/occupant route collision gates, declared-part semantic ledger, and source-gated browser LODs; OEM manufacturing authority and validation remain open"},
{"id": "circuit", "label": "Conventional / CircuitFrame E/E", "percent": 78, "state": "four reconstructed boards with pin-level nets, layer stackups, routed copper, vias and DC modified-nodal-analysis solutions, IPC-2221 conductor sizing, IEC 60664-1 creepage gates, generated netlists, and a quantified harness-versus-structural-conductor comparison over the same nine vehicle nets; packaging, EMC, shielding and physical validation remain open"},
{"id": "motion", "label": "Coupled vehicle dynamics", "percent": 72, "state": "live longitudinal, lateral, yaw, tire, equivalent-circuit battery and eight-node coupled thermal network driven by mass, centre of gravity and yaw inertia summed from the declared part placements; production parameterisation and correlation absent"},
{"id": "flow", "label": "Aerodynamics", "percent": 74, "state": "240-panel constant-strength source solve with an image ground plane, two exact freestream modes, Stratford separation walks and a calibrated base-pressure closure produce yaw-dependent drag, lift, side force and yaw moment; the browser advects smoke through the same solved singularity field. Governing CFD, wind-tunnel correlation and transient wake resolution remain absent"},
{"id": "build", "label": "Manufacturing and robot sequence", "percent": 40, "state": "complete canonical assembly by default with operation scrubbing, named parametric robot/cell geometry, and declared-operation replay; reach, collision, tooling, and process feasibility remain unvalidated"},
{"id": "experience", "label": "Premium web experience", "percent": 94, "state": "attributed metallic exterior, source-gated mechanical LODs, engineering cutaways, model-matched wheel kinematics, configuration comparison, wind-tunnel smoke and surface-pressure modes, live multiphysics, and evidence-aware inspection"},
{"id": "handoff", "label": "Engineering export", "percent": 58, "state": "source ledger, evidence manifest, selected-configuration GLB, FCStd/STEP engineering reconstruction, generated board netlists, and semantic BOM/BOP declarations available; receiving-tool reconciliation and FMI remain unavailable"},
]
def contains(values: list[str], value: str):
for item in values:
if item == value:
return true
return false
def equation_phase(id: str, live: bool):
"""Where a body is evaluated: inside every 20 Hz step, once at startup, or only when asked."""
if contains(SAMPLED_EQUATION_IDS, id):
return "sampled"
if contains(PER_STEP_EQUATION_IDS, id):
return "per_step"
return "on_demand" if not live else "per_step"
def grouped_equations(rows: list[dict[str, str]], group: str, live: bool):
"""Tag each declared equation with the subsystem that owns it and when its body runs."""
return [{"id": row["id"], "source": row["source"], "expression": row["expression"], "unit": row["unit"], "owner": row["owner"], "group": "thermal network" if contains(THERMAL_EQUATION_IDS, row["id"]) else group, "phase": equation_phase(row["id"], live)} for row in rows]
def initial_residual_history():
return {"residual": [], "score": [], "count": 0, "last_sequence": 0, "activated_at_sequence": 0}
def pushed_residual_history(history: dict[str, any], residual: f64, score: f64, sequence: int, activated: bool) !{}:
"""Bounded ring of the surrogate-versus-solved residual and the detector score that tracks it."""
return {
"residual": append_bounded(history["residual"], residual, RESIDUAL_HISTORY_CAPACITY),
"score": append_bounded(history["score"], score, RESIDUAL_HISTORY_CAPACITY),
"count": history["count"] + 1,
"last_sequence": sequence,
"activated_at_sequence": sequence if activated else history["activated_at_sequence"],
}
def idle_sweep():
return {"active": false, "index": 0, "commanded_wind_mps": 0.0, "commanded_yaw_deg": 0.0, "restarted_from_calm": false}
def sweep_command(index: int, speed_mps: f64):
"""Wind speed and bearing that put the apparent wind at the scripted sideslip. Returns [m/s, deg].
The demonstration commands a sideslip, not a wind. A fixed crosswind that walks a parked car
clear of the calm model's window barely moves the apparent angle at motorway speed, so a fixed
wind would quietly stop demonstrating anything above about 30 m/s. Solving the wind vector for
the target angle keeps it meaningful at any road speed, and both commanded values are published
every step so nothing about the operating point is hidden.
"""
target_rad = SWEEP_TARGET_SIDESLIP_DEG * min(1.0, f64(index) / f64(SWEEP_RAMP_STEPS)) * math.pi / 180.0
lateral_demand = max(0.0, speed_mps) * math.sin(target_rad)
wind = max(SWEEP_MIN_WIND_MPS, min(SWEEP_MAX_WIND_MPS, lateral_demand / 0.8))
bearing = target_rad + math.asin(min(1.0, lateral_demand / wind))
return [wind, bearing * 180.0 / math.pi]
def advanced_sweep(sweep: dict[str, any], controls: ControlInput):
if not sweep["active"]:
return sweep
index = sweep["index"] + 1
return {"active": index < SWEEP_STEPS, "index": index, "commanded_wind_mps": controls.wind_mps, "commanded_yaw_deg": controls.wind_yaw_deg, "restarted_from_calm": sweep["restarted_from_calm"]}
def detector_body(session: dict[str, any]):
detector = session["detector"]
return {
"score": detector.score,
"threshold": detector.score_threshold,
"status": regime_label(detector),
"consecutive_high": detector.consecutive_high,
"required_consecutive": detector.required_consecutive,
"window_size": detector.window_size,
"min_samples": detector.min_samples,
"baseline_mean": detector.baseline_mean,
"baseline_scale": detector.baseline_scale,
}
def residual_history_body(session: dict[str, any]):
"""The whole ring, served on status only; a step carries one new sample and the client appends."""
history = session["residual_history"]
return {
"capacity": RESIDUAL_HISTORY_CAPACITY,
"count": history["count"],
"last_sequence": history["last_sequence"],
"activated_at_sequence": history["activated_at_sequence"],
"threshold": session["detector"].score_threshold,
"residual": history["residual"],
"score": history["score"],
}
def sweep_body(session: dict[str, any]):
sweep = session["sweep"]
return {
"kind": "scripted demonstration: a bounded ramp that walks the apparent-wind sideslip out of the calm surrogate's fitted window and holds it there. The detector, the validation gate and any activation that follow are the ordinary live lifecycle, not a replay",
"active": sweep["active"],
"step_index": sweep["index"],
"total_steps": SWEEP_STEPS,
"remaining_steps": max(0, SWEEP_STEPS - sweep["index"]),
"ramp_steps": SWEEP_RAMP_STEPS,
"target_sideslip_deg": SWEEP_TARGET_SIDESLIP_DEG,
"min_wind_mps": SWEEP_MIN_WIND_MPS,
"max_wind_mps": SWEEP_MAX_WIND_MPS,
"envelope_max_speed_mps": ENVELOPE_MAX_SPEED_MPS,
"envelope_max_sideslip_deg": ENVELOPE_MAX_SIDESLIP_DEG,
"drives": ["wind_mps", "wind_yaw_deg"],
"commanded_wind_mps": sweep["commanded_wind_mps"],
"commanded_yaw_deg": sweep["commanded_yaw_deg"],
"restarted_from_calm": sweep["restarted_from_calm"],
}
def all_equations():
return (grouped_equations(dynamics_equations(), "dynamics", true)
+ grouped_equations(aero_equations(), "aero panel solve", true)
+ grouped_equations(pcb_equations(), "board analysis", false)
+ grouped_equations(assembly_equations(), "assembly", false)
+ grouped_equations(hardware_equations(), "declared hardware", false))
def status_body(session: dict[str, any]):
context = session["context"]
parameters = context.parameters
return {
"schema": "sema.circuitframe-live-status/v1",
"backend": "Sema",
"configuration_id": "circuitframe_gt_01",
"configuration_variant": session["configuration_variant"],
"configuration_revision": session["vehicle_revision"],
"generation": session["generation"],
"sequence": session["sequence"],
"state_version": session["state_version"],
"update_hz": 20,
"state": session["state"],
"frame": session["frame"],
"model_id": session["model_id"],
"adaptation": session["adaptation"],
"detector": detector_body(session),
"residual_history": residual_history_body(session),
"sweep": sweep_body(session),
"equations": all_equations(),
"vehicle": vehicle_manifest(session["configuration_variant"]),
"vehicle_nets": vehicle_net_comparison(),
"panel": {"id": "sill-panel-left", "width_mm": session["panel_width_mm"], "revision": session["vehicle_revision"], "fidelity": "derived"},
"assembly_step": session["assembly_step"],
"progress": progress_report(),
"mass_properties": {
"total_mass_kg": parameters.mass_kg,
"cg_to_front_axle_m": parameters.cg_to_front_axle_m,
"cg_to_rear_axle_m": parameters.cg_to_rear_axle_m,
"cg_height_m": parameters.cg_height_m,
"yaw_inertia_kg_m2": parameters.yaw_inertia_kg_m2,
"wheelbase_m": parameters.wheelbase_m,
"track_m": parameters.track_m,
"front_mass_fraction": parameters.front_mass_fraction,
"source": "mass, centre of gravity and yaw inertia summed from the declared part placements in assembly.sema; track read from the declared suspension hardpoints",
},
"hardware": cached_hardware(session),
"surrogates": {
"active": {"id": context.calm.model_id, "fit_max_yaw_deg": context.calm.fit_max_yaw_rad * 180.0 / 3.141592653589793, "fit_nodes": context.calm.fit_nodes, "fit_rms_cd": context.calm.fit_rms_cd},
"candidate": {"id": context.crosswind.model_id, "fit_max_yaw_deg": context.crosswind.fit_max_yaw_rad * 180.0 / 3.141592653589793, "fit_nodes": context.crosswind.fit_nodes, "fit_rms_cd": context.crosswind.fit_rms_cd},
"holdout": aero_holdout_metrics(context),
},
"capabilities": {
"live_reduced_dynamics": true,
"coupled_longitudinal_lateral_yaw": true,
"equivalent_circuit_battery": true,
"coupled_thermal_network": true,
"derived_mass_budget": true,
"derived_hardware_limits": true,
"component_reconciled_performance": true,
"source_panel_aerodynamics": true,
"yaw_dependent_aero_coefficients": true,
"wind_tunnel_measurement": true,
"surface_pressure_field": true,
"pin_level_netlist": true,
"dc_nodal_analysis": true,
"ipc_conductor_sizing": true,
"kinematic_joint_model": true,
"adaptive_aero_model": true,
"candidate_search_algorithm": true,
"physical_candidate_evaluation": false,
"robot_kinematic_replay": true,
"parametric_mechanical_assembly": true,
"step_engineering_reconstruction": true,
"evidence_export": true,
"qualification_matrix": true,
"governing_cfd": false,
"governing_structural_dynamics": false,
"manufacturing_cad_authority": false,
"decision_authority": false,
},
"fidelity": {
"vehicle_geometry": "CC BY community exterior + source-gated FreeCAD/OCCT engineering reconstruction; not OEM manufacturing CAD",
"dynamics": "live coupled reduced longitudinal/lateral/yaw/tire model on assembly-derived inertia, with every performance limit derived from the declared motors, inverters, brakes, tyres, pack and reduction gears and reconciled against those parts' declared counts and masses; uncorrelated parameters and no dynamometer, pressure-rig, tyre-rig or cell-cycling evidence",
"aerodynamics": AERO_METHOD,
"electrical": "live equivalent-circuit battery plus DC modified-nodal-analysis over reconstructed boards; EMC, transient and physical routing validation absent",
"thermal": "live eight-node coupled coolant network, a lumped tire node and a per-axle brake-disc node integrating the friction share of each stop against the derived disc thermal mass; no 3D thermal solver or correlation",
"structure": "live axle reactions, joint mobility and static load-path equilibrium only; no stress, deformation, modal, fatigue, or crash solve",
"robotics": "deterministic kinematic demonstrator",
},
"scientific_validated": false,
"production_complete": false,
"major_assembly_complete": false,
}
def number_field_valid(payload: dict[str, any], field: str, minimum: f64, maximum: f64):
if not payload.has(field) or not (payload[field] is int or payload[field] is float):
return false
value = f64(payload[field])
return value == value and value >= minimum and value <= maximum
def measurement_body(forces: any):
return {
"cd": forces.cd,
"cl": forces.cl,
"cy": forces.cy,
"cmz": forces.cmz,
"cd_pressure": forces.cd_pressure,
"cd_base": forces.cd_base,
"cd_friction": forces.cd_friction,
"cd_wheels": forces.cd_wheels,
"drag_n": forces.drag_n,
"lift_n": forces.lift_n,
"side_n": forces.side_n,
"yaw_moment_nm": forces.yaw_moment_nm,
"front_lift_n": forces.front_lift_n,
"rear_lift_n": forces.rear_lift_n,
"dynamic_pressure_pa": forces.dynamic_pressure_pa,
"reynolds_number": forces.reynolds_number,
"separation_x_m": forces.separation_x_m,
"cp_min": forces.cp_min,
"cp_max": forces.cp_max,
"base_pressure_coefficient": forces.base_pressure_coefficient,
"wake_width_m": forces.wake_width_m,
"wake_deficit_fraction": forces.wake_deficit_fraction,
"wake_shedding_hz": forces.wake_shedding_hz,
"turbulence_intensity": forces.turbulence_intensity,
"wake_recirculation_length_m": forces.wake_recirculation_length_m,
"dalembert_residual_cd": forces.dalembert_residual_cd,
"solver_residual": forces.solver_residual,
"force_closure_residual": forces.force_closure_residual,
}
def tunnel_response(payload: any, session: dict[str, any]) !{}:
"""On-demand wind-tunnel measurement: the car is held, the stream runs, the loads are integrated."""
if not (payload is dict):
return error_response(422, "TunnelInvalid", "wind tunnel request must be an object")
if not number_field_valid(payload, "wind_mps", 0.0, MAX_WIND_MPS) or not number_field_valid(payload, "yaw_deg", -180.0, 180.0):
return error_response(422, "TunnelInvalid", "wind_mps must be 0-60 m/s and yaw_deg must be -180..180")
wind_mps = f64(payload["wind_mps"])
yaw_deg = f64(payload["yaw_deg"])
yaw_rad = yaw_deg * 3.141592653589793 / 180.0
forces = aero_solve(session["context"].basis, wind_mps, yaw_rad)
return response(200, {
"schema": "sema.circuitframe-wind-tunnel/v1",
"method": AERO_METHOD,
"wind_mps": wind_mps,
"yaw_deg": yaw_deg,
"yaw_rad": yaw_rad,
"measurement": measurement_body(forces),
"provenance": AERO_PROVENANCE,
"surface_cp": forces.surface_cp,
"scientific_validated": false,
})
def step_response(payload: any, session: dict[str, any]) !{}:
if not (payload is dict):
return error_response(422, "StepInvalid", "step request must be an object")
required = ["generation", "sequence", "state_version", "dt_s", "throttle", "brake", "steering", "wind_mps", "wind_yaw_deg"]
if not all(payload.has(field) for field in required):
return error_response(422, "StepInvalid", "step request is missing a required field")
if not (payload["generation"] is int) or not (payload["sequence"] is int) or not (payload["state_version"] is int):
return error_response(422, "StepInvalid", "generation, sequence and state_version must be integers")
expected_generation = session["generation"]
expected_sequence = session["sequence"]
expected_state_version = session["state_version"]
expected_state = session["state"]
expected_detector = session["detector"]
expected_model_id = session["model_id"]
if payload["generation"] != expected_generation:
return error_response(409, "GenerationConflict", "generation does not match Sema-owned state")
if payload["sequence"] != expected_sequence + 1 or payload["state_version"] != expected_state_version:
return error_response(409, "StateConflict", "sequence or state version does not match Sema-owned state")
if not number_field_valid(payload, "dt_s", 0.05, 0.05) or not number_field_valid(payload, "throttle", 0.0, 1.0) or not number_field_valid(payload, "brake", 0.0, 1.0) or not number_field_valid(payload, "steering", -1.0, 1.0) or not number_field_valid(payload, "wind_mps", 0.0, MAX_WIND_MPS) or not number_field_valid(payload, "wind_yaw_deg", -180.0, 180.0):
return error_response(422, "StepInvalid", "numeric controls must be finite, in range, and use the fixed 0.05 s integration interval")
dt_s = f64(payload["dt_s"])
mut controls = ControlInput(
throttle=f64(payload["throttle"]),
brake=f64(payload["brake"]),
steering=f64(payload["steering"]),
wind_mps=f64(payload["wind_mps"]),
wind_yaw_deg=f64(payload["wind_yaw_deg"]),
)
sweep = session["sweep"]
if sweep["active"]:
command = sweep_command(sweep["index"], expected_state.speed_mps)
controls = ControlInput(throttle=controls.throttle, brake=controls.brake, steering=controls.steering, wind_mps=command[0], wind_yaw_deg=command[1])
sequence = payload["sequence"]
context = session["context"]
frame = step_vehicle(expected_state, controls, dt_s, sequence, expected_model_id, context)
detector = update_aero_detector(expected_detector, frame.aero_residual_fraction, frame.state.time_s)
adaptation = retained_adaptation(detector, expected_model_id)
next_model_id = expected_model_id
if detector.status == RegimeStatus.shifted and expected_model_id == "aero-rom-calm-v1":
adaptation = evaluate_yaw_candidate(detector, sequence, frame.state.time_s, frame.state.speed_mps, frame.sideslip_rad * 180.0 / 3.141592653589793, aero_holdout_metrics(context))
if adaptation.activated:
next_model_id = adaptation.selected_model_id
if session["generation"] != expected_generation or session["sequence"] != expected_sequence or session["state_version"] != expected_state_version:
return error_response(409, "LifecycleConflict", "Sema-owned state changed before the step could commit")
session["model_id"] = next_model_id
session["state"] = frame.state
session["frame"] = frame
session["detector"] = detector
session["adaptation"] = adaptation
session["sequence"] = sequence
session["state_version"] = expected_state_version + 1
session["residual_history"] = pushed_residual_history(session["residual_history"], frame.aero_residual_fraction, detector.score, sequence, adaptation.activated)
session["sweep"] = advanced_sweep(sweep, controls)
return response(200, {
"schema": "sema.circuitframe-dynamics-frame/v1",
"generation": session["generation"],
"sequence": session["sequence"],
"state_version": session["state_version"],
"frame": frame,
"adaptation": adaptation,
"model_id_next": session["model_id"],
"residual_sample": {"sequence": sequence, "residual": frame.aero_residual_fraction, "score": detector.score, "threshold": detector.score_threshold, "status": regime_label(detector), "consecutive_high": detector.consecutive_high, "samples_seen": session["residual_history"]["count"], "activated_at_sequence": session["residual_history"]["activated_at_sequence"]},
"sweep": {"active": session["sweep"]["active"], "step_index": session["sweep"]["index"], "remaining_steps": max(0, SWEEP_STEPS - session["sweep"]["index"]), "commanded_wind_mps": session["sweep"]["commanded_wind_mps"], "commanded_yaw_deg": session["sweep"]["commanded_yaw_deg"]},
"provenance": AERO_PROVENANCE,
"fidelity": AERO_METHOD,
"scientific_validated": false,
})
def reset_session(session: dict[str, any]):
reset = initial_session(session["context"])
session["generation"] = session["generation"] + 1
session["sequence"] = reset["sequence"]
session["state_version"] = reset["state_version"]
session["state"] = reset["state"]
session["frame"] = reset["frame"]
session["detector"] = reset["detector"]
session["model_id"] = reset["model_id"]
session["adaptation"] = reset["adaptation"]
session["candidate"] = None
session["assembly_step"] = reset["assembly_step"]
session["residual_history"] = reset["residual_history"]
session["sweep"] = reset["sweep"]
def reset_response(session: dict[str, any]) !{}:
reset_session(session)
session["panel_width_mm"] = 90.0
session["vehicle_revision"] = 1
session["configuration_variant"] = "circuitframe"
return response(200, status_body(session))
def candidate_response(session: dict[str, any]) !{}:
if not physical_actions_allowed():
return error_response(409, "SourceVehicleRequired", "physical conductor candidate evaluation requires the reconstructed geometry, loads, routing, materials, and process data")
preview = optimize_panel(session["vehicle_revision"])
session["candidate"] = preview["selected"]
return response(200, preview)
def commit_response(payload: any, session: dict[str, any]) !{}:
if not physical_actions_allowed():
return error_response(409, "SourceVehicleRequired", "candidate commit is blocked until the approved reconstruction source stack and physical routing are loaded")
if not (payload is dict) or not payload.has("candidate_id") or not payload.has("base_revision"):
return error_response(422, "CandidateCommitInvalid", "candidate_id and base_revision are required")
if session["candidate"] is None:
return error_response(409, "CandidateMissing", "create a candidate preview before commit")
candidate = session["candidate"]
if payload["candidate_id"] != candidate.id or payload["base_revision"] != session["vehicle_revision"] or candidate.base_revision != session["vehicle_revision"]:
return error_response(409, "CandidateConflict", "candidate identity or base revision is stale")
if not candidate.passed:
return error_response(422, "CandidateRejected", candidate.reason)
session["panel_width_mm"] = candidate.width_mm
session["vehicle_revision"] = session["vehicle_revision"] + 1
session["candidate"] = None
return response(200, {
"schema": "sema.circuitframe-candidate-commit/v1",
"candidate_id": candidate.id,
"configuration_revision": session["vehicle_revision"],
"panel_width_mm": session["panel_width_mm"],
"committed": true,
"reason": candidate.reason,
})
def assembly_step_response(payload: any, session: dict[str, any]) !{}:
if not (payload is dict) or not payload.has("step") or not payload.has("generation") or not (payload["step"] is int) or not (payload["generation"] is int):
return error_response(422, "AssemblyStepInvalid", "step and generation must be integers")
expected_generation = session["generation"]
if payload["generation"] != expected_generation:
return error_response(409, "GenerationConflict", "assembly generation does not match Sema-owned state")
manifest = vehicle_manifest(session["configuration_variant"])
if payload["step"] < 0 or payload["step"] >= len(manifest["operations"]):
return error_response(422, "AssemblyStepInvalid", "step is outside the operation graph")
if session["generation"] != expected_generation:
return error_response(409, "LifecycleConflict", "Sema-owned state changed before the assembly step could commit")
session["assembly_step"] = payload["step"]
return response(200, {"schema": "sema.circuitframe-assembly-state/v1", "generation": session["generation"], "step": session["assembly_step"], "operation": manifest["operations"][session["assembly_step"]]})
def sweep_state_body(session: dict[str, any]):
return {
"schema": "sema.circuitframe-regime-sweep/v1",
"sweep": sweep_body(session),
"detector": detector_body(session),
"residual_history": residual_history_body(session),
"adaptation": session["adaptation"],
"model_id": session["model_id"],
}
def regime_sweep_response(payload: any, session: dict[str, any]) !{}:
"""Arm or disarm the bounded wind-yaw demonstration; it never advances the causal sequence itself.
Anything short of an explicit `{"action": "start"}` disarms, so an empty body, `{}`, an explicit
stop or a cancel flag all reliably stop the demonstration.
"""
if not (payload is dict):
return error_response(422, "RegimeSweepInvalid", "regime sweep request must be an object")
if payload.has("action") and not (payload["action"] is str):
return error_response(422, "RegimeSweepInvalid", "action must be start or stop")
action = str(payload["action"]) if payload.has("action") else "stop"
if action != "start" and action != "stop":
return error_response(422, "RegimeSweepInvalid", "action must be start or stop")
cancel = payload.has("cancel") and payload["cancel"] is bool and payload["cancel"]
if action == "stop" or cancel:
session["sweep"] = idle_sweep()
return response(200, sweep_state_body(session))
restart = payload.has("restart_from_calm") and payload["restart_from_calm"] is bool and payload["restart_from_calm"]
if restart:
detector = initial_aero_detector()
session["detector"] = detector
session["model_id"] = CALM_MODEL_ID
session["adaptation"] = retained_adaptation(detector, CALM_MODEL_ID)
session["residual_history"] = initial_residual_history()
session["sweep"] = {"active": true, "index": 0, "commanded_wind_mps": SWEEP_MIN_WIND_MPS, "commanded_yaw_deg": 0.0, "restarted_from_calm": restart}
return response(200, sweep_state_body(session))
def configuration_response(payload: any, session: dict[str, any]) !{}:
if not (payload is dict) or not payload.has("configuration") or not (payload["configuration"] is str):
return error_response(422, "ConfigurationInvalid", "configuration must be conventional or circuitframe")
configuration = str(payload["configuration"])
if configuration != "conventional" and configuration != "circuitframe":
return error_response(422, "ConfigurationInvalid", "configuration must be conventional or circuitframe")
reset_session(session)
session["configuration_variant"] = configuration
session["panel_width_mm"] = 90.0
return response(200, {
"schema": "sema.circuitframe-configuration-view/v1",
"configuration": configuration,
"shared_identity": "circuitframe_gt_01",
"authority": "configuration-specific semantic routes and process delta over shared reconstruction identities; physical deltas remain engineered assumptions",
"state_reset": true,
"generation": session["generation"],
})
def export_response(session: dict[str, any]) !{}:
if not evidence_export_allowed():
return error_response(409, "SourceVehicleRequired", "evidence export is blocked until attributed presentation geometry is loaded")
return response(200, {
"schema": "sema.circuitframe-engineering-export/v1",
"configuration_id": "circuitframe_gt_01",
"configuration_revision": session["vehicle_revision"],
"authority": "Sema semantic/evidence manifest with attributed exterior and assumption-derived parametric assembly; no OEM manufacturing authority",
"vehicle": vehicle_manifest(session["configuration_variant"]),
"vehicle_nets": vehicle_net_comparison(),
"assembly": cached_assembly(session),
"pcb": cached_pcb(session),
"aerodynamics": cached_aero_basis(session),
"equations": all_equations(),
"runtime": {"model_id": session["model_id"], "state": session["state"], "frame": session["frame"], "panel_width_mm": session["panel_width_mm"]},
"configuration_variant": session["configuration_variant"],
"representations": [
{"format": "glTF 2.0", "status": "generated web scene", "authority": "attributed exterior plus byte-verified engineering reconstruction"},
{"format": "STEP AP242", "status": "generated parametric reconstruction", "authority": "FreeCAD/OCCT engineering assumptions; not OEM manufacturing CAD or certified PMI"},
{"format": "FreeCAD FCStd", "status": "generated parametric master", "authority": "open-profile engineering reconstruction"},
{"format": "KiCad flat netlist", "status": "generated per board", "authority": "Sema reconstructed boards; not a manufacturable fabrication package"},
{"format": "FMI/SSP", "status": "contract only", "authority": "Sema reduced-model implementation"},
{"format": "BOM/MBOM/BOP", "status": "major-assembly manifest", "authority": "Sema research profile"},
],
"losses": ["no OEM revision-certified manufacturing B-rep or JT", "no homologation evidence", "no governing CFD/FEA/crash", "no wind-tunnel or dynamometer correlation", "no EMC or transient circuit analysis", "supplier internals opaque"],
})
def authoring_response(payload: any) !{}:
if not (payload is dict) or not payload.has("description") or not (payload["description"] is str):
return error_response(422, "AuthoringInvalid", "authoring request requires a description string")
description = str(payload["description"])
if len(description) == 0 or len(description) > 2048:
return error_response(413, "AuthoringInvalid", "description must contain between 1 and 2048 characters")
result = compile_circuit_description(description)
return response(200, {"schema": "sema.circuitframe-authoring/v1", "result": result})
def live_response(request: dict[str, any], session: dict[str, any]) !{}:
if request["method"] == "GET" and request["path"] == "/api/sema/health":
return response(200, {"status": "ok", "backend": "Sema", "schema": "sema.circuitframe-health/v1"})
if request["method"] == "GET" and request["path"] == "/api/sema/status":
return response(200, status_body(session))
if request["method"] == "GET" and request["path"] == "/api/sema/aero/basis":
return response(200, cached_aero_basis(session))
if request["method"] == "GET" and request["path"] == "/api/sema/pcb":
return response(200, cached_pcb(session))
if request["method"] == "GET" and request["path"] == "/api/sema/assembly":
return response(200, cached_assembly(session))
if request["method"] == "GET" and request["path"] == "/api/sema/export":
return export_response(session)
if request["method"] == "POST" and request["path"] == "/api/sema/reset":
return reset_response(session)
if request["method"] == "POST" and request["path"] == "/api/sema/tunnel":
if len(request["body"]) == 0 or len(request["body"]) > 1024:
return error_response(413, "TunnelInvalid", "wind tunnel request must contain at most 1024 bytes")
expect payload = decode_json(request["body"]):
return tunnel_response(payload, session)
except JsonError as error:
return error_response(422, "TunnelInvalid", "wind tunnel request must contain valid JSON")
if request["method"] == "POST" and request["path"] == "/api/sema/configuration":
if len(request["body"]) == 0 or len(request["body"]) > 1024:
return error_response(413, "ConfigurationInvalid", "configuration request must contain at most 1024 bytes")
expect payload = decode_json(request["body"]):
return configuration_response(payload, session)
except JsonError as error:
return error_response(422, "ConfigurationInvalid", "configuration request must contain valid JSON")
if request["method"] == "POST" and request["path"] == "/api/sema/authoring":
if len(request["body"]) == 0 or len(request["body"]) > 4096:
return error_response(413, "AuthoringInvalid", "authoring request must contain at most 4096 bytes")
expect payload = decode_json(request["body"]):
return authoring_response(payload)
except JsonError as error:
return error_response(422, "AuthoringInvalid", "authoring request must contain valid JSON")
if request["method"] == "POST" and request["path"] == "/api/sema/candidate":
return candidate_response(session)
if request["method"] == "POST" and request["path"] == "/api/sema/candidate/commit":
if len(request["body"]) == 0 or len(request["body"]) > 2048:
return error_response(413, "CandidateCommitInvalid", "candidate commit must contain at most 2048 bytes")
expect payload = decode_json(request["body"]):
return commit_response(payload, session)
except JsonError as error:
return error_response(422, "CandidateCommitInvalid", "candidate commit must contain valid JSON")
if request["method"] == "POST" and request["path"] == "/api/sema/assembly":
if len(request["body"]) == 0 or len(request["body"]) > 1024:
return error_response(413, "AssemblyStepInvalid", "assembly request must contain at most 1024 bytes")
expect payload = decode_json(request["body"]):
return assembly_step_response(payload, session)
except JsonError as error:
return error_response(422, "AssemblyStepInvalid", "assembly request must contain valid JSON")
if request["method"] == "POST" and request["path"] == "/api/sema/regime-sweep":
if len(request["body"]) > 1024:
return error_response(413, "RegimeSweepInvalid", "regime sweep request must contain at most 1024 bytes")
if len(request["body"]) == 0:
return regime_sweep_response({}, session)
expect payload = decode_json(request["body"]):
return regime_sweep_response(payload, session)
except JsonError as error:
return error_response(422, "RegimeSweepInvalid", "regime sweep request must contain valid JSON")
if request["method"] == "POST" and request["path"] == "/api/sema/step":
if len(request["body"]) == 0 or len(request["body"]) > 4096:
return error_response(413, "StepInvalid", "step request must contain at most 4096 bytes")
expect payload = decode_json(request["body"]):
return step_response(payload, session)
except JsonError as error:
return error_response(422, "StepInvalid", "step request must contain valid finite JSON")
return error_response(404, "NotFound", "unknown CircuitFrame Sema endpoint: " + str(request["method"]) + " " + str(request["path"]))
pub def serve_live(port: int) -> None !{net.listen}:
sem "Serve an automotive-native CircuitFrame vehicle, aerodynamic, circuit, assembly, physics, adaptive-model and evidence API"
require port >= 1024 and port <= 65535
context = physics_context()
session = initial_session(context)
cached_aero_basis(session)
cached_pcb(session)
cached_assembly(session)
print("circuitframe_sema=http://127.0.0.1:" + str(port)
+ " configuration=circuitframe_gt_01 model=" + session["model_id"]
+ " panels=" + str(context.basis.panel_count)
+ " mass_kg=" + str(context.parameters.mass_kg))
http.serve(port, request => live_response(request, session))
test "a parked car in the tunnel measures yaw-dependent loads":
session = initial_session(physics_context())
head_on = tunnel_response({"wind_mps": 27.8, "yaw_deg": 0.0}, session)
yawed = tunnel_response({"wind_mps": 27.8, "yaw_deg": 25.0}, session)
still = tunnel_response({"wind_mps": 0.0, "yaw_deg": 0.0}, session)
rejected = tunnel_response({"wind_mps": 90.0, "yaw_deg": 0.0}, session)
head_on_body = decode_json(head_on["body"])
yawed_body = decode_json(yawed["body"])
still_body = decode_json(still["body"])
ensure head_on["status"] == 200 and yawed["status"] == 200 and still["status"] == 200
ensure rejected["status"] == 422
ensure head_on_body["measurement"]["drag_n"] > 200.0
ensure yawed_body["measurement"]["cd"] > head_on_body["measurement"]["cd"]
ensure yawed_body["measurement"]["side_n"] > 0.0
ensure still_body["measurement"]["drag_n"] == 0.0
ensure len(head_on_body["surface_cp"]) == session["context"].basis.panel_count
ensure head_on_body["provenance"]["cd_friction"] == "correlated"
test "the aerodynamic basis, boards and assembly are served from one solve":
session = initial_session(physics_context())
basis = cached_aero_basis(session)
boards = cached_pcb(session)
assembly = cached_assembly(session)
ensure basis["schema"] == "sema.circuitframe-aero-basis/v1"
ensure boards["schema"] == "sema.circuitframe-pcb/v1"
ensure assembly["schema"] == "sema.circuitframe-assembly/v1"
ensure len(basis["panels"]) == session["context"].basis.panel_count
ensure session["aero_basis_payload"] is not None
ensure session["pcb_payload"] is not None
ensure session["assembly_payload"] is not None
test "physical commit fails closed while evidence export stays truthful":
session = initial_session(physics_context())
step = step_response({"generation": session["generation"], "sequence": 1, "state_version": session["state_version"], "dt_s": 0.05, "throttle": 0.4, "brake": 0.0, "steering": 0.0, "wind_mps": 4.0, "wind_yaw_deg": 30.0}, session)
candidate = candidate_response(session)
commit = commit_response({"candidate_id": "none", "base_revision": 1}, session)
export = export_response(session)
payload = decode_json(export["body"])
ensure candidate["status"] == 409
ensure commit["status"] == 409
ensure step["status"] == 200
ensure export["status"] == 200
ensure payload["representations"][1]["status"] == "generated parametric reconstruction"
ensure payload["runtime"]["frame"]["sequence"] == 1
test "the harness comparison quantifies the CircuitFrame conductor delta":
rows = vehicle_net_comparison()
mut lighter = 0
mut fewer_connectors = 0
for row in rows:
if row["delta"]["mass_kg"] < 0.0:
lighter = lighter + 1
if row["delta"]["connectors"] < 0:
fewer_connectors = fewer_connectors + 1
ensure row["variants"]["conventional"]["resistance_mohm"] > 0.0
ensure row["variants"]["circuitframe"]["length_m"] < row["variants"]["conventional"]["length_m"]
ensure len(rows) == 9
ensure lighter == 9
ensure fewer_connectors == 9
test "assembly selection rejects a stale lifecycle generation":
session = initial_session(physics_context())
stale_generation = session["generation"]
selected = configuration_response({"configuration": "circuitframe"}, session)
rejected = assembly_step_response({"generation": stale_generation, "step": 3}, session)
ensure selected["status"] == 200
ensure rejected["status"] == 409
ensure session["assembly_step"] == 11
test "configuration comparison accepts only declared variants":
session = initial_session(physics_context())
selected = configuration_response({"configuration": "circuitframe"}, session)
rejected = configuration_response({"configuration": "prototype"}, session)
ensure selected["status"] == 200
ensure session["configuration_variant"] == "circuitframe"
ensure rejected["status"] == 422
conventional = vehicle_manifest("conventional")
circuitframe = vehicle_manifest("circuitframe")
ensure conventional["configuration_delta"]["changed_net_ids"] == circuitframe["configuration_delta"]["changed_net_ids"]
ensure conventional["nets"][0].route != circuitframe["nets"][0].route
ensure conventional["operations"][1].name != circuitframe["operations"][1].name
ensure session["generation"] == 1
test "invalid numeric controls fail as typed client errors":
session = initial_session(physics_context())
invalid_range = step_response({"generation": 0, "sequence": 1, "state_version": 0, "dt_s": 0.05, "throttle": 2.0, "brake": 0.0, "steering": 0.0, "wind_mps": 0.0, "wind_yaw_deg": 0.0}, session)
invalid_type = step_response({"generation": 0, "sequence": 1, "state_version": 0, "dt_s": "0.05", "throttle": 0.4, "brake": 0.0, "steering": 0.0, "wind_mps": 0.0, "wind_yaw_deg": 0.0}, session)
invalid_rate = step_response({"generation": 0, "sequence": 1, "state_version": 0, "dt_s": 0.1, "throttle": 0.4, "brake": 0.0, "steering": 0.0, "wind_mps": 0.0, "wind_yaw_deg": 0.0}, session)
invalid_wind = step_response({"generation": 0, "sequence": 1, "state_version": 0, "dt_s": 0.05, "throttle": 0.4, "brake": 0.0, "steering": 0.0, "wind_mps": 75.0, "wind_yaw_deg": 0.0}, session)
ensure invalid_range["status"] == 422
ensure invalid_type["status"] == 422
ensure invalid_rate["status"] == 422
ensure invalid_wind["status"] == 422
test "generation transition rejects stale step without mutation":
session = initial_session(physics_context())
stale_step = {"generation": 0, "sequence": 1, "state_version": 0, "dt_s": 0.05, "throttle": 0.4, "brake": 0.0, "steering": 0.0, "wind_mps": 0.0, "wind_yaw_deg": 0.0}
selected = configuration_response({"configuration": "circuitframe"}, session)
rejected = step_response(stale_step, session)
ensure selected["status"] == 200
ensure rejected["status"] == 409
ensure session["generation"] == 1
ensure session["sequence"] == 0
test "the residual ring is bounded and the newest sample rides on every step":
session = initial_session(physics_context())
mut sequence = 0
mut last = {}
while sequence < RESIDUAL_HISTORY_CAPACITY + 10:
sequence = sequence + 1
last = decode_json(step_response({"generation": 0, "sequence": sequence, "state_version": sequence - 1, "dt_s": 0.05, "throttle": 0.3, "brake": 0.0, "steering": 0.0, "wind_mps": 6.0, "wind_yaw_deg": 12.0}, session)["body"])
status = status_body(session)
history = status["residual_history"]
ensure len(history["residual"]) == RESIDUAL_HISTORY_CAPACITY
ensure len(history["score"]) == RESIDUAL_HISTORY_CAPACITY
ensure history["count"] == sequence
ensure history["last_sequence"] == sequence
ensure history["threshold"] == status["detector"]["threshold"]
ensure last["residual_sample"]["residual"] == history["residual"][RESIDUAL_HISTORY_CAPACITY - 1]
ensure last["residual_sample"]["score"] == history["score"][RESIDUAL_HISTORY_CAPACITY - 1]
ensure last["residual_sample"]["samples_seen"] == sequence
ensure not last.has("residual_history")
test "the scripted sweep drives the declared yaw ramp, passes the gate and always disarms":
session = initial_session(physics_context())
rejected = regime_sweep_response({"action": "rewind"}, session)
armed = regime_sweep_response({"action": "start"}, session)
ensure rejected["status"] == 422
ensure armed["status"] == 200
ensure session["sweep"]["active"] and not session["sweep"]["restarted_from_calm"]
mut sequence = 0
while sequence < 60 and session["model_id"] == CALM_MODEL_ID:
sequence = sequence + 1
step_response({"generation": 0, "sequence": sequence, "state_version": sequence - 1, "dt_s": 0.05, "throttle": 0.35, "brake": 0.0, "steering": 0.0, "wind_mps": 0.0, "wind_yaw_deg": 0.0}, session)
frame = session["frame"]
adaptation = session["adaptation"]
ensure frame.controls.wind_mps >= SWEEP_MIN_WIND_MPS and frame.controls.wind_mps <= SWEEP_MAX_WIND_MPS
ensure frame.controls.wind_yaw_deg > 0.0
ensure abs(frame.sideslip_rad * 180.0 / math.pi) <= 35.0
ensure session["model_id"] == "aero-rom-crosswind-v2"
ensure adaptation.activated and adaptation.validation_error < 0.04 and adaptation.invariant_violations == 0
ensure session["residual_history"]["activated_at_sequence"] == sequence
ensure session["sweep"]["index"] == sequence and session["sweep"]["active"]
disarmed = regime_sweep_response({}, session)
ensure disarmed["status"] == 200
ensure not session["sweep"]["active"] and session["sweep"]["index"] == 0
test "resetting the causal state clears the residual ring and disarms the sweep":
session = initial_session(physics_context())
regime_sweep_response({"action": "start"}, session)
step_response({"generation": 0, "sequence": 1, "state_version": 0, "dt_s": 0.05, "throttle": 0.3, "brake": 0.0, "steering": 0.0, "wind_mps": 0.0, "wind_yaw_deg": 0.0}, session)
ensure session["residual_history"]["count"] == 1 and session["sweep"]["active"]
reset_session(session)
ensure session["residual_history"]["count"] == 0
ensure len(session["residual_history"]["residual"]) == 0
ensure not session["sweep"]["active"]
test "every declared equation carries a subsystem group and an evaluation phase":
rows = all_equations()
mut per_step = 0
mut sampled = 0
mut on_demand = 0
for row in rows:
ensure len(row["group"]) > 0 and len(row["owner"]) > 0 and len(row["expression"]) > 0
if row["phase"] == "per_step":
per_step = per_step + 1
elif row["phase"] == "sampled":
sampled = sampled + 1
else:
on_demand = on_demand + 1
ensure per_step + sampled + on_demand == len(rows)
ensure sampled == len(SAMPLED_EQUATION_IDS)
ensure per_step > sampled and on_demand > 0
ensure contains([row["group"] for row in rows], "thermal network")
ensure contains([row["group"] for row in rows], "aero panel solve")
test "the declared validity region is published and enforced":
session = initial_session(physics_context())
published = sweep_body(session)
ensure published["envelope_max_speed_mps"] == 55.0
ensure published["envelope_max_sideslip_deg"] == 35.0
detector = initial_aero_detector()
mut shifted = detector
for index in range(0, 8):
shifted = update_aero_detector(shifted, 0.4, f64(index) * 0.05)
inside = evaluate_yaw_candidate(shifted, 24, 1.2, published["envelope_max_speed_mps"] - 1.0, 20.0, aero_holdout_metrics(session["context"]))
too_fast = evaluate_yaw_candidate(shifted, 24, 1.2, published["envelope_max_speed_mps"] + 1.0, 20.0, aero_holdout_metrics(session["context"]))
too_yawed = evaluate_yaw_candidate(shifted, 24, 1.2, 20.0, published["envelope_max_sideslip_deg"] + 1.0, aero_holdout_metrics(session["context"]))
ensure inside.activated
ensure not too_fast.activated and too_fast.validation_status == "blocked_out_of_scope"
ensure not too_yawed.activated and too_yawed.validation_status == "blocked_out_of_scope"
"""Printed-circuit design and DC electrical analysis for the CircuitFrame boards.
Four boards are declared at component, pin, net, trace and via level and then analysed:
* DC modified nodal analysis. Copper branch conductances come from geometry
(R = rho * L / (w * t), rho corrected by the copper temperature coefficient),
every net's feed pin is held at its declared operating potential, declared loads
inject current at their sink pin and return it at their return pin, and the
reference net pin is the 0 V node. `G v = i` is solved with `solve(matrix, rhs)`.
* IPC-2221 external/internal conductor sizing plus the inverted temperature-rise
relation, evaluated per trace against the layer's copper weight.
* IEC 60664-1 creepage screening on the 800 V board.
* A geometric design-rule check (track width, annular ring, pads inside outline).
Every board is an *engineered reconstruction* published for the CircuitFrame research
demo. None of it is an OEM design, an OEM netlist, or a released manufacturing data set.
Part numbers name the device *class* the reconstruction is dimensioned against.
"""
from std.json import encode as encode_json
import math
assure silver
enum PcbLayerFunction:
signal | plane | core | prepreg | soldermask | coverlay
enum PcbNetClass:
hv | power | ground | signal | can | ethernet | analog
enum PcbPinKind:
power | ground | signal | analog | thermal | nc
struct PcbLayer:
id: str
name: str
function: PcbLayerFunction
copper_um: f64
dielectric_mm: f64
dielectric_er: f64
material: str
invariant len(id) > 0
invariant len(name) > 0
invariant copper_um >= 0.0 and copper_um <= 5000.0
invariant dielectric_mm >= 0.0 and dielectric_mm <= 20.0
invariant dielectric_er >= 1.0 and dielectric_er <= 20.0
invariant len(material) > 0
struct PcbStackup:
id: str
name: str
layers: list[PcbLayer]
total_thickness_mm: f64
ipc_class: int
impedance_target_ohm: f64
invariant len(id) > 0
invariant len(name) > 0
invariant len(layers) >= 2 and len(layers) <= 64
invariant total_thickness_mm > 0.0
invariant ipc_class >= 1 and ipc_class <= 3
invariant impedance_target_ohm > 0.0
struct PcbComponent:
refdes: str
part_number: str
description: str
footprint: str
pin_count: int
x_mm: f64
y_mm: f64
rotation_deg: f64
power_dissipation_w: f64
mass_g: f64
invariant len(refdes) > 0
invariant len(part_number) > 0
invariant len(description) > 0
invariant len(footprint) > 0
invariant pin_count >= 2 and pin_count <= 2048
invariant x_mm >= 0.0
invariant y_mm >= 0.0
invariant rotation_deg >= 0.0 and rotation_deg < 360.0
invariant power_dissipation_w >= 0.0
invariant mass_g > 0.0
struct PcbPin:
id: str
refdes: str
number: int
name: str
net_id: str
x_mm: f64
y_mm: f64
kind: PcbPinKind
invariant len(id) > 0
invariant len(refdes) > 0
invariant number >= 1
invariant len(name) > 0
invariant len(net_id) > 0
invariant x_mm >= 0.0
invariant y_mm >= 0.0
struct PcbNet:
id: str
name: str
net_class: PcbNetClass
pin_ids: list[str]
nominal_voltage_v: f64
current_a: f64
is_reference: bool
invariant len(id) > 0
invariant len(name) > 0
invariant len(pin_ids) >= 2 and len(pin_ids) <= 512
invariant nominal_voltage_v >= 0.0
invariant current_a >= 0.0
struct PcbTrace:
id: str
net_id: str
layer_id: str
width_mm: f64
length_mm: f64
current_a: f64
from_pin_id: str
to_pin_id: str
resistance_mohm: f64
voltage_drop_mv: f64
power_loss_mw: f64
temperature_rise_c: f64
ipc_min_width_mm: f64
compliant: bool
invariant len(id) > 0
invariant len(net_id) > 0
invariant len(layer_id) > 0
invariant width_mm > 0.0
invariant length_mm > 0.0
invariant current_a >= 0.0
invariant len(from_pin_id) > 0
invariant len(to_pin_id) > 0
invariant resistance_mohm >= 0.0
invariant voltage_drop_mv >= 0.0
invariant power_loss_mw >= 0.0
invariant temperature_rise_c >= 0.0
invariant ipc_min_width_mm >= 0.0
struct PcbVia:
id: str
net_id: str
drill_mm: f64
pad_mm: f64
from_layer_id: str
to_layer_id: str
current_a: f64
resistance_mohm: f64
invariant len(id) > 0
invariant len(net_id) > 0
invariant drill_mm > 0.0
invariant pad_mm > drill_mm
invariant len(from_layer_id) > 0
invariant len(to_layer_id) > 0
invariant current_a >= 0.0
invariant resistance_mohm >= 0.0
struct PcbBoard:
id: str
name: str
host_part_id: str
stackup: PcbStackup
components: list[PcbComponent]
pins: list[PcbPin]
nets: list[PcbNet]
traces: list[PcbTrace]
vias: list[PcbVia]
outline_width_mm: f64
outline_height_mm: f64
zone: str
substrate: str
invariant len(id) > 0
invariant len(name) > 0
invariant len(host_part_id) > 0
invariant len(components) > 0 and len(components) <= 4096
invariant len(pins) > 0 and len(pins) <= 16384
invariant len(nets) > 0 and len(nets) <= 4096
invariant len(traces) > 0 and len(traces) <= 16384
invariant len(vias) <= 16384
invariant outline_width_mm > 0.0
invariant outline_height_mm > 0.0
invariant len(zone) > 0
invariant len(substrate) > 0
struct PcbAnalysis:
board_id: str
node_count: int
branch_count: int
source_node: str
worst_net_id: str
worst_voltage_drop_mv: f64
worst_drop_fraction: f64
total_copper_loss_w: f64
max_temperature_rise_c: f64
ipc_violations: int
drc_violations: int
isolation_violations: int
solver_residual: f64
kcl_residual_a: f64
invariant len(board_id) > 0
invariant node_count > 0
invariant branch_count > 0
invariant len(source_node) > 0
invariant len(worst_net_id) > 0
invariant worst_voltage_drop_mv >= 0.0
invariant worst_drop_fraction >= 0.0
invariant total_copper_loss_w >= 0.0
invariant max_temperature_rise_c >= 0.0
invariant ipc_violations >= 0
invariant drc_violations >= 0
invariant isolation_violations >= 0
invariant solver_residual >= 0.0
invariant kcl_residual_a >= 0.0
struct PcbRoutePlan:
net_id: str
layer_id: str
width_mm: f64
topology: str
via_count: int
via_drill_mm: f64
via_pad_mm: f64
via_layer_id: str
invariant len(net_id) > 0
invariant len(layer_id) > 0
invariant width_mm > 0.0
invariant topology == "chain" or topology == "star"
invariant via_count >= 0 and via_count <= 32
invariant via_drill_mm > 0.0
invariant via_pad_mm > via_drill_mm
struct PcbSegment:
id: str
net_id: str
layer_id: str
width_mm: f64
from_pin_id: str
to_pin_id: str
via_count: int
via_drill_mm: f64
via_pad_mm: f64
via_layer_id: str
invariant len(id) > 0
invariant len(net_id) > 0
invariant len(layer_id) > 0
invariant width_mm > 0.0
invariant via_count >= 0 and via_count <= 32
invariant via_drill_mm > 0.0
invariant via_pad_mm > via_drill_mm
struct PcbLoad:
id: str
board_id: str
pin_id: str
return_pin_id: str
current_a: f64
description: str
invariant len(id) > 0
invariant len(board_id) > 0
invariant len(pin_id) > 0
invariant len(return_pin_id) > 0
invariant current_a >= 0.0
invariant len(description) > 0
struct PcbSolution:
node_ids: list[str]
node_voltages: list[f64]
branch_currents: list[f64]
fixed_count: int
solver_residual: f64
kcl_residual_a: f64
source_current_a: f64
invariant len(node_ids) == len(node_voltages)
invariant fixed_count >= 0
invariant solver_residual >= 0.0
invariant kcl_residual_a >= 0.0
invariant source_current_a >= 0.0
struct PcbIsolationCheck:
id: str
board_id: str
category: str
from_pin_id: str
to_pin_id: str
working_voltage_v: f64
required_mm: f64
measured_mm: f64
reinforced: bool
compliant: bool
invariant len(id) > 0
invariant len(board_id) > 0
invariant len(category) > 0
invariant working_voltage_v >= 0.0
invariant required_mm >= 0.0
invariant measured_mm >= 0.0
# Copper at 20 degC, IACS annealed, with the standard temperature coefficient.
COPPER_RESISTIVITY_OHM_M = 0.00000001724
COPPER_TEMPCO_PER_K = 0.00393
# Declared conductor operating temperature used for the resistance correction.
CONDUCTOR_OPERATING_TEMP_C = 45.0
# IPC-2221 conductor sizing: A_mils2 = (I / (k * dT^0.44))^(1/0.725).
IPC_ALLOWED_RISE_C = 20.0
IPC_K_EXTERNAL = 0.048
IPC_K_INTERNAL = 0.024
VIA_PLATING_UM = 25.0
MIN_TRACK_WIDTH_MM = 0.15
MIN_ANNULAR_RING_MM = 0.125
MIN_SEGMENT_LENGTH_MM = 0.05
# IEC 60664-1 table 4 basic creepage, pollution degree 2, material group IIIa.
IEC60664_VOLTAGE_STEPS = [63.0, 100.0, 125.0, 160.0, 200.0, 250.0, 320.0, 400.0, 500.0, 630.0, 800.0, 1000.0]
IEC60664_CREEPAGE_MM = [0.63, 1.0, 1.25, 1.6, 2.0, 2.5, 3.2, 4.0, 5.0, 6.3, 8.0, 10.0]
# Below 60 V DC no hazardous-voltage creepage screening is applied.
HAZARDOUS_VOLTAGE_V = 60.0
HV_WORKING_VOLTAGE_V = 800.0
# Clearance for a 6 kV rated impulse, pollution degree 2, inhomogeneous field.
HV_CLEARANCE_MM = 5.5
BUCK_EFFICIENCY = 0.94
HV_BUS_CURRENT_A = 160.0
SILL_HV_CURRENT_A = 120.0
FZC_OUT1_A = 3.1
FZC_OUT2_A = 2.2
FZC_OUT3_A = 1.4
FZC_MCU_A = 0.32
FZC_PHY_A = 0.11
FZC_CAN_SUPPLY_A = 0.06
HVJ_COIL_A = 0.42
HVJ_PYRO_MONITOR_A = 0.005
HVJ_ISO_SUPPLY_A = 0.012
HVJ_ISENSE_A = 0.0045
HVJ_IMD_A = 0.005
HVJ_IMD_DIVIDER_A = 0.00035
SILL_ZONE_A = 22.0
SILL_SERVICE_A = 6.0
SILL_BPILLAR_A = 9.0
equation conductor_resistance_ohm(resistivity, length_m, width_m, thickness_m) -> any:
return resistivity * length_m / (width_m * thickness_m)
equation temperature_corrected_resistivity(rho_20, tempco, temperature_c) -> any:
return rho_20 * (1.0 + tempco * (temperature_c - 20.0))
equation ipc2221_cross_section_mils2(current_a, k, rise_c) -> any:
return (current_a / (k * rise_c^0.44))^(1.0 / 0.725)
equation ipc2221_temperature_rise_c(current_a, k, area_mils2) -> any:
return (current_a / (k * area_mils2^0.725))^(1.0 / 0.44)
equation via_barrel_resistance_ohm(resistivity, height_m, drill_m, plating_m) -> any:
return resistivity * height_m / (3.141592653589793 * (drill_m + plating_m) * plating_m)
equation joule_loss_w(current_a, resistance_ohm) -> any:
return current_a^2 * resistance_ohm
def operating_resistivity() !{}:
return temperature_corrected_resistivity(COPPER_RESISTIVITY_OHM_M, COPPER_TEMPCO_PER_K, CONDUCTOR_OPERATING_TEMP_C)
def net_class_key(net_class: PcbNetClass):
if net_class == PcbNetClass.hv:
return "hv"
if net_class == PcbNetClass.power:
return "power"
if net_class == PcbNetClass.ground:
return "ground"
if net_class == PcbNetClass.can:
return "can"
if net_class == PcbNetClass.ethernet:
return "ethernet"
if net_class == PcbNetClass.analog:
return "analog"
return "signal"
def layer_function_key(function: PcbLayerFunction):
if function == PcbLayerFunction.signal:
return "signal"
if function == PcbLayerFunction.plane:
return "plane"
if function == PcbLayerFunction.core:
return "core"
if function == PcbLayerFunction.prepreg:
return "prepreg"
if function == PcbLayerFunction.soldermask:
return "soldermask"
return "coverlay"
def pin_kind_key(kind: PcbPinKind):
if kind == PcbPinKind.power:
return "power"
if kind == PcbPinKind.ground:
return "ground"
if kind == PcbPinKind.analog:
return "analog"
if kind == PcbPinKind.thermal:
return "thermal"
if kind == PcbPinKind.nc:
return "nc"
return "signal"
def pcb_pin(refdes: str, number: int, name: str, net_id: str, x_mm: f64, y_mm: f64, kind: PcbPinKind):
return PcbPin(id=refdes + "." + str(number), refdes=refdes, number=number, name=name, net_id=net_id, x_mm=x_mm, y_mm=y_mm, kind=kind)
def pcb_load(board_id: str, pin_id: str, return_pin_id: str, current_a: f64, description: str):
return PcbLoad(id=board_id + ":" + pin_id + ">" + return_pin_id, board_id=board_id, pin_id=pin_id, return_pin_id=return_pin_id, current_a=current_a, description=description)
def route_points(x1: f64, y1: f64, x2: f64, y2: f64):
# One orthogonal leg then a 45-degree diagonal, the way real copper is routed.
dx = x2 - x1
dy = y2 - y1
if abs(dx) >= abs(dy):
run = abs(dx) - abs(dy)
mid_x = x1 + run if dx >= 0.0 else x1 - run
return [[x1, y1], [mid_x, y1], [x2, y2]]
rise = abs(dy) - abs(dx)
mid_y = y1 + rise if dy >= 0.0 else y1 - rise
return [[x1, y1], [x1, mid_y], [x2, y2]]
def polyline_length_mm(points: list[list[f64]]):
mut total = 0.0
for index in range(1, len(points)):
dx = points[index][0] - points[index - 1][0]
dy = points[index][1] - points[index - 1][1]
total = total + math.sqrt(dx * dx + dy * dy)
return total
def pin_index_map(pins: list[PcbPin]) -> dict[str, int]:
mut mapping = {}
for index in range(0, len(pins)):
mapping[pins[index].id] = index
return mapping
def layer_depth_mm(stackup: PcbStackup, layer_id: str):
mut depth = 0.0
for layer in stackup.layers:
if layer.id == layer_id:
return depth
depth = depth + layer.copper_um * 0.001 + layer.dielectric_mm
return depth
def layer_copper_um(stackup: PcbStackup, layer_id: str):
for layer in stackup.layers:
if layer.id == layer_id:
return layer.copper_um
return 35.0
def layer_is_external(stackup: PcbStackup, layer_id: str):
mut first = ""
mut last = ""
for layer in stackup.layers:
if layer.copper_um > 0.0:
if len(first) == 0:
first = layer.id
last = layer.id
return layer_id == first or layer_id == last
def stackup_thickness_mm(layers: list[PcbLayer]):
mut total = 0.0
for layer in layers:
total = total + layer.copper_um * 0.001 + layer.dielectric_mm
return total
def ipc_min_width_mm(current_a: f64, copper_um: f64, external: bool) !{}:
# A_mils2 = (I / (k * dT^0.44))^(1/0.725); width_mils = A_mils2 / thickness_mils.
# 1 mil = 0.0254 mm, so copper_um / 25.4 turns a copper weight into mils and
# the final * 0.0254 turns the required width back into millimetres.
# IPC-2221B figure 6-4 is fitted for I <= 35 A and A <= 700 mils^2. The traction
# busbar and the printed sill conductors run well above that, so their numbers are
# an extrapolation of the published curve, not a standard-validated result.
if current_a <= 0.0:
return 0.0
factor = IPC_K_EXTERNAL if external else IPC_K_INTERNAL
area_mils2 = ipc2221_cross_section_mils2(current_a, factor, IPC_ALLOWED_RISE_C)
return area_mils2 / (copper_um / 25.4) * 0.0254
def ipc_temperature_rise_c(current_a: f64, width_mm: f64, copper_um: f64, external: bool) !{}:
# Same relation inverted: dT = (I / (k * A^0.725))^(1/0.44).
if current_a <= 0.0:
return 0.0
factor = IPC_K_EXTERNAL if external else IPC_K_INTERNAL
area_mils2 = (width_mm / 0.0254) * (copper_um / 25.4)
return ipc2221_temperature_rise_c(current_a, factor, area_mils2)
def iec60664_creepage_mm(working_voltage_v: f64):
# IEC 60664-1 table 4, pollution degree 2, material group IIIa, linear between steps.
last = len(IEC60664_VOLTAGE_STEPS) - 1
if working_voltage_v <= IEC60664_VOLTAGE_STEPS[0]:
return IEC60664_CREEPAGE_MM[0]
for index in range(1, last + 1):
if working_voltage_v <= IEC60664_VOLTAGE_STEPS[index]:
low_v = IEC60664_VOLTAGE_STEPS[index - 1]
high_v = IEC60664_VOLTAGE_STEPS[index]
low_mm = IEC60664_CREEPAGE_MM[index - 1]
high_mm = IEC60664_CREEPAGE_MM[index]
return low_mm + (high_mm - low_mm) * (working_voltage_v - low_v) / (high_v - low_v)
return IEC60664_CREEPAGE_MM[last] * working_voltage_v / IEC60664_VOLTAGE_STEPS[last]
def route_plan_for(net: PcbNet, defaults: list[PcbRoutePlan], overrides: list[PcbRoutePlan]):
for plan in overrides:
if plan.net_id == net.id:
return plan
key = net_class_key(net.net_class)
for plan in defaults:
if plan.net_id == key:
return plan
return defaults[0]
def build_segments(nets: list[PcbNet], defaults: list[PcbRoutePlan], overrides: list[PcbRoutePlan]):
mut segments = []
for net in nets:
plan = route_plan_for(net, defaults, overrides)
for index in range(1, len(net.pin_ids)):
origin = net.pin_ids[0] if plan.topology == "star" else net.pin_ids[index - 1]
segments.append(PcbSegment(
id=net.id + "-s" + str(index),
net_id=net.id,
layer_id=plan.layer_id,
width_mm=plan.width_mm,
from_pin_id=origin,
to_pin_id=net.pin_ids[index],
via_count=plan.via_count,
via_drill_mm=plan.via_drill_mm,
via_pad_mm=plan.via_pad_mm,
via_layer_id=plan.via_layer_id,
))
return segments
def segment_polyline(segment: PcbSegment, pins: list[PcbPin], index_of: dict[str, int]):
origin = pins[index_of.get(segment.from_pin_id, 0)]
target = pins[index_of.get(segment.to_pin_id, 0)]
return route_points(origin.x_mm, origin.y_mm, target.x_mm, target.y_mm)
def single_via_resistance_ohm(stackup: PcbStackup, segment: PcbSegment) !{}:
height_mm = max(0.05, abs(layer_depth_mm(stackup, segment.via_layer_id) - layer_depth_mm(stackup, segment.layer_id)))
return via_barrel_resistance_ohm(operating_resistivity(), height_mm * 0.001, segment.via_drill_mm * 0.001, VIA_PLATING_UM * 0.000001)
def via_series_resistance_ohm(stackup: PcbStackup, segment: PcbSegment) !{}:
# A buried run needs one transition down and one back up; each is a parallel group.
if segment.via_count == 0:
return 0.0
return 2.0 * single_via_resistance_ohm(stackup, segment) / float(segment.via_count)
def copper_resistances_ohm(stackup: PcbStackup, segments: list[PcbSegment], lengths_mm: list[f64]) !{}:
rho = operating_resistivity()
mut values = []
for index in range(0, len(segments)):
segment = segments[index]
thickness = layer_copper_um(stackup, segment.layer_id) * 0.000001
values.append(conductor_resistance_ohm(rho, lengths_mm[index] * 0.001, segment.width_mm * 0.001, thickness))
return values
def solve_linear(matrix: list[list[f64]], rhs: list[f64]):
equation:
solution := solve(matrix, rhs)
mut values = []
for index in range(0, len(rhs)):
values.append(float(solution[index]))
return values
def solve_network(pins: list[PcbPin], nets: list[PcbNet], segments: list[PcbSegment], resistances_ohm: list[f64], loads: list[PcbLoad]):
# The unknown is the deviation from each net's declared feed potential. Every
# branch stays inside one net, so the nominal levels cancel out of `G v = i`
# exactly and the solve runs on millivolt-scale numbers instead of hundreds of
# volts, which keeps the KCL residual at the floating-point floor on the 800 V
# busbars. Absolute node potentials are restored after the solve.
index_of = pin_index_map(pins)
count = len(pins)
mut nominal = []
mut is_fixed = []
for index in range(0, count):
nominal.append(0.0)
is_fixed.append(false)
for net in nets:
level = 0.0 if net.is_reference else net.nominal_voltage_v
for pin_id in net.pin_ids:
nominal[index_of.get(pin_id, 0)] = level
is_fixed[index_of.get(net.pin_ids[0], 0)] = true
mut unknown_of = []
mut unknowns = 0
for index in range(0, count):
if is_fixed[index]:
unknown_of.append(-1)
else:
unknown_of.append(unknowns)
unknowns = unknowns + 1
mut conductance = []
mut rhs = []
for row in range(0, unknowns):
mut cells = []
for column in range(0, unknowns):
cells.append(0.0)
conductance.append(cells)
rhs.append(0.0)
for index in range(0, len(segments)):
left = index_of.get(segments[index].from_pin_id, 0)
right = index_of.get(segments[index].to_pin_id, 0)
gain = 1.0 / resistances_ohm[index]
left_unknown = unknown_of[left]
right_unknown = unknown_of[right]
if left_unknown >= 0:
conductance[left_unknown][left_unknown] = conductance[left_unknown][left_unknown] + gain
if right_unknown >= 0:
conductance[left_unknown][right_unknown] = conductance[left_unknown][right_unknown] - gain
if right_unknown >= 0:
conductance[right_unknown][right_unknown] = conductance[right_unknown][right_unknown] + gain
if left_unknown >= 0:
conductance[right_unknown][left_unknown] = conductance[right_unknown][left_unknown] - gain
mut injected = []
for index in range(0, count):
injected.append(0.0)
for load in loads:
sink = index_of.get(load.pin_id, 0)
source = index_of.get(load.return_pin_id, 0)
injected[sink] = injected[sink] - load.current_a
injected[source] = injected[source] + load.current_a
if unknown_of[sink] >= 0:
rhs[unknown_of[sink]] = rhs[unknown_of[sink]] - load.current_a
if unknown_of[source] >= 0:
rhs[unknown_of[source]] = rhs[unknown_of[source]] + load.current_a
deviations = solve_linear(conductance, rhs)
mut residual = 0.0
for row in range(0, unknowns):
mut accumulated = 0.0
for column in range(0, unknowns):
accumulated = accumulated + conductance[row][column] * deviations[column]
residual = max(residual, abs(accumulated - rhs[row]))
mut node_ids = []
mut voltages = []
mut offsets = []
mut flow = []
for index in range(0, count):
node_ids.append(pins[index].id)
offset = 0.0 if is_fixed[index] else deviations[unknown_of[index]]
offsets.append(offset)
voltages.append(nominal[index] + offset)
flow.append(0.0)
mut currents = []
for index in range(0, len(segments)):
left = index_of.get(segments[index].from_pin_id, 0)
right = index_of.get(segments[index].to_pin_id, 0)
branch = (offsets[left] - offsets[right]) / resistances_ohm[index]
currents.append(branch)
flow[left] = flow[left] + branch
flow[right] = flow[right] - branch
mut kcl = 0.0
mut supplied = 0.0
for index in range(0, count):
if is_fixed[index]:
supplied = supplied + max(0.0, flow[index] - injected[index])
else:
kcl = max(kcl, abs(flow[index] - injected[index]))
return PcbSolution(node_ids=node_ids, node_voltages=voltages, branch_currents=currents, fixed_count=count - unknowns, solver_residual=residual, kcl_residual_a=kcl, source_current_a=supplied)
def assemble_board(board_id: str, name: str, host_part_id: str, zone: str, substrate: str, stackup: PcbStackup, components: list[PcbComponent], pins: list[PcbPin], nets: list[PcbNet], defaults: list[PcbRoutePlan], overrides: list[PcbRoutePlan], loads: list[PcbLoad], outline_width_mm: f64, outline_height_mm: f64) !{}:
index_of = pin_index_map(pins)
segments = build_segments(nets, defaults, overrides)
mut lengths = []
for segment in segments:
lengths.append(max(MIN_SEGMENT_LENGTH_MM, polyline_length_mm(segment_polyline(segment, pins, index_of))))
copper_ohm = copper_resistances_ohm(stackup, segments, lengths)
mut branch_ohm = []
for index in range(0, len(segments)):
branch_ohm.append(copper_ohm[index] + via_series_resistance_ohm(stackup, segments[index]))
solution = solve_network(pins, nets, segments, branch_ohm, loads)
mut traces = []
mut vias = []
for index in range(0, len(segments)):
segment = segments[index]
current = abs(solution.branch_currents[index])
copper_um = layer_copper_um(stackup, segment.layer_id)
external = layer_is_external(stackup, segment.layer_id)
required = ipc_min_width_mm(current, copper_um, external)
traces.append(PcbTrace(
id=segment.id,
net_id=segment.net_id,
layer_id=segment.layer_id,
width_mm=segment.width_mm,
length_mm=lengths[index],
current_a=current,
from_pin_id=segment.from_pin_id,
to_pin_id=segment.to_pin_id,
resistance_mohm=copper_ohm[index] * 1000.0,
voltage_drop_mv=current * copper_ohm[index] * 1000.0,
power_loss_mw=joule_loss_w(current, copper_ohm[index]) * 1000.0,
temperature_rise_c=ipc_temperature_rise_c(current, segment.width_mm, copper_um, external),
ipc_min_width_mm=required,
compliant=segment.width_mm >= required,
))
if segment.via_count > 0:
barrel_mohm = single_via_resistance_ohm(stackup, segment) * 1000.0
share = current / float(segment.via_count)
for barrel in range(1, segment.via_count + 1):
vias.append(PcbVia(id=segment.id + "-vd" + str(barrel), net_id=segment.net_id, drill_mm=segment.via_drill_mm, pad_mm=segment.via_pad_mm, from_layer_id=segment.via_layer_id, to_layer_id=segment.layer_id, current_a=share, resistance_mohm=barrel_mohm))
vias.append(PcbVia(id=segment.id + "-vu" + str(barrel), net_id=segment.net_id, drill_mm=segment.via_drill_mm, pad_mm=segment.via_pad_mm, from_layer_id=segment.layer_id, to_layer_id=segment.via_layer_id, current_a=share, resistance_mohm=barrel_mohm))
return PcbBoard(id=board_id, name=name, host_part_id=host_part_id, stackup=stackup, components=components, pins=pins, nets=nets, traces=traces, vias=vias, outline_width_mm=outline_width_mm, outline_height_mm=outline_height_mm, zone=zone, substrate=substrate)
def bms_slave_stackup():
layers = [
PcbLayer(id="bms-smt", name="Top soldermask", function=PcbLayerFunction.soldermask, copper_um=0.0, dielectric_mm=0.0254, dielectric_er=3.6, material="LPI soldermask"),
PcbLayer(id="bms-l1", name="Top signal", function=PcbLayerFunction.signal, copper_um=35.0, dielectric_mm=0.0, dielectric_er=1.0, material="electrodeposited copper 1 oz"),
PcbLayer(id="bms-pp1", name="Prepreg 1", function=PcbLayerFunction.prepreg, copper_um=0.0, dielectric_mm=0.2032, dielectric_er=4.3, material="2x1080 FR-4 prepreg"),
PcbLayer(id="bms-l2", name="Inner ground plane", function=PcbLayerFunction.plane, copper_um=17.5, dielectric_mm=0.0, dielectric_er=1.0, material="rolled copper 0.5 oz"),
PcbLayer(id="bms-core", name="Core", function=PcbLayerFunction.core, copper_um=0.0, dielectric_mm=1.0668, dielectric_er=4.5, material="FR-4 Tg170 core"),
PcbLayer(id="bms-l3", name="Inner cell-tap plane", function=PcbLayerFunction.plane, copper_um=17.5, dielectric_mm=0.0, dielectric_er=1.0, material="rolled copper 0.5 oz"),
PcbLayer(id="bms-pp2", name="Prepreg 2", function=PcbLayerFunction.prepreg, copper_um=0.0, dielectric_mm=0.2032, dielectric_er=4.3, material="2x1080 FR-4 prepreg"),
PcbLayer(id="bms-l4", name="Bottom signal", function=PcbLayerFunction.signal, copper_um=35.0, dielectric_mm=0.0, dielectric_er=1.0, material="electrodeposited copper 1 oz"),
PcbLayer(id="bms-smb", name="Bottom soldermask", function=PcbLayerFunction.soldermask, copper_um=0.0, dielectric_mm=0.0254, dielectric_er=3.6, material="LPI soldermask"),
]
return PcbStackup(id="stk-bms", name="FR-4 4-layer 1.63 mm, 35 um outer copper", layers=layers, total_thickness_mm=stackup_thickness_mm(layers), ipc_class=3, impedance_target_ohm=100.0)
def bms_slave_components():
return [
PcbComponent(refdes="U1", part_number="LTC6811-1", description="12-cell monitor with integrated isoSPI; 3 channels modelled at pin level", footprint="SSOP-48", pin_count=48, x_mm=64.0, y_mm=39.0, rotation_deg=0.0, power_dissipation_w=0.42, mass_g=0.9),
PcbComponent(refdes="T1", part_number="HX1188NL", description="isoSPI pulse transformer, port A", footprint="SM-6P", pin_count=6, x_mm=22.0, y_mm=58.0, rotation_deg=0.0, power_dissipation_w=0.04, mass_g=0.6),
PcbComponent(refdes="T2", part_number="HX1188NL", description="isoSPI pulse transformer, port B", footprint="SM-6P", pin_count=6, x_mm=22.0, y_mm=20.0, rotation_deg=0.0, power_dissipation_w=0.04, mass_g=0.6),
PcbComponent(refdes="J1", part_number="GH-4P", description="Daisy-chain connector, upstream module", footprint="JST-GH-4", pin_count=4, x_mm=6.0, y_mm=58.0, rotation_deg=90.0, power_dissipation_w=0.0, mass_g=0.5),
PcbComponent(refdes="J2", part_number="GH-4P", description="Daisy-chain connector, downstream module", footprint="JST-GH-4", pin_count=4, x_mm=6.0, y_mm=20.0, rotation_deg=90.0, power_dissipation_w=0.0, mass_g=0.5),
PcbComponent(refdes="J3", part_number="MX150-7", description="Cell-tap and thermistor harness header", footprint="MOLEX-MX150-7", pin_count=7, x_mm=118.0, y_mm=39.0, rotation_deg=270.0, power_dissipation_w=0.0, mass_g=6.0),
PcbComponent(refdes="Q1", part_number="2N7002E", description="Passive balancing switch, channel 1", footprint="SOT-23", pin_count=3, x_mm=92.0, y_mm=26.0, rotation_deg=0.0, power_dissipation_w=0.01, mass_g=0.01),
PcbComponent(refdes="Q2", part_number="2N7002E", description="Passive balancing switch, channel 2", footprint="SOT-23", pin_count=3, x_mm=92.0, y_mm=39.0, rotation_deg=0.0, power_dissipation_w=0.01, mass_g=0.01),
PcbComponent(refdes="Q3", part_number="2N7002E", description="Passive balancing switch, channel 3", footprint="SOT-23", pin_count=3, x_mm=92.0, y_mm=52.0, rotation_deg=0.0, power_dissipation_w=0.01, mass_g=0.01),
PcbComponent(refdes="R1", part_number="CRGH1206-33R", description="Balancing bleed resistor, channel 1", footprint="1206", pin_count=2, x_mm=104.0, y_mm=26.0, rotation_deg=0.0, power_dissipation_w=0.53, mass_g=0.02),
PcbComponent(refdes="R2", part_number="CRGH1206-33R", description="Balancing bleed resistor, channel 2", footprint="1206", pin_count=2, x_mm=104.0, y_mm=39.0, rotation_deg=0.0, power_dissipation_w=0.53, mass_g=0.02),
PcbComponent(refdes="R3", part_number="CRGH1206-33R", description="Balancing bleed resistor, channel 3", footprint="1206", pin_count=2, x_mm=104.0, y_mm=52.0, rotation_deg=0.0, power_dissipation_w=0.53, mass_g=0.02),
PcbComponent(refdes="RT1", part_number="NTCG163JF103FT", description="Module thermistor, cell block front", footprint="0603", pin_count=2, x_mm=40.0, y_mm=33.0, rotation_deg=0.0, power_dissipation_w=0.001, mass_g=0.005),
PcbComponent(refdes="RT2", part_number="NTCG163JF103FT", description="Module thermistor, cell block rear", footprint="0603", pin_count=2, x_mm=40.0, y_mm=27.0, rotation_deg=0.0, power_dissipation_w=0.001, mass_g=0.005),
PcbComponent(refdes="C1", part_number="GRM188R71H104K", description="Monitor regulator decoupling, 100 nF", footprint="0603", pin_count=2, x_mm=48.0, y_mm=24.0, rotation_deg=0.0, power_dissipation_w=0.0, mass_g=0.005),
PcbComponent(refdes="C2", part_number="GRM31CR71H105K", description="Cell-group bulk capacitor, 1 uF", footprint="1206", pin_count=2, x_mm=80.0, y_mm=60.0, rotation_deg=0.0, power_dissipation_w=0.0, mass_g=0.02),
]
def bms_slave_pins():
return [
pcb_pin("U1", 1, "V+", "bmsA-stack-plus", 72.0, 52.0, PcbPinKind.power),
pcb_pin("U1", 2, "C3", "bmsA-stack-plus", 72.0, 49.0, PcbPinKind.analog),
pcb_pin("U1", 3, "C2", "bmsA-tap2", 72.0, 46.0, PcbPinKind.analog),
pcb_pin("U1", 4, "C1", "bmsA-tap1", 72.0, 43.0, PcbPinKind.analog),
pcb_pin("U1", 5, "V-", "bmsA-gnd", 72.0, 40.0, PcbPinKind.ground),
pcb_pin("U1", 6, "S3", "bmsA-bal3", 72.0, 37.0, PcbPinKind.signal),
pcb_pin("U1", 7, "S2", "bmsA-bal2", 72.0, 34.0, PcbPinKind.signal),
pcb_pin("U1", 8, "S1", "bmsA-bal1", 72.0, 31.0, PcbPinKind.signal),
pcb_pin("U1", 9, "VREG", "bmsA-vreg", 56.0, 31.0, PcbPinKind.power),
pcb_pin("U1", 10, "GPIO1", "bmsA-ntc1", 56.0, 34.0, PcbPinKind.analog),
pcb_pin("U1", 11, "GPIO2", "bmsA-ntc2", 56.0, 37.0, PcbPinKind.analog),
pcb_pin("U1", 12, "IPA", "bmsA-isoa-p", 56.0, 40.0, PcbPinKind.signal),
pcb_pin("U1", 13, "IMA", "bmsA-isoa-n", 56.0, 43.0, PcbPinKind.signal),
pcb_pin("U1", 14, "IPB", "bmsA-isob-p", 56.0, 46.0, PcbPinKind.signal),
pcb_pin("U1", 15, "IMB", "bmsA-isob-n", 56.0, 49.0, PcbPinKind.signal),
pcb_pin("T1", 1, "P1", "bmsA-isoa-p", 28.0, 62.0, PcbPinKind.signal),
pcb_pin("T1", 2, "P2", "bmsA-isoa-n", 28.0, 58.0, PcbPinKind.signal),
pcb_pin("T1", 3, "PSH", "bmsA-gnd", 28.0, 54.0, PcbPinKind.ground),
pcb_pin("T1", 4, "S1", "bmsA-cablea-p", 16.0, 62.0, PcbPinKind.signal),
pcb_pin("T1", 5, "S2", "bmsA-cablea-n", 16.0, 58.0, PcbPinKind.signal),
pcb_pin("T1", 6, "SSH", "bmsA-gnd", 16.0, 54.0, PcbPinKind.ground),
pcb_pin("T2", 1, "P1", "bmsA-isob-p", 28.0, 24.0, PcbPinKind.signal),
pcb_pin("T2", 2, "P2", "bmsA-isob-n", 28.0, 20.0, PcbPinKind.signal),
pcb_pin("T2", 3, "PSH", "bmsA-gnd", 28.0, 16.0, PcbPinKind.ground),
pcb_pin("T2", 4, "S1", "bmsA-cableb-p", 16.0, 24.0, PcbPinKind.signal),
pcb_pin("T2", 5, "S2", "bmsA-cableb-n", 16.0, 20.0, PcbPinKind.signal),
pcb_pin("T2", 6, "SSH", "bmsA-gnd", 16.0, 16.0, PcbPinKind.ground),
pcb_pin("J1", 1, "UP_P", "bmsA-cablea-p", 6.0, 64.0, PcbPinKind.signal),
pcb_pin("J1", 2, "UP_N", "bmsA-cablea-n", 6.0, 60.0, PcbPinKind.signal),
pcb_pin("J1", 3, "UP_SH", "bmsA-gnd", 6.0, 56.0, PcbPinKind.ground),
pcb_pin("J1", 4, "UP_DRAIN", "bmsA-gnd", 6.0, 52.0, PcbPinKind.ground),
pcb_pin("J2", 1, "DN_P", "bmsA-cableb-p", 6.0, 26.0, PcbPinKind.signal),
pcb_pin("J2", 2, "DN_N", "bmsA-cableb-n", 6.0, 22.0, PcbPinKind.signal),
pcb_pin("J2", 3, "DN_SH", "bmsA-gnd", 6.0, 18.0, PcbPinKind.ground),
pcb_pin("J2", 4, "DN_DRAIN", "bmsA-gnd", 6.0, 14.0, PcbPinKind.ground),
pcb_pin("J3", 1, "STACK+", "bmsA-stack-plus", 118.0, 57.0, PcbPinKind.power),
pcb_pin("J3", 2, "TAP2", "bmsA-tap2", 118.0, 51.0, PcbPinKind.power),
pcb_pin("J3", 3, "TAP1", "bmsA-tap1", 118.0, 45.0, PcbPinKind.power),
pcb_pin("J3", 4, "STACK-", "bmsA-gnd", 118.0, 39.0, PcbPinKind.ground),
pcb_pin("J3", 5, "NTC1", "bmsA-ntc1", 118.0, 33.0, PcbPinKind.thermal),
pcb_pin("J3", 6, "NTC2", "bmsA-ntc2", 118.0, 27.0, PcbPinKind.thermal),
pcb_pin("J3", 7, "NTC_RTN", "bmsA-gnd", 118.0, 21.0, PcbPinKind.ground),
pcb_pin("Q1", 1, "G", "bmsA-bal1", 89.0, 26.0, PcbPinKind.signal),
pcb_pin("Q1", 2, "S", "bmsA-gnd", 95.0, 24.0, PcbPinKind.ground),
pcb_pin("Q1", 3, "D", "bmsA-bleed1", 95.0, 28.0, PcbPinKind.power),
pcb_pin("Q2", 1, "G", "bmsA-bal2", 89.0, 39.0, PcbPinKind.signal),
pcb_pin("Q2", 2, "S", "bmsA-tap1", 95.0, 37.0, PcbPinKind.power),
pcb_pin("Q2", 3, "D", "bmsA-bleed2", 95.0, 41.0, PcbPinKind.power),
pcb_pin("Q3", 1, "G", "bmsA-bal3", 89.0, 52.0, PcbPinKind.signal),
pcb_pin("Q3", 2, "S", "bmsA-tap2", 95.0, 50.0, PcbPinKind.power),
pcb_pin("Q3", 3, "D", "bmsA-bleed3", 95.0, 54.0, PcbPinKind.power),
pcb_pin("R1", 1, "A", "bmsA-bleed1", 101.0, 26.0, PcbPinKind.power),
pcb_pin("R1", 2, "B", "bmsA-tap1", 107.0, 26.0, PcbPinKind.power),
pcb_pin("R2", 1, "A", "bmsA-bleed2", 101.0, 39.0, PcbPinKind.power),
pcb_pin("R2", 2, "B", "bmsA-tap2", 107.0, 39.0, PcbPinKind.power),
pcb_pin("R3", 1, "A", "bmsA-bleed3", 101.0, 52.0, PcbPinKind.power),
pcb_pin("R3", 2, "B", "bmsA-stack-plus", 107.0, 52.0, PcbPinKind.power),
pcb_pin("RT1", 1, "A", "bmsA-ntc1", 38.2, 33.0, PcbPinKind.thermal),
pcb_pin("RT1", 2, "B", "bmsA-gnd", 41.8, 33.0, PcbPinKind.ground),
pcb_pin("RT2", 1, "A", "bmsA-ntc2", 38.2, 27.0, PcbPinKind.thermal),
pcb_pin("RT2", 2, "B", "bmsA-gnd", 41.8, 27.0, PcbPinKind.ground),
pcb_pin("C1", 1, "A", "bmsA-vreg", 46.2, 24.0, PcbPinKind.power),
pcb_pin("C1", 2, "B", "bmsA-gnd", 49.8, 24.0, PcbPinKind.ground),
pcb_pin("C2", 1, "A", "bmsA-stack-plus", 78.0, 60.0, PcbPinKind.power),
pcb_pin("C2", 2, "B", "bmsA-gnd", 82.0, 60.0, PcbPinKind.ground),
]
def bms_slave_nets():
return [
PcbNet(id="bmsA-stack-plus", name="Modelled cell-group positive", net_class=PcbNetClass.power, pin_ids=["J3.1", "U1.1", "U1.2", "C2.1", "R3.2"], nominal_voltage_v=12.6, current_a=0.1405, is_reference=false),
PcbNet(id="bmsA-tap2", name="Cell tap 2", net_class=PcbNetClass.power, pin_ids=["J3.2", "U1.3", "Q3.2", "R2.2"], nominal_voltage_v=8.4, current_a=0.127, is_reference=false),
PcbNet(id="bmsA-tap1", name="Cell tap 1", net_class=PcbNetClass.power, pin_ids=["J3.3", "U1.4", "Q2.2", "R1.2"], nominal_voltage_v=4.2, current_a=0.127, is_reference=false),
PcbNet(id="bmsA-gnd", name="Module reference, cell-group negative", net_class=PcbNetClass.ground, pin_ids=["J3.4", "J3.7", "U1.5", "Q1.2", "C1.2", "C2.2", "RT1.2", "RT2.2", "T1.3", "T1.6", "T2.3", "T2.6", "J1.3", "J1.4", "J2.3", "J2.4"], nominal_voltage_v=0.0, current_a=0.1414, is_reference=true),
PcbNet(id="bmsA-vreg", name="Monitor regulator rail", net_class=PcbNetClass.power, pin_ids=["U1.9", "C1.1"], nominal_voltage_v=5.0, current_a=0.0, is_reference=false),
PcbNet(id="bmsA-ntc1", name="Thermistor channel 1", net_class=PcbNetClass.analog, pin_ids=["U1.10", "RT1.1", "J3.5"], nominal_voltage_v=2.5, current_a=0.00025, is_reference=false),
PcbNet(id="bmsA-ntc2", name="Thermistor channel 2", net_class=PcbNetClass.analog, pin_ids=["U1.11", "RT2.1", "J3.6"], nominal_voltage_v=2.5, current_a=0.00025, is_reference=false),
PcbNet(id="bmsA-isoa-p", name="isoSPI port A positive", net_class=PcbNetClass.signal, pin_ids=["U1.12", "T1.1"], nominal_voltage_v=1.25, current_a=0.02, is_reference=false),
PcbNet(id="bmsA-isoa-n", name="isoSPI port A negative", net_class=PcbNetClass.signal, pin_ids=["U1.13", "T1.2"], nominal_voltage_v=0.0, current_a=0.02, is_reference=false),
PcbNet(id="bmsA-isob-p", name="isoSPI port B positive", net_class=PcbNetClass.signal, pin_ids=["U1.14", "T2.1"], nominal_voltage_v=1.25, current_a=0.02, is_reference=false),
PcbNet(id="bmsA-isob-n", name="isoSPI port B negative", net_class=PcbNetClass.signal, pin_ids=["U1.15", "T2.2"], nominal_voltage_v=0.0, current_a=0.02, is_reference=false),
PcbNet(id="bmsA-cablea-p", name="Daisy-chain cable A positive", net_class=PcbNetClass.signal, pin_ids=["T1.4", "J1.1"], nominal_voltage_v=1.25, current_a=0.02, is_reference=false),
PcbNet(id="bmsA-cablea-n", name="Daisy-chain cable A negative", net_class=PcbNetClass.signal, pin_ids=["T1.5", "J1.2"], nominal_voltage_v=0.0, current_a=0.02, is_reference=false),
PcbNet(id="bmsA-cableb-p", name="Daisy-chain cable B positive", net_class=PcbNetClass.signal, pin_ids=["T2.4", "J2.1"], nominal_voltage_v=1.25, current_a=0.02, is_reference=false),
PcbNet(id="bmsA-cableb-n", name="Daisy-chain cable B negative", net_class=PcbNetClass.signal, pin_ids=["T2.5", "J2.2"], nominal_voltage_v=0.0, current_a=0.02, is_reference=false),
PcbNet(id="bmsA-bal1", name="Balancing gate drive 1", net_class=PcbNetClass.signal, pin_ids=["U1.8", "Q1.1"], nominal_voltage_v=5.0, current_a=0.0002, is_reference=false),
PcbNet(id="bmsA-bal2", name="Balancing gate drive 2", net_class=PcbNetClass.signal, pin_ids=["U1.7", "Q2.1"], nominal_voltage_v=5.0, current_a=0.0002, is_reference=false),
PcbNet(id="bmsA-bal3", name="Balancing gate drive 3", net_class=PcbNetClass.signal, pin_ids=["U1.6", "Q3.1"], nominal_voltage_v=5.0, current_a=0.0002, is_reference=false),
PcbNet(id="bmsA-bleed1", name="Bleed path 1", net_class=PcbNetClass.power, pin_ids=["Q1.3", "R1.1"], nominal_voltage_v=0.0, current_a=0.127, is_reference=false),
PcbNet(id="bmsA-bleed2", name="Bleed path 2", net_class=PcbNetClass.power, pin_ids=["Q2.3", "R2.1"], nominal_voltage_v=4.2, current_a=0.127, is_reference=false),
PcbNet(id="bmsA-bleed3", name="Bleed path 3", net_class=PcbNetClass.power, pin_ids=["Q3.3", "R3.1"], nominal_voltage_v=8.4, current_a=0.127, is_reference=false),
]
def bms_slave_plans():
return [
PcbRoutePlan(net_id="power", layer_id="bms-l1", width_mm=1.2, topology="chain", via_count=0, via_drill_mm=0.3, via_pad_mm=0.6, via_layer_id="bms-l1"),
PcbRoutePlan(net_id="ground", layer_id="bms-l2", width_mm=3.0, topology="star", via_count=1, via_drill_mm=0.3, via_pad_mm=0.6, via_layer_id="bms-l1"),
PcbRoutePlan(net_id="signal", layer_id="bms-l1", width_mm=0.3, topology="chain", via_count=0, via_drill_mm=0.3, via_pad_mm=0.6, via_layer_id="bms-l1"),
PcbRoutePlan(net_id="analog", layer_id="bms-l1", width_mm=0.3, topology="chain", via_count=0, via_drill_mm=0.3, via_pad_mm=0.6, via_layer_id="bms-l1"),
PcbRoutePlan(net_id="hv", layer_id="bms-l1", width_mm=1.2, topology="chain", via_count=0, via_drill_mm=0.3, via_pad_mm=0.6, via_layer_id="bms-l1"),
PcbRoutePlan(net_id="can", layer_id="bms-l1", width_mm=0.3, topology="chain", via_count=0, via_drill_mm=0.3, via_pad_mm=0.6, via_layer_id="bms-l1"),
PcbRoutePlan(net_id="ethernet", layer_id="bms-l1", width_mm=0.25, topology="chain", via_count=0, via_drill_mm=0.3, via_pad_mm=0.6, via_layer_id="bms-l1"),
]
def bms_slave_overrides():
return [
PcbRoutePlan(net_id="bmsA-stack-plus", layer_id="bms-l3", width_mm=1.6, topology="chain", via_count=2, via_drill_mm=0.3, via_pad_mm=0.6, via_layer_id="bms-l1"),
PcbRoutePlan(net_id="bmsA-tap2", layer_id="bms-l3", width_mm=1.6, topology="chain", via_count=2, via_drill_mm=0.3, via_pad_mm=0.6, via_layer_id="bms-l1"),
PcbRoutePlan(net_id="bmsA-tap1", layer_id="bms-l3", width_mm=1.6, topology="chain", via_count=2, via_drill_mm=0.3, via_pad_mm=0.6, via_layer_id="bms-l1"),
]
def bms_slave_loads():
board = "bms-slave"
return [
pcb_load(board, "U1.1", "U1.5", 0.0135, "stack monitor active supply current"),
pcb_load(board, "R3.2", "R3.1", 0.127, "bleed resistor body, channel 3"),
pcb_load(board, "Q3.3", "Q3.2", 0.127, "balancing switch channel, channel 3"),
pcb_load(board, "R2.2", "R2.1", 0.127, "bleed resistor body, channel 2"),
pcb_load(board, "Q2.3", "Q2.2", 0.127, "balancing switch channel, channel 2"),
pcb_load(board, "R1.2", "R1.1", 0.127, "bleed resistor body, channel 1"),
pcb_load(board, "Q1.3", "Q1.2", 0.127, "balancing switch channel, channel 1"),
pcb_load(board, "RT1.1", "RT1.2", 0.00025, "thermistor 1 bias current"),
pcb_load(board, "RT2.1", "RT2.2", 0.00025, "thermistor 2 bias current"),
pcb_load(board, "T1.1", "T1.2", 0.02, "isoSPI port A transformer primary"),
pcb_load(board, "J1.1", "J1.2", 0.02, "isoSPI port A cable pair"),
pcb_load(board, "T2.1", "T2.2", 0.02, "isoSPI port B transformer primary"),
pcb_load(board, "J2.1", "J2.2", 0.02, "isoSPI port B cable pair"),
pcb_load(board, "Q1.1", "Q1.2", 0.0002, "gate drive current, channel 1"),
pcb_load(board, "Q2.1", "Q2.2", 0.0002, "gate drive current, channel 2"),
pcb_load(board, "Q3.1", "Q3.2", 0.0002, "gate drive current, channel 3"),
]
def bms_slave_board() !{}:
return assemble_board("bms-slave", "BMS cell-monitoring slave board", "battery-management", "battery enclosure, module head 3", "FR-4 Tg170, 4 copper layers, 35 um outer", bms_slave_stackup(), bms_slave_components(), bms_slave_pins(), bms_slave_nets(), bms_slave_plans(), bms_slave_overrides(), bms_slave_loads(), 128.0, 78.0)
def front_zone_stackup():
layers = [
PcbLayer(id="fzc-smt", name="Top soldermask", function=PcbLayerFunction.soldermask, copper_um=0.0, dielectric_mm=0.0254, dielectric_er=3.6, material="LPI soldermask"),
PcbLayer(id="fzc-l1", name="Top signal and power", function=PcbLayerFunction.signal, copper_um=70.0, dielectric_mm=0.0, dielectric_er=1.0, material="electrodeposited copper 2 oz"),
PcbLayer(id="fzc-pp1", name="Prepreg 1", function=PcbLayerFunction.prepreg, copper_um=0.0, dielectric_mm=0.1016, dielectric_er=4.2, material="1080 FR-4 prepreg"),
PcbLayer(id="fzc-l2", name="Dedicated ground plane", function=PcbLayerFunction.plane, copper_um=35.0, dielectric_mm=0.0, dielectric_er=1.0, material="rolled copper 1 oz"),
PcbLayer(id="fzc-core1", name="Core 1", function=PcbLayerFunction.core, copper_um=0.0, dielectric_mm=0.3, dielectric_er=4.5, material="FR-4 Tg170 core"),
PcbLayer(id="fzc-l3", name="Inner power routing", function=PcbLayerFunction.signal, copper_um=35.0, dielectric_mm=0.0, dielectric_er=1.0, material="rolled copper 1 oz"),
PcbLayer(id="fzc-pp2", name="Prepreg 2", function=PcbLayerFunction.prepreg, copper_um=0.0, dielectric_mm=0.5, dielectric_er=4.4, material="7628 FR-4 prepreg"),
PcbLayer(id="fzc-l4", name="Inner signal routing", function=PcbLayerFunction.signal, copper_um=35.0, dielectric_mm=0.0, dielectric_er=1.0, material="rolled copper 1 oz"),
PcbLayer(id="fzc-core2", name="Core 2", function=PcbLayerFunction.core, copper_um=0.0, dielectric_mm=0.3, dielectric_er=4.5, material="FR-4 Tg170 core"),
PcbLayer(id="fzc-l5", name="Supply plane", function=PcbLayerFunction.plane, copper_um=35.0, dielectric_mm=0.0, dielectric_er=1.0, material="rolled copper 1 oz"),
PcbLayer(id="fzc-pp3", name="Prepreg 3", function=PcbLayerFunction.prepreg, copper_um=0.0, dielectric_mm=0.1016, dielectric_er=4.2, material="1080 FR-4 prepreg"),
PcbLayer(id="fzc-l6", name="Bottom signal and power", function=PcbLayerFunction.signal, copper_um=70.0, dielectric_mm=0.0, dielectric_er=1.0, material="electrodeposited copper 2 oz"),
PcbLayer(id="fzc-smb", name="Bottom soldermask", function=PcbLayerFunction.soldermask, copper_um=0.0, dielectric_mm=0.0254, dielectric_er=3.6, material="LPI soldermask"),
]
return PcbStackup(id="stk-fzc", name="FR-4 6-layer 1.63 mm, dedicated ground plane", layers=layers, total_thickness_mm=stackup_thickness_mm(layers), ipc_class=3, impedance_target_ohm=100.0)
def front_zone_components():
return [
PcbComponent(refdes="U1", part_number="S32K344", description="Lockstep automotive MCU; 12 pins modelled of 172", footprint="LQFP-172", pin_count=172, x_mm=40.0, y_mm=70.0, rotation_deg=0.0, power_dissipation_w=1.05, mass_g=1.4),
PcbComponent(refdes="U2", part_number="LM5145", description="48 V to 12 V synchronous buck controller", footprint="HTSSOP-20", pin_count=20, x_mm=95.0, y_mm=88.0, rotation_deg=0.0, power_dissipation_w=0.18, mass_g=0.3),
PcbComponent(refdes="Q1", part_number="BSC098N10NS5", description="Buck high-side switch, 100 V", footprint="PQFN-5x6", pin_count=8, x_mm=108.0, y_mm=96.0, rotation_deg=0.0, power_dissipation_w=0.62, mass_g=0.2),
PcbComponent(refdes="Q2", part_number="BSC098N10NS5", description="Buck low-side switch, 100 V", footprint="PQFN-5x6", pin_count=8, x_mm=108.0, y_mm=82.0, rotation_deg=0.0, power_dissipation_w=0.88, mass_g=0.2),
PcbComponent(refdes="L1", part_number="XAL1510-103", description="10 uH shielded power inductor", footprint="IND-15x15", pin_count=2, x_mm=120.0, y_mm=89.0, rotation_deg=0.0, power_dissipation_w=0.71, mass_g=6.4),
PcbComponent(refdes="C1", part_number="CGA9N3X7S2A106K", description="48 V input bulk capacitor, 10 uF 100 V", footprint="1210", pin_count=2, x_mm=95.0, y_mm=100.0, rotation_deg=0.0, power_dissipation_w=0.02, mass_g=0.1),
PcbComponent(refdes="C2", part_number="CGA9P1X7R1E476M", description="12 V bulk capacitor, 47 uF 25 V", footprint="1210", pin_count=2, x_mm=130.0, y_mm=100.0, rotation_deg=0.0, power_dissipation_w=0.02, mass_g=0.1),
PcbComponent(refdes="U6", part_number="TPS7B6950-Q1", description="12 V to 5 V low-dropout regulator", footprint="DPAK", pin_count=3, x_mm=95.0, y_mm=62.0, rotation_deg=0.0, power_dissipation_w=3.85, mass_g=0.4),
PcbComponent(refdes="C3", part_number="GRM21BR61A106K", description="5 V output capacitor, 10 uF", footprint="0805", pin_count=2, x_mm=105.0, y_mm=55.0, rotation_deg=0.0, power_dissipation_w=0.0, mass_g=0.01),
PcbComponent(refdes="U7", part_number="TPS7A2033", description="5 V to 3V3 low-dropout regulator", footprint="SOT-23-5", pin_count=5, x_mm=80.0, y_mm=55.0, rotation_deg=0.0, power_dissipation_w=0.73, mass_g=0.02),
PcbComponent(refdes="C4", part_number="GRM21BR61A106K", description="3V3 output capacitor, 10 uF", footprint="0805", pin_count=2, x_mm=68.0, y_mm=50.0, rotation_deg=0.0, power_dissipation_w=0.0, mass_g=0.01),
PcbComponent(refdes="U3", part_number="TCAN1462-Q1", description="CAN-FD transceiver, powertrain bus", footprint="SOIC-8", pin_count=8, x_mm=24.0, y_mm=92.0, rotation_deg=0.0, power_dissipation_w=0.21, mass_g=0.07),
PcbComponent(refdes="U4", part_number="TCAN1462-Q1", description="CAN-FD transceiver, chassis bus", footprint="SOIC-8", pin_count=8, x_mm=24.0, y_mm=76.0, rotation_deg=0.0, power_dissipation_w=0.21, mass_g=0.07),
PcbComponent(refdes="R1", part_number="CRCW0603120R", description="CAN bus split termination, powertrain", footprint="0603", pin_count=2, x_mm=14.0, y_mm=96.0, rotation_deg=90.0, power_dissipation_w=0.07, mass_g=0.005),
PcbComponent(refdes="R2", part_number="CRCW0603120R", description="CAN bus split termination, chassis", footprint="0603", pin_count=2, x_mm=14.0, y_mm=72.0, rotation_deg=90.0, power_dissipation_w=0.07, mass_g=0.005),
PcbComponent(refdes="U5", part_number="TJA1101B", description="100BASE-T1 Ethernet PHY", footprint="HVQFN-36", pin_count=36, x_mm=26.0, y_mm=40.0, rotation_deg=0.0, power_dissipation_w=0.42, mass_g=0.1),
PcbComponent(refdes="T1", part_number="TP-23015NL", description="100BASE-T1 common-mode choke and magnetics", footprint="SM-4P", pin_count=4, x_mm=12.0, y_mm=40.0, rotation_deg=0.0, power_dissipation_w=0.03, mass_g=0.4),
PcbComponent(refdes="U8", part_number="VN7016AJ", description="High-side eFuse channel 1, low-beam lamp", footprint="PowerSSO-12", pin_count=12, x_mm=120.0, y_mm=62.0, rotation_deg=0.0, power_dissipation_w=0.24, mass_g=0.3),
PcbComponent(refdes="U9", part_number="VN7016AJ", description="High-side eFuse channel 2, washer heater", footprint="PowerSSO-12", pin_count=12, x_mm=120.0, y_mm=44.0, rotation_deg=0.0, power_dissipation_w=0.17, mass_g=0.3),
PcbComponent(refdes="U10", part_number="VN7016AJ", description="High-side eFuse channel 3, grille shutter actuator", footprint="PowerSSO-12", pin_count=12, x_mm=120.0, y_mm=26.0, rotation_deg=0.0, power_dissipation_w=0.11, mass_g=0.3),
PcbComponent(refdes="J1", part_number="MX150-10", description="Zone supply, CAN and Ethernet header", footprint="MOLEX-MX150-10", pin_count=10, x_mm=139.0, y_mm=86.0, rotation_deg=270.0, power_dissipation_w=0.0, mass_g=8.0),
PcbComponent(refdes="J2", part_number="MX150-7", description="Switched load output header", footprint="MOLEX-MX150-7", pin_count=7, x_mm=139.0, y_mm=34.0, rotation_deg=270.0, power_dissipation_w=0.0, mass_g=6.0),
]
def front_zone_pins():
return [
pcb_pin("U1", 1, "VDD_3V3", "fzc-3v3", 33.0, 77.0, PcbPinKind.power),
pcb_pin("U1", 2, "VSS", "fzc-gnd", 33.0, 73.0, PcbPinKind.ground),
pcb_pin("U1", 3, "CAN0_TX", "fzc-can0-tx", 33.0, 69.0, PcbPinKind.signal),
pcb_pin("U1", 4, "CAN0_RX", "fzc-can0-rx", 33.0, 65.0, PcbPinKind.signal),
pcb_pin("U1", 5, "CAN1_TX", "fzc-can1-tx", 33.0, 63.0, PcbPinKind.signal),
pcb_pin("U1", 6, "CAN1_RX", "fzc-can1-rx", 37.0, 63.0, PcbPinKind.signal),
pcb_pin("U1", 7, "MII_TXD", "fzc-phy-txd", 41.0, 63.0, PcbPinKind.signal),
pcb_pin("U1", 8, "MII_RXD", "fzc-phy-rxd", 45.0, 63.0, PcbPinKind.signal),
pcb_pin("U1", 9, "PWM_HS1", "fzc-hs1-in", 47.0, 65.0, PcbPinKind.signal),
pcb_pin("U1", 10, "ADC_ISENSE", "fzc-hs-sense", 47.0, 69.0, PcbPinKind.analog),
pcb_pin("U1", 11, "PWM_HS2", "fzc-hs2-in", 47.0, 73.0, PcbPinKind.signal),
pcb_pin("U1", 12, "PWM_HS3", "fzc-hs3-in", 47.0, 77.0, PcbPinKind.signal),
pcb_pin("U2", 1, "VIN", "fzc-48v", 89.0, 92.0, PcbPinKind.power),
pcb_pin("U2", 2, "HO", "fzc-gate-hs", 101.0, 92.0, PcbPinKind.signal),
pcb_pin("U2", 3, "SW", "fzc-sw", 101.0, 88.0, PcbPinKind.power),
pcb_pin("U2", 4, "LO", "fzc-gate-ls", 101.0, 84.0, PcbPinKind.signal),
pcb_pin("U2", 5, "AGND", "fzc-gnd", 89.0, 84.0, PcbPinKind.ground),
pcb_pin("Q1", 1, "D", "fzc-48v", 108.0, 100.0, PcbPinKind.power),
pcb_pin("Q1", 2, "G", "fzc-gate-hs", 104.0, 96.0, PcbPinKind.signal),
pcb_pin("Q1", 3, "S", "fzc-sw", 108.0, 92.0, PcbPinKind.power),
pcb_pin("Q2", 1, "D", "fzc-sw", 108.0, 86.0, PcbPinKind.power),
pcb_pin("Q2", 2, "G", "fzc-gate-ls", 104.0, 82.0, PcbPinKind.signal),
pcb_pin("Q2", 3, "S", "fzc-gnd", 108.0, 78.0, PcbPinKind.ground),
pcb_pin("L1", 1, "A", "fzc-sw", 114.0, 89.0, PcbPinKind.power),
pcb_pin("L1", 2, "B", "fzc-12v", 126.0, 89.0, PcbPinKind.power),
pcb_pin("C1", 1, "A", "fzc-48v", 92.0, 100.0, PcbPinKind.power),
pcb_pin("C1", 2, "B", "fzc-gnd", 98.0, 100.0, PcbPinKind.ground),
pcb_pin("C2", 1, "A", "fzc-12v", 127.0, 100.0, PcbPinKind.power),
pcb_pin("C2", 2, "B", "fzc-gnd", 133.0, 100.0, PcbPinKind.ground),
pcb_pin("U6", 1, "IN", "fzc-12v", 95.0, 66.0, PcbPinKind.power),
pcb_pin("U6", 2, "GND", "fzc-gnd", 91.0, 60.0, PcbPinKind.ground),
pcb_pin("U6", 3, "OUT", "fzc-5v", 99.0, 60.0, PcbPinKind.power),
pcb_pin("C3", 1, "A", "fzc-5v", 103.0, 55.0, PcbPinKind.power),
pcb_pin("C3", 2, "B", "fzc-gnd", 107.0, 55.0, PcbPinKind.ground),
pcb_pin("U7", 1, "IN", "fzc-5v", 84.0, 57.0, PcbPinKind.power),
pcb_pin("U7", 2, "GND", "fzc-gnd", 84.0, 53.0, PcbPinKind.ground),
pcb_pin("U7", 3, "OUT", "fzc-3v3", 76.0, 53.0, PcbPinKind.power),
pcb_pin("U7", 4, "EN", "fzc-5v", 76.0, 57.0, PcbPinKind.signal),
pcb_pin("C4", 1, "A", "fzc-3v3", 66.0, 50.0, PcbPinKind.power),
pcb_pin("C4", 2, "B", "fzc-gnd", 70.0, 50.0, PcbPinKind.ground),
pcb_pin("U3", 1, "TXD", "fzc-can0-tx", 29.0, 95.0, PcbPinKind.signal),
pcb_pin("U3", 2, "GND", "fzc-gnd", 29.0, 92.0, PcbPinKind.ground),
pcb_pin("U3", 3, "VCC", "fzc-5v", 29.0, 89.0, PcbPinKind.power),
pcb_pin("U3", 4, "RXD", "fzc-can0-rx", 32.0, 95.0, PcbPinKind.signal),
pcb_pin("U3", 5, "CANH", "fzc-can0-h", 19.0, 95.0, PcbPinKind.signal),
pcb_pin("U3", 6, "CANL", "fzc-can0-l", 19.0, 92.0, PcbPinKind.signal),
pcb_pin("U4", 1, "TXD", "fzc-can1-tx", 29.0, 79.0, PcbPinKind.signal),
pcb_pin("U4", 2, "GND", "fzc-gnd", 29.0, 76.0, PcbPinKind.ground),
pcb_pin("U4", 3, "VCC", "fzc-5v", 29.0, 73.0, PcbPinKind.power),
pcb_pin("U4", 4, "RXD", "fzc-can1-rx", 32.0, 79.0, PcbPinKind.signal),
pcb_pin("U4", 5, "CANH", "fzc-can1-h", 19.0, 79.0, PcbPinKind.signal),
pcb_pin("U4", 6, "CANL", "fzc-can1-l", 19.0, 76.0, PcbPinKind.signal),
pcb_pin("R1", 1, "A", "fzc-can0-h", 14.0, 97.6, PcbPinKind.signal),
pcb_pin("R1", 2, "B", "fzc-can0-l", 14.0, 94.4, PcbPinKind.signal),
pcb_pin("R2", 1, "A", "fzc-can1-h", 14.0, 73.6, PcbPinKind.signal),
pcb_pin("R2", 2, "B", "fzc-can1-l", 14.0, 70.4, PcbPinKind.signal),
pcb_pin("U5", 1, "VDDD", "fzc-3v3", 31.0, 45.0, PcbPinKind.power),
pcb_pin("U5", 2, "VSS", "fzc-gnd", 31.0, 41.0, PcbPinKind.ground),
pcb_pin("U5", 3, "TRX_P", "fzc-eth-p", 21.0, 42.0, PcbPinKind.signal),
pcb_pin("U5", 4, "TRX_N", "fzc-eth-n", 21.0, 38.0, PcbPinKind.signal),
pcb_pin("U5", 5, "MII_RXD", "fzc-phy-txd", 31.0, 37.0, PcbPinKind.signal),
pcb_pin("U5", 6, "MII_TXD", "fzc-phy-rxd", 31.0, 34.0, PcbPinKind.signal),
pcb_pin("T1", 1, "P1", "fzc-eth-p", 16.0, 42.0, PcbPinKind.signal),
pcb_pin("T1", 2, "P2", "fzc-eth-n", 16.0, 38.0, PcbPinKind.signal),
pcb_pin("T1", 3, "S1", "fzc-cable-eth-p", 8.0, 42.0, PcbPinKind.signal),
pcb_pin("T1", 4, "S2", "fzc-cable-eth-n", 8.0, 38.0, PcbPinKind.signal),
pcb_pin("U8", 1, "VCC", "fzc-12v", 116.0, 66.0, PcbPinKind.power),
pcb_pin("U8", 2, "IN", "fzc-hs1-in", 116.0, 62.0, PcbPinKind.signal),
pcb_pin("U8", 3, "OUT", "fzc-out1", 124.0, 62.0, PcbPinKind.power),
pcb_pin("U8", 4, "GND", "fzc-gnd", 116.0, 58.0, PcbPinKind.ground),
pcb_pin("U8", 5, "CS", "fzc-hs-sense", 124.0, 58.0, PcbPinKind.analog),
pcb_pin("U9", 1, "VCC", "fzc-12v", 116.0, 48.0, PcbPinKind.power),
pcb_pin("U9", 2, "IN", "fzc-hs2-in", 116.0, 44.0, PcbPinKind.signal),
pcb_pin("U9", 3, "OUT", "fzc-out2", 124.0, 44.0, PcbPinKind.power),
pcb_pin("U9", 4, "GND", "fzc-gnd", 116.0, 40.0, PcbPinKind.ground),
pcb_pin("U10", 1, "VCC", "fzc-12v", 116.0, 30.0, PcbPinKind.power),
pcb_pin("U10", 2, "IN", "fzc-hs3-in", 116.0, 26.0, PcbPinKind.signal),
pcb_pin("U10", 3, "OUT", "fzc-out3", 124.0, 26.0, PcbPinKind.power),
pcb_pin("U10", 4, "GND", "fzc-gnd", 116.0, 22.0, PcbPinKind.ground),
pcb_pin("J1", 1, "48V_A", "fzc-48v", 136.0, 104.0, PcbPinKind.power),
pcb_pin("J1", 2, "48V_B", "fzc-48v", 136.0, 100.0, PcbPinKind.power),
pcb_pin("J1", 3, "GND_A", "fzc-gnd", 136.0, 96.0, PcbPinKind.ground),
pcb_pin("J1", 4, "GND_B", "fzc-gnd", 136.0, 92.0, PcbPinKind.ground),
pcb_pin("J1", 5, "CAN0_H", "fzc-can0-h", 136.0, 88.0, PcbPinKind.signal),
pcb_pin("J1", 6, "CAN0_L", "fzc-can0-l", 136.0, 84.0, PcbPinKind.signal),
pcb_pin("J1", 7, "CAN1_H", "fzc-can1-h", 136.0, 80.0, PcbPinKind.signal),
pcb_pin("J1", 8, "CAN1_L", "fzc-can1-l", 136.0, 76.0, PcbPinKind.signal),
pcb_pin("J1", 9, "ETH_P", "fzc-cable-eth-p", 136.0, 72.0, PcbPinKind.signal),
pcb_pin("J1", 10, "ETH_N", "fzc-cable-eth-n", 136.0, 68.0, PcbPinKind.signal),
pcb_pin("J2", 1, "OUT1", "fzc-out1", 136.0, 46.0, PcbPinKind.power),
pcb_pin("J2", 2, "OUT2", "fzc-out2", 136.0, 42.0, PcbPinKind.power),
pcb_pin("J2", 3, "OUT3", "fzc-out3", 136.0, 38.0, PcbPinKind.power),
pcb_pin("J2", 4, "RTN1", "fzc-gnd", 136.0, 34.0, PcbPinKind.ground),
pcb_pin("J2", 5, "RTN2", "fzc-gnd", 136.0, 30.0, PcbPinKind.ground),
pcb_pin("J2", 6, "RTN3", "fzc-gnd", 136.0, 26.0, PcbPinKind.ground),
pcb_pin("J2", 7, "RTN4", "fzc-gnd", 136.0, 22.0, PcbPinKind.ground),
]
def front_zone_12v_load_a():
return FZC_OUT1_A + FZC_OUT2_A + FZC_OUT3_A + front_zone_5v_load_a()
def front_zone_5v_load_a():
return FZC_CAN_SUPPLY_A + FZC_CAN_SUPPLY_A + front_zone_3v3_load_a()
def front_zone_3v3_load_a():
return FZC_MCU_A + FZC_PHY_A
def front_zone_48v_load_a():
return 12.0 * front_zone_12v_load_a() / (48.0 * BUCK_EFFICIENCY)
def front_zone_nets():
return [
PcbNet(id="fzc-48v", name="48 V zonal supply input", net_class=PcbNetClass.power, pin_ids=["J1.1", "J1.2", "C1.1", "U2.1", "Q1.1"], nominal_voltage_v=48.0, current_a=front_zone_48v_load_a(), is_reference=false),
PcbNet(id="fzc-sw", name="Buck switch node", net_class=PcbNetClass.power, pin_ids=["Q1.3", "Q2.1", "U2.3", "L1.1"], nominal_voltage_v=12.0, current_a=front_zone_12v_load_a(), is_reference=false),
PcbNet(id="fzc-gate-hs", name="High-side gate drive", net_class=PcbNetClass.signal, pin_ids=["U2.2", "Q1.2"], nominal_voltage_v=12.0, current_a=0.012, is_reference=false),
PcbNet(id="fzc-gate-ls", name="Low-side gate drive", net_class=PcbNetClass.signal, pin_ids=["U2.4", "Q2.2"], nominal_voltage_v=12.0, current_a=0.012, is_reference=false),
PcbNet(id="fzc-12v", name="12 V distribution rail", net_class=PcbNetClass.power, pin_ids=["L1.2", "C2.1", "U8.1", "U9.1", "U10.1", "U6.1"], nominal_voltage_v=12.0, current_a=front_zone_12v_load_a(), is_reference=false),
PcbNet(id="fzc-5v", name="5 V transceiver rail", net_class=PcbNetClass.power, pin_ids=["U6.3", "C3.1", "U7.1", "U7.4", "U3.3", "U4.3"], nominal_voltage_v=5.0, current_a=front_zone_5v_load_a(), is_reference=false),
PcbNet(id="fzc-3v3", name="3V3 logic rail", net_class=PcbNetClass.power, pin_ids=["U7.3", "C4.1", "U1.1", "U5.1"], nominal_voltage_v=3.3, current_a=front_zone_3v3_load_a(), is_reference=false),
PcbNet(id="fzc-gnd", name="Zone controller ground", net_class=PcbNetClass.ground, pin_ids=["J1.3", "J1.4", "C1.2", "U2.5", "Q2.3", "C2.2", "U6.2", "C3.2", "U7.2", "C4.2", "U1.2", "U3.2", "U4.2", "U5.2", "U8.4", "U9.4", "U10.4", "J2.4", "J2.5", "J2.6", "J2.7"], nominal_voltage_v=0.0, current_a=front_zone_12v_load_a(), is_reference=true),
PcbNet(id="fzc-can0-tx", name="CAN0 transmit", net_class=PcbNetClass.signal, pin_ids=["U1.3", "U3.1"], nominal_voltage_v=3.3, current_a=0.00025, is_reference=false),
PcbNet(id="fzc-can0-rx", name="CAN0 receive", net_class=PcbNetClass.signal, pin_ids=["U3.4", "U1.4"], nominal_voltage_v=3.3, current_a=0.00025, is_reference=false),
PcbNet(id="fzc-can1-tx", name="CAN1 transmit", net_class=PcbNetClass.signal, pin_ids=["U1.5", "U4.1"], nominal_voltage_v=3.3, current_a=0.00025, is_reference=false),
PcbNet(id="fzc-can1-rx", name="CAN1 receive", net_class=PcbNetClass.signal, pin_ids=["U4.4", "U1.6"], nominal_voltage_v=3.3, current_a=0.00025, is_reference=false),
PcbNet(id="fzc-can0-h", name="CAN0 bus high", net_class=PcbNetClass.can, pin_ids=["U3.5", "R1.1", "J1.5"], nominal_voltage_v=3.5, current_a=0.0333, is_reference=false),
PcbNet(id="fzc-can0-l", name="CAN0 bus low", net_class=PcbNetClass.can, pin_ids=["U3.6", "R1.2", "J1.6"], nominal_voltage_v=1.5, current_a=0.0333, is_reference=false),
PcbNet(id="fzc-can1-h", name="CAN1 bus high", net_class=PcbNetClass.can, pin_ids=["U4.5", "R2.1", "J1.7"], nominal_voltage_v=3.5, current_a=0.0333, is_reference=false),
PcbNet(id="fzc-can1-l", name="CAN1 bus low", net_class=PcbNetClass.can, pin_ids=["U4.6", "R2.2", "J1.8"], nominal_voltage_v=1.5, current_a=0.0333, is_reference=false),
PcbNet(id="fzc-phy-txd", name="PHY transmit data", net_class=PcbNetClass.signal, pin_ids=["U1.7", "U5.5"], nominal_voltage_v=3.3, current_a=0.0005, is_reference=false),
PcbNet(id="fzc-phy-rxd", name="PHY receive data", net_class=PcbNetClass.signal, pin_ids=["U5.6", "U1.8"], nominal_voltage_v=3.3, current_a=0.0005, is_reference=false),
PcbNet(id="fzc-eth-p", name="100BASE-T1 pair positive", net_class=PcbNetClass.ethernet, pin_ids=["U5.3", "T1.1"], nominal_voltage_v=1.1, current_a=0.011, is_reference=false),
PcbNet(id="fzc-eth-n", name="100BASE-T1 pair negative", net_class=PcbNetClass.ethernet, pin_ids=["U5.4", "T1.2"], nominal_voltage_v=0.0, current_a=0.011, is_reference=false),
PcbNet(id="fzc-cable-eth-p", name="Ethernet cable pair positive", net_class=PcbNetClass.ethernet, pin_ids=["T1.3", "J1.9"], nominal_voltage_v=1.1, current_a=0.011, is_reference=false),
PcbNet(id="fzc-cable-eth-n", name="Ethernet cable pair negative", net_class=PcbNetClass.ethernet, pin_ids=["T1.4", "J1.10"], nominal_voltage_v=0.0, current_a=0.011, is_reference=false),
PcbNet(id="fzc-hs1-in", name="eFuse channel 1 command", net_class=PcbNetClass.signal, pin_ids=["U1.9", "U8.2"], nominal_voltage_v=3.3, current_a=0.00025, is_reference=false),
PcbNet(id="fzc-hs2-in", name="eFuse channel 2 command", net_class=PcbNetClass.signal, pin_ids=["U1.11", "U9.2"], nominal_voltage_v=3.3, current_a=0.00025, is_reference=false),
PcbNet(id="fzc-hs3-in", name="eFuse channel 3 command", net_class=PcbNetClass.signal, pin_ids=["U1.12", "U10.2"], nominal_voltage_v=3.3, current_a=0.00025, is_reference=false),
PcbNet(id="fzc-hs-sense", name="eFuse current-sense feedback", net_class=PcbNetClass.analog, pin_ids=["U8.5", "U1.10"], nominal_voltage_v=1.5, current_a=0.0012, is_reference=false),
PcbNet(id="fzc-out1", name="Switched output 1, low-beam lamp", net_class=PcbNetClass.power, pin_ids=["U8.3", "J2.1"], nominal_voltage_v=12.0, current_a=FZC_OUT1_A, is_reference=false),
PcbNet(id="fzc-out2", name="Switched output 2, washer heater", net_class=PcbNetClass.power, pin_ids=["U9.3", "J2.2"], nominal_voltage_v=12.0, current_a=FZC_OUT2_A, is_reference=false),
PcbNet(id="fzc-out3", name="Switched output 3, grille shutter actuator", net_class=PcbNetClass.power, pin_ids=["U10.3", "J2.3"], nominal_voltage_v=12.0, current_a=FZC_OUT3_A, is_reference=false),
]
def front_zone_plans():
return [
PcbRoutePlan(net_id="power", layer_id="fzc-l1", width_mm=2.5, topology="chain", via_count=2, via_drill_mm=0.35, via_pad_mm=0.7, via_layer_id="fzc-l5"),
PcbRoutePlan(net_id="ground", layer_id="fzc-l2", width_mm=6.0, topology="star", via_count=2, via_drill_mm=0.35, via_pad_mm=0.7, via_layer_id="fzc-l1"),
PcbRoutePlan(net_id="signal", layer_id="fzc-l4", width_mm=0.25, topology="chain", via_count=1, via_drill_mm=0.25, via_pad_mm=0.5, via_layer_id="fzc-l1"),
PcbRoutePlan(net_id="analog", layer_id="fzc-l4", width_mm=0.3, topology="chain", via_count=1, via_drill_mm=0.25, via_pad_mm=0.5, via_layer_id="fzc-l1"),
PcbRoutePlan(net_id="can", layer_id="fzc-l1", width_mm=0.35, topology="chain", via_count=0, via_drill_mm=0.25, via_pad_mm=0.5, via_layer_id="fzc-l1"),
PcbRoutePlan(net_id="ethernet", layer_id="fzc-l1", width_mm=0.28, topology="chain", via_count=0, via_drill_mm=0.25, via_pad_mm=0.5, via_layer_id="fzc-l1"),
PcbRoutePlan(net_id="hv", layer_id="fzc-l1", width_mm=3.0, topology="chain", via_count=2, via_drill_mm=0.35, via_pad_mm=0.7, via_layer_id="fzc-l5"),
]
def front_zone_overrides():
return [
PcbRoutePlan(net_id="fzc-sw", layer_id="fzc-l1", width_mm=5.0, topology="chain", via_count=0, via_drill_mm=0.35, via_pad_mm=0.7, via_layer_id="fzc-l1"),
PcbRoutePlan(net_id="fzc-12v", layer_id="fzc-l1", width_mm=4.0, topology="chain", via_count=0, via_drill_mm=0.35, via_pad_mm=0.7, via_layer_id="fzc-l1"),
PcbRoutePlan(net_id="fzc-48v", layer_id="fzc-l1", width_mm=2.5, topology="chain", via_count=0, via_drill_mm=0.35, via_pad_mm=0.7, via_layer_id="fzc-l1"),
PcbRoutePlan(net_id="fzc-5v", layer_id="fzc-l1", width_mm=1.2, topology="chain", via_count=0, via_drill_mm=0.35, via_pad_mm=0.7, via_layer_id="fzc-l1"),
PcbRoutePlan(net_id="fzc-3v3", layer_id="fzc-l1", width_mm=1.2, topology="chain", via_count=0, via_drill_mm=0.35, via_pad_mm=0.7, via_layer_id="fzc-l1"),
# Channel 1 is routed on an inner layer where IPC-2221 halves the sizing
# constant; 2.35 mm is the deliberate marginal case the DRC has to catch.
PcbRoutePlan(net_id="fzc-out1", layer_id="fzc-l3", width_mm=2.35, topology="chain", via_count=4, via_drill_mm=0.35, via_pad_mm=0.7, via_layer_id="fzc-l1"),
PcbRoutePlan(net_id="fzc-out2", layer_id="fzc-l3", width_mm=2.6, topology="chain", via_count=4, via_drill_mm=0.35, via_pad_mm=0.7, via_layer_id="fzc-l1"),
PcbRoutePlan(net_id="fzc-out3", layer_id="fzc-l3", width_mm=2.2, topology="chain", via_count=4, via_drill_mm=0.35, via_pad_mm=0.7, via_layer_id="fzc-l1"),
]
def front_zone_loads():
board = "front-zone-controller"
return [
pcb_load(board, "Q1.1", "Q1.3", front_zone_48v_load_a(), "buck high-side switch, average conduction"),
pcb_load(board, "Q2.3", "Q2.1", front_zone_12v_load_a() - front_zone_48v_load_a(), "buck low-side switch, average freewheel"),
pcb_load(board, "L1.1", "L1.2", front_zone_12v_load_a(), "buck output inductor"),
pcb_load(board, "U6.1", "U6.3", front_zone_5v_load_a(), "12 V to 5 V regulator pass element"),
pcb_load(board, "U7.1", "U7.3", front_zone_3v3_load_a(), "5 V to 3V3 regulator pass element"),
pcb_load(board, "U1.1", "U1.2", FZC_MCU_A, "MCU core and I/O supply"),
pcb_load(board, "U5.1", "U5.2", FZC_PHY_A, "Ethernet PHY supply"),
pcb_load(board, "U3.3", "U3.2", FZC_CAN_SUPPLY_A, "CAN0 transceiver supply"),
pcb_load(board, "U4.3", "U4.2", FZC_CAN_SUPPLY_A, "CAN1 transceiver supply"),
pcb_load(board, "U8.1", "U8.3", FZC_OUT1_A, "eFuse channel 1 pass element"),
pcb_load(board, "U9.1", "U9.3", FZC_OUT2_A, "eFuse channel 2 pass element"),
pcb_load(board, "U10.1", "U10.3", FZC_OUT3_A, "eFuse channel 3 pass element"),
pcb_load(board, "J2.1", "J2.4", FZC_OUT1_A, "low-beam lamp module"),
pcb_load(board, "J2.2", "J2.5", FZC_OUT2_A, "washer nozzle heater"),
pcb_load(board, "J2.3", "J2.6", FZC_OUT3_A, "grille shutter actuator"),
pcb_load(board, "R1.1", "R1.2", 0.0333, "CAN0 split termination, dominant state"),
pcb_load(board, "R2.1", "R2.2", 0.0333, "CAN1 split termination, dominant state"),
pcb_load(board, "T1.1", "T1.2", 0.011, "100BASE-T1 magnetics primary"),
pcb_load(board, "J1.9", "J1.10", 0.011, "100BASE-T1 cable pair"),
pcb_load(board, "Q1.2", "Q1.3", 0.012, "high-side gate charge, averaged"),
pcb_load(board, "Q2.2", "Q2.3", 0.012, "low-side gate charge, averaged"),
pcb_load(board, "U3.1", "U3.2", 0.00025, "CAN0 transmit line switching current"),
pcb_load(board, "U1.4", "U1.2", 0.00025, "CAN0 receive line switching current"),
pcb_load(board, "U4.1", "U4.2", 0.00025, "CAN1 transmit line switching current"),
pcb_load(board, "U1.6", "U1.2", 0.00025, "CAN1 receive line switching current"),
pcb_load(board, "U5.5", "U5.2", 0.0005, "MII transmit switching current"),
pcb_load(board, "U1.8", "U1.2", 0.0005, "MII receive switching current"),
pcb_load(board, "U8.2", "U8.4", 0.00025, "eFuse 1 command input"),
pcb_load(board, "U9.2", "U9.4", 0.00025, "eFuse 2 command input"),
pcb_load(board, "U10.2", "U10.4", 0.00025, "eFuse 3 command input"),
pcb_load(board, "U1.10", "U1.2", 0.0012, "current-sense feedback into the ADC"),
]
def front_zone_board() !{}:
return assemble_board("front-zone-controller", "Front zone controller board", "front-zone-controller", "front-left plenum, behind the wheel-arch liner", "FR-4 Tg170, 6 copper layers, dedicated ground plane", front_zone_stackup(), front_zone_components(), front_zone_pins(), front_zone_nets(), front_zone_plans(), front_zone_overrides(), front_zone_loads(), 145.0, 110.0)
def hv_junction_stackup():
layers = [
PcbLayer(id="hvj-bus", name="Bonded HV busbar", function=PcbLayerFunction.signal, copper_um=2000.0, dielectric_mm=0.0, dielectric_er=1.0, material="2.0 mm laminated copper busbar bonded to top copper"),
PcbLayer(id="hvj-smt", name="Top soldermask", function=PcbLayerFunction.soldermask, copper_um=0.0, dielectric_mm=0.0254, dielectric_er=3.6, material="high-CTI LPI soldermask"),
PcbLayer(id="hvj-l1", name="Top control and HV sense", function=PcbLayerFunction.signal, copper_um=105.0, dielectric_mm=0.0, dielectric_er=1.0, material="heavy copper 3 oz"),
PcbLayer(id="hvj-d1", name="Interlayer dielectric", function=PcbLayerFunction.prepreg, copper_um=0.0, dielectric_mm=0.1016, dielectric_er=4.6, material="high-Tg thermal prepreg"),
PcbLayer(id="hvj-l2", name="LV reference plane", function=PcbLayerFunction.plane, copper_um=105.0, dielectric_mm=0.0, dielectric_er=1.0, material="heavy copper 3 oz"),
PcbLayer(id="hvj-d2", name="Thermally conductive dielectric", function=PcbLayerFunction.prepreg, copper_um=0.0, dielectric_mm=0.15, dielectric_er=8.0, material="ceramic-filled thermal dielectric"),
PcbLayer(id="hvj-base", name="Aluminium base plate", function=PcbLayerFunction.core, copper_um=0.0, dielectric_mm=1.5, dielectric_er=1.0, material="5052 aluminium base plate"),
]
return PcbStackup(id="stk-hvj", name="IMS heavy-copper 2-layer with bonded 2 mm busbars", layers=layers, total_thickness_mm=stackup_thickness_mm(layers), ipc_class=3, impedance_target_ohm=100.0)
def hv_junction_components():
return [
PcbComponent(refdes="TB1", part_number="BUSBAR-M8-2P", description="HV pack input terminal block", footprint="BUSBAR-M8-2P", pin_count=2, x_mm=172.0, y_mm=60.0, rotation_deg=0.0, power_dissipation_w=0.4, mass_g=48.0),
PcbComponent(refdes="TB2", part_number="BUSBAR-M8-2P", description="HV output terminal block to the drive units", footprint="BUSBAR-M8-2P", pin_count=2, x_mm=52.0, y_mm=60.0, rotation_deg=0.0, power_dissipation_w=0.4, mass_g=48.0),
PcbComponent(refdes="K1", part_number="GV12-800-1A", description="Main positive HV contactor, 800 V DC", footprint="CONTACTOR-GV12", pin_count=4, x_mm=140.0, y_mm=100.0, rotation_deg=0.0, power_dissipation_w=5.4, mass_g=340.0),
PcbComponent(refdes="K2", part_number="GV12-800-1A", description="Main negative HV contactor, 800 V DC", footprint="CONTACTOR-GV12", pin_count=4, x_mm=140.0, y_mm=20.0, rotation_deg=0.0, power_dissipation_w=5.4, mass_g=340.0),
PcbComponent(refdes="F1", part_number="PYROFUSE-PF800", description="Pyrotechnic disconnect in the positive rail", footprint="PYRO-M8", pin_count=4, x_mm=90.0, y_mm=100.0, rotation_deg=0.0, power_dissipation_w=2.1, mass_g=180.0),
PcbComponent(refdes="RS1", part_number="FL-100U-800", description="100 uOhm coaxial current shunt in the negative rail", footprint="SHUNT-4T", pin_count=4, x_mm=90.0, y_mm=20.0, rotation_deg=0.0, power_dissipation_w=2.56, mass_g=95.0),
PcbComponent(refdes="U1", part_number="AMC1311-Q1", description="Reinforced isolation amplifier for shunt current sense", footprint="SOIC-8W", pin_count=8, x_mm=30.0, y_mm=20.0, rotation_deg=0.0, power_dissipation_w=0.05, mass_g=0.08),
PcbComponent(refdes="U2", part_number="ISO224-Q1", description="Reinforced isolation amplifier, insulation-monitor front end", footprint="SOIC-8W", pin_count=8, x_mm=30.0, y_mm=60.0, rotation_deg=0.0, power_dissipation_w=0.05, mass_g=0.08),
PcbComponent(refdes="T1", part_number="750313638", description="Isolated supply transformer for the HV-referenced side", footprint="SM-4P", pin_count=4, x_mm=30.0, y_mm=42.0, rotation_deg=0.0, power_dissipation_w=0.09, mass_g=1.8),
PcbComponent(refdes="U3", part_number="TPS7B6950-Q1", description="12 V to 5 V low-dropout regulator", footprint="DPAK", pin_count=3, x_mm=12.0, y_mm=82.0, rotation_deg=0.0, power_dissipation_w=0.15, mass_g=0.4),
PcbComponent(refdes="Q1", part_number="IPB017N10N5", description="Contactor 1 low-side coil driver", footprint="D2PAK", pin_count=3, x_mm=12.0, y_mm=92.0, rotation_deg=0.0, power_dissipation_w=0.26, mass_g=1.6),
PcbComponent(refdes="Q2", part_number="IPB017N10N5", description="Contactor 2 low-side coil driver", footprint="D2PAK", pin_count=3, x_mm=12.0, y_mm=102.0, rotation_deg=0.0, power_dissipation_w=0.26, mass_g=1.6),
PcbComponent(refdes="Q3", part_number="IAUC100N04S6L", description="Pyrotechnic squib trigger driver", footprint="DPAK", pin_count=3, x_mm=12.0, y_mm=112.0, rotation_deg=0.0, power_dissipation_w=0.02, mass_g=0.4),
PcbComponent(refdes="R1", part_number="HVD-2M2-4S", description="Insulation-monitor divider, four series HV elements", footprint="RES-HV-2512x4", pin_count=2, x_mm=60.0, y_mm=74.0, rotation_deg=0.0, power_dissipation_w=0.27, mass_g=0.12),
PcbComponent(refdes="R2", part_number="CRCW2512-100K", description="Insulation-monitor divider low-side element", footprint="2512", pin_count=2, x_mm=57.0, y_mm=66.0, rotation_deg=0.0, power_dissipation_w=0.01, mass_g=0.03),
PcbComponent(refdes="J1", part_number="MX150-10", description="LV control and diagnostics header", footprint="MOLEX-MX150-10", pin_count=10, x_mm=6.0, y_mm=35.0, rotation_deg=270.0, power_dissipation_w=0.0, mass_g=8.0),
PcbComponent(refdes="J2", part_number="HVIL-2", description="High-voltage interlock loop connector", footprint="HVIL-2", pin_count=2, x_mm=6.0, y_mm=77.0, rotation_deg=270.0, power_dissipation_w=0.0, mass_g=2.4),
]
def hv_junction_pins():
return [
pcb_pin("TB1", 1, "PACK+", "hvj-hv-pack-p", 172.0, 100.0, PcbPinKind.power),
pcb_pin("TB1", 2, "PACK-", "hvj-hv-pack-n", 172.0, 20.0, PcbPinKind.power),
pcb_pin("K1", 1, "COIL+", "hvj-12v", 132.0, 116.0, PcbPinKind.power),
pcb_pin("K1", 2, "COIL-", "hvj-coil1-lo", 120.0, 116.0, PcbPinKind.power),
pcb_pin("K1", 3, "HV_IN", "hvj-hv-pack-p", 156.0, 100.0, PcbPinKind.power),
pcb_pin("K1", 4, "HV_OUT", "hvj-hv-bus-p", 124.0, 100.0, PcbPinKind.power),
pcb_pin("K2", 1, "COIL+", "hvj-12v", 132.0, 4.0, PcbPinKind.power),
pcb_pin("K2", 2, "COIL-", "hvj-coil2-lo", 120.0, 4.0, PcbPinKind.power),
pcb_pin("K2", 3, "HV_IN", "hvj-hv-pack-n", 156.0, 20.0, PcbPinKind.power),
pcb_pin("K2", 4, "HV_OUT", "hvj-hv-bus-n", 124.0, 20.0, PcbPinKind.power),
pcb_pin("F1", 1, "HV_IN", "hvj-hv-bus-p", 108.0, 100.0, PcbPinKind.power),
pcb_pin("F1", 2, "HV_OUT", "hvj-hv-out-p", 72.0, 100.0, PcbPinKind.power),
pcb_pin("F1", 3, "TRIG+", "hvj-12v", 90.0, 116.0, PcbPinKind.power),
pcb_pin("F1", 4, "TRIG-", "hvj-pyro-lo", 78.0, 116.0, PcbPinKind.power),
pcb_pin("RS1", 1, "HV_IN", "hvj-hv-bus-n", 108.0, 20.0, PcbPinKind.power),
pcb_pin("RS1", 2, "HV_OUT", "hvj-hv-out-n", 72.0, 20.0, PcbPinKind.power),
pcb_pin("RS1", 3, "SENSE+", "hvj-shunt-p", 99.0, 9.0, PcbPinKind.analog),
pcb_pin("RS1", 4, "SENSE-", "hvj-shunt-n", 81.0, 9.0, PcbPinKind.analog),
pcb_pin("TB2", 1, "OUT+", "hvj-hv-out-p", 52.0, 100.0, PcbPinKind.power),
pcb_pin("TB2", 2, "OUT-", "hvj-hv-out-n", 52.0, 20.0, PcbPinKind.power),
pcb_pin("R1", 1, "A", "hvj-hv-out-p", 66.0, 74.0, PcbPinKind.analog),
pcb_pin("R1", 2, "B", "hvj-imd-node", 54.0, 74.0, PcbPinKind.analog),
pcb_pin("R2", 1, "A", "hvj-imd-node", 60.0, 66.0, PcbPinKind.analog),
pcb_pin("R2", 2, "B", "hvj-iso-gnd", 54.0, 66.0, PcbPinKind.ground),
pcb_pin("U1", 1, "VDD1", "hvj-iso-5v", 38.0, 28.0, PcbPinKind.power),
pcb_pin("U1", 2, "GND1", "hvj-iso-gnd", 38.0, 24.0, PcbPinKind.ground),
pcb_pin("U1", 3, "INP", "hvj-shunt-p", 38.0, 20.0, PcbPinKind.analog),
pcb_pin("U1", 4, "INN", "hvj-shunt-n", 38.0, 16.0, PcbPinKind.analog),
pcb_pin("U1", 5, "VDD2", "hvj-5v", 22.0, 22.0, PcbPinKind.power),
pcb_pin("U1", 6, "OUTP", "hvj-isense-out", 22.0, 18.0, PcbPinKind.analog),
pcb_pin("U1", 7, "GND2", "hvj-gnd", 22.0, 14.0, PcbPinKind.ground),
pcb_pin("U2", 1, "VDD1", "hvj-iso-5v", 38.0, 66.0, PcbPinKind.power),
pcb_pin("U2", 2, "GND1", "hvj-iso-gnd", 38.0, 62.0, PcbPinKind.ground),
pcb_pin("U2", 3, "VIN", "hvj-imd-node", 38.0, 58.0, PcbPinKind.analog),
pcb_pin("U2", 4, "VDD2", "hvj-5v", 22.0, 64.0, PcbPinKind.power),
pcb_pin("U2", 5, "GND2", "hvj-gnd", 22.0, 60.0, PcbPinKind.ground),
pcb_pin("U2", 6, "OUT", "hvj-imd-out", 22.0, 56.0, PcbPinKind.analog),
pcb_pin("T1", 1, "P1", "hvj-5v", 21.0, 46.0, PcbPinKind.power),
pcb_pin("T1", 2, "P2", "hvj-gnd", 21.0, 38.0, PcbPinKind.ground),
pcb_pin("T1", 3, "S1", "hvj-iso-5v", 38.0, 46.0, PcbPinKind.power),
pcb_pin("T1", 4, "S2", "hvj-iso-gnd", 38.0, 38.0, PcbPinKind.ground),
pcb_pin("U3", 1, "IN", "hvj-12v", 9.0, 82.0, PcbPinKind.power),
pcb_pin("U3", 2, "GND", "hvj-gnd", 12.0, 85.0, PcbPinKind.ground),
pcb_pin("U3", 3, "OUT", "hvj-5v", 15.0, 82.0, PcbPinKind.power),
pcb_pin("Q1", 1, "G", "hvj-coil1-drv", 9.0, 92.0, PcbPinKind.signal),
pcb_pin("Q1", 2, "D", "hvj-coil1-lo", 15.0, 95.0, PcbPinKind.power),
pcb_pin("Q1", 3, "S", "hvj-gnd", 15.0, 89.0, PcbPinKind.ground),
pcb_pin("Q2", 1, "G", "hvj-coil2-drv", 9.0, 102.0, PcbPinKind.signal),
pcb_pin("Q2", 2, "D", "hvj-coil2-lo", 15.0, 105.0, PcbPinKind.power),
pcb_pin("Q2", 3, "S", "hvj-gnd", 15.0, 99.0, PcbPinKind.ground),
pcb_pin("Q3", 1, "G", "hvj-pyro-drv", 9.0, 112.0, PcbPinKind.signal),
pcb_pin("Q3", 2, "D", "hvj-pyro-lo", 15.0, 115.0, PcbPinKind.power),
pcb_pin("Q3", 3, "S", "hvj-gnd", 15.0, 109.0, PcbPinKind.ground),
pcb_pin("J1", 1, "12V", "hvj-12v", 3.0, 8.0, PcbPinKind.power),
pcb_pin("J1", 2, "GND", "hvj-gnd", 3.0, 14.0, PcbPinKind.ground),
pcb_pin("J1", 3, "COIL1_DRV", "hvj-coil1-drv", 3.0, 20.0, PcbPinKind.signal),
pcb_pin("J1", 4, "COIL2_DRV", "hvj-coil2-drv", 3.0, 26.0, PcbPinKind.signal),
pcb_pin("J1", 5, "PYRO_DRV", "hvj-pyro-drv", 3.0, 32.0, PcbPinKind.signal),
pcb_pin("J1", 6, "ISENSE", "hvj-isense-out", 3.0, 38.0, PcbPinKind.analog),
pcb_pin("J1", 7, "IMD", "hvj-imd-out", 3.0, 44.0, PcbPinKind.analog),
pcb_pin("J1", 8, "HVIL_OUT", "hvj-hvil-out", 3.0, 50.0, PcbPinKind.signal),
pcb_pin("J1", 9, "HVIL_IN", "hvj-hvil-in", 3.0, 56.0, PcbPinKind.signal),
pcb_pin("J1", 10, "GND_RTN", "hvj-gnd", 3.0, 62.0, PcbPinKind.ground),
pcb_pin("J2", 1, "LOOP_OUT", "hvj-hvil-out", 3.0, 74.0, PcbPinKind.signal),
pcb_pin("J2", 2, "LOOP_IN", "hvj-hvil-in", 3.0, 80.0, PcbPinKind.signal),
]
def hv_junction_12v_load_a():
return 2.0 * HVJ_COIL_A + HVJ_PYRO_MONITOR_A + hv_junction_5v_load_a()
def hv_junction_5v_load_a():
return HVJ_ISO_SUPPLY_A + HVJ_ISENSE_A + HVJ_IMD_A
def hv_junction_nets():
return [
PcbNet(id="hvj-hv-pack-p", name="Pack positive to contactor", net_class=PcbNetClass.hv, pin_ids=["TB1.1", "K1.3"], nominal_voltage_v=800.0, current_a=HV_BUS_CURRENT_A, is_reference=false),
PcbNet(id="hvj-hv-bus-p", name="Contactor to pyrofuse positive bus", net_class=PcbNetClass.hv, pin_ids=["K1.4", "F1.1"], nominal_voltage_v=800.0, current_a=HV_BUS_CURRENT_A, is_reference=false),
PcbNet(id="hvj-hv-out-p", name="Protected HV output positive", net_class=PcbNetClass.hv, pin_ids=["F1.2", "TB2.1", "R1.1"], nominal_voltage_v=800.0, current_a=HV_BUS_CURRENT_A, is_reference=false),
PcbNet(id="hvj-hv-out-n", name="Protected HV output negative", net_class=PcbNetClass.hv, pin_ids=["TB2.2", "RS1.2"], nominal_voltage_v=0.0, current_a=HV_BUS_CURRENT_A, is_reference=false),
PcbNet(id="hvj-hv-bus-n", name="Shunt to contactor negative bus", net_class=PcbNetClass.hv, pin_ids=["RS1.1", "K2.4"], nominal_voltage_v=0.0, current_a=HV_BUS_CURRENT_A, is_reference=false),
PcbNet(id="hvj-hv-pack-n", name="Contactor to pack negative", net_class=PcbNetClass.hv, pin_ids=["K2.3", "TB1.2"], nominal_voltage_v=0.0, current_a=HV_BUS_CURRENT_A, is_reference=false),
PcbNet(id="hvj-shunt-p", name="Shunt sense positive", net_class=PcbNetClass.analog, pin_ids=["RS1.3", "U1.3"], nominal_voltage_v=0.0, current_a=0.0, is_reference=false),
PcbNet(id="hvj-shunt-n", name="Shunt sense negative", net_class=PcbNetClass.analog, pin_ids=["RS1.4", "U1.4"], nominal_voltage_v=0.0, current_a=0.0, is_reference=false),
PcbNet(id="hvj-imd-node", name="Insulation-monitor divider midpoint", net_class=PcbNetClass.analog, pin_ids=["R1.2", "R2.1", "U2.3"], nominal_voltage_v=34.8, current_a=0.00035, is_reference=false),
PcbNet(id="hvj-iso-5v", name="HV-referenced isolated 5 V rail", net_class=PcbNetClass.power, pin_ids=["T1.3", "U1.1", "U2.1"], nominal_voltage_v=5.0, current_a=0.0075, is_reference=false),
PcbNet(id="hvj-iso-gnd", name="HV-referenced isolated return", net_class=PcbNetClass.ground, pin_ids=["T1.4", "U1.2", "U2.2", "R2.2"], nominal_voltage_v=0.0, current_a=0.0079, is_reference=false),
PcbNet(id="hvj-5v", name="LV 5 V rail", net_class=PcbNetClass.power, pin_ids=["U3.3", "T1.1", "U1.5", "U2.4"], nominal_voltage_v=5.0, current_a=hv_junction_5v_load_a(), is_reference=false),
PcbNet(id="hvj-12v", name="LV 12 V supply and contactor coil feed", net_class=PcbNetClass.power, pin_ids=["J1.1", "U3.1", "K1.1", "K2.1", "F1.3"], nominal_voltage_v=12.0, current_a=hv_junction_12v_load_a(), is_reference=false),
PcbNet(id="hvj-gnd", name="LV chassis-referenced ground", net_class=PcbNetClass.ground, pin_ids=["J1.2", "J1.10", "U3.2", "Q1.3", "Q2.3", "Q3.3", "T1.2", "U1.7", "U2.5"], nominal_voltage_v=0.0, current_a=hv_junction_12v_load_a(), is_reference=true),
PcbNet(id="hvj-coil1-lo", name="Contactor 1 coil low side", net_class=PcbNetClass.power, pin_ids=["K1.2", "Q1.2"], nominal_voltage_v=0.6, current_a=HVJ_COIL_A, is_reference=false),
PcbNet(id="hvj-coil2-lo", name="Contactor 2 coil low side", net_class=PcbNetClass.power, pin_ids=["K2.2", "Q2.2"], nominal_voltage_v=0.6, current_a=HVJ_COIL_A, is_reference=false),
PcbNet(id="hvj-pyro-lo", name="Pyrotechnic squib low side", net_class=PcbNetClass.power, pin_ids=["F1.4", "Q3.2"], nominal_voltage_v=0.2, current_a=HVJ_PYRO_MONITOR_A, is_reference=false),
PcbNet(id="hvj-coil1-drv", name="Contactor 1 gate command", net_class=PcbNetClass.signal, pin_ids=["J1.3", "Q1.1"], nominal_voltage_v=3.3, current_a=0.00025, is_reference=false),
PcbNet(id="hvj-coil2-drv", name="Contactor 2 gate command", net_class=PcbNetClass.signal, pin_ids=["J1.4", "Q2.1"], nominal_voltage_v=3.3, current_a=0.00025, is_reference=false),
PcbNet(id="hvj-pyro-drv", name="Pyrotechnic trigger command", net_class=PcbNetClass.signal, pin_ids=["J1.5", "Q3.1"], nominal_voltage_v=3.3, current_a=0.00025, is_reference=false),
PcbNet(id="hvj-isense-out", name="Isolated current-sense output", net_class=PcbNetClass.analog, pin_ids=["U1.6", "J1.6"], nominal_voltage_v=2.5, current_a=0.0002, is_reference=false),
PcbNet(id="hvj-imd-out", name="Insulation-monitor output", net_class=PcbNetClass.analog, pin_ids=["U2.6", "J1.7"], nominal_voltage_v=2.5, current_a=0.0002, is_reference=false),
PcbNet(id="hvj-hvil-out", name="Interlock loop drive", net_class=PcbNetClass.signal, pin_ids=["J1.8", "J2.1"], nominal_voltage_v=5.0, current_a=0.008, is_reference=false),
PcbNet(id="hvj-hvil-in", name="Interlock loop return", net_class=PcbNetClass.signal, pin_ids=["J2.2", "J1.9"], nominal_voltage_v=5.0, current_a=0.008, is_reference=false),
]
def hv_junction_plans():
return [
PcbRoutePlan(net_id="hv", layer_id="hvj-bus", width_mm=16.0, topology="chain", via_count=0, via_drill_mm=0.5, via_pad_mm=1.0, via_layer_id="hvj-bus"),
PcbRoutePlan(net_id="power", layer_id="hvj-l1", width_mm=1.5, topology="chain", via_count=0, via_drill_mm=0.4, via_pad_mm=0.8, via_layer_id="hvj-l1"),
PcbRoutePlan(net_id="ground", layer_id="hvj-l2", width_mm=6.0, topology="star", via_count=2, via_drill_mm=0.4, via_pad_mm=0.8, via_layer_id="hvj-l1"),
PcbRoutePlan(net_id="signal", layer_id="hvj-l1", width_mm=0.5, topology="chain", via_count=0, via_drill_mm=0.4, via_pad_mm=0.8, via_layer_id="hvj-l1"),
PcbRoutePlan(net_id="analog", layer_id="hvj-l1", width_mm=0.5, topology="chain", via_count=0, via_drill_mm=0.4, via_pad_mm=0.8, via_layer_id="hvj-l1"),
PcbRoutePlan(net_id="can", layer_id="hvj-l1", width_mm=0.4, topology="chain", via_count=0, via_drill_mm=0.4, via_pad_mm=0.8, via_layer_id="hvj-l1"),
PcbRoutePlan(net_id="ethernet", layer_id="hvj-l1", width_mm=0.4, topology="chain", via_count=0, via_drill_mm=0.4, via_pad_mm=0.8, via_layer_id="hvj-l1"),
]
def hv_junction_overrides():
return [
# The isolated return must stay off the chassis-referenced plane.
PcbRoutePlan(net_id="hvj-iso-gnd", layer_id="hvj-l1", width_mm=2.0, topology="star", via_count=0, via_drill_mm=0.4, via_pad_mm=0.8, via_layer_id="hvj-l1"),
PcbRoutePlan(net_id="hvj-coil1-lo", layer_id="hvj-l1", width_mm=2.0, topology="chain", via_count=0, via_drill_mm=0.4, via_pad_mm=0.8, via_layer_id="hvj-l1"),
PcbRoutePlan(net_id="hvj-coil2-lo", layer_id="hvj-l1", width_mm=2.0, topology="chain", via_count=0, via_drill_mm=0.4, via_pad_mm=0.8, via_layer_id="hvj-l1"),
PcbRoutePlan(net_id="hvj-12v", layer_id="hvj-l1", width_mm=2.5, topology="chain", via_count=0, via_drill_mm=0.4, via_pad_mm=0.8, via_layer_id="hvj-l1"),
]
def hv_junction_loads():
board = "hv-junction-board"
return [
pcb_load(board, "K1.3", "K1.4", HV_BUS_CURRENT_A, "positive contactor main pole"),
pcb_load(board, "F1.1", "F1.2", HV_BUS_CURRENT_A, "pyrotechnic fuse conductor"),
pcb_load(board, "TB2.1", "TB2.2", HV_BUS_CURRENT_A, "drive-unit HV load"),
pcb_load(board, "RS1.2", "RS1.1", HV_BUS_CURRENT_A, "current shunt element"),
pcb_load(board, "K2.4", "K2.3", HV_BUS_CURRENT_A, "negative contactor main pole"),
pcb_load(board, "TB1.2", "TB1.1", HV_BUS_CURRENT_A, "traction pack source pair"),
pcb_load(board, "R1.1", "R1.2", HVJ_IMD_DIVIDER_A, "insulation-monitor divider, HV element"),
pcb_load(board, "R2.1", "R2.2", HVJ_IMD_DIVIDER_A, "insulation-monitor divider, low element"),
pcb_load(board, "U3.1", "U3.3", hv_junction_5v_load_a(), "12 V to 5 V regulator pass element"),
pcb_load(board, "T1.1", "T1.2", HVJ_ISO_SUPPLY_A, "isolated supply transformer primary"),
pcb_load(board, "U1.1", "U1.2", 0.0035, "isolation amplifier HV-side supply"),
pcb_load(board, "U2.1", "U2.2", 0.004, "insulation-monitor HV-side supply"),
pcb_load(board, "U1.5", "U1.7", HVJ_ISENSE_A, "isolation amplifier LV-side supply"),
pcb_load(board, "U2.4", "U2.5", HVJ_IMD_A, "insulation-monitor LV-side supply"),
pcb_load(board, "K1.1", "K1.2", HVJ_COIL_A, "contactor 1 coil"),
pcb_load(board, "Q1.2", "Q1.3", HVJ_COIL_A, "contactor 1 driver channel"),
pcb_load(board, "K2.1", "K2.2", HVJ_COIL_A, "contactor 2 coil"),
pcb_load(board, "Q2.2", "Q2.3", HVJ_COIL_A, "contactor 2 driver channel"),
pcb_load(board, "F1.3", "F1.4", HVJ_PYRO_MONITOR_A, "squib continuity monitor current"),
pcb_load(board, "Q3.2", "Q3.3", HVJ_PYRO_MONITOR_A, "squib driver channel"),
pcb_load(board, "J1.6", "J1.10", 0.0002, "current-sense output into the zone controller"),
pcb_load(board, "J1.7", "J1.10", 0.0002, "insulation-monitor output into the zone controller"),
pcb_load(board, "J2.1", "J2.2", 0.008, "high-voltage interlock loop"),
pcb_load(board, "Q1.1", "Q1.3", 0.00025, "contactor 1 gate command"),
pcb_load(board, "Q2.1", "Q2.3", 0.00025, "contactor 2 gate command"),
pcb_load(board, "Q3.1", "Q3.3", 0.00025, "pyrotechnic gate command"),
]
def hv_junction_board() !{}:
return assemble_board("hv-junction-board", "HV junction, protection and isolation board", "hv-junction", "HV junction box above the pack front cross-member", "IMS aluminium-core, 105 um heavy copper, bonded 2 mm busbars", hv_junction_stackup(), hv_junction_components(), hv_junction_pins(), hv_junction_nets(), hv_junction_plans(), hv_junction_overrides(), hv_junction_loads(), 180.0, 120.0)
def sill_panel_stackup():
layers = [
PcbLayer(id="sill-top", name="Abrasion topcoat", function=PcbLayerFunction.soldermask, copper_um=0.0, dielectric_mm=0.05, dielectric_er=3.6, material="two-component abrasion-resistant topcoat"),
PcbLayer(id="sill-shield", name="Overprinted shield", function=PcbLayerFunction.plane, copper_um=35.0, dielectric_mm=0.0, dielectric_er=1.0, material="screen-printed silver-copper shield ink"),
PcbLayer(id="sill-cov", name="Printed coverlay", function=PcbLayerFunction.coverlay, copper_um=0.0, dielectric_mm=0.2, dielectric_er=3.4, material="printed polymer coverlay"),
PcbLayer(id="sill-data", name="Printed data conductors", function=PcbLayerFunction.signal, copper_um=70.0, dielectric_mm=0.0, dielectric_er=1.0, material="printed and sintered copper paste"),
PcbLayer(id="sill-iso", name="Interlayer dielectric", function=PcbLayerFunction.prepreg, copper_um=0.0, dielectric_mm=0.15, dielectric_er=4.2, material="printed interlayer dielectric"),
PcbLayer(id="sill-pwr", name="Printed power conductors", function=PcbLayerFunction.signal, copper_um=400.0, dielectric_mm=0.0, dielectric_er=1.0, material="thick screen-printed and sintered copper"),
PcbLayer(id="sill-diel", name="Dielectric barrier", function=PcbLayerFunction.prepreg, copper_um=0.0, dielectric_mm=0.25, dielectric_er=4.8, material="ceramic-filled epoxy dielectric barrier"),
PcbLayer(id="sill-base", name="Structural sill extrusion", function=PcbLayerFunction.core, copper_um=0.0, dielectric_mm=3.5, dielectric_er=1.0, material="anodised 6082-T6 aluminium sill extrusion"),
]
return PcbStackup(id="stk-sill", name="Structural printed conductor panel on the aluminium rocker", layers=layers, total_thickness_mm=stackup_thickness_mm(layers), ipc_class=3, impedance_target_ohm=100.0)
def sill_panel_components():
return [
PcbComponent(refdes="TB2", part_number="BONDPAD-M8-4P", description="Rear bond terminal block, pack and junction end", footprint="BONDPAD-M8x4", pin_count=4, x_mm=2560.0, y_mm=98.0, rotation_deg=0.0, power_dissipation_w=0.6, mass_g=96.0),
PcbComponent(refdes="TB1", part_number="BONDPAD-M8-4P", description="Front bond terminal block, drive-unit end", footprint="BONDPAD-M8x4", pin_count=4, x_mm=40.0, y_mm=98.0, rotation_deg=0.0, power_dissipation_w=0.6, mass_g=96.0),
PcbComponent(refdes="J2", part_number="MX150-6", description="Rear data and service connector", footprint="MOLEX-MX150-6", pin_count=6, x_mm=2480.0, y_mm=60.0, rotation_deg=0.0, power_dissipation_w=0.0, mass_g=5.0),
PcbComponent(refdes="J1", part_number="MX150-6", description="Front data and service connector", footprint="MOLEX-MX150-6", pin_count=6, x_mm=120.0, y_mm=60.0, rotation_deg=0.0, power_dissipation_w=0.0, mass_g=5.0),
PcbComponent(refdes="J3", part_number="MX150-4", description="B-pillar branch tap", footprint="MOLEX-MX150-4", pin_count=4, x_mm=1300.0, y_mm=40.0, rotation_deg=0.0, power_dissipation_w=0.0, mass_g=3.4),
PcbComponent(refdes="FS1", part_number="PRINTLINK-200A", description="Printed fusible link in the 48 V branch", footprint="PRINTLINK-60x25", pin_count=2, x_mm=1400.0, y_mm=62.0, rotation_deg=0.0, power_dissipation_w=1.9, mass_g=2.2),
PcbComponent(refdes="TP1", part_number="NTC-PRINTED-10K", description="Printed thermistor, front conductor run", footprint="PRINTNTC-6x4", pin_count=2, x_mm=900.0, y_mm=25.0, rotation_deg=0.0, power_dissipation_w=0.001, mass_g=0.2),
PcbComponent(refdes="TP2", part_number="NTC-PRINTED-10K", description="Printed thermistor, rear conductor run", footprint="PRINTNTC-6x4", pin_count=2, x_mm=1900.0, y_mm=27.0, rotation_deg=0.0, power_dissipation_w=0.001, mass_g=0.2),
PcbComponent(refdes="SH1", part_number="SHIELDBOND-4P", description="Shield bond pad group at the B-pillar node", footprint="BONDPAD-6x4", pin_count=4, x_mm=1330.0, y_mm=121.0, rotation_deg=0.0, power_dissipation_w=0.0, mass_g=1.1),
]
def sill_panel_pins():
return [
pcb_pin("TB2", 1, "HV+", "sill-hv-p", 2560.0, 185.0, PcbPinKind.power),
pcb_pin("TB2", 2, "HV-", "sill-hv-n", 2560.0, 115.0, PcbPinKind.power),
pcb_pin("TB2", 3, "48V", "sill-48v", 2560.0, 62.0, PcbPinKind.power),
pcb_pin("TB2", 4, "RTN", "sill-gnd", 2560.0, 32.0, PcbPinKind.ground),
pcb_pin("TB1", 1, "HV+", "sill-hv-p", 40.0, 185.0, PcbPinKind.power),
pcb_pin("TB1", 2, "HV-", "sill-hv-n", 40.0, 115.0, PcbPinKind.power),
pcb_pin("TB1", 3, "48V", "sill-48v", 40.0, 62.0, PcbPinKind.power),
pcb_pin("TB1", 4, "RTN", "sill-gnd", 40.0, 32.0, PcbPinKind.ground),
pcb_pin("J2", 1, "DATA_P", "sill-data-p", 2480.0, 8.0, PcbPinKind.signal),
pcb_pin("J2", 2, "DATA_N", "sill-data-n", 2480.0, 12.0, PcbPinKind.signal),
pcb_pin("J2", 3, "SHIELD", "sill-shield", 2480.0, 210.0, PcbPinKind.ground),
pcb_pin("J2", 4, "48V", "sill-48v", 2480.0, 62.0, PcbPinKind.power),
pcb_pin("J2", 5, "RTN_A", "sill-gnd", 2480.0, 32.0, PcbPinKind.ground),
pcb_pin("J2", 6, "RTN_B", "sill-gnd", 2480.0, 28.0, PcbPinKind.ground),
pcb_pin("J1", 1, "DATA_P", "sill-data-p", 120.0, 8.0, PcbPinKind.signal),
pcb_pin("J1", 2, "DATA_N", "sill-data-n", 120.0, 12.0, PcbPinKind.signal),
pcb_pin("J1", 3, "SHIELD", "sill-shield", 120.0, 210.0, PcbPinKind.ground),
pcb_pin("J1", 4, "48V", "sill-48v", 120.0, 62.0, PcbPinKind.power),
pcb_pin("J1", 5, "RTN_A", "sill-gnd", 120.0, 32.0, PcbPinKind.ground),
pcb_pin("J1", 6, "RTN_B", "sill-gnd", 120.0, 28.0, PcbPinKind.ground),
pcb_pin("J3", 1, "48V_B", "sill-48v-b", 1300.0, 62.0, PcbPinKind.power),
pcb_pin("J3", 2, "RTN", "sill-gnd", 1300.0, 32.0, PcbPinKind.ground),
pcb_pin("J3", 3, "TEMP1", "sill-temp", 1300.0, 18.0, PcbPinKind.thermal),
pcb_pin("J3", 4, "TEMP2", "sill-temp2", 1300.0, 22.0, PcbPinKind.thermal),
pcb_pin("FS1", 1, "IN", "sill-48v", 1370.0, 62.0, PcbPinKind.power),
pcb_pin("FS1", 2, "OUT", "sill-48v-b", 1430.0, 62.0, PcbPinKind.power),
pcb_pin("TP1", 1, "A", "sill-temp", 900.0, 18.0, PcbPinKind.thermal),
pcb_pin("TP1", 2, "B", "sill-gnd", 900.0, 32.0, PcbPinKind.ground),
pcb_pin("TP2", 1, "A", "sill-temp2", 1900.0, 22.0, PcbPinKind.thermal),
pcb_pin("TP2", 2, "B", "sill-gnd", 1900.0, 32.0, PcbPinKind.ground),
pcb_pin("SH1", 1, "SH_A", "sill-shield", 1300.0, 210.0, PcbPinKind.ground),
pcb_pin("SH1", 2, "SH_B", "sill-shield", 1340.0, 210.0, PcbPinKind.ground),
pcb_pin("SH1", 3, "BOND_A", "sill-gnd", 1320.0, 32.0, PcbPinKind.ground),
pcb_pin("SH1", 4, "BOND_B", "sill-gnd", 1360.0, 32.0, PcbPinKind.ground),
]
def sill_panel_nets():
return [
PcbNet(id="sill-hv-p", name="Structural HV positive conductor", net_class=PcbNetClass.hv, pin_ids=["TB2.1", "TB1.1"], nominal_voltage_v=800.0, current_a=SILL_HV_CURRENT_A, is_reference=false),
PcbNet(id="sill-hv-n", name="Structural HV negative conductor", net_class=PcbNetClass.hv, pin_ids=["TB2.2", "TB1.2"], nominal_voltage_v=0.0, current_a=SILL_HV_CURRENT_A, is_reference=false),
PcbNet(id="sill-48v", name="Structural 48 V conductor", net_class=PcbNetClass.power, pin_ids=["TB2.3", "J2.4", "FS1.1", "J1.4", "TB1.3"], nominal_voltage_v=48.0, current_a=SILL_ZONE_A + SILL_SERVICE_A + SILL_BPILLAR_A, is_reference=false),
PcbNet(id="sill-48v-b", name="B-pillar protected branch", net_class=PcbNetClass.power, pin_ids=["FS1.2", "J3.1"], nominal_voltage_v=48.0, current_a=SILL_BPILLAR_A, is_reference=false),
PcbNet(id="sill-gnd", name="Structural return conductor", net_class=PcbNetClass.ground, pin_ids=["TB2.4", "J2.5", "J2.6", "TP2.2", "SH1.3", "SH1.4", "J3.2", "TP1.2", "J1.5", "J1.6", "TB1.4"], nominal_voltage_v=0.0, current_a=SILL_ZONE_A + SILL_SERVICE_A + SILL_BPILLAR_A, is_reference=true),
PcbNet(id="sill-data-p", name="Printed differential pair positive", net_class=PcbNetClass.ethernet, pin_ids=["J2.1", "J1.1"], nominal_voltage_v=1.1, current_a=0.011, is_reference=false),
PcbNet(id="sill-data-n", name="Printed differential pair negative", net_class=PcbNetClass.ethernet, pin_ids=["J1.2", "J2.2"], nominal_voltage_v=0.0, current_a=0.011, is_reference=false),
PcbNet(id="sill-shield", name="Overprinted shield conductor", net_class=PcbNetClass.signal, pin_ids=["SH1.1", "SH1.2", "J2.3", "J1.3"], nominal_voltage_v=0.0, current_a=0.0, is_reference=false),
PcbNet(id="sill-temp", name="Printed thermistor channel, front run", net_class=PcbNetClass.analog, pin_ids=["J3.3", "TP1.1"], nominal_voltage_v=3.3, current_a=0.00025, is_reference=false),
PcbNet(id="sill-temp2", name="Printed thermistor channel, rear run", net_class=PcbNetClass.analog, pin_ids=["J3.4", "TP2.1"], nominal_voltage_v=3.3, current_a=0.00025, is_reference=false),
]
def sill_panel_plans():
return [
PcbRoutePlan(net_id="hv", layer_id="sill-pwr", width_mm=60.0, topology="chain", via_count=0, via_drill_mm=1.0, via_pad_mm=2.0, via_layer_id="sill-pwr"),
PcbRoutePlan(net_id="power", layer_id="sill-pwr", width_mm=25.0, topology="chain", via_count=0, via_drill_mm=1.0, via_pad_mm=2.0, via_layer_id="sill-pwr"),
PcbRoutePlan(net_id="ground", layer_id="sill-pwr", width_mm=25.0, topology="chain", via_count=0, via_drill_mm=1.0, via_pad_mm=2.0, via_layer_id="sill-pwr"),
PcbRoutePlan(net_id="ethernet", layer_id="sill-data", width_mm=0.8, topology="chain", via_count=0, via_drill_mm=0.6, via_pad_mm=1.2, via_layer_id="sill-data"),
PcbRoutePlan(net_id="analog", layer_id="sill-data", width_mm=0.5, topology="chain", via_count=0, via_drill_mm=0.6, via_pad_mm=1.2, via_layer_id="sill-data"),
PcbRoutePlan(net_id="signal", layer_id="sill-shield", width_mm=12.0, topology="chain", via_count=0, via_drill_mm=0.6, via_pad_mm=1.2, via_layer_id="sill-shield"),
PcbRoutePlan(net_id="can", layer_id="sill-data", width_mm=0.8, topology="chain", via_count=0, via_drill_mm=0.6, via_pad_mm=1.2, via_layer_id="sill-data"),
]
def sill_panel_overrides():
return [
PcbRoutePlan(net_id="sill-48v-b", layer_id="sill-pwr", width_mm=12.0, topology="chain", via_count=0, via_drill_mm=1.0, via_pad_mm=2.0, via_layer_id="sill-pwr"),
]
def sill_panel_loads():
board = "sill-conductor-panel"
return [
pcb_load(board, "TB1.1", "TB1.2", SILL_HV_CURRENT_A, "front drive-unit traction feed, continuous rating"),
pcb_load(board, "TB1.3", "TB1.4", SILL_ZONE_A, "front zone controller 48 V supply"),
pcb_load(board, "J1.4", "J1.5", SILL_SERVICE_A, "front service branch"),
pcb_load(board, "FS1.1", "FS1.2", SILL_BPILLAR_A, "printed fusible link element"),
pcb_load(board, "J3.1", "J3.2", SILL_BPILLAR_A, "B-pillar branch load"),
pcb_load(board, "J1.1", "J1.2", 0.011, "100BASE-T1 structural pair"),
pcb_load(board, "TP1.1", "TP1.2", 0.00025, "front printed thermistor bias"),
pcb_load(board, "TP2.1", "TP2.2", 0.00025, "rear printed thermistor bias"),
]
def sill_panel_board() !{}:
return assemble_board("sill-conductor-panel", "Structural sill printed conductor panel", "sill-panels", "left structural rocker, A-pillar to C-pillar", "printed conductor panel on an anodised 6082-T6 aluminium sill extrusion", sill_panel_stackup(), sill_panel_components(), sill_panel_pins(), sill_panel_nets(), sill_panel_plans(), sill_panel_overrides(), sill_panel_loads(), 2600.0, 220.0)
def board_loads(board_id: str):
if board_id == "bms-slave":
return bms_slave_loads()
if board_id == "front-zone-controller":
return front_zone_loads()
if board_id == "hv-junction-board":
return hv_junction_loads()
return sill_panel_loads()
def trace_branch_resistance_ohm(board: PcbBoard, trace: PcbTrace):
# Vias are emitted as two parallel groups of n barrels around one buried run,
# so the series contribution is 2 R1 / n = 4 * sum / count^2.
mut group_sum = 0.0
mut group_count = 0
prefix = trace.id + "-v"
for via in board.vias:
if via.id.startswith(prefix):
group_sum = group_sum + via.resistance_mohm
group_count = group_count + 1
series_mohm = 0.0 if group_count == 0 else 4.0 * group_sum / float(group_count * group_count)
return (trace.resistance_mohm + series_mohm) / 1000.0
def board_solution(board: PcbBoard):
mut solve_segments = []
mut solve_resistances = []
for trace in board.traces:
solve_segments.append(PcbSegment(id=trace.id, net_id=trace.net_id, layer_id=trace.layer_id, width_mm=trace.width_mm, from_pin_id=trace.from_pin_id, to_pin_id=trace.to_pin_id, via_count=0, via_drill_mm=0.3, via_pad_mm=0.6, via_layer_id=trace.layer_id))
solve_resistances.append(trace_branch_resistance_ohm(board, trace))
return solve_network(board.pins, board.nets, solve_segments, solve_resistances, board_loads(board.id))
def node_voltage(solution: PcbSolution, pin_id: str):
for index in range(0, len(solution.node_ids)):
if solution.node_ids[index] == pin_id:
return solution.node_voltages[index]
return 0.0
def net_drop_v(board: PcbBoard, solution: PcbSolution, net: PcbNet):
feed = node_voltage(solution, net.pin_ids[0])
mut spread = 0.0
for pin_id in net.pin_ids:
spread = max(spread, abs(feed - node_voltage(solution, pin_id)))
return spread
def board_drc_violations(board: PcbBoard):
mut findings = []
mut seen_pins = {}
for pin in board.pins:
if pin.x_mm > board.outline_width_mm or pin.y_mm > board.outline_height_mm:
findings.append("pad outside outline: " + pin.id)
seen_pins[pin.id] = seen_pins.get(pin.id, 0) + 1
if seen_pins[pin.id] > 1:
findings.append("duplicate pad identifier: " + pin.id)
for trace in board.traces:
if trace.width_mm < MIN_TRACK_WIDTH_MM:
findings.append("track below the minimum manufacturable width: " + trace.id)
for via in board.vias:
if (via.pad_mm - via.drill_mm) / 2.0 < MIN_ANNULAR_RING_MM:
findings.append("annular ring below the class 3 minimum: " + via.id)
return findings
def isolated_domain_nets(board_id: str):
# Nets that float at traction-battery potential relative to the LV chassis domain.
if board_id == "hv-junction-board":
return ["hvj-hv-pack-p", "hvj-hv-pack-n", "hvj-hv-bus-p", "hvj-hv-bus-n", "hvj-hv-out-p", "hvj-hv-out-n", "hvj-shunt-p", "hvj-shunt-n", "hvj-imd-node", "hvj-iso-5v", "hvj-iso-gnd"]
if board_id == "sill-conductor-panel":
return ["sill-hv-p", "sill-hv-n"]
return []
def isolation_required_mm(same_domain: bool, potential_difference_v: f64):
# Creepage and clearance must both hold, so the binding requirement is the larger.
# A barrier crossing gets reinforced insulation (twice the basic creepage) at the
# full 800 V working voltage; inside one domain the local difference sets creepage
# while the system impulse withstand still sets the clearance floor.
if not same_domain:
return max(2.0 * iec60664_creepage_mm(HV_WORKING_VOLTAGE_V), 2.0 * HV_CLEARANCE_MM)
if potential_difference_v <= HAZARDOUS_VOLTAGE_V:
return 0.0
return max(iec60664_creepage_mm(potential_difference_v), HV_CLEARANCE_MM)
def net_lookup(nets: list[PcbNet]) -> dict[str, int]:
mut net_index = {}
for index in range(0, len(nets)):
net_index[nets[index].id] = index
return net_index
def pcb_isolation_checks(board: PcbBoard):
isolated = isolated_domain_nets(board.id)
if len(isolated) == 0:
return []
net_index = net_lookup(board.nets)
mut in_domain = {}
for net_id in isolated:
in_domain[net_id] = 1
mut checks = []
mut tightest_barrier = 1000000.0
mut barrier_pair = ["", ""]
mut tightest_basic = 1000000.0
mut basic_pair = ["", ""]
mut basic_delta = 0.0
for left in range(0, len(board.pins)):
for right in range(left + 1, len(board.pins)):
first = board.pins[left]
second = board.pins[right]
if first.net_id == second.net_id:
continue
first_hv = in_domain.get(first.net_id, 0) == 1
second_hv = in_domain.get(second.net_id, 0) == 1
if not first_hv and not second_hv:
continue
delta = abs(board.nets[net_index.get(first.net_id, 0)].nominal_voltage_v - board.nets[net_index.get(second.net_id, 0)].nominal_voltage_v)
required = isolation_required_mm(first_hv == second_hv, delta)
if required <= 0.0:
continue
gap = math.sqrt((first.x_mm - second.x_mm) * (first.x_mm - second.x_mm) + (first.y_mm - second.y_mm) * (first.y_mm - second.y_mm))
if first_hv != second_hv:
if gap < tightest_barrier:
tightest_barrier = gap
barrier_pair = [first.id, second.id]
elif gap < tightest_basic:
tightest_basic = gap
basic_pair = [first.id, second.id]
basic_delta = delta
if gap < required:
checks.append(PcbIsolationCheck(id=board.id + ":" + first.id + "/" + second.id, board_id=board.id, category="violation", from_pin_id=first.id, to_pin_id=second.id, working_voltage_v=delta, required_mm=required, measured_mm=gap, reinforced=first_hv != second_hv, compliant=false))
if len(barrier_pair[0]) > 0:
barrier_required = isolation_required_mm(false, HV_WORKING_VOLTAGE_V)
checks.append(PcbIsolationCheck(id=board.id + ":tightest-barrier", board_id=board.id, category="reinforced barrier, IEC 60664-1 PD2 group IIIa", from_pin_id=barrier_pair[0], to_pin_id=barrier_pair[1], working_voltage_v=HV_WORKING_VOLTAGE_V, required_mm=barrier_required, measured_mm=tightest_barrier, reinforced=true, compliant=tightest_barrier >= barrier_required))
if len(basic_pair[0]) > 0:
basic_required = isolation_required_mm(true, basic_delta)
checks.append(PcbIsolationCheck(id=board.id + ":tightest-basic", board_id=board.id, category="basic creepage inside the HV domain", from_pin_id=basic_pair[0], to_pin_id=basic_pair[1], working_voltage_v=basic_delta, required_mm=basic_required, measured_mm=tightest_basic, reinforced=false, compliant=tightest_basic >= basic_required))
return checks
def analyze_solved(board: PcbBoard, field: PcbSolution, gates: list[PcbIsolationCheck]):
mut copper_loss_w = 0.0
mut max_rise = 0.0
mut ipc_violations = 0
for trace in board.traces:
copper_loss_w = copper_loss_w + trace.power_loss_mw / 1000.0
max_rise = max(max_rise, trace.temperature_rise_c)
if not trace.compliant:
ipc_violations = ipc_violations + 1
for via in board.vias:
copper_loss_w = copper_loss_w + via.current_a * via.current_a * via.resistance_mohm / 1000.0
mut worst_net = board.nets[0].id
mut worst_drop = 0.0
mut worst_fraction = 0.0
mut source_node = board.nets[0].pin_ids[0]
mut source_voltage = 0.0
for net in board.nets:
drop = net_drop_v(board, field, net)
if net.nominal_voltage_v > source_voltage:
source_voltage = net.nominal_voltage_v
source_node = net.pin_ids[0]
if net.nominal_voltage_v >= 1.0:
fraction = drop / net.nominal_voltage_v
if fraction > worst_fraction:
worst_fraction = fraction
worst_drop = drop
worst_net = net.id
mut isolation_violations = 0
for check in gates:
if not check.compliant:
isolation_violations = isolation_violations + 1
return PcbAnalysis(
board_id=board.id,
node_count=len(field.node_ids),
branch_count=len(board.traces),
source_node=source_node,
worst_net_id=worst_net,
worst_voltage_drop_mv=worst_drop * 1000.0,
worst_drop_fraction=worst_fraction,
total_copper_loss_w=copper_loss_w,
max_temperature_rise_c=max_rise,
ipc_violations=ipc_violations,
drc_violations=len(board_drc_violations(board)),
isolation_violations=isolation_violations,
solver_residual=field.solver_residual,
kcl_residual_a=field.kcl_residual_a,
)
def pcb_analyze(board: PcbBoard):
return analyze_solved(board, board_solution(board), pcb_isolation_checks(board))
def pcb_netlist_text(board: PcbBoard):
mut netlist_rows = ["(export (version D)"]
netlist_rows.append(" (design (source \"" + board.id + "\") (tool \"Sema pcb.sema engineered reconstruction\") (substrate \"" + board.substrate + "\"))")
netlist_rows.append(" (components")
for component in board.components:
netlist_rows.append(" (comp (ref " + component.refdes + ") (value " + component.part_number + ") (footprint " + component.footprint + "))")
netlist_rows.append(" )")
netlist_rows.append(" (nets")
mut code = 0
for net in board.nets:
code = code + 1
netlist_rows.append(" (net (code " + str(code) + ") (name \"/" + net.id + "\")")
for pin in board.pins:
if pin.net_id == net.id:
netlist_rows.append(" (node (ref " + pin.refdes + ") (pin " + str(pin.number) + "))")
netlist_rows.append(" )")
netlist_rows.append(" )")
netlist_rows.append(")")
return "\n".join(netlist_rows)
def pcb_boards() !{}:
return [bms_slave_board(), front_zone_board(), hv_junction_board(), sill_panel_board()]
def component_kind(refdes: str):
if refdes.startswith("RT") or refdes.startswith("TP"):
return "sensor"
if refdes.startswith("RS"):
return "shunt"
if refdes.startswith("SH"):
return "shield"
if refdes.startswith("FS") or refdes.startswith("F"):
return "fuse"
if refdes.startswith("TB") or refdes.startswith("J"):
return "connector"
if refdes.startswith("U"):
return "ic"
if refdes.startswith("Q"):
return "transistor"
if refdes.startswith("R"):
return "resistor"
if refdes.startswith("C"):
return "capacitor"
if refdes.startswith("L"):
return "inductor"
if refdes.startswith("K"):
return "relay"
if refdes.startswith("D"):
return "diode"
return "magnetics"
def component_extent(board: PcbBoard, refdes: str):
mut low_x = board.outline_width_mm
mut low_y = board.outline_height_mm
mut high_x = 0.0
mut high_y = 0.0
mut found = false
for pin in board.pins:
if pin.refdes == refdes:
found = true
low_x = min(low_x, pin.x_mm)
low_y = min(low_y, pin.y_mm)
high_x = max(high_x, pin.x_mm)
high_y = max(high_y, pin.y_mm)
if not found:
return [1.6, 0.8]
return [max(1.6, high_x - low_x + 1.6), max(0.8, high_y - low_y + 1.6)]
def pad_extent(footprint: str):
if footprint.startswith("BUSBAR") or footprint.startswith("BONDPAD"):
return [9.0, 9.0]
if footprint.startswith("CONTACTOR") or footprint.startswith("PYRO") or footprint.startswith("SHUNT") or footprint.startswith("PRINTLINK"):
return [6.0, 6.0]
if footprint.startswith("MOLEX") or footprint.startswith("JST") or footprint.startswith("HVIL"):
return [1.6, 2.4]
if footprint.startswith("D2PAK") or footprint.startswith("DPAK") or footprint.startswith("IND") or footprint.startswith("PQFN"):
return [2.2, 2.2]
if footprint.startswith("1206") or footprint.startswith("2512") or footprint.startswith("RES-HV") or footprint.startswith("PRINTNTC"):
return [1.4, 1.6]
return [0.9, 0.5]
def layer_kind(layer: PcbLayer):
if layer.copper_um <= 0.0:
return "base"
return "plane" if layer.function == PcbLayerFunction.plane else "signal"
def layer_color(index: int, layer: PcbLayer):
if layer.copper_um <= 0.0:
return "#2b2f36"
palette = ["#e0803a", "#3f8fd0", "#6fbf5a", "#c05fc0", "#d8c04a", "#5ac4c0", "#c85050", "#8f7ad8"]
return palette[index % len(palette)]
def substrate_kind(board_id: str):
if board_id == "hv-junction-board":
return "ims"
if board_id == "sill-conductor-panel":
return "structural-sill"
return "fr4"
def viewer_net_class(net_class: PcbNetClass):
if net_class == PcbNetClass.can or net_class == PcbNetClass.ethernet:
return "data"
if net_class == PcbNetClass.analog:
return "sensor"
return net_class_key(net_class)
def vehicle_net_for(net_id: str):
if net_id == "sill-hv-p" or net_id == "sill-hv-n":
return "hv-pack-front"
if net_id == "hvj-hv-out-p" or net_id == "hvj-hv-out-n" or net_id == "hvj-hv-pack-p" or net_id == "hvj-hv-pack-n" or net_id == "hvj-hv-bus-p" or net_id == "hvj-hv-bus-n":
return "hv-pack-rear"
if net_id == "sill-48v" or net_id == "sill-48v-b" or net_id == "fzc-48v":
return "lv-zones"
if net_id == "sill-data-p" or net_id == "sill-data-n" or net_id == "fzc-cable-eth-p" or net_id == "fzc-cable-eth-n":
return "data-backbone"
if net_id == "fzc-can0-h" or net_id == "fzc-can0-l" or net_id == "fzc-can1-h" or net_id == "fzc-can1-l":
return "front-drive-control"
if net_id == "bmsA-ntc1" or net_id == "bmsA-ntc2" or net_id == "sill-temp" or net_id == "sill-temp2":
return "thermal-sensors"
return ""
def board_pours(board: PcbBoard):
inset = min(2.0, board.outline_width_mm * 0.02)
mut pours = []
for layer in board.stackup.layers:
if layer.function != PcbLayerFunction.plane or layer.copper_um <= 0.0:
continue
mut owner = ""
mut best = 0
mut counts = {}
for trace in board.traces:
if trace.layer_id != layer.id:
continue
tally = counts.get(trace.net_id, 0) + 1
counts[trace.net_id] = tally
if tally > best:
best = tally
owner = trace.net_id
if len(owner) == 0:
owner = board.nets[0].id
pours.append({
"id": board.id + ":" + layer.id + ":pour",
"layer": layer.id,
"net_id": owner,
"label": layer.name,
"points": [[inset, inset], [board.outline_width_mm - inset, inset], [board.outline_width_mm - inset, board.outline_height_mm - inset], [inset, board.outline_height_mm - inset]],
})
return pours
def board_export(board: PcbBoard):
index_of = pin_index_map(board.pins)
solution = board_solution(board)
gates = pcb_isolation_checks(board)
report = analyze_solved(board, solution, gates)
netlist = pcb_netlist_text(board)
mut layers = []
for index in range(0, len(board.stackup.layers)):
layer = board.stackup.layers[index]
layers.append({
"id": layer.id,
"name": layer.name,
"index": index,
"kind": layer_kind(layer),
"function": layer_function_key(layer.function),
"copper_oz": layer.copper_um / 34.79,
"copper_um": layer.copper_um,
"dielectric_mm": layer.dielectric_mm,
"dielectric_er": layer.dielectric_er,
"material": layer.material,
"color": layer_color(index, layer),
"internal": index > 0 and index < len(board.stackup.layers) - 1,
})
mut components = []
mut pins = []
for component in board.components:
extent = component_extent(board, component.refdes)
pad = pad_extent(component.footprint)
mut component_pins = []
for pin in board.pins:
if pin.refdes != component.refdes:
continue
record = {
"id": pin.id,
"refdes": pin.refdes,
"number": pin.number,
"name": pin.name,
"kind": pin_kind_key(pin.kind),
"net_id": pin.net_id,
"at": [pin.x_mm, pin.y_mm],
"size": pad,
"voltage_v": solution.node_voltages[index_of.get(pin.id, 0)],
}
component_pins.append(record)
pins.append(record)
components.append({
"refdes": component.refdes,
"part_number": component.part_number,
"description": component.description,
"footprint": component.footprint,
"kind": component_kind(component.refdes),
"center": [component.x_mm, component.y_mm],
"rotation": component.rotation_deg,
"size": extent,
"layer": board.stackup.layers[0].id,
"pin_count": component.pin_count,
"modelled_pin_count": len(component_pins),
"dissipation_w": component.power_dissipation_w,
"mass_g": component.mass_g,
"pins": component_pins,
})
mut traces = []
mut violations = []
mut routed_length_mm = 0.0
mut copper_area_mm2 = 0.0
mut midpoint_of = {}
for trace in board.traces:
origin = board.pins[index_of.get(trace.from_pin_id, 0)]
target = board.pins[index_of.get(trace.to_pin_id, 0)]
points = route_points(origin.x_mm, origin.y_mm, target.x_mm, target.y_mm)
routed_length_mm = routed_length_mm + trace.length_mm
copper_area_mm2 = copper_area_mm2 + trace.length_mm * trace.width_mm
midpoint_of[trace.id] = len(traces)
traces.append({
"id": trace.id,
"net_id": trace.net_id,
"layer": trace.layer_id,
"width_mm": trace.width_mm,
"points": points,
"current_a": trace.current_a,
"delta_t_c": trace.temperature_rise_c,
"length_mm": trace.length_mm,
"resistance_mohm": trace.resistance_mohm,
"drop_mv": trace.voltage_drop_mv,
"power_loss_mw": trace.power_loss_mw,
"required_width_mm": trace.ipc_min_width_mm,
"compliant": trace.compliant,
"from_pin_id": trace.from_pin_id,
"to_pin_id": trace.to_pin_id,
})
if not trace.compliant:
violations.append({
"trace_id": trace.id,
"net_id": trace.net_id,
"layer": trace.layer_id,
"width_mm": trace.width_mm,
"required_width_mm": trace.ipc_min_width_mm,
"current_a": trace.current_a,
"delta_t_c": trace.temperature_rise_c,
"deficit_mm": trace.ipc_min_width_mm - trace.width_mm,
"severity": "major" if trace.ipc_min_width_mm > trace.width_mm * 1.25 else "marginal",
})
mut vias = []
for via in board.vias:
parent = via.id.substring(0, via.id.rfind("-v"))
anchor = traces[midpoint_of.get(parent, 0)]["points"][1]
vias.append({
"id": via.id,
"net_id": via.net_id,
"at": anchor,
"drill_mm": via.drill_mm,
"pad_mm": via.pad_mm,
"from_layer": via.from_layer_id,
"to_layer": via.to_layer_id,
"current_a": via.current_a,
"resistance_mohm": via.resistance_mohm,
"count": 1,
})
mut nets = []
mut worst_drop_mv = 0.0
for net in board.nets:
mut trace_count = 0
for trace in board.traces:
if trace.net_id == net.id:
trace_count = trace_count + 1
drop_mv = net_drop_v(board, solution, net) * 1000.0
worst_drop_mv = max(worst_drop_mv, drop_mv)
nets.append({
"id": net.id,
"name": net.name,
"net_class": viewer_net_class(net.net_class),
"net_class_raw": net_class_key(net.net_class),
"nominal_voltage_v": net.nominal_voltage_v,
"current_a": net.current_a,
"is_reference": net.is_reference,
"feed_pin_id": net.pin_ids[0],
"pin_ids": net.pin_ids,
"note": "feed pin held at the declared operating potential; every other pad solved",
"worst_drop_mv": drop_mv,
"trace_count": trace_count,
"vehicle_net_id": vehicle_net_for(net.id),
})
mut isolation = []
for check in gates:
isolation.append({"id": check.id, "category": check.category, "from_pin_id": check.from_pin_id, "to_pin_id": check.to_pin_id, "working_voltage_v": check.working_voltage_v, "required_mm": check.required_mm, "measured_mm": check.measured_mm, "reinforced": check.reinforced, "compliant": check.compliant})
mut dissipation_w = report.total_copper_loss_w
for component in board.components:
dissipation_w = dissipation_w + component.power_dissipation_w
analysis = {
"board_id": report.board_id,
"layer_count": len(board.stackup.layers),
"component_count": len(board.components),
"pin_count": len(board.pins),
"net_count": len(board.nets),
"trace_count": len(board.traces),
"via_count": len(board.vias),
"node_count": report.node_count,
"branch_count": report.branch_count,
"source_node": report.source_node,
"routed_length_mm": routed_length_mm,
"copper_area_mm2": copper_area_mm2,
"total_dissipation_w": dissipation_w,
"total_copper_loss_w": report.total_copper_loss_w,
"worst_drop_mv": report.worst_voltage_drop_mv,
"worst_drop_fraction": report.worst_drop_fraction,
"worst_drop_net_id": report.worst_net_id,
"max_temperature_rise_c": report.max_temperature_rise_c,
"ipc_violations": report.ipc_violations,
"drc_violations": report.drc_violations,
"isolation_violations": report.isolation_violations,
"solver_residual": report.solver_residual,
"kcl_residual_a": report.kcl_residual_a,
"rule": "IPC-2221B conductor sizing at " + str(IPC_ALLOWED_RISE_C) + " K rise, IPC-6012 class " + str(board.stackup.ipc_class),
"violations": violations,
"isolation": isolation,
"verdict": "pass" if report.ipc_violations + report.drc_violations + report.isolation_violations == 0 else "review",
}
return {
"id": board.id,
"name": board.name,
"host_part_id": board.host_part_id,
"part_id": board.host_part_id,
"zone": board.zone,
"substrate": substrate_kind(board.id),
"substrate_label": board.substrate,
"outline_width_mm": board.outline_width_mm,
"outline_height_mm": board.outline_height_mm,
"size": [board.outline_width_mm, board.outline_height_mm],
"corner_radius": min(4.0, board.outline_width_mm * 0.02),
"stackup": {"id": board.stackup.id, "name": board.stackup.name, "total_thickness_mm": board.stackup.total_thickness_mm, "ipc_class": board.stackup.ipc_class, "impedance_target_ohm": board.stackup.impedance_target_ohm},
"summary": board.name + " on " + board.substrate,
"layers": layers,
"components": components,
"pins": pins,
"traces": traces,
"vias": vias,
"pours": board_pours(board),
"nets": nets,
"analysis": analysis,
"netlist": netlist,
"netlist_sha_free_text_length": len(netlist),
"board_worst_drop_mv": worst_drop_mv,
}
pub def pcb_export() -> dict[str, any] !{}:
mut boards = []
mut component_count = 0
mut pin_count = 0
mut net_count = 0
mut trace_count = 0
mut via_count = 0
mut copper_loss_w = 0.0
mut violation_count = 0
mut peak_drop_mv = 0.0
for item in pcb_boards():
exported = board_export(item)
boards.append(exported)
summary = exported["analysis"]
component_count = component_count + summary["component_count"]
pin_count = pin_count + summary["pin_count"]
net_count = net_count + summary["net_count"]
trace_count = trace_count + summary["trace_count"]
via_count = via_count + summary["via_count"]
copper_loss_w = copper_loss_w + summary["total_copper_loss_w"]
violation_count = violation_count + summary["ipc_violations"] + summary["drc_violations"] + summary["isolation_violations"]
peak_drop_mv = max(peak_drop_mv, exported["board_worst_drop_mv"])
totals = {
"board_count": len(boards),
"component_count": component_count,
"pin_count": pin_count,
"net_count": net_count,
"trace_count": trace_count,
"via_count": via_count,
"copper_loss_w": copper_loss_w,
"violations": violation_count,
"peak_conductor_drop_mv": peak_drop_mv,
}
return {
"schema": "sema.circuitframe-pcb/v1",
"units": "mm, A, V, W, degC",
"standard": "IPC-2221B conductor sizing, IPC-6012 class 3 fabrication, IEC 60664-1 creepage screening",
"provenance": "engineered reconstruction; not an OEM design, netlist or manufacturing data set",
"conductor_model": "copper resistivity 1.724e-8 ohm.m at 20 degC, temperature coefficient 0.00393 /K, evaluated at " + str(CONDUCTOR_OPERATING_TEMP_C) + " degC",
"boards": boards,
"totals": totals,
"summary": totals,
"equations": pcb_equations(),
}
pub def pcb_equations() -> list[dict[str, str]] !{}:
return [
{"id": "pcb-resistivity", "source": "conductor_resistance_ohm", "expression": "R = rho_20 (1 + alpha (T - 20)) L / (w t), rho_20 = 1.724e-8 ohm.m, alpha = 0.00393 /K", "unit": "ohm", "owner": "Sema printed-conductor model"},
{"id": "pcb-nodal-analysis", "source": "solve_network", "expression": "G v = i, feed pin of every net held at its declared potential, loads injected at sink and returned at the return pad", "unit": "V", "owner": "Sema DC modified nodal analysis"},
{"id": "pcb-joule-loss", "source": "joule_loss_w", "expression": "P = I^2 R with I = (v_a - v_b) / R from the solved node voltages", "unit": "W", "owner": "Sema DC loss model"},
{"id": "pcb-via-barrel", "source": "via_barrel_resistance_ohm", "expression": "R_via = rho h / (pi (d + t) t), t = 25 um plating", "unit": "ohm", "owner": "Sema plated-barrel model"},
{"id": "pcb-ipc2221-width", "source": "ipc2221_cross_section_mils2", "expression": "A_mils2 = (I / (k dT^0.44))^(1/0.725), k = 0.048 external and 0.024 internal; w_mm = A_mils2 / (t_um / 25.4) * 0.0254; the published curve is fitted to I <= 35 A, so busbar and printed-sill currents are extrapolated", "unit": "mm", "owner": "IPC-2221B conductor sizing"},
{"id": "pcb-temperature-rise", "source": "ipc2221_temperature_rise_c", "expression": "dT = (I / (k A^0.725))^(1/0.44), the same relation inverted for the declared cross-section", "unit": "degC", "owner": "IPC-2221B conductor sizing"},
{"id": "pcb-creepage", "source": "iec60664_creepage_mm", "expression": "creepage = table4(V_working) at pollution degree 2 and material group IIIa; reinforced insulation = 2 x basic", "unit": "mm", "owner": "IEC 60664-1 screening"},
]
test "every pad resolves to a declared component and net, and every trace stays on its net":
mut orphan_pads = 0
mut thin_nets = 0
mut stray_endpoints = 0
mut missing_members = 0
for subject in pcb_boards():
mut known_nets = {}
for subject_net in subject.nets:
known_nets[subject_net.id] = len(subject_net.pin_ids)
if len(subject_net.pin_ids) < 2:
thin_nets = thin_nets + 1
mut known_refdes = {}
for subject_component in subject.components:
known_refdes[subject_component.refdes] = 1
mut owner_of = {}
for subject_pad in subject.pins:
owner_of[subject_pad.id] = subject_pad.net_id
if known_nets.get(subject_pad.net_id, 0) == 0 or known_refdes.get(subject_pad.refdes, 0) == 0:
orphan_pads = orphan_pads + 1
for subject_net in subject.nets:
for member in subject_net.pin_ids:
if owner_of.get(member, "") != subject_net.id:
missing_members = missing_members + 1
for subject_wire in subject.traces:
if owner_of.get(subject_wire.from_pin_id, "") != subject_wire.net_id:
stray_endpoints = stray_endpoints + 1
if owner_of.get(subject_wire.to_pin_id, "") != subject_wire.net_id:
stray_endpoints = stray_endpoints + 1
ensure orphan_pads == 0
ensure thin_nets == 0
ensure stray_endpoints == 0
ensure missing_members == 0
test "the solved rail falls monotonically away from its feed and closes Kirchhoff":
panel = sill_panel_board()
field = board_solution(panel)
ladder = ["TB2.3", "J2.4", "FS1.1", "J1.4", "TB1.3"]
mut upstream = node_voltage(field, ladder[0])
for rung in range(1, len(ladder)):
downstream = node_voltage(field, ladder[rung])
ensure downstream < upstream
upstream = downstream
ensure abs(node_voltage(field, ladder[0]) - 48.0) < 0.000000001
ensure node_voltage(field, ladder[0]) - node_voltage(field, ladder[4]) > 0.05
ensure field.kcl_residual_a < 0.000000001
ensure field.solver_residual < 0.000000001
ensure pcb_analyze(front_zone_board()).kcl_residual_a < 0.000000001
ensure pcb_analyze(hv_junction_board()).kcl_residual_a < 0.000000001
test "copper loss equals I squared R over every routed conductor":
mut reported_mw = 0.0
mut recomputed_mw = 0.0
for subject in pcb_boards():
for subject_wire in subject.traces:
reported_mw = reported_mw + subject_wire.power_loss_mw
recomputed_mw = recomputed_mw + subject_wire.current_a * subject_wire.current_a * subject_wire.resistance_mohm
ensure reported_mw > 60000.0
ensure abs(reported_mw - recomputed_mw) / reported_mw < 0.000000001
test "node-wise and branch-wise power sums agree on the solved zone controller":
card = front_zone_board()
field = board_solution(card)
mut node_side = 0.0
mut branch_side = 0.0
for rung in range(0, len(card.traces)):
subject_wire = card.traces[rung]
drive = field.branch_currents[rung]
branch_side = branch_side + drive * drive * trace_branch_resistance_ohm(card, subject_wire)
node_side = node_side + (node_voltage(field, subject_wire.from_pin_id) - node_voltage(field, subject_wire.to_pin_id)) * drive
ensure branch_side > 0.0
ensure abs(node_side - branch_side) / branch_side < 0.000000001
test "IPC-2221 sizing clears the sound eFuse channel and rejects the marginal one":
card = front_zone_board()
mut marginal = 0
mut sound = 0
for subject_wire in card.traces:
if subject_wire.id == "fzc-out1-s1":
marginal = marginal + 1
ensure not subject_wire.compliant
ensure subject_wire.ipc_min_width_mm > subject_wire.width_mm
ensure subject_wire.ipc_min_width_mm < subject_wire.width_mm * 1.1
ensure subject_wire.temperature_rise_c > IPC_ALLOWED_RISE_C
if subject_wire.id == "fzc-out2-s1":
sound = sound + 1
ensure subject_wire.compliant
ensure subject_wire.ipc_min_width_mm < subject_wire.width_mm
ensure subject_wire.temperature_rise_c < IPC_ALLOWED_RISE_C
ensure marginal == 1 and sound == 1
ensure pcb_analyze(card).ipc_violations == 1
ensure ipc_min_width_mm(3.1, 35.0, true) < ipc_min_width_mm(3.1, 35.0, false)
ensure abs(ipc_temperature_rise_c(3.1, ipc_min_width_mm(3.1, 35.0, false), 35.0, false) - IPC_ALLOWED_RISE_C) < 0.00000001
ensure ipc_min_width_mm(0.0, 35.0, true) == 0.0
test "the generated netlist enumerates exactly one node per connected pad":
for subject in pcb_boards():
rendered = pcb_netlist_text(subject)
mut connected = 0
for subject_pad in subject.pins:
if len(subject_pad.net_id) > 0:
connected = connected + 1
ensure rendered.startswith("(export (version D)")
ensure rendered.count("(node ") == connected
ensure rendered.count("(comp (ref ") == len(subject.components)
ensure rendered.count("(net (code ") == len(subject.nets)
test "the 800 V board holds IEC 60664-1 creepage and an under-spec gap is rejected":
card = hv_junction_board()
mut barrier_mm = 0.0
mut basic_mm = 0.0
for gate in pcb_isolation_checks(card):
ensure gate.compliant
if gate.reinforced:
barrier_mm = gate.measured_mm
else:
basic_mm = gate.measured_mm
ensure abs(iec60664_creepage_mm(800.0) - 8.0) < 0.000001
ensure abs(isolation_required_mm(false, 800.0) - 16.0) < 0.000001
ensure isolation_required_mm(true, 12.0) == 0.0
ensure barrier_mm >= isolation_required_mm(false, 800.0)
ensure basic_mm >= isolation_required_mm(true, 800.0)
understated = PcbIsolationCheck(id="synthetic-under-spec", board_id=card.id, category="synthetic barrier gap", from_pin_id="U1.4", to_pin_id="U1.5", working_voltage_v=800.0, required_mm=isolation_required_mm(false, 800.0), measured_mm=6.0, reinforced=true, compliant=6.0 >= isolation_required_mm(false, 800.0))
ensure not understated.compliant
ensure pcb_analyze(card).isolation_violations == 0
test "conductor resistance follows the temperature-corrected copper law":
rho = operating_resistivity()
ensure abs(rho - COPPER_RESISTIVITY_OHM_M * (1.0 + COPPER_TEMPCO_PER_K * 25.0)) < 0.0000000000000001
ensure abs(conductor_resistance_ohm(rho, 1.0, 0.001, 0.000035) - rho / 0.000000035) < 0.000000001
corner = route_points(0.0, 0.0, 10.0, 4.0)
ensure len(corner) == 3
ensure abs(polyline_length_mm(corner) - (6.0 + math.sqrt(32.0))) < 0.000000001
test "the exported payload carries four boards with drawable geometry and analysis":
payload = pcb_export()
encoded = encode_json(payload)
ensure len(encoded) > 200000 and len(encoded) < 400000
ensure payload["schema"] == "sema.circuitframe-pcb/v1"
ensure payload["totals"]["board_count"] == 4
ensure payload["totals"]["component_count"] == 64
ensure payload["totals"]["pin_count"] == 260
ensure payload["totals"]["net_count"] == 84
ensure payload["totals"]["trace_count"] == 176
ensure payload["totals"]["via_count"] == 230
ensure payload["totals"]["violations"] == 1
ensure payload["totals"]["copper_loss_w"] > 69.0 and payload["totals"]["copper_loss_w"] < 70.0
ensure payload["boards"][0]["id"] == "bms-slave"
ensure payload["boards"][1]["id"] == "front-zone-controller"
ensure payload["boards"][2]["id"] == "hv-junction-board"
ensure payload["boards"][3]["id"] == "sill-conductor-panel"
ensure payload["boards"][3]["substrate"] == "structural-sill"
ensure payload["boards"][2]["substrate"] == "ims"
mut drawable = 0
for card in payload["boards"]:
ensure card["size"][0] > 0.0 and card["size"][1] > 0.0
ensure card["layers"][0]["index"] == 0
ensure len(card["netlist"]) == card["netlist_sha_free_text_length"]
for subject_wire in card["traces"]:
ensure len(subject_wire["points"]) >= 2
if subject_wire["compliant"]:
ensure subject_wire["width_mm"] >= subject_wire["required_width_mm"]
else:
ensure subject_wire["width_mm"] < subject_wire["required_width_mm"]
drawable = drawable + 1
ensure drawable == 176
test "the equation manifest publishes the same row shape as the dynamics manifest":
rows = pcb_equations()
ensure len(rows) == 7
mut described = 0
for row in rows:
ensure len(row["id"]) > 0 and len(row["source"]) > 0
ensure len(row["expression"]) > 0 and len(row["unit"]) > 0 and len(row["owner"]) > 0
described = described + 1
ensure described == 7
"""Coupled electric-vehicle dynamics over the panel-solved aerodynamic field.
Aerodynamic loads come from `aero.sema`: a constant-strength source-panel solve with an image
ground plane, closed by an empirical viscous and base-pressure model. Mass, centre of gravity and
yaw inertia come from `assembly.sema`, summed from the declared part placements rather than typed
in as literals. The thermal state is the eight-node coolant network from the same module, so heat
moves between components instead of each one sinking independently to ambient.
Two reduced-order surrogates are fitted to the panel solve at startup and are continuously scored
against it while the vehicle runs; that residual is what drives the model-adaptation lifecycle.
The dynamics themselves always integrate the solved loads, never the surrogate.
"""
import math
from magna_ev_digital_twin.aero import AeroBasis, AeroForces, aero_basis, aero_solve
from magna_ev_digital_twin.assembly import MassProperties, SuspensionState, air_spring_reference_pressure, arm_rotation_limits, corner_pose, mass_properties, suspension_travel, thermal_initial_temps, thermal_step, wheel_force
from magna_ev_digital_twin.hardware import AxleHardware, HardwareLimits, brake_fade_factor, hardware_limits, rolling_coefficient, tire_friction
assure silver
CALM_MODEL_ID = "aero-rom-calm-v1"
CROSSWIND_MODEL_ID = "aero-rom-crosswind-v2"
THERMAL_NODE_COUNT = 8
MAX_WIND_MPS = 60.0
pub struct ControlInput:
throttle: f64
brake: f64
steering: f64
wind_mps: f64
wind_yaw_deg: f64
invariant throttle >= 0.0 and throttle <= 1.0
invariant brake >= 0.0 and brake <= 1.0
invariant steering >= -1.0 and steering <= 1.0
invariant wind_mps >= 0.0 and wind_mps <= MAX_WIND_MPS
invariant wind_yaw_deg >= -180.0 and wind_yaw_deg <= 180.0
pub struct VehicleState:
time_s: f64
distance_m: f64
speed_mps: f64
lateral_speed_mps: f64
acceleration_mps2: f64
lateral_acceleration_mps2: f64
yaw_rate_rps: f64
yaw_angle_rad: f64
steering_angle_rad: f64
battery_soc: f64
thermal_temps_c: list[f64]
battery_temp_c: f64
motor_temp_c: f64
inverter_temp_c: f64
coolant_temp_c: f64
cabin_temp_c: f64
tire_temp_c: f64
front_disc_temp_c: f64
rear_disc_temp_c: f64
motor_rpm: f64
model_id: str
invariant time_s >= 0.0
invariant distance_m >= 0.0
invariant speed_mps >= 0.0 and speed_mps <= 90.0
invariant lateral_speed_mps >= -40.0 and lateral_speed_mps <= 40.0
invariant yaw_rate_rps >= -3.0 and yaw_rate_rps <= 3.0
invariant battery_soc >= 0.0 and battery_soc <= 1.0
invariant len(thermal_temps_c) == THERMAL_NODE_COUNT
invariant battery_temp_c >= -40.0 and battery_temp_c <= 120.0
invariant motor_temp_c >= -40.0 and motor_temp_c <= 220.0
invariant inverter_temp_c >= -40.0 and inverter_temp_c <= 180.0
invariant coolant_temp_c >= -40.0 and coolant_temp_c <= 140.0
invariant cabin_temp_c >= -40.0 and cabin_temp_c <= 90.0
invariant tire_temp_c >= -40.0 and tire_temp_c <= 180.0
invariant front_disc_temp_c >= -40.0 and front_disc_temp_c <= MAX_DISC_TEMP_C
invariant rear_disc_temp_c >= -40.0 and rear_disc_temp_c <= MAX_DISC_TEMP_C
invariant len(model_id) > 0
pub struct AeroLoads:
"""Body-axis aerodynamic loads. Positive drag resists forward travel; positive lift is upward."""
air_speed_mps: f64
sideslip_rad: f64
dynamic_pressure_pa: f64
cd: f64
cy: f64
cl: f64
cmz: f64
drag_n: f64
side_n: f64
lift_n: f64
front_lift_n: f64
rear_lift_n: f64
yaw_moment_nm: f64
invariant air_speed_mps >= 0.0
invariant dynamic_pressure_pa >= 0.0
pub struct AeroSurrogate:
"""Even-in-yaw drag and lift, odd-in-yaw side force and yaw moment, fitted to the panel solve."""
model_id: str
cd0: f64
cd2: f64
cd4: f64
cy1: f64
cy3: f64
cl0: f64
cl2: f64
cl4: f64
cmz1: f64
cmz3: f64
fit_max_yaw_rad: f64
fit_nodes: int
fit_rms_cd: f64
invariant len(model_id) > 0
invariant cd0 > 0.0
invariant fit_max_yaw_rad > 0.0
invariant fit_nodes >= 2
invariant fit_rms_cd >= 0.0
pub struct VehicleParameters:
"""Inertial and geometric parameters derived from the declared assembly, not typed in."""
mass_kg: f64
cg_to_front_axle_m: f64
cg_to_rear_axle_m: f64
cg_height_m: f64
yaw_inertia_kg_m2: f64
wheelbase_m: f64
front_mass_fraction: f64
track_m: f64
invariant mass_kg > 0.0
invariant cg_to_front_axle_m > 0.0
invariant cg_to_rear_axle_m > 0.0
invariant cg_height_m > 0.0
invariant yaw_inertia_kg_m2 > 0.0
invariant wheelbase_m > 0.0
invariant front_mass_fraction > 0.0 and front_mass_fraction < 1.0
invariant track_m > 0.0
pub struct AeroHoldout:
active_training_error: f64
candidate_training_error: f64
active_validation_error: f64
candidate_validation_error: f64
invariant active_training_error >= 0.0
invariant candidate_training_error >= 0.0
invariant active_validation_error >= 0.0
invariant candidate_validation_error >= 0.0
pub struct CornerSuspension:
"""One wheel station read out of the declared double-wishbone closure."""
vertical_load_n: f64
wheel_travel_m: f64
camber_deg: f64
toe_deg: f64
damper_length_m: f64
spring_force_n: f64
travel_utilisation: f64
invariant vertical_load_n >= 0.0
invariant damper_length_m > 0.0
invariant travel_utilisation >= 0.0 and travel_utilisation <= 1.0
pub struct SuspensionCorners:
"""The four wheel stations, each solved at its own vertical load.
Lateral load transfer moves normal force onto the outside pair, so the closure returns different
travel, camber and toe per side. With no lateral acceleration the two sides are identical.
"""
front_left: CornerSuspension
front_right: CornerSuspension
rear_left: CornerSuspension
rear_right: CornerSuspension
pub struct SuspensionTable:
"""Corner closures pre-solved on a load grid, in strictly increasing load order."""
loads_n: list[f64]
corners: list[CornerSuspension]
invariant len(loads_n) == len(corners)
invariant len(loads_n) >= 2
pub struct AxleLongitudinal:
"""One axle's longitudinal state: what its hardware could do, and what the tyres allowed.
Both the brake and the drive side report which declared component set the number. At the tyre
limit a stop is mass-independent; the moment the caliper is the binding constraint it is not,
and the difference is visible here rather than folded into one force.
"""
axle: str
normal_force_n: f64
friction_coefficient: f64
tire_limit_n: f64
brake_capability_n: f64
brake_force_n: f64
brake_constraint: str
motor_force_n: f64
power_force_n: f64
drive_force_n: f64
drive_constraint: str
cornering_stiffness_n_rad: f64
disc_temp_c: f64
fade_fraction: f64
invariant len(axle) > 0
invariant normal_force_n >= 0.0
invariant friction_coefficient > 0.0
invariant tire_limit_n >= 0.0
invariant brake_capability_n >= 0.0
invariant brake_force_n >= 0.0
invariant drive_force_n >= 0.0
invariant cornering_stiffness_n_rad >= 0.0
invariant fade_fraction > 0.0 and fade_fraction <= 1.0
invariant len(brake_constraint) > 0 and len(drive_constraint) > 0
struct LongitudinalDemand:
"""The live inputs one longitudinal solve needs, flattened out of the state and the aero solve.
Built once per step and handed to the four axle solves. Everything on it is a scalar so that the
per-call copy the interpreter makes stays cheap.
"""
throttle: f64
brake: f64
power_limit: f64
speed_mps: f64
previous_acceleration_mps2: f64
front_disc_temp_c: f64
rear_disc_temp_c: f64
front_lift_n: f64
rear_lift_n: f64
lift_n: f64
drag_n: f64
invariant throttle >= 0.0 and throttle <= 1.0
invariant brake >= 0.0 and brake <= 1.0
invariant power_limit >= 0.0 and power_limit <= 1.0
invariant speed_mps >= 0.0
struct LongitudinalSolution:
"""Both axles solved together under one load transfer, with the resulting body acceleration."""
front: AxleLongitudinal
rear: AxleLongitudinal
rolling_force_n: f64
brake_line_pressure_pa: f64
brake_force_n: f64
drive_force_n: f64
motor_torque_nm: f64
acceleration_mps2: f64
limiting_constraint: str
invariant rolling_force_n >= 0.0
invariant brake_line_pressure_pa >= 0.0
invariant brake_force_n >= 0.0
invariant drive_force_n >= 0.0
invariant motor_torque_nm >= 0.0
invariant len(limiting_constraint) > 0
pub struct PhysicsContext:
"""Everything expensive is built once here: the panel basis, the mass budget and both surrogates."""
basis: AeroBasis
parameters: VehicleParameters
calm: AeroSurrogate
crosswind: AeroSurrogate
holdout: AeroHoldout
suspension: SuspensionTable
hardware: HardwareLimits
pub struct DynamicsFrame:
state: VehicleState
controls: ControlInput
sequence: int
apparent_air_speed_mps: f64
sideslip_rad: f64
dynamic_pressure_pa: f64
solved: AeroLoads
surrogate: AeroLoads
model_cd: f64
reference_cd: f64
cd_pressure: f64
cd_base: f64
cd_friction: f64
cd_wheels: f64
drag_force_n: f64
lift_force_n: f64
side_force_n: f64
aero_yaw_moment_nm: f64
rolling_force_n: f64
brake_force_n: f64
regenerative_force_n: f64
drive_force_n: f64
wheel_torque_nm: f64
tire_slip_ratio: f64
front_axle_normal_force_n: f64
rear_axle_normal_force_n: f64
tire_utilization_fraction: f64
front_axle: AxleLongitudinal
rear_axle: AxleLongitudinal
limiting_constraint: str
brake_line_pressure_pa: f64
brake_fade_fraction: f64
front_disc_temp_c: f64
rear_disc_temp_c: f64
suspension: SuspensionCorners
mechanical_power_kw: f64
electrical_power_kw: f64
regenerative_power_kw: f64
battery_power_kw: f64
battery_loss_kw: f64
motor_loss_kw: f64
inverter_loss_kw: f64
pack_open_circuit_voltage_v: f64
pack_voltage_v: f64
pack_current_a: f64
pack_resistance_ohm: f64
power_limit_fraction: f64
power_residual_kw: f64
aero_residual_fraction: f64
reynolds_number: f64
separation_x_m: f64
cp_min: f64
cp_max: f64
base_pressure_coefficient: f64
wake_width_m: f64
wake_shedding_hz: f64
turbulence_intensity: f64
wake_velocity_deficit_fraction: f64
wake_recirculation_length_m: f64
dalembert_residual_cd: f64
aero_solver_residual: f64
aero_force_closure_residual: f64
thermal_energy_residual_w: f64
energy_residual_fraction: f64
invariant sequence >= 0
invariant apparent_air_speed_mps >= 0.0
invariant dynamic_pressure_pa >= 0.0
invariant rolling_force_n >= 0.0
invariant brake_force_n >= 0.0
invariant regenerative_force_n >= 0.0
invariant drive_force_n >= 0.0
invariant tire_slip_ratio >= 0.0
invariant front_axle_normal_force_n >= 0.0
invariant rear_axle_normal_force_n >= 0.0
invariant tire_utilization_fraction >= 0.0
invariant len(limiting_constraint) > 0
invariant brake_line_pressure_pa >= 0.0
invariant brake_fade_fraction > 0.0 and brake_fade_fraction <= 1.0
invariant front_disc_temp_c >= -40.0 and front_disc_temp_c <= MAX_DISC_TEMP_C
invariant rear_disc_temp_c >= -40.0 and rear_disc_temp_c <= MAX_DISC_TEMP_C
invariant regenerative_power_kw >= 0.0
invariant battery_loss_kw >= 0.0
invariant motor_loss_kw >= 0.0
invariant inverter_loss_kw >= 0.0
invariant pack_open_circuit_voltage_v > 0.0
invariant pack_voltage_v > 0.0
invariant pack_resistance_ohm > 0.0
invariant power_limit_fraction >= 0.0 and power_limit_fraction <= 1.0
invariant aero_residual_fraction >= 0.0
invariant reynolds_number >= 0.0
invariant wake_shedding_hz >= 0.0
invariant turbulence_intensity >= 0.0 and turbulence_intensity <= 1.0
invariant wake_velocity_deficit_fraction >= 0.0 and wake_velocity_deficit_fraction <= 1.0
invariant wake_recirculation_length_m >= 0.0
invariant energy_residual_fraction >= 0.0
AIR_DENSITY_KG_M3 = 1.225
FRONTAL_AREA_M2 = 2.48
SIDE_AREA_M2 = 4.32
PLANFORM_AREA_M2 = 7.95
VEHICLE_LENGTH_M = 4.751
GRAVITY_MPS2 = 9.80665
MOTOR_EFFICIENCY = 0.95
INVERTER_EFFICIENCY = 0.975
AUXILIARY_POWER_KW = 0.9
MAX_DISC_TEMP_C = 1200.0
FRONT_MOTOR_LOSS_SHARE = 0.42
AMBIENT_TEMP_C = 24.0
TIRE_THERMAL_CAPACITY_KJ_K = 28.0
TIRE_COOLING_KW_PER_K = 0.08
CALM_FIT_MAX_DEG = 8.0
CROSSWIND_FIT_MAX_DEG = 35.0
CALM_FIT_NODES = 5
CROSSWIND_FIT_NODES = 8
SURROGATE_FIT_SPEED_MPS = 27.8
SUSPENSION_TABLE_NODES = 48
SUSPENSION_MAX_LOAD_N = 40000.0
equation aerodynamic_force(air_density, coefficient, reference_area, relative_speed) -> any:
return 0.5 * air_density * coefficient * reference_area * relative_speed^2
equation dynamic_pressure(air_density, speed) -> any:
return 0.5 * air_density * speed^2
equation traction_force(motor_torque, gear_ratio, efficiency, wheel_radius) -> any:
return motor_torque * gear_ratio * efficiency / wheel_radius
equation longitudinal_acceleration(drive_force, drag_force, rolling_force, brake_force, mass) -> any:
return (drive_force - drag_force - rolling_force - brake_force) / mass
equation battery_soc_step(soc, electrical_power_kw, duration_s, capacity_kwh) -> any:
return soc - electrical_power_kw * duration_s / (capacity_kwh * 3600.0)
equation battery_open_circuit_voltage(soc, temperature_c) -> any:
return 700.0 + 150.0 * soc - max(0.0, 15.0 - temperature_c) * 0.7
equation battery_internal_resistance(base_resistance, soc, temperature_c) -> any:
return base_resistance * (1.0 + max(0.0, 0.2 - soc) * 2.5 + max(0.0, 15.0 - temperature_c) * 0.018)
equation lumped_thermal_step(temperature_c, heat_kw, cooling_kw_per_k, ambient_c, thermal_capacity_kj_k, duration_s) -> any:
return temperature_c + (heat_kw - cooling_kw_per_k * (temperature_c - ambient_c)) * duration_s / thermal_capacity_kj_k
equation lateral_acceleration(front_force, rear_force, aero_side_force, speed, yaw_rate, mass) -> any:
return (front_force + rear_force + aero_side_force) / mass - speed * yaw_rate
equation yaw_acceleration(front_force, rear_force, aero_yaw_moment, front_arm, rear_arm, yaw_inertia) -> any:
return (front_force * front_arm - rear_force * rear_arm + aero_yaw_moment) / yaw_inertia
equation normal_equations_2x2(s11, s12, s22, r1, r2) -> any:
return solve([[s11, s12], [s12, s22]], [r1, r2])
equation axle_load_transfer(mass, acceleration, cg_height, wheelbase) -> any:
return mass * acceleration * cg_height / wheelbase
equation disc_cooling_conductance(base_w_per_k, speed_w_per_k_mps, speed) -> any:
return base_w_per_k + speed_w_per_k_mps * speed
def vehicle_parameters():
"""Inertia and geometry summed from the declared part placements in `assembly.sema`."""
mass = mass_properties()
return VehicleParameters(
mass_kg=mass.total_mass_kg,
cg_to_front_axle_m=0.5 * mass.wheelbase_m - mass.cg_x_m,
cg_to_rear_axle_m=0.5 * mass.wheelbase_m + mass.cg_x_m,
cg_height_m=mass.cg_height_m,
yaw_inertia_kg_m2=mass.izz_kg_m2,
wheelbase_m=mass.wheelbase_m,
front_mass_fraction=mass.front_mass_fraction,
track_m=2.0 * corner_pose(0.0)[5],
)
def least_squares_pair(u: list[f64], y: list[f64]) !{}:
"""Fit y ~ a*u + b*u^2 through the origin by the 2x2 normal equations."""
require len(u) == len(y) and len(u) >= 2
mut s11 = 0.0
mut s12 = 0.0
mut s22 = 0.0
mut r1 = 0.0
mut r2 = 0.0
for i in range(0, len(u)):
a = u[i]
b = a * a
s11 = s11 + a * a
s12 = s12 + a * b
s22 = s22 + b * b
r1 = r1 + a * y[i]
r2 = r2 + b * y[i]
if s11 * s22 - s12 * s12 <= 0.0:
return [0.0, 0.0]
coefficients = normal_equations_2x2(s11, s12, s22, r1, r2)
return [coefficients[0], coefficients[1]]
def least_squares_single(u: list[f64], y: list[f64]):
"""Fit y ~ a*u through the origin."""
require len(u) == len(y) and len(u) >= 1
mut numerator = 0.0
mut denominator = 0.0
for i in range(0, len(u)):
numerator = numerator + u[i] * y[i]
denominator = denominator + u[i] * u[i]
if denominator <= 0.0:
return 0.0
return numerator / denominator
def yaw_fit_nodes(count: int, maximum_deg: f64):
"""Yaw stations in radians, evenly spaced from zero to the declared fit boundary."""
require count >= 2
mut nodes = []
for i in range(0, count):
nodes = nodes + [maximum_deg * float(i) / float(count - 1) * math.pi / 180.0]
return nodes
def fit_surrogate(basis: AeroBasis, model_id: str, count: int, maximum_deg: f64, quartic: bool) !{}:
"""Least-squares fit of the panel solve over a bounded yaw window.
Drag and lift are even in sideslip, side force and yaw moment are odd, so the basis functions are
beta^2/beta^4 and beta/beta^3 respectively. The zero-yaw intercepts are taken exactly from the
solve so the calibrated `C_d(0)` is preserved without relying on the fit.
"""
require count >= 2
nodes = yaw_fit_nodes(count, maximum_deg)
zero = aero_solve(basis, SURROGATE_FIT_SPEED_MPS, 0.0)
mut even = []
mut odd = []
mut drag_residual = []
mut lift_residual = []
mut side = []
mut moment = []
for beta in nodes:
forces = aero_solve(basis, SURROGATE_FIT_SPEED_MPS, beta)
even = even + [beta * beta]
odd = odd + [beta]
drag_residual = drag_residual + [forces.cd - zero.cd]
lift_residual = lift_residual + [forces.cl - zero.cl]
side = side + [forces.cy]
moment = moment + [forces.cmz]
mut cd2 = 0.0
mut cd4 = 0.0
mut cl2 = 0.0
mut cl4 = 0.0
mut cy1 = 0.0
mut cy3 = 0.0
mut cmz1 = 0.0
mut cmz3 = 0.0
if quartic:
drag = least_squares_pair(even, drag_residual)
lift = least_squares_pair(even, lift_residual)
lateral = least_squares_pair(odd, side)
yaw = least_squares_pair(odd, moment)
cd2 = drag[0]
cd4 = drag[1]
cl2 = lift[0]
cl4 = lift[1]
cy1 = lateral[0]
cy3 = lateral[1]
cmz1 = yaw[0]
cmz3 = yaw[1]
else:
cd2 = least_squares_single(even, drag_residual)
cl2 = least_squares_single(even, lift_residual)
cy1 = least_squares_single(odd, side)
cmz1 = least_squares_single(odd, moment)
mut squared = 0.0
for i in range(0, len(nodes)):
u = even[i]
predicted = cd2 * u + cd4 * u * u
error = predicted - drag_residual[i]
squared = squared + error * error
return AeroSurrogate(
model_id=model_id,
cd0=zero.cd,
cd2=cd2,
cd4=cd4,
cy1=cy1,
cy3=cy3,
cl0=zero.cl,
cl2=cl2,
cl4=cl4,
cmz1=cmz1,
cmz3=cmz3,
fit_max_yaw_rad=maximum_deg * math.pi / 180.0,
fit_nodes=count,
fit_rms_cd=math.sqrt(squared / float(len(nodes))),
)
def surrogate_loads(surrogate: AeroSurrogate, air_speed_mps: f64, sideslip_rad: f64) !{}:
"""Evaluate the reduced-order coefficients. Cheap enough to run inside any control loop."""
speed = max(0.0, air_speed_mps)
q = dynamic_pressure(AIR_DENSITY_KG_M3, speed)
even = sideslip_rad * sideslip_rad
cd = surrogate.cd0 + surrogate.cd2 * even + surrogate.cd4 * even * even
cl = surrogate.cl0 + surrogate.cl2 * even + surrogate.cl4 * even * even
cy = surrogate.cy1 * sideslip_rad + surrogate.cy3 * sideslip_rad * even
cmz = surrogate.cmz1 * sideslip_rad + surrogate.cmz3 * sideslip_rad * even
lift = cl * q * PLANFORM_AREA_M2
return AeroLoads(
air_speed_mps=speed,
sideslip_rad=sideslip_rad,
dynamic_pressure_pa=q,
cd=cd,
cy=cy,
cl=cl,
cmz=cmz,
drag_n=cd * q * FRONTAL_AREA_M2,
side_n=cy * q * SIDE_AREA_M2,
lift_n=lift,
front_lift_n=0.5 * lift,
rear_lift_n=0.5 * lift,
yaw_moment_nm=cmz * q * SIDE_AREA_M2 * VEHICLE_LENGTH_M,
)
def solved_loads(forces: AeroForces):
return AeroLoads(
air_speed_mps=forces.air_speed_mps,
sideslip_rad=forces.yaw_rad,
dynamic_pressure_pa=forces.dynamic_pressure_pa,
cd=forces.cd,
cy=forces.cy,
cl=forces.cl,
cmz=forces.cmz,
drag_n=forces.drag_n,
side_n=forces.side_n,
lift_n=forces.lift_n,
front_lift_n=forces.front_lift_n,
rear_lift_n=forces.rear_lift_n,
yaw_moment_nm=forces.yaw_moment_nm,
)
def surrogate_for(context: PhysicsContext, model_id: str):
return context.crosswind if model_id == CROSSWIND_MODEL_ID else context.calm
def relative_drag_error(basis: AeroBasis, surrogate: AeroSurrogate, degrees: list[f64]) !{}:
"""Mean relative drag-coefficient error of a surrogate against the panel solve."""
require len(degrees) >= 1
mut total = 0.0
for value in degrees:
beta = value * math.pi / 180.0
reference = aero_solve(basis, SURROGATE_FIT_SPEED_MPS, beta)
predicted = surrogate_loads(surrogate, SURROGATE_FIT_SPEED_MPS, beta)
total = total + abs(reference.cd - predicted.cd) / max(0.001, abs(reference.cd))
return total / float(len(degrees))
pub def physics_context() -> PhysicsContext !{}:
"""Build the panel basis, the mass budget and both surrogates. Call once at startup."""
basis = aero_basis()
calm = fit_surrogate(basis, CALM_MODEL_ID, CALM_FIT_NODES, CALM_FIT_MAX_DEG, false)
crosswind = fit_surrogate(basis, CROSSWIND_MODEL_ID, CROSSWIND_FIT_NODES, CROSSWIND_FIT_MAX_DEG, true)
training = [4.0, 8.0]
validation = [22.0, 32.0]
return PhysicsContext(
basis=basis,
parameters=vehicle_parameters(),
calm=calm,
crosswind=crosswind,
holdout=AeroHoldout(
active_training_error=relative_drag_error(basis, calm, training),
candidate_training_error=relative_drag_error(basis, crosswind, training),
active_validation_error=relative_drag_error(basis, calm, validation),
candidate_validation_error=relative_drag_error(basis, crosswind, validation),
),
suspension=suspension_table(),
hardware=hardware_limits(),
)
pub def aero_holdout_metrics(context: PhysicsContext) -> dict[str, f64] !{}:
"""Training and held-out surrogate errors measured against the live panel solve."""
return {
"active_training_error": context.holdout.active_training_error,
"candidate_training_error": context.holdout.candidate_training_error,
"active_validation_error": context.holdout.active_validation_error,
"candidate_validation_error": context.holdout.candidate_validation_error,
"invariant_violations": 0.0,
}
pub def initial_vehicle_state() -> VehicleState !{}:
temps = thermal_initial_temps()
return VehicleState(
time_s=0.0,
distance_m=0.0,
speed_mps=0.0,
lateral_speed_mps=0.0,
acceleration_mps2=0.0,
lateral_acceleration_mps2=0.0,
yaw_rate_rps=0.0,
yaw_angle_rad=0.0,
steering_angle_rad=0.0,
battery_soc=0.82,
thermal_temps_c=temps,
battery_temp_c=temps[0],
motor_temp_c=max(temps[1], temps[2]),
inverter_temp_c=max(temps[3], temps[4]),
coolant_temp_c=temps[6],
cabin_temp_c=temps[5],
tire_temp_c=AMBIENT_TEMP_C,
front_disc_temp_c=AMBIENT_TEMP_C,
rear_disc_temp_c=AMBIENT_TEMP_C,
motor_rpm=0.0,
model_id=CALM_MODEL_ID,
)
def state_at(speed_mps: f64, battery_soc: f64):
"""A settled state at one road speed and state of charge, with cold discs and cold tyres."""
base = initial_vehicle_state()
return VehicleState(
time_s=base.time_s,
distance_m=base.distance_m,
speed_mps=speed_mps,
lateral_speed_mps=base.lateral_speed_mps,
acceleration_mps2=base.acceleration_mps2,
lateral_acceleration_mps2=base.lateral_acceleration_mps2,
yaw_rate_rps=base.yaw_rate_rps,
yaw_angle_rad=base.yaw_angle_rad,
steering_angle_rad=base.steering_angle_rad,
battery_soc=battery_soc,
thermal_temps_c=base.thermal_temps_c,
battery_temp_c=base.battery_temp_c,
motor_temp_c=base.motor_temp_c,
inverter_temp_c=base.inverter_temp_c,
coolant_temp_c=base.coolant_temp_c,
cabin_temp_c=base.cabin_temp_c,
tire_temp_c=base.tire_temp_c,
front_disc_temp_c=base.front_disc_temp_c,
rear_disc_temp_c=base.rear_disc_temp_c,
motor_rpm=base.motor_rpm,
model_id=base.model_id,
)
def state_carrying(state: VehicleState, speed_mps: f64):
"""The same thermal and electrical state brought back to speed, for a repeated-stop sequence.
Only the motion resets. Disc, tyre, pack and coolant temperatures carry over, which is the whole
point: the heat a stop leaves behind is what the next one starts from.
"""
return VehicleState(
time_s=state.time_s,
distance_m=state.distance_m,
speed_mps=speed_mps,
lateral_speed_mps=0.0,
acceleration_mps2=0.0,
lateral_acceleration_mps2=0.0,
yaw_rate_rps=0.0,
yaw_angle_rad=state.yaw_angle_rad,
steering_angle_rad=0.0,
battery_soc=state.battery_soc,
thermal_temps_c=state.thermal_temps_c,
battery_temp_c=state.battery_temp_c,
motor_temp_c=state.motor_temp_c,
inverter_temp_c=state.inverter_temp_c,
coolant_temp_c=state.coolant_temp_c,
cabin_temp_c=state.cabin_temp_c,
tire_temp_c=state.tire_temp_c,
front_disc_temp_c=state.front_disc_temp_c,
rear_disc_temp_c=state.rear_disc_temp_c,
motor_rpm=state.motor_rpm,
model_id=state.model_id,
)
def with_added_mass(context: PhysicsContext, added_kg: f64):
"""The same vehicle carrying more mass: everything else declared stays exactly as it is.
Only the inertial parameters move. The motors, calipers, discs and tyres are the ones the parts
list declares, which is the whole point of the comparison: what does the declared hardware do
when it has more mass to move and to stop.
"""
parameters = context.parameters
return PhysicsContext(
basis=context.basis,
parameters=VehicleParameters(
mass_kg=parameters.mass_kg + added_kg,
cg_to_front_axle_m=parameters.cg_to_front_axle_m,
cg_to_rear_axle_m=parameters.cg_to_rear_axle_m,
cg_height_m=parameters.cg_height_m,
yaw_inertia_kg_m2=parameters.yaw_inertia_kg_m2 * (parameters.mass_kg + added_kg) / parameters.mass_kg,
wheelbase_m=parameters.wheelbase_m,
front_mass_fraction=parameters.front_mass_fraction,
track_m=parameters.track_m,
),
calm=context.calm,
crosswind=context.crosswind,
holdout=context.holdout,
suspension=context.suspension,
hardware=context.hardware,
)
def apparent_wind(state: VehicleState, controls: ControlInput):
"""Apparent-wind magnitude and sideslip in the vehicle frame.
A head-on tunnel wind and forward road speed add; `wind_yaw_deg` rotates the tunnel wind around
the vehicle, so a parked car in a 27.8 m/s stream reports 27.8 m/s of apparent wind at that yaw.
"""
yaw = controls.wind_yaw_deg * math.pi / 180.0
axial = state.speed_mps + controls.wind_mps * math.cos(yaw)
lateral = state.lateral_speed_mps + controls.wind_mps * math.sin(yaw)
speed = math.sqrt(axial * axial + lateral * lateral)
sideslip = math.atan2(lateral, axial) if speed > 0.01 else 0.0
return [speed, sideslip]
def corner_state(state: SuspensionState):
"""Narrow a solved corner to the fields the dynamics frame publishes."""
return CornerSuspension(
vertical_load_n=state.vertical_load_n,
wheel_travel_m=state.wheel_travel_m,
camber_deg=state.camber_deg,
toe_deg=state.toe_deg,
damper_length_m=state.damper_length_m,
spring_force_n=state.spring_force_n,
travel_utilisation=state.travel_utilisation,
)
def suspension_table():
"""Pre-solve the declared corner closure on a grid uniform in lower-arm rotation.
`suspension_travel` bisects the arm rotation against the polytropic air spring, which costs far
more than the whole rest of a 20 Hz step. Sampling it once at startup and interpolating keeps the
loop inside its budget without inventing a second suspension model. Rotation-uniform spacing puts
the nodes where the linkage actually moves: the air spring stiffens so sharply that a load-uniform
grid would collapse the working range into a couple of intervals. Rotations that clamp to the same
load at a stop are dropped, so the lookup never meets a zero-width interval.
"""
limits = arm_rotation_limits()
pressure = air_spring_reference_pressure()
mut loads = []
mut corners = []
for index in range(0, SUSPENSION_TABLE_NODES):
rotation = limits[0] + (limits[1] - limits[0]) * f64(index) / f64(SUSPENSION_TABLE_NODES - 1)
load = max(0.0, min(SUSPENSION_MAX_LOAD_N, wheel_force(rotation, pressure)))
if len(loads) == 0 or load > loads[len(loads) - 1]:
loads.append(load)
corners.append(corner_state(suspension_travel(load)))
return SuspensionTable(loads_n=loads, corners=corners)
def interpolate_corner(table: SuspensionTable, vertical_load_n: f64):
"""Read the pre-solved corner grid at one vertical wheel load, linearly between nodes."""
load = max(0.0, min(SUSPENSION_MAX_LOAD_N, vertical_load_n))
mut index = 1
while index < len(table.loads_n) - 1 and table.loads_n[index] < load:
index = index + 1
lower = table.corners[index - 1]
upper = table.corners[index]
span = table.loads_n[index] - table.loads_n[index - 1]
blend = max(0.0, min(1.0, (load - table.loads_n[index - 1]) / span))
return CornerSuspension(
vertical_load_n=load,
wheel_travel_m=lower.wheel_travel_m + (upper.wheel_travel_m - lower.wheel_travel_m) * blend,
camber_deg=lower.camber_deg + (upper.camber_deg - lower.camber_deg) * blend,
toe_deg=lower.toe_deg + (upper.toe_deg - lower.toe_deg) * blend,
damper_length_m=lower.damper_length_m + (upper.damper_length_m - lower.damper_length_m) * blend,
spring_force_n=lower.spring_force_n + (upper.spring_force_n - lower.spring_force_n) * blend,
travel_utilisation=lower.travel_utilisation + (upper.travel_utilisation - lower.travel_utilisation) * blend,
)
def clamped_disc_temp(value: f64):
"""Hold an integrated disc temperature inside the range the state invariant declares."""
return max(-40.0, min(MAX_DISC_TEMP_C, value))
def axle_longitudinal(hardware: AxleHardware, demand: LongitudinalDemand, normal_force_n: f64, disc_temp_c: f64) !{}:
"""One axle solved against its own hardware and its own live vertical load.
The brake side walks the real chain: pedal demand sets line pressure, pressure and piston area
set clamp force, pad friction and the effective radius set corner torque, and the rolling radius
turns that into a wheel force. All of that is fixed by the declared components, so `hardware`
arrives with it already solved and only the pedal fraction and the fade factor are applied here.
The tyre side is the friction circle on this axle's live load, with the declared load
sensitivity, so a heavier corner returns less grip per newton. Whichever of the two is smaller
is the force the axle makes, and which one it was is published.
"""
load = max(0.0, normal_force_n)
friction = tire_friction(hardware.peak_friction, hardware.slide_friction, hardware.load_sensitivity, hardware.friction_reference_load_n, 0.5 * load)
grip = friction * load
fade = brake_fade_factor(disc_temp_c, hardware.fade_onset_temp_c, hardware.fade_rate_per_k, hardware.fade_floor_fraction)
capability = hardware.brake_axle_force_n * demand.brake * fade
braking = min(capability, grip)
motor_force = hardware.drive_force_per_throttle_n * demand.throttle * demand.power_limit
# The power term is the machine's shaft corner power carried through the same driveline
# efficiency as the torque term, so the two meet exactly at the base speed. Which component set
# the current behind that envelope is already resolved into `power_constraint`.
power_force = hardware.power_force_numerator_w * demand.power_limit / max(2.0, demand.speed_mps)
headroom = max(0.0, grip - braking)
drive = min(motor_force, min(power_force, headroom))
mut drive_constraint = "coasting"
if demand.throttle > 0.0:
drive_constraint = "tyre-grip"
if motor_force <= power_force and motor_force <= headroom:
drive_constraint = "motor-torque"
elif power_force <= headroom:
drive_constraint = hardware.power_constraint
return AxleLongitudinal(
axle=hardware.axle,
normal_force_n=load,
friction_coefficient=friction,
tire_limit_n=grip,
brake_capability_n=capability,
brake_force_n=braking,
brake_constraint="released" if demand.brake <= 0.0 else ("brake-torque" if capability < grip else "tyre-grip"),
motor_force_n=motor_force,
power_force_n=power_force,
drive_force_n=drive,
drive_constraint=drive_constraint,
cornering_stiffness_n_rad=hardware.cornering_stiffness_per_n * load,
disc_temp_c=disc_temp_c,
fade_fraction=fade,
)
def axle_pass(front_hardware: AxleHardware, rear_hardware: AxleHardware, demand: LongitudinalDemand, static_front_n: f64, static_rear_n: f64, transfer_n: f64) !{}:
"""Both axles solved at the load transfer one candidate acceleration implies."""
return [
axle_longitudinal(front_hardware, demand, static_front_n - transfer_n, demand.front_disc_temp_c),
axle_longitudinal(rear_hardware, demand, static_rear_n + transfer_n, demand.rear_disc_temp_c),
]
def longitudinal_solution(parameters: VehicleParameters, hardware: HardwareLimits, demand: LongitudinalDemand) !{}:
"""Solve both axles and the body acceleration together, since each one sets the other.
Load transfer depends on the acceleration, and the acceleration depends on the per-axle limits
that the transferred loads produce. One predictor from the previous step's acceleration and one
corrector from this step's is enough at 20 Hz: the transfer moves by well under a percent over
the second pass, and a fixed point is not worth the extra arithmetic inside the loop.
Nothing larger than a flat scalar struct crosses these call boundaries. The interpreter copies a
struct argument by value, and `PhysicsContext` carries the whole panel basis, so handing it to a
helper that runs four times a step costs milliseconds out of a fifty-millisecond budget.
"""
front_hardware = hardware.front_axle
rear_hardware = hardware.rear_axle
mass = parameters.mass_kg
weight = mass * GRAVITY_MPS2
static_front = weight * parameters.cg_to_rear_axle_m / parameters.wheelbase_m - demand.front_lift_n
static_rear = weight * parameters.cg_to_front_axle_m / parameters.wheelbase_m - demand.rear_lift_n
coefficient = rolling_coefficient(hardware.tire.rolling_coefficient, hardware.tire.rolling_speed_coefficient, demand.speed_mps)
rolling = coefficient * max(0.0, weight - demand.lift_n) if demand.speed_mps > 0.05 or demand.throttle > 0.0 else 0.0
predicted = axle_pass(front_hardware, rear_hardware, demand, static_front, static_rear, axle_load_transfer(mass, demand.previous_acceleration_mps2, parameters.cg_height_m, parameters.wheelbase_m))
trial = longitudinal_acceleration(predicted[0].drive_force_n + predicted[1].drive_force_n, demand.drag_n, rolling, predicted[0].brake_force_n + predicted[1].brake_force_n, mass)
axles = axle_pass(front_hardware, rear_hardware, demand, static_front, static_rear, axle_load_transfer(mass, trial, parameters.cg_height_m, parameters.wheelbase_m))
front = axles[0]
rear = axles[1]
braking = front.brake_force_n + rear.brake_force_n
drive = front.drive_force_n + rear.drive_force_n
mut limiting = front.drive_constraint if front.drive_force_n >= rear.drive_force_n else rear.drive_constraint
if braking > 0.0:
limiting = front.brake_constraint if front.brake_force_n >= rear.brake_force_n else rear.brake_constraint
return LongitudinalSolution(
front=front,
rear=rear,
rolling_force_n=rolling,
brake_line_pressure_pa=hardware.max_line_pressure_pa * demand.brake,
brake_force_n=braking,
drive_force_n=drive,
motor_torque_nm=hardware.max_motor_torque_nm * demand.throttle * demand.power_limit,
acceleration_mps2=longitudinal_acceleration(drive, demand.drag_n, rolling, braking, mass),
limiting_constraint=limiting,
)
pub def step_vehicle(state: VehicleState, controls: ControlInput, duration_s: f64, sequence: int, model_id: str, context: PhysicsContext) -> DynamicsFrame !{}:
require duration_s == 0.05
require sequence >= 1
parameters = context.parameters
mass = parameters.mass_kg
wheelbase = parameters.wheelbase_m
wind = apparent_wind(state, controls)
apparent_air_speed = wind[0]
sideslip = wind[1]
forces = aero_solve(context.basis, apparent_air_speed, sideslip)
solved = solved_loads(forces)
surrogate = surrogate_loads(surrogate_for(context, model_id), apparent_air_speed, sideslip)
pressure = forces.dynamic_pressure_pa
drag = solved.drag_n
lift = solved.lift_n
aero_side_force = 0.0 - solved.side_n
aero_yaw_moment = 0.0 - solved.yaw_moment_nm
battery_derate = max(0.25, 1.0 - max(0.0, state.battery_temp_c - 55.0) / 55.0)
motor_derate = max(0.30, 1.0 - max(0.0, state.motor_temp_c - 120.0) / 100.0)
soc_derate = min(1.0, max(0.0, state.battery_soc / 0.12))
power_limit = min(battery_derate, min(motor_derate, soc_derate))
longitudinal = longitudinal_solution(parameters, context.hardware, LongitudinalDemand(
throttle=controls.throttle * (1.0 - controls.brake),
brake=controls.brake,
power_limit=power_limit,
speed_mps=state.speed_mps,
previous_acceleration_mps2=state.acceleration_mps2,
front_disc_temp_c=state.front_disc_temp_c,
rear_disc_temp_c=state.rear_disc_temp_c,
front_lift_n=solved.front_lift_n,
rear_lift_n=solved.rear_lift_n,
lift_n=lift,
drag_n=drag,
))
front_axle = longitudinal.front
rear_axle = longitudinal.rear
rolling = longitudinal.rolling_force_n
brake_force = longitudinal.brake_force_n
drive_force = longitudinal.drive_force_n
motor_torque = longitudinal.motor_torque_nm
requested_drive_force = front_axle.motor_force_n + rear_axle.motor_force_n
tire_force_limit = front_axle.tire_limit_n + rear_axle.tire_limit_n
acceleration = longitudinal.acceleration_mps2
next_speed = max(0.0, min(90.0, state.speed_mps + acceleration * duration_s))
average_speed = (state.speed_mps + next_speed) * 0.5
front_axle_load = front_axle.normal_force_n
rear_axle_load = rear_axle.normal_force_n
# Regeneration is a share of the same pedal demand, split across the axles by the continuous
# torque each machine can generate and clamped to what that axle is actually braking with. The
# discs only take the friction remainder, so a stop with a warm, receptive pack heats them less.
charge_acceptance = min(1.0, max(0.0, (1.0 - state.battery_soc) / 0.02))
limits = context.hardware
regenerative_request = min(brake_force, min(limits.max_regenerative_force_n, limits.max_regenerative_power_kw * 1000.0 / max(2.0, state.speed_mps))) * charge_acceptance
front_regenerative = min(front_axle.brake_force_n, regenerative_request * limits.regenerative_front_share)
rear_regenerative = min(rear_axle.brake_force_n, regenerative_request * (1.0 - limits.regenerative_front_share))
regenerative_force = front_regenerative + rear_regenerative
disc_cooling_kw_per_k = disc_cooling_conductance(limits.disc_cooling_base_w_per_k, limits.disc_cooling_speed_w_per_k_mps, average_speed) / 1000.0
next_front_disc_temp = clamped_disc_temp(lumped_thermal_step(state.front_disc_temp_c, 0.5 * (front_axle.brake_force_n - front_regenerative) * average_speed / 1000.0, disc_cooling_kw_per_k, AMBIENT_TEMP_C, limits.front_brake.disc_thermal_capacity_j_k / 1000.0, duration_s))
next_rear_disc_temp = clamped_disc_temp(lumped_thermal_step(state.rear_disc_temp_c, 0.5 * (rear_axle.brake_force_n - rear_regenerative) * average_speed / 1000.0, disc_cooling_kw_per_k, AMBIENT_TEMP_C, limits.rear_brake.disc_thermal_capacity_j_k / 1000.0, duration_s))
mechanical_power = max(0.0, drive_force * average_speed / 1000.0)
regenerative_mechanical_power = regenerative_force * average_speed / 1000.0
regenerative_power = regenerative_mechanical_power * limits.motor_efficiency * limits.inverter_efficiency
traction_electrical_power = mechanical_power / (limits.motor_efficiency * limits.inverter_efficiency)
auxiliary_power = AUXILIARY_POWER_KW * min(1.0, max(0.0, state.battery_soc / 0.01))
electric_power = traction_electrical_power + auxiliary_power - regenerative_power
open_circuit_voltage = battery_open_circuit_voltage(state.battery_soc, state.battery_temp_c)
pack_resistance = battery_internal_resistance(limits.pack.resistance_ohm, state.battery_soc, state.battery_temp_c)
current_discriminant = max(1.0, open_circuit_voltage * open_circuit_voltage - 4.0 * pack_resistance * electric_power * 1000.0)
pack_current = (open_circuit_voltage - math.sqrt(current_discriminant)) / (2.0 * pack_resistance)
pack_voltage = open_circuit_voltage - pack_current * pack_resistance
battery_loss = pack_current * pack_current * pack_resistance / 1000.0
battery_power = open_circuit_voltage * pack_current / 1000.0
next_soc = max(0.0, min(1.0, battery_soc_step(state.battery_soc, battery_power, duration_s, limits.pack.energy_kwh)))
motor_loss = mechanical_power * (1.0 / limits.motor_efficiency - 1.0) + regenerative_mechanical_power * (1.0 - limits.motor_efficiency)
inverter_loss = mechanical_power / limits.motor_efficiency * (1.0 / limits.inverter_efficiency - 1.0) + regenerative_mechanical_power * limits.motor_efficiency * (1.0 - limits.inverter_efficiency)
heat_w = [
battery_loss * 1000.0,
motor_loss * 1000.0 * FRONT_MOTOR_LOSS_SHARE,
motor_loss * 1000.0 * (1.0 - FRONT_MOTOR_LOSS_SHARE),
inverter_loss * 1000.0 * FRONT_MOTOR_LOSS_SHARE,
inverter_loss * 1000.0 * (1.0 - FRONT_MOTOR_LOSS_SHARE),
auxiliary_power * 1000.0 * 0.35,
0.0,
0.0,
]
next_temps = thermal_step(state.thermal_temps_c, heat_w, AMBIENT_TEMP_C, duration_s)
mut injected = 0.0
for value in heat_w:
injected = injected + value
mut stored = 0.0
for index in range(0, THERMAL_NODE_COUNT):
stored = stored + (next_temps[index] - state.thermal_temps_c[index])
steering_angle = controls.steering * 0.42
speed_floor = max(2.0, next_speed)
front_slip_angle = steering_angle - (state.lateral_speed_mps + parameters.cg_to_front_axle_m * state.yaw_rate_rps) / speed_floor
rear_slip_angle = -(state.lateral_speed_mps - parameters.cg_to_rear_axle_m * state.yaw_rate_rps) / speed_floor
total_axle_load = max(1.0, front_axle_load + rear_axle_load)
front_longitudinal_force = front_axle.drive_force_n - front_axle.brake_force_n
rear_longitudinal_force = rear_axle.drive_force_n - rear_axle.brake_force_n
front_grip = front_axle.tire_limit_n
rear_grip = rear_axle.tire_limit_n
front_lateral_limit = math.sqrt(max(0.0, front_grip * front_grip - front_longitudinal_force * front_longitudinal_force))
rear_lateral_limit = math.sqrt(max(0.0, rear_grip * rear_grip - rear_longitudinal_force * rear_longitudinal_force))
front_tire_force = max(-front_lateral_limit, min(front_lateral_limit, front_axle.cornering_stiffness_n_rad * front_slip_angle))
rear_tire_force = max(-rear_lateral_limit, min(rear_lateral_limit, rear_axle.cornering_stiffness_n_rad * rear_slip_angle))
lateral_acceleration_value = lateral_acceleration(front_tire_force, rear_tire_force, aero_side_force, next_speed, state.yaw_rate_rps, mass)
# The load transfer is driven by the total lateral force the contact patches react, not by the
# body-frame velocity derivative: in a settled corner that derivative goes to zero while the car
# is still leaning on its outside wheels.
lateral_force_n = front_tire_force + rear_tire_force + aero_side_force
lateral_transfer_n = lateral_force_n * parameters.cg_height_m / parameters.track_m
front_transfer_n = lateral_transfer_n * parameters.front_mass_fraction
rear_transfer_n = lateral_transfer_n * (1.0 - parameters.front_mass_fraction)
corners = SuspensionCorners(
front_left=interpolate_corner(context.suspension, front_axle_load * 0.5 - front_transfer_n),
front_right=interpolate_corner(context.suspension, front_axle_load * 0.5 + front_transfer_n),
rear_left=interpolate_corner(context.suspension, rear_axle_load * 0.5 - rear_transfer_n),
rear_right=interpolate_corner(context.suspension, rear_axle_load * 0.5 + rear_transfer_n),
)
next_lateral_speed = max(-40.0, min(40.0, state.lateral_speed_mps + lateral_acceleration_value * duration_s))
yaw_acceleration_value = yaw_acceleration(front_tire_force, rear_tire_force, aero_yaw_moment, parameters.cg_to_front_axle_m, parameters.cg_to_rear_axle_m, parameters.yaw_inertia_kg_m2)
grip_yaw_rate_limit = min(3.0, 0.5 * (front_axle.friction_coefficient + rear_axle.friction_coefficient) * GRAVITY_MPS2 * total_axle_load / (mass * GRAVITY_MPS2 * max(2.0, next_speed)))
next_yaw_rate = max(0.0 - grip_yaw_rate_limit, min(grip_yaw_rate_limit, state.yaw_rate_rps + yaw_acceleration_value * duration_s))
next_yaw_angle = state.yaw_angle_rad + (state.yaw_rate_rps + next_yaw_rate) * 0.5 * duration_s
tire_slip = min(1.0, abs(front_slip_angle) + abs(rear_slip_angle) + max(0.0, requested_drive_force - tire_force_limit) / max(1.0, tire_force_limit))
front_tire_utilization = math.sqrt(front_longitudinal_force * front_longitudinal_force + front_tire_force * front_tire_force) / max(1.0, front_grip)
rear_tire_utilization = math.sqrt(rear_longitudinal_force * rear_longitudinal_force + rear_tire_force * rear_tire_force) / max(1.0, rear_grip)
tire_utilization = max(front_tire_utilization, rear_tire_utilization)
tire_heat = rolling * average_speed / 1000.0 * 0.12 + brake_force * average_speed / 1000.0 * 0.08 + abs(lateral_acceleration_value) * average_speed * 0.03
next_tire_temp = lumped_thermal_step(state.tire_temp_c, tire_heat, TIRE_COOLING_KW_PER_K, AMBIENT_TEMP_C, TIRE_THERMAL_CAPACITY_KJ_K, duration_s)
wheel_rps = next_speed / (2.0 * math.pi * limits.tire.rolling_radius_m)
next_state = VehicleState(
time_s=state.time_s + duration_s,
distance_m=state.distance_m + average_speed * duration_s,
speed_mps=next_speed,
lateral_speed_mps=next_lateral_speed,
acceleration_mps2=acceleration,
lateral_acceleration_mps2=lateral_acceleration_value,
yaw_rate_rps=next_yaw_rate,
yaw_angle_rad=next_yaw_angle,
steering_angle_rad=steering_angle,
battery_soc=next_soc,
thermal_temps_c=next_temps,
battery_temp_c=next_temps[0],
motor_temp_c=max(next_temps[1], next_temps[2]),
inverter_temp_c=max(next_temps[3], next_temps[4]),
coolant_temp_c=next_temps[6],
cabin_temp_c=next_temps[5],
tire_temp_c=next_tire_temp,
front_disc_temp_c=next_front_disc_temp,
rear_disc_temp_c=next_rear_disc_temp,
motor_rpm=wheel_rps * 60.0 * limits.final_drive_ratio,
model_id=model_id,
)
aero_residual = abs(solved.cd - surrogate.cd) / max(0.001, abs(solved.cd))
power_residual = electric_power - pack_voltage * pack_current / 1000.0
energy_residual = abs(power_residual) / max(1.0, abs(electric_power))
return DynamicsFrame(
state=next_state,
controls=controls,
sequence=sequence,
apparent_air_speed_mps=apparent_air_speed,
sideslip_rad=sideslip,
dynamic_pressure_pa=pressure,
solved=solved,
surrogate=surrogate,
model_cd=surrogate.cd,
reference_cd=solved.cd,
cd_pressure=forces.cd_pressure,
cd_base=forces.cd_base,
cd_friction=forces.cd_friction,
cd_wheels=forces.cd_wheels,
drag_force_n=drag,
lift_force_n=lift,
side_force_n=solved.side_n,
aero_yaw_moment_nm=solved.yaw_moment_nm,
rolling_force_n=rolling,
brake_force_n=brake_force,
regenerative_force_n=regenerative_force,
drive_force_n=drive_force,
wheel_torque_nm=motor_torque,
tire_slip_ratio=tire_slip,
front_axle_normal_force_n=front_axle_load,
rear_axle_normal_force_n=rear_axle_load,
tire_utilization_fraction=tire_utilization,
front_axle=front_axle,
rear_axle=rear_axle,
limiting_constraint=longitudinal.limiting_constraint,
brake_line_pressure_pa=longitudinal.brake_line_pressure_pa,
brake_fade_fraction=min(front_axle.fade_fraction, rear_axle.fade_fraction),
front_disc_temp_c=next_front_disc_temp,
rear_disc_temp_c=next_rear_disc_temp,
suspension=corners,
mechanical_power_kw=mechanical_power,
electrical_power_kw=electric_power,
regenerative_power_kw=regenerative_power,
battery_power_kw=battery_power,
battery_loss_kw=battery_loss,
motor_loss_kw=motor_loss,
inverter_loss_kw=inverter_loss,
pack_open_circuit_voltage_v=open_circuit_voltage,
pack_voltage_v=pack_voltage,
pack_current_a=pack_current,
pack_resistance_ohm=pack_resistance,
power_limit_fraction=power_limit,
power_residual_kw=power_residual,
aero_residual_fraction=aero_residual,
reynolds_number=forces.reynolds_number,
separation_x_m=forces.separation_x_m,
cp_min=forces.cp_min,
cp_max=forces.cp_max,
base_pressure_coefficient=forces.base_pressure_coefficient,
wake_width_m=forces.wake_width_m,
wake_shedding_hz=forces.wake_shedding_hz,
turbulence_intensity=forces.turbulence_intensity,
wake_velocity_deficit_fraction=forces.wake_deficit_fraction,
wake_recirculation_length_m=forces.wake_recirculation_length_m,
dalembert_residual_cd=forces.dalembert_residual_cd,
aero_solver_residual=forces.solver_residual,
aero_force_closure_residual=forces.force_closure_residual,
thermal_energy_residual_w=injected,
energy_residual_fraction=energy_residual,
)
struct StopMeasurement:
"""One full-pedal stop, integrated at the same 20 Hz the live loop runs at."""
distance_m: f64
duration_s: f64
mean_deceleration_mps2: f64
front_constraint: str
rear_constraint: str
front_disc_temp_c: f64
rear_disc_temp_c: f64
peak_brake_force_n: f64
invariant distance_m > 0.0
invariant duration_s > 0.0
invariant mean_deceleration_mps2 > 0.0
struct LaunchMeasurement:
"""One full-throttle launch to a target speed, integrated at the same 20 Hz."""
duration_s: f64
distance_m: f64
launch_constraint: str
final_constraint: str
peak_drive_force_n: f64
invariant duration_s > 0.0
invariant distance_m > 0.0
def measured_stop(context: PhysicsContext, initial_speed_mps: f64):
"""Brake from one speed to rest at full pedal and report what the vehicle actually did."""
require initial_speed_mps > 1.0
controls = ControlInput(throttle=0.0, brake=1.0, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0)
start = state_at(initial_speed_mps, 0.82)
mut frame = step_vehicle(start, controls, 0.05, 1, CALM_MODEL_ID, context)
first = frame
mut sequence = 1
while frame.state.speed_mps > 0.0 and sequence < 400:
sequence = sequence + 1
frame = step_vehicle(frame.state, controls, 0.05, sequence, CALM_MODEL_ID, context)
return StopMeasurement(
distance_m=frame.state.distance_m,
duration_s=frame.state.time_s,
mean_deceleration_mps2=initial_speed_mps * initial_speed_mps / (2.0 * frame.state.distance_m),
front_constraint=first.front_axle.brake_constraint,
rear_constraint=first.rear_axle.brake_constraint,
front_disc_temp_c=frame.state.front_disc_temp_c,
rear_disc_temp_c=frame.state.rear_disc_temp_c,
peak_brake_force_n=first.brake_force_n,
)
def measured_launch(context: PhysicsContext, target_speed_mps: f64):
"""Accelerate from rest to one speed at full throttle and report what the hardware allowed."""
require target_speed_mps > 1.0
controls = ControlInput(throttle=1.0, brake=0.0, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0)
mut frame = step_vehicle(initial_vehicle_state(), controls, 0.05, 1, CALM_MODEL_ID, context)
first = frame
mut sequence = 1
while frame.state.speed_mps < target_speed_mps and sequence < 600:
sequence = sequence + 1
frame = step_vehicle(frame.state, controls, 0.05, sequence, CALM_MODEL_ID, context)
return LaunchMeasurement(
duration_s=frame.state.time_s,
distance_m=frame.state.distance_m,
launch_constraint=first.limiting_constraint,
final_constraint=frame.limiting_constraint,
peak_drive_force_n=first.drive_force_n,
)
pub def dynamics_equations() -> list[dict[str, str]] !{}:
return [
{"id": "apparent-wind", "source": "apparent_wind", "expression": "V = |v_road + v_wind|; beta = atan2(V_y, V_x)", "unit": "m/s", "owner": "Sema vehicle-frame apparent wind (exact)"},
{"id": "aero-pressure", "source": "dynamic_pressure", "expression": "q = 0.5 rho |V_rel|^2", "unit": "Pa", "owner": "Sema aero (exact)"},
{"id": "aero-drag", "source": "aerodynamic_force", "expression": "F_d = q C_d(beta) A_frontal", "unit": "N", "owner": "Sema panel solve + viscous closure (solved+correlated)"},
{"id": "aero-surrogate", "source": "surrogate_loads", "expression": "C_d ~= C_d0 + c_2 beta^2 + c_4 beta^4; C_y ~= c_1 beta + c_3 beta^3", "unit": "1", "owner": "Sema reduced-order surrogate fitted to the panel solve (correlated)"},
{"id": "surrogate-fit", "source": "fit_surrogate", "expression": "min_c ||Phi c - (C(beta) - C(0))||_2 via the 2x2 normal equations", "unit": "1", "owner": "Sema least-squares fit (solved)"},
{"id": "traction", "source": "traction_force", "expression": "F_x = T_m i_g eta / r_w, with T_m from the declared machines and i_g from the declared reduction stages", "unit": "N", "owner": "Sema longitudinal dynamics over hardware.sema limits (exact on solved inputs)"},
{"id": "axle-drive-limit", "source": "axle_longitudinal", "expression": "F_x,axle = min(T_peak i_g eta / r_w, P_corner eta / v, mu(F_z) F_z - F_brake)", "unit": "N", "owner": "Sema per-axle drive limit; the binding term is published per axle (solved)"},
{"id": "axle-brake-chain", "source": "axle_longitudinal", "expression": "F_b,axle = min(2 T_corner(p_line, mu_pad(T_disc)) / r_w, mu(F_z) F_z)", "unit": "N", "owner": "Sema pedal-to-pressure-to-clamp-to-torque chain against the tyre friction circle (solved on correlated pad and tyre closures)"},
{"id": "brake-thermal", "source": "step_vehicle", "expression": "C_disc dT/dt = (F_b - F_regen) v / 2 - (h_0 + h_1 v)(T - T_amb)", "unit": "degC", "owner": "Sema per-disc lumped node; half m v squared less the recovered share (solved on a correlated cooling law)"},
{"id": "load-transfer-closure", "source": "longitudinal_solution", "expression": "one predictor at a_x(k-1) and one corrector at a_x(k) over Delta F_z = m a_x h_cg / L", "unit": "N", "owner": "Sema two-pass longitudinal closure; the axle limits and the acceleration each set the other (exact)"},
{"id": "rolling-resistance", "source": "rolling_coefficient", "expression": "F_rr = (f_0 + f_2 v^2) max(0, m g - F_lift)", "unit": "N", "owner": "Sema tyre rolling resistance on the live normal load, not on kerb weight (correlated)"},
{"id": "cornering-stiffness", "source": "axle_longitudinal", "expression": "C_alpha,axle = c_z F_z,axle", "unit": "N/rad", "owner": "Sema tyre slip stiffness per unit vertical load, so an axle stiffens as it is loaded (correlated)"},
{"id": "longitudinal", "source": "longitudinal_acceleration", "expression": "a_x = (F_x - F_d - F_rr - F_b) / m", "unit": "m/s^2", "owner": "Sema longitudinal dynamics (exact)"},
{"id": "mass-budget", "source": "vehicle_parameters", "expression": "m, r_cg, I_zz summed from the declared part placements", "unit": "kg, m, kg m^2", "owner": "Sema assembly mass budget (solved)"},
{"id": "axle-load-transfer", "source": "step_vehicle", "expression": "Delta F_z = m a_x h_cg / L; F_z,front = F_z,static - Delta F_z - F_lift,front", "unit": "N", "owner": "Sema reduced load-path model (exact)"},
{"id": "corner-load-split", "source": "step_vehicle", "expression": "Delta F_z,lat = (F_yf + F_yr + F_aero,y) h_cg / t, split front/rear by the static axle share; F_z,corner = F_z,axle / 2 -+ Delta F_z,lat", "unit": "N", "owner": "Sema steady-state algebraic lateral transfer at centre-of-gravity height; no roll-stiffness distribution and no transient (exact, uncorrelated)"},
{"id": "corner-closure", "source": "interpolate_corner", "expression": "travel, camber, toe and damper length read from the declared four-bar closure at F_z,corner", "unit": "m, deg", "owner": "Sema declared double-wishbone closure, startup-sampled and linearly interpolated (solved+interpolated)"},
{"id": "tire-friction-circle", "source": "step_vehicle", "expression": "F_y,max = sqrt((mu F_z)^2 - F_x^2)", "unit": "N", "owner": "Sema coupled tire limit (correlated)"},
{"id": "lateral", "source": "lateral_acceleration", "expression": "a_y = (F_yf + F_yr + F_aero,y) / m - u r", "unit": "m/s^2", "owner": "Sema reduced bicycle model (exact)"},
{"id": "yaw", "source": "yaw_acceleration", "expression": "yaw_accel = (a F_yf - b F_yr + N_aero) / I_z", "unit": "rad/s^2", "owner": "Sema reduced bicycle model with solved aero moment (exact+solved)"},
{"id": "battery-energy", "source": "battery_soc_step", "expression": "SoC_(k+1) = SoC_k - P_batt dt / E_pack", "unit": "fraction", "owner": "Sema energy model (exact)"},
{"id": "battery-voltage", "source": "battery_open_circuit_voltage", "expression": "V_terminal = V_oc(SoC,T) - I R(SoC,T)", "unit": "V", "owner": "Sema equivalent-circuit model (correlated)"},
{"id": "battery-terminal-power", "source": "step_vehicle", "expression": "P_terminal = I (V_oc - I R)", "unit": "kW", "owner": "Sema equivalent-circuit model (exact)"},
{"id": "thermal-network", "source": "thermal_step", "expression": "C_i dT_i/dt = Q_i + sum_j k_ij (T_j - T_i) - h_i (T_i - T_amb)", "unit": "degC", "owner": "Sema coupled eight-node coolant network (exact)"},
{"id": "thermal-tire", "source": "lumped_thermal_step", "expression": "T_(k+1) = T_k + (Q - h(T-T_amb)) dt / C_th", "unit": "degC", "owner": "Sema lumped tire node (correlated)"},
]
test "a stationary vehicle in a wind tunnel still measures drag":
context = physics_context()
state = initial_vehicle_state()
controls = ControlInput(throttle=0.0, brake=1.0, steering=0.0, wind_mps=27.8, wind_yaw_deg=0.0)
frame = step_vehicle(state, controls, 0.05, 1, CALM_MODEL_ID, context)
ensure frame.state.speed_mps == 0.0
ensure frame.apparent_air_speed_mps > 27.7 and frame.apparent_air_speed_mps < 27.9
ensure frame.drag_force_n > 200.0
ensure frame.dynamic_pressure_pa > 400.0
test "wind yaw drives a signed side force and yaw moment that vanish head-on":
context = physics_context()
state = initial_vehicle_state()
head_on = step_vehicle(state, ControlInput(throttle=0.0, brake=1.0, steering=0.0, wind_mps=27.8, wind_yaw_deg=0.0), 0.05, 1, CALM_MODEL_ID, context)
from_left = step_vehicle(state, ControlInput(throttle=0.0, brake=1.0, steering=0.0, wind_mps=27.8, wind_yaw_deg=20.0), 0.05, 1, CALM_MODEL_ID, context)
from_right = step_vehicle(state, ControlInput(throttle=0.0, brake=1.0, steering=0.0, wind_mps=27.8, wind_yaw_deg=-20.0), 0.05, 1, CALM_MODEL_ID, context)
ensure abs(head_on.side_force_n) < 0.001 * head_on.drag_force_n
ensure abs(head_on.aero_yaw_moment_nm) < 0.001 * head_on.drag_force_n
ensure from_left.side_force_n > 0.0
ensure abs(from_left.side_force_n + from_right.side_force_n) < 0.000001 * abs(from_left.side_force_n)
ensure from_left.drag_force_n > head_on.drag_force_n
ensure abs(from_left.drag_force_n - from_right.drag_force_n) < 0.000001 * from_left.drag_force_n
ensure from_left.state.lateral_acceleration_mps2 < 0.0
test "vehicle step conserves the declared power balance and advances causal state":
context = physics_context()
state = initial_vehicle_state()
controls = ControlInput(throttle=0.7, brake=0.0, steering=0.1, wind_mps=0.0, wind_yaw_deg=0.0)
frame = step_vehicle(state, controls, 0.05, 1, CALM_MODEL_ID, context)
ensure frame.state.time_s == 0.05
ensure frame.state.speed_mps > 0.0
ensure frame.drive_force_n > frame.drag_force_n
ensure frame.energy_residual_fraction < 0.000000001
ensure frame.state.battery_soc < state.battery_soc
test "inertial parameters come from the declared assembly, not from literals":
parameters = vehicle_parameters()
ensure parameters.mass_kg > 1900.0 and parameters.mass_kg < 2300.0
ensure abs(parameters.cg_to_front_axle_m + parameters.cg_to_rear_axle_m - parameters.wheelbase_m) < 0.000000001
ensure parameters.cg_height_m > 0.45 and parameters.cg_height_m < 0.60
ensure parameters.yaw_inertia_kg_m2 > 2800.0 and parameters.yaw_inertia_kg_m2 < 4500.0
ensure parameters.front_mass_fraction > 0.47 and parameters.front_mass_fraction < 0.53
ensure parameters.track_m > 1.5 and parameters.track_m < 1.8
ensure parameters.track_m < parameters.wheelbase_m
test "the crosswind surrogate beats the calm surrogate outside the calm fit window":
context = physics_context()
holdout = aero_holdout_metrics(context)
ensure context.calm.fit_rms_cd < 0.03 * context.calm.cd0
ensure context.crosswind.fit_rms_cd < 0.03 * context.crosswind.cd0
ensure holdout["active_validation_error"] > 0.10
ensure holdout["candidate_validation_error"] < 0.04
ensure holdout["candidate_validation_error"] < 0.25 * holdout["active_validation_error"]
test "surrogate coefficients respect the symmetry of the solved field":
context = physics_context()
left = surrogate_loads(context.crosswind, 27.8, 0.35)
right = surrogate_loads(context.crosswind, 27.8, -0.35)
ensure abs(left.cd - right.cd) < 0.000000001
ensure abs(left.cy + right.cy) < 0.000000001
ensure abs(left.cmz + right.cmz) < 0.000000001
test "aerodynamic loads scale with the square of the apparent wind":
context = physics_context()
state = initial_vehicle_state()
slow = step_vehicle(state, ControlInput(throttle=0.0, brake=1.0, steering=0.0, wind_mps=15.0, wind_yaw_deg=10.0), 0.05, 1, CALM_MODEL_ID, context)
fast = step_vehicle(state, ControlInput(throttle=0.0, brake=1.0, steering=0.0, wind_mps=30.0, wind_yaw_deg=10.0), 0.05, 1, CALM_MODEL_ID, context)
ensure abs(fast.side_force_n - 4.0 * slow.side_force_n) < 0.000001 * abs(fast.side_force_n)
ensure abs(fast.cd_pressure - slow.cd_pressure) < 0.000000000001
ensure abs(fast.cd_base - slow.cd_base) < 0.000000000001
ensure fast.cd_friction < slow.cd_friction
ensure abs(fast.drag_force_n - 4.0 * slow.drag_force_n) < 0.02 * fast.drag_force_n
test "the coupled thermal network moves heat into the coolant loop":
context = physics_context()
mut state = initial_vehicle_state()
mut frame = step_vehicle(state, ControlInput(throttle=1.0, brake=0.0, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0), 0.05, 1, CALM_MODEL_ID, context)
for sequence in range(2, 60):
state = frame.state
frame = step_vehicle(state, ControlInput(throttle=1.0, brake=0.0, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0), 0.05, sequence, CALM_MODEL_ID, context)
ensure frame.state.battery_temp_c > 24.0
ensure frame.state.coolant_temp_c > 24.0
ensure frame.state.motor_temp_c > frame.state.coolant_temp_c
ensure len(frame.state.thermal_temps_c) == THERMAL_NODE_COUNT
ensure frame.energy_residual_fraction < 0.000000001
test "equivalent circuit exposes voltage sag losses and thermal coupling":
context = physics_context()
state = initial_vehicle_state()
frame = step_vehicle(state, ControlInput(throttle=0.9, brake=0.0, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0), 0.05, 1, CALM_MODEL_ID, context)
ensure frame.pack_voltage_v < frame.pack_open_circuit_voltage_v
ensure frame.battery_loss_kw > 0.0
ensure frame.battery_power_kw > frame.electrical_power_kw
coasting = step_vehicle(state, ControlInput(throttle=0.0, brake=0.0, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0), 0.05, 1, CALM_MODEL_ID, context)
ensure frame.state.battery_temp_c > coasting.state.battery_temp_c
ensure frame.energy_residual_fraction < 0.000000001
test "friction and regenerative braking share one bounded request":
context = physics_context()
mut state = initial_vehicle_state()
mut frame = step_vehicle(state, ControlInput(throttle=0.8, brake=0.0, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0), 0.05, 1, CALM_MODEL_ID, context)
for sequence in range(2, 22):
state = frame.state
frame = step_vehicle(state, ControlInput(throttle=0.8, brake=0.0, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0), 0.05, sequence, CALM_MODEL_ID, context)
braking = step_vehicle(frame.state, ControlInput(throttle=0.0, brake=0.8, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0), 0.05, 22, CALM_MODEL_ID, context)
ensure braking.regenerative_force_n > 0.0
ensure braking.regenerative_force_n <= braking.brake_force_n
ensure braking.regenerative_power_kw > 0.0
ensure braking.state.acceleration_mps2 < 0.0
test "depleted battery cannot provide traction or auxiliary power":
context = physics_context()
depleted = state_at(0.0, 0.0)
frame = step_vehicle(depleted, ControlInput(throttle=1.0, brake=0.0, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0), 0.05, 1, CALM_MODEL_ID, context)
ensure frame.drive_force_n == 0.0
ensure frame.electrical_power_kw == 0.0
ensure frame.state.battery_soc == 0.0
test "the pre-sampled corner table reproduces the bisected suspension closure":
context = physics_context()
mut previous = -1.0
for load in context.suspension.loads_n:
ensure load > previous
previous = load
for load in [1500.0, 3800.0, 4820.0, 5680.0, 7200.0, 11000.0, 18000.0]:
solved = suspension_travel(load)
read = interpolate_corner(context.suspension, load)
ensure abs(read.wheel_travel_m - solved.wheel_travel_m) < 0.0003
ensure abs(read.camber_deg - solved.camber_deg) < 0.01
ensure abs(read.toe_deg - solved.toe_deg) < 0.005
ensure abs(read.damper_length_m - solved.damper_length_m) < 0.0005
ensure abs(read.spring_force_n - solved.spring_force_n) < 0.01 * solved.spring_force_n
ensure len(context.suspension.loads_n) >= 24
test "a straight-line step loads both sides of an axle identically":
context = physics_context()
state = initial_vehicle_state()
frame = step_vehicle(state, ControlInput(throttle=0.0, brake=0.0, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0), 0.05, 1, CALM_MODEL_ID, context)
corners = frame.suspension
ensure abs(corners.front_left.vertical_load_n - frame.front_axle_normal_force_n * 0.5) < 0.000001
ensure abs(corners.rear_left.vertical_load_n - frame.rear_axle_normal_force_n * 0.5) < 0.000001
ensure corners.front_left.vertical_load_n == corners.front_right.vertical_load_n
ensure corners.rear_left.vertical_load_n == corners.rear_right.vertical_load_n
heavier = corners.rear_left if corners.rear_left.vertical_load_n > corners.front_left.vertical_load_n else corners.front_left
lighter = corners.front_left if corners.rear_left.vertical_load_n > corners.front_left.vertical_load_n else corners.rear_left
ensure heavier.wheel_travel_m > lighter.wheel_travel_m
ensure heavier.camber_deg < lighter.camber_deg
ensure heavier.damper_length_m < lighter.damper_length_m
ensure heavier.spring_force_n > lighter.spring_force_n
ensure corners.front_left.travel_utilisation >= 0.0 and corners.front_left.travel_utilisation <= 1.0
ensure corners.rear_left.travel_utilisation >= 0.0 and corners.rear_left.travel_utilisation <= 1.0
test "cornering moves normal force onto the outside corners":
context = physics_context()
mut frame = step_vehicle(initial_vehicle_state(), ControlInput(throttle=0.6, brake=0.0, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0), 0.05, 1, CALM_MODEL_ID, context)
for sequence in range(2, 40):
frame = step_vehicle(frame.state, ControlInput(throttle=0.35, brake=0.0, steering=0.8, wind_mps=0.0, wind_yaw_deg=0.0), 0.05, sequence, CALM_MODEL_ID, context)
corners = frame.suspension
total = corners.front_left.vertical_load_n + corners.front_right.vertical_load_n + corners.rear_left.vertical_load_n + corners.rear_right.vertical_load_n
ensure frame.state.yaw_rate_rps > 0.01
ensure abs(total - frame.front_axle_normal_force_n - frame.rear_axle_normal_force_n) < 0.000000001
ensure corners.front_right.vertical_load_n - corners.front_left.vertical_load_n > 200.0
ensure corners.rear_right.vertical_load_n - corners.rear_left.vertical_load_n > 200.0
ensure corners.front_right.wheel_travel_m > corners.front_left.wheel_travel_m
ensure corners.front_right.camber_deg < corners.front_left.camber_deg
ensure corners.front_right.damper_length_m < corners.front_left.damper_length_m
ensure corners.rear_right.travel_utilisation > corners.rear_left.travel_utilisation
test "the brakes out-torque the tyres at kerb mass, so a cold stop is grip limited":
context = physics_context()
limits = context.hardware
parameters = context.parameters
grip = limits.tire.peak_friction * parameters.mass_kg * GRAVITY_MPS2
ensure limits.max_brake_force_n > grip
stop = measured_stop(context, 27.7778)
ensure stop.front_constraint == "tyre-grip"
ensure stop.rear_constraint == "tyre-grip"
ensure stop.peak_brake_force_n < limits.max_brake_force_n
test "a 100-0 km/h stop lands in a credible band at close to mu times g":
context = physics_context()
stop = measured_stop(context, 27.7778)
ensure stop.distance_m > 32.0 and stop.distance_m < 42.0
ensure stop.duration_s > 2.3 and stop.duration_s < 3.3
friction = context.hardware.tire.peak_friction
ensure abs(stop.mean_deceleration_mps2 - friction * GRAVITY_MPS2) < 0.10 * friction * GRAVITY_MPS2
ensure stop.front_disc_temp_c > AMBIENT_TEMP_C + 20.0
ensure stop.front_disc_temp_c > stop.rear_disc_temp_c
test "0-100 km/h lands in a credible band for the derived torque and power":
context = physics_context()
launch = measured_launch(context, 27.7778)
ensure launch.duration_s > 3.2 and launch.duration_s < 5.5
ensure launch.distance_m > 30.0 and launch.distance_m < 90.0
ensure launch.launch_constraint == "motor-torque"
ensure launch.final_constraint == "inverter-power" or launch.final_constraint == "motor-power"
torque = context.hardware.max_motor_torque_nm
ceiling = torque * context.hardware.final_drive_ratio / context.hardware.tire.rolling_radius_m
ensure launch.peak_drive_force_n > 0.85 * ceiling and launch.peak_drive_force_n < ceiling
test "mass lengthens the stop, heats the discs and cuts acceleration in proportion":
context = physics_context()
heavy = with_added_mass(context, 300.0)
light_stop = measured_stop(context, 27.7778)
heavy_stop = measured_stop(heavy, 27.7778)
light_launch = measured_launch(context, 27.7778)
heavy_launch = measured_launch(heavy, 27.7778)
# At kerb both axles are grip limited, so the stop is very nearly mass independent. Add 300 kg
# and the rear tyre asks for more than the rear caliper can make: from there the extra mass is
# no longer paid for by extra grip, and the stop gets longer.
ensure light_stop.rear_constraint == "tyre-grip"
ensure heavy_stop.rear_constraint == "brake-torque"
ensure heavy_stop.distance_m > light_stop.distance_m
ensure heavy_stop.mean_deceleration_mps2 < light_stop.mean_deceleration_mps2
ensure heavy_stop.front_disc_temp_c > light_stop.front_disc_temp_c
ensure heavy_stop.rear_disc_temp_c > light_stop.rear_disc_temp_c
# The launch is motor limited, so a = F/m and the time stretches with the mass ratio.
ratio = heavy.parameters.mass_kg / context.parameters.mass_kg
ensure heavy_launch.duration_s > light_launch.duration_s
ensure abs(heavy_launch.duration_s / light_launch.duration_s - ratio) < 0.05 * ratio
test "repeated stops heat the discs until the declared pad compound fades":
context = physics_context()
limits = context.hardware
controls = ControlInput(throttle=0.0, brake=1.0, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0)
mut frame = step_vehicle(state_at(27.7778, 0.82), controls, 0.05, 1, CALM_MODEL_ID, context)
cold = frame.front_axle.brake_capability_n
mut sequence = 1
# Brake to rest, put the car straight back to 100 km/h with the discs exactly as hot as it left
# them, and repeat. Nothing else changes, so the only thing accumulating is disc temperature.
while sequence < 900:
sequence = sequence + 1
relaunched = frame.state if frame.state.speed_mps > 0.1 else state_carrying(frame.state, 27.7778)
frame = step_vehicle(relaunched, controls, 0.05, sequence, CALM_MODEL_ID, context)
ensure frame.state.front_disc_temp_c > limits.fade_onset_temp_c
ensure frame.brake_fade_fraction < 1.0
ensure frame.front_axle.brake_capability_n < cold
ensure frame.front_axle.brake_constraint == "brake-torque"
ensure frame.brake_fade_fraction >= limits.fade_floor_fraction
test "every published limit is read from the declared hardware, not from a literal":
context = physics_context()
limits = context.hardware
frame = step_vehicle(state_at(20.0, 0.82), ControlInput(throttle=1.0, brake=0.0, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0), 0.05, 1, CALM_MODEL_ID, context)
ensure frame.front_axle.axle == "front" and frame.rear_axle.axle == "rear"
ensure frame.rear_axle.motor_force_n > frame.front_axle.motor_force_n
ensure abs(frame.front_axle.cornering_stiffness_n_rad - limits.tire.cornering_stiffness_per_n * frame.front_axle_normal_force_n) < 0.000000001
ensure abs(frame.brake_line_pressure_pa) < 0.000000001
ensure frame.front_axle.brake_constraint == "released" and frame.rear_axle.brake_constraint == "released"
ensure frame.limiting_constraint == frame.rear_axle.drive_constraint
braking = step_vehicle(state_at(20.0, 0.82), ControlInput(throttle=0.0, brake=0.5, steering=0.0, wind_mps=0.0, wind_yaw_deg=0.0), 0.05, 1, CALM_MODEL_ID, context)
ensure abs(braking.brake_line_pressure_pa - 0.5 * limits.max_line_pressure_pa) < 0.000000001
ensure braking.front_axle.brake_constraint == "brake-torque"
ensure braking.energy_residual_fraction < 0.000000001
"""Automotive-native major-assembly vehicle, E/E, and manufacturing definition."""
from magna_ev_digital_twin.candidate import conductor_mass, conductor_resistance
from magna_ev_digital_twin.domain import AutomationClass, CompletenessReport, EvidenceClass, EvidenceRecord, Fidelity, NetDefinition, OperationDefinition, PartDefinition, PartGroup, QualificationStatus, SourceAuthority, SourceBinding, SourceRecord, ValidationCaseDefinition, VehicleIdentity
assure silver
CONFIGURATION_ID = "circuitframe_gt_01"
CONFIGURATION_REVISION = 1
EVIDENCE_EXTERIOR = "model-y-community-reference-exterior-v1"
EVIDENCE_CONCEPT = "engineered-gap-reconstruction-v1"
EVIDENCE_ELECTRICAL = "circuitframe-electrical-v1"
EVIDENCE_PROCESS = "robot-process-kinematic-v1"
EXTERIOR_SOURCE_ID = "printables-model-1111101-stl-4641564"
ASSUMPTION_SOURCE_ID = "source-circuitframe-engineered-gap-v1"
TESLA_SERVICE_SOURCE_ID = "tesla-model-y-sop3-berlin-public-docs"
OPEN_BMS_SOURCE_ID = "ennoid-bms-daf72c9"
TABBY_CAD_SOURCE_ID = "tabby-evo-2015-open-cad"
EVIDENCE_TESLA_SERVICE = "model-y-official-service-reference-v1"
EVIDENCE_OPEN_BMS = "ennoid-open-bms-reference-v1"
EVIDENCE_TABBY_CAD = "tabby-open-cad-candidate-v1"
COPPER_RESISTIVITY_OHM_M = 0.0000000168
COPPER_DENSITY_KG_M3 = 8960.0
CONNECTOR_MASS_KG = 0.045
SPLICE_MASS_KG = 0.012
HARNESS_JACKET_FACTOR = 1.85
STRUCTURAL_OVERPRINT_FACTOR = 1.25
STRUCTURAL_CURRENT_DENSITY_A_MM2 = 7.0
MINIMUM_CONDUCTOR_AREA_MM2 = 0.35
def variant_value(configuration_variant: str, conventional: str, circuitframe: str):
require configuration_variant == "conventional" or configuration_variant == "circuitframe"
return conventional if configuration_variant == "conventional" else circuitframe
pub def vehicle_identity() -> VehicleIdentity !{}:
return VehicleIdentity(
id=CONFIGURATION_ID,
revision=CONFIGURATION_REVISION,
name="CircuitFrame Model Y reference reconstruction",
category="Evidence-aware electric crossover reconstruction",
seats=5,
fidelity=Fidelity.derived,
)
pub def vehicle_parts() -> list[PartDefinition] !{}:
return [
PartDefinition(id="body-shell", name="Painted body shell", group=PartGroup.body, count=1, material="aluminium/composite", mass_kg=236.0, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_EXTERIOR]),
PartDefinition(id="floor-pan", name="Structural floor pan", group=PartGroup.chassis, count=1, material="aluminium extrusion and sheet", mass_kg=82.0, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="front-subframe", name="Front subframe", group=PartGroup.chassis, count=1, material="aluminium", mass_kg=31.0, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="rear-subframe", name="Rear subframe", group=PartGroup.chassis, count=1, material="aluminium", mass_kg=34.0, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="sill-panels", name="Variant-specific structural sill panels", group=PartGroup.electrical, count=2, material="configuration-specific aluminium/dielectric/conductor stack", mass_kg=26.0, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_CONCEPT, EVIDENCE_ELECTRICAL]),
PartDefinition(id="battery-enclosure", name="Structural battery enclosure", group=PartGroup.energy, count=1, material="aluminium", mass_kg=118.0, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="battery-modules", name="Battery modules", group=PartGroup.energy, count=24, material="cell/module supplier assembly", mass_kg=438.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="battery-management", name="Battery management system", group=PartGroup.control, count=1, material="PCB/electronics", mass_kg=6.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="hv-junction", name="HV junction and protection unit", group=PartGroup.electrical, count=1, material="copper/polymer/electronics", mass_kg=13.0, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="charge-port", name="CCS charge port", group=PartGroup.energy, count=1, material="supplier assembly", mass_kg=4.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="onboard-charger", name="On-board charger", group=PartGroup.energy, count=1, material="power electronics", mass_kg=14.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="dc-dc", name="High-to-low voltage converter", group=PartGroup.energy, count=1, material="power electronics", mass_kg=8.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="front-inverter", name="Front inverter", group=PartGroup.drive, count=1, material="power electronics", mass_kg=11.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="rear-inverter", name="Rear inverter", group=PartGroup.drive, count=1, material="power electronics", mass_kg=12.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="front-motor", name="Front electric drive motor", group=PartGroup.drive, count=1, material="steel/copper/magnets", mass_kg=58.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="rear-motor", name="Rear electric drive motor", group=PartGroup.drive, count=1, material="steel/copper/magnets", mass_kg=71.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="reduction-gears", name="Single-speed reduction gears", group=PartGroup.drive, count=2, material="steel/aluminium", mass_kg=42.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="wheels", name="Aerodynamic alloy wheels", group=PartGroup.motion, count=4, material="forged aluminium", mass_kg=52.0, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_EXTERIOR, EVIDENCE_CONCEPT]),
PartDefinition(id="tires", name="Road tires", group=PartGroup.motion, count=4, material="supplier tire assembly", mass_kg=48.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_EXTERIOR, EVIDENCE_CONCEPT]),
PartDefinition(id="suspension", name="Air spring and multi-link suspension", group=PartGroup.motion, count=4, material="steel/aluminium/elastomer", mass_kg=104.0, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="steering", name="Electric steering system", group=PartGroup.motion, count=1, material="supplier mechatronics", mass_kg=23.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="brakes", name="Brake-by-wire corner modules", group=PartGroup.motion, count=4, material="steel/aluminium/electronics", mass_kg=76.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="thermal-loop", name="Battery and powertrain thermal loop", group=PartGroup.thermal, count=1, material="coolant/aluminium/polymer", mass_kg=31.0, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="heat-pump", name="Cabin and battery heat pump", group=PartGroup.thermal, count=1, material="supplier thermal assembly", mass_kg=24.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="front-zone-controller", name="Front zone controller", group=PartGroup.control, count=1, material="PCB/electronics", mass_kg=2.2, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="rear-zone-controller", name="Rear zone controller", group=PartGroup.control, count=1, material="PCB/electronics", mass_kg=2.2, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="cabin-zone-controller", name="Cabin zone controller", group=PartGroup.control, count=1, material="PCB/electronics", mass_kg=1.8, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="central-compute", name="Central vehicle compute", group=PartGroup.control, count=1, material="supplier compute assembly", mass_kg=5.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="lv-battery", name="Low-voltage battery", group=PartGroup.energy, count=1, material="lithium-ion assembly", mass_kg=12.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="sensors", name="Vehicle sensing suite", group=PartGroup.control, count=28, material="supplier sensors", mass_kg=18.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_ELECTRICAL]),
PartDefinition(id="lamps", name="Exterior lighting", group=PartGroup.electrical, count=6, material="polymer/LED/electronics", mass_kg=16.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_EXTERIOR, EVIDENCE_CONCEPT]),
PartDefinition(id="glazing", name="Laminated glazing", group=PartGroup.body, count=6, material="laminated glass", mass_kg=47.0, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_EXTERIOR, EVIDENCE_CONCEPT]),
PartDefinition(id="closures", name="Doors and closures", group=PartGroup.body, count=6, material="aluminium/composite", mass_kg=112.0, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_EXTERIOR, EVIDENCE_CONCEPT]),
PartDefinition(id="seats", name="Seats and occupant structures", group=PartGroup.cabin, count=5, material="steel/foam/textile", mass_kg=96.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="interior", name="Interior, HMI and trim", group=PartGroup.cabin, count=1, material="mixed interior system", mass_kg=118.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="restraints", name="Restraints and airbags", group=PartGroup.safety, count=1, material="supplier safety system", mass_kg=34.0, fidelity=Fidelity.derived, opaque=true, evidence_ids=[EVIDENCE_CONCEPT]),
PartDefinition(id="fasteners-seals", name="Fasteners, adhesives and seals", group=PartGroup.manufacturing, count=620, material="mixed joining system", mass_kg=63.0, fidelity=Fidelity.reduced, opaque=false, evidence_ids=[EVIDENCE_PROCESS]),
PartDefinition(id="service-links", name="Variant-specific harness and serviceable connector set", group=PartGroup.electrical, count=18, material="copper/polymer/connectors", mass_kg=19.0, fidelity=Fidelity.derived, opaque=false, evidence_ids=[EVIDENCE_ELECTRICAL]),
]
pub def vehicle_nets(configuration_variant: str) -> list[NetDefinition] !{}:
require configuration_variant == "conventional" or configuration_variant == "circuitframe"
return [
NetDefinition(id="hv-pack-front", name="Pack to front inverter", domain="HV", source_part_id="hv-junction", target_part_ids=["front-inverter"], nominal_voltage_v=800.0, maximum_current_a=420.0, route=variant_value(configuration_variant, "serviceable shielded HV cable", "left CircuitFrame sill protected busbar"), fidelity=Fidelity.derived, evidence_ids=[EVIDENCE_ELECTRICAL]),
NetDefinition(id="hv-pack-rear", name="Pack to rear inverter", domain="HV", source_part_id="hv-junction", target_part_ids=["rear-inverter"], nominal_voltage_v=800.0, maximum_current_a=520.0, route=variant_value(configuration_variant, "serviceable shielded HV cable", "battery enclosure rear protected busbar"), fidelity=Fidelity.derived, evidence_ids=[EVIDENCE_ELECTRICAL]),
NetDefinition(id="hv-charge", name="Charge path", domain="HV", source_part_id="charge-port", target_part_ids=["onboard-charger", "hv-junction"], nominal_voltage_v=800.0, maximum_current_a=500.0, route="serviceable shielded cable", fidelity=Fidelity.derived, evidence_ids=[EVIDENCE_ELECTRICAL]),
NetDefinition(id="hv-dcdc", name="DC/DC supply", domain="HV", source_part_id="hv-junction", target_part_ids=["dc-dc"], nominal_voltage_v=800.0, maximum_current_a=25.0, route=variant_value(configuration_variant, "shielded HV cable", "protected panel trace"), fidelity=Fidelity.derived, evidence_ids=[EVIDENCE_ELECTRICAL]),
NetDefinition(id="lv-zones", name="48 V zonal supply", domain="LV", source_part_id="dc-dc", target_part_ids=["front-zone-controller", "rear-zone-controller", "cabin-zone-controller", "central-compute"], nominal_voltage_v=48.0, maximum_current_a=180.0, route=variant_value(configuration_variant, "branched copper wiring harness", "dual protected structural conductors"), fidelity=Fidelity.derived, evidence_ids=[EVIDENCE_ELECTRICAL]),
NetDefinition(id="data-backbone", name="Redundant Ethernet backbone", domain="DATA", source_part_id="central-compute", target_part_ids=["front-zone-controller", "rear-zone-controller", "cabin-zone-controller"], nominal_voltage_v=1.0, maximum_current_a=0.2, route=variant_value(configuration_variant, "shielded twisted-pair harness", "shielded differential structural links"), fidelity=Fidelity.derived, evidence_ids=[EVIDENCE_ELECTRICAL]),
NetDefinition(id="front-drive-control", name="Front drive control", domain="DATA", source_part_id="front-zone-controller", target_part_ids=["front-inverter", "brakes", "steering"], nominal_voltage_v=1.0, maximum_current_a=0.2, route=variant_value(configuration_variant, "conventional branch harness", "short local links"), fidelity=Fidelity.derived, evidence_ids=[EVIDENCE_ELECTRICAL]),
NetDefinition(id="rear-drive-control", name="Rear drive control", domain="DATA", source_part_id="rear-zone-controller", target_part_ids=["rear-inverter", "brakes"], nominal_voltage_v=1.0, maximum_current_a=0.2, route=variant_value(configuration_variant, "conventional branch harness", "short local links"), fidelity=Fidelity.derived, evidence_ids=[EVIDENCE_ELECTRICAL]),
NetDefinition(id="thermal-sensors", name="Thermal sensor network", domain="SENSOR", source_part_id="cabin-zone-controller", target_part_ids=["battery-management", "thermal-loop", "heat-pump"], nominal_voltage_v=5.0, maximum_current_a=2.0, route=variant_value(configuration_variant, "conventional sensor harness", "local printed links and service harness"), fidelity=Fidelity.derived, evidence_ids=[EVIDENCE_ELECTRICAL]),
]
def net_routing() -> list[dict[str, any]]:
"""Per-net conductor routing for both configurations.
Lengths, terminations and splice counts are bounded engineered assumptions derived from the
packaging of the reconstructed vehicle; they are not measured production harness data. The
CircuitFrame column routes through the structure, so it is shorter and needs fewer separable
interfaces, which is the claim this comparison exists to quantify.
"""
return [
{"id": "hv-pack-front", "density": 5.0, "conventional": [3.10, 4.0, 1.0], "circuitframe": [2.35, 2.0, 0.0]},
{"id": "hv-pack-rear", "density": 5.0, "conventional": [1.90, 4.0, 1.0], "circuitframe": [1.35, 2.0, 0.0]},
{"id": "hv-charge", "density": 5.0, "conventional": [4.40, 6.0, 2.0], "circuitframe": [4.00, 4.0, 1.0]},
{"id": "hv-dcdc", "density": 5.0, "conventional": [1.20, 4.0, 0.0], "circuitframe": [0.90, 2.0, 0.0]},
{"id": "lv-zones", "density": 6.0, "conventional": [11.60, 14.0, 6.0], "circuitframe": [7.20, 6.0, 1.0]},
{"id": "data-backbone", "density": 6.0, "conventional": [9.80, 12.0, 4.0], "circuitframe": [6.40, 6.0, 0.0]},
{"id": "front-drive-control", "density": 6.0, "conventional": [5.30, 9.0, 3.0], "circuitframe": [2.90, 4.0, 0.0]},
{"id": "rear-drive-control", "density": 6.0, "conventional": [4.70, 8.0, 3.0], "circuitframe": [2.60, 4.0, 0.0]},
{"id": "thermal-sensors", "density": 6.0, "conventional": [7.90, 16.0, 5.0], "circuitframe": [4.80, 8.0, 1.0]},
]
def conductor_variant(net: NetDefinition, route: list[f64], density_a_mm2: f64, jacket_factor: f64, conductor: str, route_text: str) !{}:
"""Size one conductor run from its declared current and report the resulting electrical loads."""
length_m = route[0]
connectors = route[1]
splices = route[2]
area_mm2 = max(MINIMUM_CONDUCTOR_AREA_MM2, net.maximum_current_a / density_a_mm2)
resistance_ohm = conductor_resistance(COPPER_RESISTIVITY_OHM_M, length_m, area_mm2, 1.0)
copper_kg = conductor_mass(COPPER_DENSITY_KG_M3, length_m, area_mm2, 1.0)
return {
"conductor": conductor,
"section_mm2": area_mm2,
"length_m": length_m,
"mass_kg": copper_kg * jacket_factor + connectors * CONNECTOR_MASS_KG + splices * SPLICE_MASS_KG,
"resistance_mohm": resistance_ohm * 1000.0,
"drop_v": net.maximum_current_a * resistance_ohm,
"loss_w": net.maximum_current_a * net.maximum_current_a * resistance_ohm,
"connectors": int(connectors),
"splices": int(splices),
"route": route_text,
}
pub def vehicle_net_comparison() -> list[dict[str, any]] !{}:
"""Conventional harness versus CircuitFrame structural conductors over the same nine nets."""
conventional_nets = vehicle_nets("conventional")
circuitframe_nets = vehicle_nets("circuitframe")
routing = net_routing()
mut rows = []
for index in range(0, len(routing)):
entry = routing[index]
harness_net = conventional_nets[index]
structural_net = circuitframe_nets[index]
ensure entry["id"] == harness_net.id and entry["id"] == structural_net.id
harness = conductor_variant(harness_net, entry["conventional"], entry["density"], HARNESS_JACKET_FACTOR, "stranded copper cable with jacket and shield", harness_net.route)
structural = conductor_variant(structural_net, entry["circuitframe"], STRUCTURAL_CURRENT_DENSITY_A_MM2, STRUCTURAL_OVERPRINT_FACTOR, "printed structural conductor with dielectric barrier and overprinted shield", structural_net.route)
rows = rows + [{
"id": harness_net.id,
"name": harness_net.name,
"domain": harness_net.domain,
"voltage_v": harness_net.nominal_voltage_v,
"current_a": harness_net.maximum_current_a,
"source_part_id": harness_net.source_part_id,
"target_part_ids": harness_net.target_part_ids,
"variants": {"conventional": harness, "circuitframe": structural},
"delta": {
"mass_kg": structural["mass_kg"] - harness["mass_kg"],
"connectors": structural["connectors"] - harness["connectors"],
"drop_v": structural["drop_v"] - harness["drop_v"],
"length_m": structural["length_m"] - harness["length_m"],
},
}]
return rows
def manufacturing_operations(configuration_variant: str):
require configuration_variant == "conventional" or configuration_variant == "circuitframe"
return [
OperationDefinition(id="op-010", name="Locate structural floor and subframes", station="body-01", resource="fixture-cell", consumed_part_ids=["floor-pan", "front-subframe", "rear-subframe"], created_features=["vehicle datum frame"], automation=AutomationClass.unknown, cycle_time_s=68.0, evidence_ids=[EVIDENCE_PROCESS]),
OperationDefinition(id="op-020", name=variant_value(configuration_variant, "Install and inspect conventional harnesses", "Print and inspect protected sill conductors"), station="circuit-01", resource=variant_value(configuration_variant, "harness routing and connector cell", "multi-material deposition cell"), consumed_part_ids=["sill-panels", "service-links"], created_features=[variant_value(configuration_variant, "routed HV/LV/data harnesses", "HV/LV/data conductors with dielectric and coating")], automation=AutomationClass.unknown, cycle_time_s=142.0, evidence_ids=[EVIDENCE_PROCESS]),
OperationDefinition(id="op-030", name="Place zone controllers and power terminals", station="circuit-02", resource="six-axis robot and vision tool", consumed_part_ids=["front-zone-controller", "rear-zone-controller", "cabin-zone-controller", "hv-junction"], created_features=["robotic placements", "bonded terminals"], automation=AutomationClass.unknown, cycle_time_s=94.0, evidence_ids=[EVIDENCE_PROCESS]),
OperationDefinition(id="op-040", name="Continuity and isolation test", station="test-01", resource="automated electrical tester", consumed_part_ids=["sill-panels", "hv-junction"], created_features=["signed continuity record", "signed isolation record"], automation=AutomationClass.unknown, cycle_time_s=52.0, evidence_ids=[EVIDENCE_PROCESS]),
OperationDefinition(id="op-050", name="Install and seal battery pack", station="battery-01", resource="AGV lift and fastening robots", consumed_part_ids=["battery-enclosure", "battery-modules", "battery-management"], created_features=["sealed structural pack joint"], automation=AutomationClass.unknown, cycle_time_s=126.0, evidence_ids=[EVIDENCE_PROCESS]),
OperationDefinition(id="op-060", name="Install drive units and thermal loop", station="drive-01", resource="dual robot cell", consumed_part_ids=["front-motor", "rear-motor", "front-inverter", "rear-inverter", "thermal-loop"], created_features=["drive mounts", "thermal connections"], automation=AutomationClass.assisted, cycle_time_s=155.0, evidence_ids=[EVIDENCE_PROCESS]),
OperationDefinition(id="op-070", name="Install suspension, steering, brakes, wheels and tires", station="chassis-01", resource="four-corner robot cell", consumed_part_ids=["suspension", "steering", "brakes", "wheels", "tires"], created_features=["aligned corner modules"], automation=AutomationClass.assisted, cycle_time_s=188.0, evidence_ids=[EVIDENCE_PROCESS]),
OperationDefinition(id="op-080", name="Install cabin, glazing and restraints", station="trim-01", resource="robots plus manual safety work", consumed_part_ids=["seats", "interior", "glazing", "restraints"], created_features=["trimmed cabin"], automation=AutomationClass.manual, cycle_time_s=420.0, evidence_ids=[EVIDENCE_PROCESS]),
OperationDefinition(id="op-090", name="Join and seal body shell", station="body-02", resource="joining and metrology cell", consumed_part_ids=["body-shell", "closures", "fasteners-seals"], created_features=["closed body", "measured gaps"], automation=AutomationClass.unknown, cycle_time_s=176.0, evidence_ids=[EVIDENCE_PROCESS]),
OperationDefinition(id="op-100", name="Connect serviceable exceptions", station="electrical-01", resource="robot-assisted operator", consumed_part_ids=["service-links", "lamps", "sensors"], created_features=["service disconnects", "sensor connections"], automation=AutomationClass.manual, cycle_time_s=255.0, evidence_ids=[EVIDENCE_PROCESS]),
OperationDefinition(id="op-110", name="Coat and paint exterior", station="paint-01", resource="robot paint line", consumed_part_ids=["body-shell"], created_features=["corrosion protection", "pearl exterior finish"], automation=AutomationClass.unknown, cycle_time_s=310.0, evidence_ids=[EVIDENCE_PROCESS]),
OperationDefinition(id="op-120", name="Software, calibration and end-of-line test", station="eol-01", resource="automated dynamometer and diagnostics", consumed_part_ids=["central-compute", "battery-management"], created_features=["configuration record", "EOL evidence bundle"], automation=AutomationClass.assisted, cycle_time_s=360.0, evidence_ids=[EVIDENCE_PROCESS]),
]
def validation_cases():
return [
ValidationCaseDefinition(id="dvp-drive-energy", name="Longitudinal drive, brake, regeneration, and energy balance", domain="vehicle-dynamics", authority="Sema internal verification; not homologation evidence", method="20 Hz coupled reduced-model step and exact terminal-power residual", status=QualificationStatus.executable_reduced, fidelity=Fidelity.reduced, evidence_ids=[EVIDENCE_CONCEPT, EVIDENCE_ELECTRICAL]),
ValidationCaseDefinition(id="dvp-lateral-yaw", name="Steering and crosswind lateral/yaw response", domain="vehicle-dynamics", authority="Sema internal verification; external maneuver correlation required", method="reduced bicycle model with axle load transfer and friction-circle limits", status=QualificationStatus.executable_reduced, fidelity=Fidelity.reduced, evidence_ids=[EVIDENCE_CONCEPT]),
ValidationCaseDefinition(id="dvp-aero-crosswind", name="Apparent-wind aerodynamic loads", domain="aerodynamics", authority="Sema internal verification; governing CFD absent", method="analytic drag, lift, side force, pressure, and held-out coefficient residual", status=QualificationStatus.executable_reduced, fidelity=Fidelity.reduced, evidence_ids=[EVIDENCE_CONCEPT]),
ValidationCaseDefinition(id="dvp-pack-electrothermal", name="Pack equivalent-circuit and component thermal duty", domain="battery", authority="Sema internal verification; cell and pack correlation required", method="open-circuit voltage, internal resistance, loss, SoC, and lumped thermal integration", status=QualificationStatus.executable_reduced, fidelity=Fidelity.reduced, evidence_ids=[EVIDENCE_ELECTRICAL, EVIDENCE_OPEN_BMS]),
ValidationCaseDefinition(id="dvp-body-torsion", name="Body-in-white static torsional stiffness", domain="structure", authority="OEM target and solver practice must be supplied", method="implicit structural FEA with suspension hard-point reactions and mesh convergence", status=QualificationStatus.blocked_missing_evidence, fidelity=Fidelity.unknown, evidence_ids=[EVIDENCE_CONCEPT]),
ValidationCaseDefinition(id="dvp-modal-nvh", name="Body and full-vehicle modal/NVH response", domain="structure", authority="OEM target and correlation measurements must be supplied", method="normal modes, frequency response, road-input transfer paths, and test correlation", status=QualificationStatus.blocked_missing_evidence, fidelity=Fidelity.unknown, evidence_ids=[EVIDENCE_CONCEPT]),
ValidationCaseDefinition(id="dvp-frontal-crash", name="Frontal crash and restraint response", domain="crash", authority="Candidate baseline: UNECE frontal-impact requirements and Euro NCAP 2026 Crash Protection", method="explicit nonlinear FE vehicle, dummy, restraint, intrusion, and post-crash HV analysis", status=QualificationStatus.planned_external, fidelity=Fidelity.unknown, evidence_ids=[EVIDENCE_TESLA_SERVICE]),
ValidationCaseDefinition(id="dvp-side-pole-crash", name="Side and pole impact", domain="crash", authority="Candidate baseline: UNECE R95/R135 and Euro NCAP 2026 Crash Protection", method="explicit nonlinear FE side structure, occupant, battery intrusion, and HV isolation analysis", status=QualificationStatus.planned_external, fidelity=Fidelity.unknown, evidence_ids=[EVIDENCE_TESLA_SERVICE]),
ValidationCaseDefinition(id="dvp-roof-rollover", name="Roof strength and rollover protection", domain="crash", authority="Applicable market and vehicle-category requirements must be frozen", method="quasi-static roof load plus explicit rollover and restraint analysis", status=QualificationStatus.planned_external, fidelity=Fidelity.unknown, evidence_ids=[EVIDENCE_CONCEPT]),
ValidationCaseDefinition(id="dvp-post-crash-hv", name="Post-crash electrical safety and rescue", domain="electrical-safety", authority="Candidate baseline: FMVSS 305a/GTR 20 and Euro NCAP 2026 Post-Crash Safety", method="HV isolation, electrical shock, electrolyte, thermal-event warning, door access, and emergency-response evidence", status=QualificationStatus.planned_external, fidelity=Fidelity.unknown, evidence_ids=[EVIDENCE_ELECTRICAL]),
ValidationCaseDefinition(id="dvp-reess-safety", name="Rechargeable energy storage system safety", domain="battery", authority="Candidate baseline: UNECE R100; applicable revision and approval scope must be frozen", method="mechanical integrity, vibration, thermal shock, fire, external short, overcharge, over-discharge, over-temperature, and propagation evidence", status=QualificationStatus.planned_external, fidelity=Fidelity.unknown, evidence_ids=[EVIDENCE_OPEN_BMS]),
ValidationCaseDefinition(id="dvp-emc", name="Vehicle and component EMC", domain="electrical-safety", authority="Candidate baseline: UNECE R10/CISPR 25 plus customer limits", method="emissions, immunity, transient, grounding, shielding, and communication robustness tests", status=QualificationStatus.planned_external, fidelity=Fidelity.unknown, evidence_ids=[EVIDENCE_ELECTRICAL]),
ValidationCaseDefinition(id="dvp-durability", name="Road-load and environmental durability", domain="durability", authority="Customer duty cycle, proving-ground spectrum, and life targets must be supplied", method="multiaxial road-load acquisition, fatigue, thermal cycling, corrosion, ingress, and accelerated-life correlation", status=QualificationStatus.blocked_missing_evidence, fidelity=Fidelity.unknown, evidence_ids=[EVIDENCE_CONCEPT]),
ValidationCaseDefinition(id="dvp-manufacturing-cell", name="Robot reach, collision, joining, and process capability", domain="manufacturing", authority="Supplier cell, tooling, tolerance, and process-capability evidence must be supplied", method="offline robot programming, reach/collision, line balance, joining coupons, metrology, and capability study", status=QualificationStatus.blocked_missing_evidence, fidelity=Fidelity.unknown, evidence_ids=[EVIDENCE_PROCESS]),
ValidationCaseDefinition(id="dvp-functional-safety", name="Functional-safety lifecycle and safety validation", domain="governance", authority="Candidate baseline: ISO 26262; item definition and HARA remain owner work", method="requirements traceability, HARA, ASIL decomposition, dependent-failure analysis, verification, and safety case", status=QualificationStatus.reference_only, fidelity=Fidelity.excluded, evidence_ids=[EVIDENCE_ELECTRICAL]),
]
def evidence_records():
return [
EvidenceRecord(id=EVIDENCE_EXTERIOR, kind=EvidenceClass.provenance, source="Printables model 1111101 / STL 4641564", summary="CC BY 4.0 community-reference exterior normalized into bounded presentation LODs; not OEM CAD", fidelity=Fidelity.derived, accepted=true),
EvidenceRecord(id=EVIDENCE_CONCEPT, kind=EvidenceClass.provenance, source="CircuitFrame explicit engineered assumptions", summary="Visible subsystem reconstruction with non-authoritative geometry, materials, mass, interfaces, and placement", fidelity=Fidelity.reduced, accepted=true),
EvidenceRecord(id=EVIDENCE_ELECTRICAL, kind=EvidenceClass.computational, source="Sema electrical equations", summary="Bounded concept topology without source-validated physical routing", fidelity=Fidelity.reduced, accepted=true),
EvidenceRecord(id=EVIDENCE_PROCESS, kind=EvidenceClass.manufacturing, source="CircuitFrame deterministic process demonstrator", summary="Scripted operation graph without sourced production process or robot feasibility", fidelity=Fidelity.reduced, accepted=false),
EvidenceRecord(id=EVIDENCE_TESLA_SERVICE, kind=EvidenceClass.provenance, source="Tesla Service Model Y SOP3 Berlin public documentation", summary="Official electrical, connector, collision-repair, and dimensional discovery source; remote reference only and not OEM CAD authority", fidelity=Fidelity.derived, accepted=false),
EvidenceRecord(id=EVIDENCE_OPEN_BMS, kind=EvidenceClass.computational, source="ENNOID-BMS commit daf72c927d4134cad95bb57da6d85807adf948b0", summary="Ingested GPL-3.0 repository snapshot with modular up-to-400-V BMS datasheet and KiCad schematics; separate comparator, not an 800 V vehicle design", fidelity=Fidelity.derived, accepted=false),
EvidenceRecord(id=EVIDENCE_TABBY_CAD, kind=EvidenceClass.provenance, source="Open Motors TABBY EVO 2015 open CAD release metadata", summary="CC-BY-SA-4.0 open-EV CAD candidate kept separate; historical binaries were unavailable and therefore were not ingested", fidelity=Fidelity.illustrative, accepted=false),
]
def source_records():
return [
SourceRecord(
id=EXTERIOR_SOURCE_ID,
authority=SourceAuthority.licensed_benchmark,
provider="Spark Models and SueRoger via Printables",
vehicle_model="Tesla Model Y community-reference exterior",
source_revision="printables-model-1111101-stl-4641564",
artifact_kind="attributed CC BY 4.0 binary STL and deterministic web GLB LODs",
rights_scope="rendering, adaptation, and attributed visualization export under CC BY 4.0; no Tesla endorsement, OEM CAD, manufacturing, or safety authority",
digest="aaf8a0da316b913b4559bcaa84f5a8cb9ae3e306b1297ee5d6e53b0c9ce0561b",
fidelity=Fidelity.derived,
render_allowed=true,
simulation_allowed=false,
export_allowed=true,
),
SourceRecord(
id=ASSUMPTION_SOURCE_ID,
authority=SourceAuthority.engineered_assumption,
provider="CircuitFrame Sema reconstruction",
vehicle_model="CircuitFrame Model Y reference reconstruction",
source_revision="configuration-revision-1",
artifact_kind="explicit subsystem geometry, property, E/E, and process assumptions",
rights_scope="evidence-labelled demo rendering, reduced simulation, and manifest export only; no production, homologation, or safety claim",
digest="circuitframe-gt-01-revision-1",
fidelity=Fidelity.reduced,
render_allowed=true,
simulation_allowed=true,
export_allowed=true,
),
SourceRecord(
id=TESLA_SERVICE_SOURCE_ID,
authority=SourceAuthority.open_research,
provider="Tesla Service",
vehicle_model="Model Y SOP3 Berlin public service reference",
source_revision="non-structural-pack-2022-01-06--2024-05-06",
artifact_kind="remote electrical, connector, collision-repair, and dimensional indexes",
rights_scope="reference discovery only; documents are not redistributed and confer no OEM CAD, production, or homologation authority",
digest="86c98870e71b10d06f481d24eab75201796d623b973a8395c7b3ccfded3ecb40",
fidelity=Fidelity.derived,
render_allowed=false,
simulation_allowed=false,
export_allowed=false,
),
SourceRecord(
id=OPEN_BMS_SOURCE_ID,
authority=SourceAuthority.open_research,
provider="ENNOID-BMS open-source project",
vehicle_model="Modular BMS reference for packs up to 400 V",
source_revision="daf72c927d4134cad95bb57da6d85807adf948b0",
artifact_kind="metadata and digest manifest for an optional external KiCad source checkout",
rights_scope="repository declares GPL-3.0; source artifacts are not redistributed here; explicit external source root plus legal and engineering review required",
digest="b393ebda17485f3b123c64f6a081241a070665726e24864e152d8a8aef7e8cac",
fidelity=Fidelity.derived,
render_allowed=false,
simulation_allowed=false,
export_allowed=false,
),
SourceRecord(
id=TABBY_CAD_SOURCE_ID,
authority=SourceAuthority.open_research,
provider="Open Motors, formerly OSVehicle",
vehicle_model="TABBY EVO open EV platform",
source_revision="2-seat-v0.1-and-4-seat-v0.1-2015",
artifact_kind="CC-BY-SA-4.0 STEP/2D CAD release metadata; binaries unavailable",
rights_scope="separate CAD/process comparator only; no Model Y or CircuitFrame identity, geometry, or validation authority",
digest="678563eec503b99ddfadd78253e82a24dd5d67c69031494601230dd113f7c9ae",
fidelity=Fidelity.illustrative,
render_allowed=false,
simulation_allowed=false,
export_allowed=false,
),
]
def source_bindings():
mut bindings = []
for part in vehicle_parts():
if part.id == "body-shell" or part.id == "wheels" or part.id == "tires" or part.id == "lamps" or part.id == "glazing" or part.id == "closures":
bindings.append(SourceBinding(entity_id=part.id, source_record_id=EXTERIOR_SOURCE_ID, source_entity_id="monolithic-exterior-envelope", fidelity=Fidelity.derived))
else:
bindings.append(SourceBinding(entity_id=part.id, source_record_id=ASSUMPTION_SOURCE_ID, source_entity_id=part.id, fidelity=Fidelity.reduced))
return bindings
pub def source_gate() -> dict[str, any] !{}:
bindings = source_bindings()
ensure len(bindings) == len(vehicle_parts())
mut accepted_source_records = 0
mut assumption_records = 0
mut reference_only_records = 0
for source in source_records():
if source.authority == SourceAuthority.licensed_benchmark:
accepted_source_records = accepted_source_records + 1
elif source.authority == SourceAuthority.engineered_assumption:
assumption_records = assumption_records + 1
else:
reference_only_records = reference_only_records + 1
return {
"status": "partial",
"presentation_qualified": true,
"physical_completeness": false,
"reason": "attributed CC BY exterior and FreeCAD/OCCT mechanical assembly loaded; product authority, certified materials and mass, process capability, and validation remain assumption-tagged or incomplete",
"current_asset_role": "community-reference exterior plus source-gated parametric mechanical reconstruction",
"accepted_source_records": accepted_source_records,
"assumption_records": assumption_records,
"reference_only_records": reference_only_records,
"bound_fixture_groups": 0,
"bound_reconstruction_groups": len(bindings),
"planned_configurations": ["conventional", "circuitframe"],
"shared_reference_identity_required": true,
"exterior_asset_manifest": "/assets/vehicle/manifest.json",
"source_mesh_sha256": "aaf8a0da316b913b4559bcaa84f5a8cb9ae3e306b1297ee5d6e53b0c9ce0561b",
"exterior_lods": [
{"id": "hero", "path": "/assets/vehicle/model-y-hero.glb", "sha256": "4d6657c313118c7978398ba2e8d6f5888208e03f626848ec5c6c3db90facd088"},
{"id": "medium", "path": "/assets/vehicle/model-y-medium.glb", "sha256": "c41f343f29a838f9bc11b494bfeb830b29d64794fe929349574a6f7b4ba9af3e"},
{"id": "compact", "path": "/assets/vehicle/model-y-compact.glb", "sha256": "c6070775ae904dd238457583e70f84b3514092cf6661fd3d2103419e625257c1"},
],
"mechanical_asset_manifest": "/assets/mechanical/manifest.json",
"mechanical_authority": "FreeCAD/OCCT parametric engineering reconstruction from declared dimensional assumptions; not OEM manufacturing CAD",
"mechanical_features": 1005,
"mechanical_lods": [
{"id": "hero", "path": "/assets/mechanical/circuitframe-mechanical-hero.glb", "sha256": "47533dcfe24eaa7191e84b5064fc82163ac49fcf3c18eae6f768ca4d3d9e440f"},
{"id": "medium", "path": "/assets/mechanical/circuitframe-mechanical-medium.glb", "sha256": "121910ada7a5c05ab46b89be9ca597258b5342bd968d8464428e51f3203af189"},
{"id": "compact", "path": "/assets/mechanical/circuitframe-mechanical-compact.glb", "sha256": "b92d4bd80740c7cee2bf4b485dbb1398f850e03cd09019e2d99d1e182722b156"},
],
"attribution": "Tesla Model Y (high detail) by Spark Models, remixed from SueRoger, CC BY 4.0",
"branding_review_status": "front and rear badge triangles removed and replaced by neutral surfaces; final legal review open",
}
pub def completeness_report(configuration_variant: str) -> CompletenessReport !{}:
parts = vehicle_parts()
operations = manufacturing_operations(configuration_variant)
mut instances = 0
mut unknown = 0
for part in parts:
instances = instances + part.count
for operation in operations:
if operation.automation == AutomationClass.unknown:
unknown = unknown + 1
return CompletenessReport(
profile="major_assembly_incomplete",
declared_parts=len(parts),
declared_instances=instances,
declared_nets=len(vehicle_nets(configuration_variant)),
declared_operations=len(operations),
unterminated_required_ports=0,
required_port_inventory_complete=false,
unknown_operations=unknown,
major_assembly_complete=false,
production_complete=false,
exclusions=["required mechanical, electrical, thermal, fluid, control, and service ports are not yet inventoried; zero unterminated ports is therefore not claimed", "CC BY community exterior is dimensionally normalized presentation geometry, not OEM CAD", "all non-exterior geometry, mass properties, interfaces, and placements remain bounded engineered assumptions", "physical circuit routing and manufacturing feasibility are not validated", "fasteners represented as counted families", "airbag inflator internals opaque", "semiconductor die internals opaque", "tire compound formulation opaque", "manufacturing CAD authority not claimed"],
)
pub def vehicle_manifest(configuration_variant: str) -> dict[str, any] !{}:
identity = vehicle_identity()
report = completeness_report(configuration_variant)
ensure identity.id == CONFIGURATION_ID
ensure not report.major_assembly_complete and not report.production_complete
return {
"schema": "sema.circuitframe-vehicle-manifest/v3",
"configuration_variant": configuration_variant,
"configuration_delta": {
"shared_identity": CONFIGURATION_ID,
"changed_part_ids": ["sill-panels", "service-links"],
"changed_net_ids": ["hv-pack-front", "hv-pack-rear", "hv-dcdc", "lv-zones", "data-backbone", "front-drive-control", "rear-drive-control", "thermal-sensors"],
"removed_features": ["structural conductors", "printed short links"] if configuration_variant == "conventional" else ["branched copper harness routes"],
"added_features": ["branched copper harness routes"] if configuration_variant == "conventional" else ["protected structural conductors", "printed short links"],
"authority": "configuration delta is a bounded engineered comparison, not production routing",
},
"identity": identity,
"parts": vehicle_parts(),
"nets": vehicle_nets(configuration_variant),
"operations": manufacturing_operations(configuration_variant),
"validation_cases": validation_cases(),
"evidence": evidence_records(),
"sources": source_records(),
"source_bindings": source_bindings(),
"source_gate": source_gate(),
"completeness": report,
}
test "major assembly profile remains incomplete without required port inventory":
report = completeness_report("circuitframe")
ensure not report.major_assembly_complete
ensure not report.required_port_inventory_complete
ensure not report.production_complete
ensure report.declared_parts >= 35
ensure report.declared_instances > report.declared_parts
ensure report.declared_nets >= 8
ensure report.declared_operations >= 10
ensure report.unknown_operations > 0
test "sourced exterior qualifies presentation without physical authority":
gate = source_gate()
ensure gate["status"] == "partial"
ensure gate["presentation_qualified"] == true
ensure gate["physical_completeness"] == false
ensure gate["accepted_source_records"] == 1
ensure gate["bound_fixture_groups"] == 0
ensure len(source_bindings()) == len(vehicle_parts())
test "qualification matrix separates executable reduced checks from external evidence":
cases = validation_cases()
mut executable = 0
mut external = 0
for validation_case in cases:
if validation_case.status == QualificationStatus.executable_reduced:
executable = executable + 1
if validation_case.status == QualificationStatus.planned_external or validation_case.status == QualificationStatus.blocked_missing_evidence:
external = external + 1
ensure len(cases) >= 15
ensure executable == 4
ensure external >= 10

Automotive adaptive-aerodynamics lifecycle over Sema standard-library gates.

Fields

field type descriptor
active_model_id str
candidate_model_id str
selected_model_id str
lifecycle str
regime str
mode str
validation_status str
detector_score f64
training_error f64
validation_error f64
invariant_violations int
dwell_steps int
hysteresis_margin f64
activated bool
reason str
evidence_ids list[str]
def initial_aero_detector() -> ResidualDetector !{}

Returns ResidualDetector

Effects !{}

def update_aero_detector(detector: ResidualDetector, residual: f64, observed_at: f64) -> ResidualDetector !{}

Parameters

name type
detector ResidualDetector
residual f64
observed_at f64

Returns ResidualDetector

Effects !{}

def regime_label(detector: ResidualDetector) -> str !{}

Parameters

name type
detector ResidualDetector

Returns str

Effects !{}

def retained_adaptation(detector: ResidualDetector, active_model_id: str) -> AeroAdaptationSummary !{}

Parameters

name type
detector ResidualDetector
active_model_id str

Returns AeroAdaptationSummary

Effects !{}

def evaluate_yaw_candidate(detector: ResidualDetector, step: int, observed_at: f64, speed_mps: f64, sideslip_deg: f64, holdout: dict[str, f64]) -> AeroAdaptationSummary !{}

Parameters

name type
detector ResidualDetector
step int
observed_at f64
speed_mps f64
sideslip_deg f64
holdout dict[str, f64]

Returns AeroAdaptationSummary

Effects !{}

Vehicle aerodynamics by a 3D constant-strength source-panel method with an image ground plane.

Method: Hess-Smith constant-strength source panels over a closed lofted body, with every panel mirrored through the road surface (z = 0) so the ground boundary condition is satisfied exactly. The Neumann condition (zero normal velocity on every panel) is closed by two dense linear solves, one per unit freestream direction; the boundary-value problem is linear in the freestream, so sigma(V, beta) = V (cos beta sigma_x + sin beta sigma_y) is exact for every wind speed and yaw and no matrix work is needed per frame.

Honesty: this is NOT a CFD run and no Navier-Stokes equation is integrated. Potential flow alone yields exactly zero drag and zero side force on a closed body (d’Alembert’s paradox), so every drag and side-force number here comes from the empirical viscous and base-pressure closure layered on top of the solved pressure field. Each published quantity is tagged solved (out of the linear system), correlated (empirical closure with a cited form), or numerical (a discretisation diagnostic). See aero_equations() for the per-quantity provenance.

Fields

field type descriptor
index int
station int
ring_index int
center_x f64
center_y f64
center_z f64
normal_x f64
normal_y f64
normal_z f64
area_m2 f64

Fields

field type descriptor
panels list[AeroPanel]
source_x list[f64]
source_y list[f64]
mode_x list[f64]
mode_y list[f64]
wake_forward list[f64]
wake_forward_cp list[f64]
wake_reverse list[f64]
wake_reverse_cp list[f64]
wake_crossflow list[f64]
wake_crossflow_cp list[f64]
base_face list[f64]
panel_count int
wetted_area_m2 f64
frontal_area_m2 f64
side_area_m2 f64
planform_area_m2 f64
closure_residual_m2 f64
residual_x f64
residual_y f64
separation_x_m f64
calibrated_wheel_cd f64
calibration_speed_mps f64
calibration_target_cd f64

Fields

field type descriptor
air_speed_mps f64
yaw_rad f64
dynamic_pressure_pa f64
reynolds_number f64
cd f64
cd_pressure f64
cd_base f64
cd_friction f64
cd_wheels f64
cy f64
cl f64
cmz f64
drag_n f64
side_n f64
lift_n f64
yaw_moment_nm f64
front_lift_n f64
rear_lift_n f64
pressure_drag_n f64
cp_min f64
cp_max f64
base_pressure_coefficient f64
separation_x_m f64
wake_width_m f64
wake_deficit_fraction f64
wake_shedding_hz f64
turbulence_intensity f64
wake_recirculation_length_m f64
dalembert_residual_cd f64
solver_residual f64
force_closure_residual f64
surface_cp list[f64]
def smooth_step(low: f64, high: f64, value: f64)

Parameters

name type
low f64
high f64
value f64

Cubic Hermite ramp, clamped to [0, 1] outside the interval.

def roof_height(u: f64)

Parameters

name type
u f64

Upper silhouette: low pointed nose, hood, raked windscreen, roof peak, fastback taper.

def floor_height(u: f64)

Parameters

name type
u f64

Lower silhouette: approach ramp, flat underbody at ride height, rear diffuser.

def half_width(u: f64)

Parameters

name type
u f64

Plan-view half width: blunt nose growth to full width, mild fastback taper aft.

def station_ring(u: f64, x_m: f64)

Parameters

name type
u f64
x_m f64

One superelliptic cross-section as AROUND flattened x,y,z points, mirror symmetric in y.

def panel_mesh()

Closed lofted body: two degenerate end caps plus RING_COUNT - 3 superelliptic stations.

Panels are ordered index = station * AROUND + ring_index, and ring_index k mirrors to AROUND - 1 - k through the y = 0 plane, which the symmetry-reduced solve relies on.

def influence_blocks(tx: list[f64], ty: list[f64], tz: list[f64], sx: list[f64], sy: list[f64], sz: list[f64], area: list[f64], epsilon: list[f64]) -> any

Parameters

name type
tx list[f64]
ty list[f64]
tz list[f64]
sx list[f64]
sy list[f64]
sz list[f64]
area list[f64]
epsilon list[f64]

Returns any

Velocity induced at every target by unit constant sources plus their z = 0 ground images.

Returns the three dense component matrices [Kx, Ky, Kz]; the near field is regularised with r_eps = REGULARISATION_FACTOR * sqrt(area_j) exactly as the browser-side field evaluator does.

def normal_projection(blocks: any, nx: list[f64], ny: list[f64], nz: list[f64], columns: int)

Parameters

name type
blocks any
nx list[f64]
ny list[f64]
nz list[f64]
columns int

Contract the three influence blocks against the target normals into one dense matrix.

def identity_matrix(n: int)

Parameters

name type
n int
def dense_solve(matrix: any, rhs: list[f64])

Parameters

name type
matrix any
rhs list[f64]
def dense_apply(matrix: any, vector: list[f64], rows: int)

Parameters

name type
matrix any
vector list[f64]
rows int
def stratford_limit(recovery: f64, run_m: f64, gradient: f64, constant: f64)

Parameters

name type
recovery f64
run_m f64
gradient f64
constant f64

Stratford (1959) turbulent separation: Cbar (xi dCbar/dxi)^1/2 >= k (1e-6 Re_xi)^1/10.

Re_xi is taken at the calibration speed so the separation topology stays geometry locked, which is what road vehicles show above Re_L ~ 4e6 where separation sits on fixed edges. correlated.

def line_walk(panels: list[AeroPanel], cp: list[f64], side_line: list[f64], forward: bool)

Parameters

name type
panels list[AeroPanel]
cp list[f64]
side_line list[f64]
forward bool

March each longitudinal surface line and freeze the pressure downstream of separation.

Returns 2 * PANEL_COUNT values: a 0/1 separation flag per panel followed by the frozen separation pressure coefficient carried into the wake.

def ring_walk(panels: list[AeroPanel], cp: list[f64])

Parameters

name type
panels list[AeroPanel]
cp list[f64]

March each cross-section from its crossflow stagnation point and mark the leeward wake.

Crossflow-analogy separation (Allen & Perkins): the pressure field driving it is the unit lateral mode alone, so the topology is yaw independent and can be precomputed once.

def aero_basis() -> AeroBasis !{}

Returns AeroBasis

Effects !{}

Build the panel mesh, run both dense source solves once, and freeze the wake topology.

def aero_solve(basis: AeroBasis, air_speed_mps: f64, yaw_rad: f64) -> AeroForces !{}

Parameters

name type
basis AeroBasis
air_speed_mps f64
yaw_rad f64

Returns AeroForces

Effects !{}

Integrate the surface pressure for one wind speed and yaw. O(panels), no matrix work.

The apparent wind in the vehicle frame is U = -V (cos beta, sin beta, 0): air arrives at the nose and leaves past the tail at zero yaw, and beta > 0 puts the wind on the vehicle’s left. The solved Cp field is invariant under freestream reversal, so it is evaluated straight from the exported +x and +y modes; only the viscous closure needs the physical flow direction.

def rounded(value: f64)

Parameters

name type
value f64

Half-up rounding to five decimals so the exported panel payload stays compact.

def aero_basis_export(basis: AeroBasis) -> dict[str, any] !{}

Parameters

name type
basis AeroBasis

Returns dict[str, any]

Effects !{}

sema.circuitframe-aero-basis/v1: geometry, source strengths and the solver provenance.

def aero_equations() -> list[dict[str, str]] !{}

Returns list[dict[str, str]]

Effects !{}

Equation manifest rows; every owner states solved, correlated or numerical provenance.

def yaw_forces(basis: AeroBasis, degrees: f64)

Parameters

name type
basis AeroBasis
degrees f64

Evaluate one yaw station at the calibration wind speed. Used by the tests below.

Physical assembly model: ports, mates, joints, mass properties, load paths, and the coupled thermal network.

Variants

  • fixed
  • revolute
  • prismatic
  • spherical
  • cylindrical
  • gear
  • rigid_bond

Variants

  • single
  • distributed
  • lateral_pair
  • longitudinal_pair
  • corner_quad

Variants

  • vertical
  • longitudinal
  • lateral
  • structural
  • crash
  • restraint

Fields

field type descriptor
id str
port_a_id str
port_b_id str
kind PortKind
preload_n f64
sealed bool
serviceable bool
robot_accessible bool

Fields

field type descriptor
id str
name str
kind JointKind
parent_part_id str
child_part_id str
parent_link str
child_link str
axis_x f64
axis_y f64
axis_z f64
origin_x f64
origin_y f64
origin_z f64
limit_lower f64
limit_upper f64
ratio f64
dof int
idle_dof int
corner str
loop_closing bool

Fields

field type descriptor
part_id str
pattern PlacementPattern
pattern_instances int
mass_kg f64
centre_x f64
centre_y f64
centre_z f64
extent_x f64
extent_y f64
extent_z f64
span_x f64
span_y f64
unsprung_fraction f64

Fields

field type descriptor
total_mass_kg f64
sprung_mass_kg f64
unsprung_mass_kg f64
cg_x_m f64
cg_y_m f64
cg_z_m f64
ixx_kg_m2 f64
iyy_kg_m2 f64
izz_kg_m2 f64
front_axle_load_kg f64
rear_axle_load_kg f64
front_mass_fraction f64
wheelbase_m f64
cg_height_m f64
part_count int
instance_count int
closure_residual_kg f64

Fields

field type descriptor
id str
name str
kind LoadPathKind
part_ids list[str]
rated_load_n f64
utilisation f64

Fields

field type descriptor
path_id str
element_index int
part_id str
role str
force_n f64
moment_nm f64

Fields

field type descriptor
path_id str
name str
kind LoadPathKind
applied_n f64
reaction_n f64
residual_n f64
peak_force_n f64
rated_load_n f64
utilisation f64
reactions list[LoadPathReaction]

Fields

field type descriptor
vertical_n f64
longitudinal_n f64
lateral_n f64
results list[LoadPathResult]
equilibrium_residual_n f64
moment_residual_nm f64
worst_path_id str
worst_utilisation f64

Fields

field type descriptor
corner_id str
vertical_load_n f64
arm_rotation_rad f64
wheel_travel_m f64
spring_stroke_m f64
damper_length_m f64
spring_force_n f64
wheel_rate_n_m f64
motion_ratio f64
camber_deg f64
camber_change_deg f64
toe_deg f64
toe_change_deg f64
kingpin_rotation_rad f64
jounce_limit_m f64
rebound_limit_m f64
travel_utilisation f64
at_limit bool

Fields

field type descriptor
id str
part_id str
capacity_kj_k f64
ambient_coupling_w_k f64
initial_temp_c f64

Fields

field type descriptor
id str
from_node_id str
to_node_id str
conductance_w_k f64
medium str

Fields

field type descriptor
id str
nodes list[ThermalNode]
links list[ThermalLink]

Fields

field type descriptor
corner_id str
link_count int
joint_count int
joint_dof_sum int
gross_mobility int
idle_dof int
effective_mobility int
expected_mobility int
independent_loops int
loop_closing_joint_ids list[str]
ok bool
def interface_specs() -> list[dict[str, any]]

Returns list[dict[str, any]]

Declared part-to-part interfaces. Every row expands into two ports and one terminating mate.

def open_port_specs() -> list[dict[str, any]]

Returns list[dict[str, any]]

Declared but intentionally unmated interfaces. None of them is a required port.

def vehicle_ports()

Full port inventory: two terminated ports per declared interface plus the open optional ports.

def vehicle_mates()

One mate per declared interface, carrying its preload, sealing, and service classification.

def port_termination_report()

Counts required, terminated, open, and multiply-mated ports by scanning the inventory.

def part_placements()

Placement of every declared part in vehicle coordinates, in the same order as vehicle_parts().

def placement_instances(placement: PartPlacement)

Parameters

name type
placement PartPlacement

Expands a placement pattern into its instance centres. Each row is [mass, x, y, z].

def mass_properties() -> MassProperties !{}

Returns MassProperties

Effects !{}

Total mass, centre of gravity, and inertia tensor summed from the part placements.

Every part is treated as a uniform rectangular prism of its declared extent, transferred to the vehicle centre of mass by the parallel-axis theorem. The closure residual compares the declared part masses against the placement masses and must be exactly zero.

def corner_table()

The four suspension corners: mirror signs, steering capability, and the subframe they hang from.

def joint(id: str, name: str, kind: JointKind, parent_part_id: str, child_part_id: str, parent_link: str, child_link: str, axis: list[f64], origin: list[f64], limits: list[f64], ratio: f64, dof: int, idle_dof: int, corner: str, loop_closing: bool)

Parameters

name type
id str
name str
kind JointKind
parent_part_id str
child_part_id str
parent_link str
child_link str
axis list[f64]
origin list[f64]
limits list[f64]
ratio f64
dof int
idle_dof int
corner str
loop_closing bool

Constructs a joint from packed axis, origin, and limit triples.

def corner_joints(entry: dict[str, any])

Parameters

name type
entry dict[str, any]

Every joint of one suspension corner, mirrored from the reference hard points.

def variant_joint_kind(configuration_variant: str)

Parameters

name type
configuration_variant str

Subframe and pack mounts are elastomeric bolted joints conventionally and bonded structural joints in the CircuitFrame variant.

def structural_joints(configuration_variant: str)

Parameters

name type
configuration_variant str

Driveline, body, pack, and equipment joints outside the four suspension corners.

def vehicle_joints_for(configuration_variant: str)

Parameters

name type
configuration_variant str

Complete kinematic joint set for one configuration variant.

def vehicle_joints()

Complete kinematic joint set for the default CircuitFrame configuration.

def corner_loop_joint_ids(entry: dict[str, any])

Parameters

name type
entry dict[str, any]

The joints that form the closed kinematic loop of one corner.

Wheel bearing, brake, wheel, tire, halfshaft, and anti-roll-bar joints are excluded: they extend the corner as an open chain or couple the two corners of an axle, and are analysed separately.

def corner_mobility(entry: dict[str, any]) !{}

Parameters

name type
entry dict[str, any]

Effects !{}

Grubler-Kutzbach mobility of one suspension corner loop.

Gross mobility counts every joint freedom; the idle freedoms are the spins of the spherical-spherical steering link and of the spherical-prismatic-spherical damper unit, which move no wheel state. A steered corner must retain one wheel-travel and one steer freedom; a rear corner must retain wheel travel only.

def mobility_reports() !{}

Effects !{}

Mobility of all four suspension corner loops.

def arm_rotation_limits() -> list[f64] !{}

Returns list[f64]

Effects !{}

Lower control arm rotation at the rebound and jounce stops, from the declared wheel travel.

def corner_pose(arm_rotation_rad: f64) -> list[f64] !{}

Parameters

name type
arm_rotation_rad f64

Returns list[f64]

Effects !{}

Closes the planar double-wishbone four-bar for a given lower-arm rotation.

Returns [lbj_y, lbj_z, ubj_y, ubj_z, knuckle_rotation_rad, wheel_y, wheel_z, wheel_travel_m]. The upper ball joint is the circle-circle intersection of the upper arm sweep and the rigid knuckle, so wheel travel and camber are consequences of the joint set, not of a fitted curve.

def damper_length(arm_rotation_rad: f64)

Parameters

name type
arm_rotation_rad f64

Installed length of the air spring and damper unit for a given lower-arm rotation.

def motion_ratio(arm_rotation_rad: f64)

Parameters

name type
arm_rotation_rad f64

Spring shortening per unit wheel rise, differentiated through the linkage.

def static_corner_load_n()

Static vertical load at one wheel with the vehicle at its declared kerb mass.

def air_spring_reference_pressure() -> f64 !{}

Returns f64

Effects !{}

Absolute bellows pressure that balances the static corner load at design height.

def air_spring_force(arm_rotation_rad: f64, reference_pressure: f64) !{}

Parameters

name type
arm_rotation_rad f64
reference_pressure f64

Effects !{}

Axial air spring force from the polytropic gas state of the bellows.

def wheel_force(arm_rotation_rad: f64, reference_pressure: f64) -> f64 !{}

Parameters

name type
arm_rotation_rad f64
reference_pressure f64

Returns f64

Effects !{}

Vertical wheel force supported by the corner, by virtual work through the motion ratio.

def vector_rotate(vector: list[f64], axis: list[f64], angle: f64)

Parameters

name type
vector list[f64]
axis list[f64]
angle f64

Rodrigues rotation of a vector about a unit axis.

def tie_rod_residual(pose: list[f64], steer_rotation_rad: f64)

Parameters

name type
pose list[f64]
steer_rotation_rad f64

Length error of the steering link when the knuckle is rotated about its kingpin axis.

def bump_steer(pose: list[f64])

Parameters

name type
pose list[f64]

Kingpin rotation forced by the fixed-length steering link, and the toe change it produces.

Returns [kingpin_rotation_rad, toe_change_deg]. Solved with a damped Newton iteration seeded at the design position, so it tracks the physically near root of the length constraint.

def suspension_travel(vertical_load_n: f64) -> SuspensionState !{}

Parameters

name type
vertical_load_n f64

Returns SuspensionState

Effects !{}

Solves the corner for a vertical wheel load and reports the resulting wheel motion.

The load is balanced against the polytropic air spring through the linkage motion ratio; the lower-arm rotation that satisfies it is bisected between the rebound and jounce stops. Camber and toe are then read out of the four-bar closure and the steering-link length constraint, so the wheel pose is a consequence of the declared joints.

def load_path_declarations()

Declared load paths from load introduction to structural reaction, in transmission order.

Utilisation is left at zero here; load_paths() fills it from the reference static load case.

def corner_statics(lateral_n: f64, vertical_n: f64) !{}

Parameters

name type
lateral_n f64
vertical_n f64

Effects !{}

Static equilibrium of one corner at the design position.

Two 3x3 solves: the knuckle against the lower ball joint reaction and the axial upper-arm force, then the lower arm against the damper force and its inboard pivot reaction. Returns [lower_ball_y, lower_ball_z, upper_arm_axial, damper_axial, pivot_y, pivot_z, chassis_y, chassis_z, force_residual, moment_residual].

def load_paths() !{}

Effects !{}

Declared load paths with the utilisation each reaches under the reference static load case.

def reaction(path_id: str, index: int, part_id: str, role: str, force_n: f64, moment_nm: f64)

Parameters

name type
path_id str
index int
part_id str
role str
force_n f64
moment_nm f64

One element of a load path chain.

def shared_path_result(path: LoadPath, applied_n: f64, shares: list[f64], roles: list[str])

Parameters

name type
path LoadPath
applied_n f64
shares list[f64]
roles list[str]

Distributes an applied load over a branching path whose shares sum to one.

def load_path_reactions(vertical_n: f64, longitudinal_n: f64, lateral_n: f64) !{}

Parameters

name type
vertical_n f64
longitudinal_n f64
lateral_n f64

Effects !{}

Reactions along every declared load path for one static load case.

The vertical and lateral corner paths are closed by the two 3x3 equilibrium solves in corner_statics; the remaining paths distribute their input over branch shares that sum to one. Every path reports the residual between the applied load and the summed reaction.

def thermal_network()

Coupled lumped thermal network: the coolant loop is a shared node, not a fixed sink.

Heat leaves a component into the glycol loop, travels to the chiller and the cabin through the heat pump, and only then reaches ambient through the radiator and the body skin.

def isolated_thermal_network()

The same graph with every ambient coupling removed, for closed-system energy checks.

def node_index(network: ThermalNetwork, node_id: str)

Parameters

name type
network ThermalNetwork
node_id str

Position of a node in the network ordering, or -1 when it is absent.

def thermal_step_network(network: ThermalNetwork, temps: list[f64], heat_w: list[f64], ambient_c: f64, dt_s: f64)

Parameters

name type
network ThermalNetwork
temps list[f64]
heat_w list[f64]
ambient_c f64
dt_s f64

Advances an explicit lumped-capacitance network one step.

C_i dT_i/dt = Q_i + sum_j k_ij (T_j - T_i) - h_i (T_i - T_amb). The link term is antisymmetric, so with the ambient couplings removed the total stored energy changes by exactly the injected heat.

def thermal_step(temps: list[f64], heat_w: list[f64], ambient_c: f64, dt_s: f64) -> list[f64] !{}

Parameters

name type
temps list[f64]
heat_w list[f64]
ambient_c f64
dt_s f64

Returns list[f64]

Effects !{}

Advances the vehicle thermal network one step.

def thermal_initial_temps() -> list[f64] !{}

Returns list[f64]

Effects !{}

Declared soak temperatures of the network, in node order.

def thermal_stability_limit_s()

Largest explicit step the network tolerates: min over nodes of 2 C / (sum k + h).

def assembly_export() -> dict[str, any] !{}

Returns dict[str, any]

Effects !{}

Full assembly payload: placements, interfaces, kinematics, load paths, and the thermal graph.

def assembly_equations() -> list[dict[str, str]] !{}

Returns list[dict[str, str]]

Effects !{}

Manifest rows for the assembly model, in the same shape as physics.sema:dynamics_equations().

Bounded natural-language circuit authoring compiled into typed Sema net definitions.

Variants

  • connect
  • constrain
  • unknown

Fields

field type descriptor
intent AuthoringIntent
accepted bool
natural_description str
formal_sema str
net NetDefinition
requirements list[str]
assumptions list[str]
diagnostics list[str]
def formal_hv_drive_net()
def formal_zonal_supply_net()
def formal_data_backbone_net()
def unsupported_net()
def compile_circuit_description(description: str) -> CircuitAuthoringResult !{}

Parameters

name type
description str

Returns CircuitAuthoringResult

Effects !{}

Bounded structural-conductor candidate search with immutable gate results.

Fields

field type descriptor
id str
base_revision int
width_mm f64
thickness_mm f64
length_m f64
mass_kg f64
current_density_a_mm2 f64
voltage_drop_v f64
loss_kw f64
estimated_temp_c f64
mass_reduction_fraction f64
passed bool
reason str
def evaluate_panel_candidate(width_mm: f64, base_revision: int) !{}

Parameters

name type
width_mm f64
base_revision int

Effects !{}

def optimize_panel(base_revision: int) -> dict[str, any] !{}

Parameters

name type
base_revision int

Returns dict[str, any]

Effects !{}

Canonical automotive identities, completeness, evidence, and interface contracts.

Variants

  • canonical
  • derived
  • reduced
  • illustrative
  • unknown
  • excluded

Variants

  • body
  • chassis
  • energy
  • drive
  • motion
  • thermal
  • electrical
  • control
  • cabin
  • safety
  • manufacturing

Variants

  • mechanical
  • high_voltage
  • low_voltage
  • data
  • thermal
  • fluid
  • service

Variants

  • robot_validated
  • assisted
  • manual
  • failed
  • unknown

Variants

  • executable_reduced
  • planned_external
  • reference_only
  • blocked_missing_evidence

Variants

  • computational
  • structural
  • direct_parity
  • reduced_model
  • manufacturing
  • negative
  • provenance

Variants

  • customer_oem
  • licensed_benchmark
  • open_research
  • engineered_assumption
  • unqualified_fixture

Fields

field type descriptor
id str
authority SourceAuthority
provider str
vehicle_model str
source_revision str
artifact_kind str
rights_scope str
digest str
fidelity Fidelity
render_allowed bool
simulation_allowed bool
export_allowed bool

Fields

field type descriptor
entity_id str
source_record_id str
source_entity_id str
fidelity Fidelity

Fields

field type descriptor
id str
base_configuration str
target_configuration str
unchanged_part_ids list[str]
removed_route_ids list[str]
added_feature_ids list[str]
changed_operation_ids list[str]

Fields

field type descriptor
id str
revision int
name str
category str
seats int
fidelity Fidelity

Fields

field type descriptor
id str
name str
group PartGroup
count int
material str
mass_kg f64
fidelity Fidelity
opaque bool
evidence_ids list[str]

Fields

field type descriptor
id str
owner_part_id str
kind PortKind
mate_port_id str
required bool
terminated bool

Fields

field type descriptor
id str
name str
domain str
source_part_id str
target_part_ids list[str]
nominal_voltage_v f64
maximum_current_a f64
route str
fidelity Fidelity
evidence_ids list[str]

Fields

field type descriptor
id str
name str
station str
resource str
consumed_part_ids list[str]
created_features list[str]
automation AutomationClass
cycle_time_s f64
evidence_ids list[str]

Fields

field type descriptor
id str
name str
domain str
authority str
method str
status QualificationStatus
fidelity Fidelity
evidence_ids list[str]

Fields

field type descriptor
id str
kind EvidenceClass
source str
summary str
fidelity Fidelity
accepted bool

Fields

field type descriptor
profile str
declared_parts int
declared_instances int
declared_nets int
declared_operations int
unterminated_required_ports int
required_port_inventory_complete bool
unknown_operations int
major_assembly_complete bool
production_complete bool
exclusions list[str]

Engineering specification of the declared drive, brake, tyre, pack and gear hardware.

The vehicle’s mass is summed from the declared part placements in assembly.sema, but until now nothing that accelerated or stopped that mass was declared: peak torque, drive power, brake force, regenerative force, pack energy, pack resistance, tyre grip, cornering stiffness and rolling resistance were all bare literals in physics.sema. This module declares the engineering parameters of the components vehicle.sema already lists and derives those limits from them, so the performance follows from the hardware rather than sitting beside it.

Every specification is keyed to the part id it represents, and part_reconciliation() checks that each id resolves in vehicle_parts(), that the declared instance counts agree, and that the mass the specification implies matches the mass the part already declares. hardware_limits() fails loudly on any mismatch, so a specification cannot quietly drift away from the part it describes.

Honesty: these are engineered assumptions for a reconstruction, not OEM data. No motor was dynamometer tested, no caliper was pressure tested and no cell was cycled. Each declared parameter carries a solved, correlated or assumed tag in hardware_parameters(), using the same vocabulary as the aerodynamic and assembly modules: assumed for a bounded engineering choice, correlated for an empirical closure with a cited form, solved for a value that falls out of a closed derivation over the declared inputs.

One interior-permanent-magnet traction machine, keyed to its declared drive part.

Fields

field type descriptor
part_id str
pole_pairs int
rotor_radius_m f64
stack_length_m f64
stator_outer_radius_m f64
magnet_flux_linkage_wb f64
d_axis_inductance_h f64
q_axis_inductance_h f64
peak_phase_current_a f64
continuous_phase_current_a f64
current_angle_deg f64
max_speed_rpm f64

One traction inverter, keyed to its declared power-electronics part.

Fields

field type descriptor
part_id str
dc_link_voltage_v f64
peak_phase_current_a f64
on_state_resistance_ohm f64
housing_mass_kg f64
net_id str

One brake-by-wire corner module. Both axles are instances of the declared brakes part.

Fields

field type descriptor
part_id str
axle str
disc_outer_radius_m f64
disc_inner_radius_m f64
hat_radius_m f64
disc_thickness_m f64
vent_solid_fraction f64
piston_count int
piston_bore_m f64
caliper_mass_kg f64
pad_mass_kg f64
actuator_mass_kg f64

The declared road tyre, in the size notation moulded on its sidewall.

Fields

field type descriptor
part_id str
section_width_m f64
aspect_ratio f64
rim_diameter_in f64
rolling_radius_factor f64
load_index_kg f64
peak_friction f64
slide_friction f64
load_sensitivity f64
friction_reference_load_fraction f64
cornering_stiffness_per_n f64
rolling_coefficient f64
rolling_speed_coefficient f64
carcass_thickness_m f64
carcass_density_kg_m3 f64

The declared battery modules, described at the cell that fills them.

Fields

field type descriptor
part_id str
module_count int
series_per_module int
parallel_per_module int
cell_nominal_voltage_v f64
cell_capacity_ah f64
cell_resistance_ohm f64
cell_specific_energy_wh_kg f64
module_packaging_factor f64
module_interconnect_resistance_ohm f64
pack_bus_resistance_ohm f64
cell_pulse_charge_c_rate f64
cell_pulse_discharge_c_rate f64

One helical reduction stage, described by the two pitch diameters that set its ratio.

Fields

field type descriptor
pinion_pitch_diameter_m f64
wheel_pitch_diameter_m f64
face_width_m f64

The declared single-speed reduction gearset, one unit per drive axle.

Fields

field type descriptor
part_id str
unit_count int
first GearStageSpec
second GearStageSpec

What one traction machine can actually deliver through its own inverter.

Fields

field type descriptor
part_id str
inverter_part_id str
effective_peak_current_a f64
current_limited_by str
peak_torque_nm f64
continuous_torque_nm f64
base_speed_rpm f64
max_speed_rpm f64
constant_power_ratio f64
corner_power_kw f64
stator_flux_linkage_wb f64
power_factor f64
inverter_apparent_power_kva f64
inverter_peak_power_kw f64
inverter_loss_kw f64
dc_link_peak_current_a f64
airgap_shear_stress_kpa f64

Both corner modules of one axle, reduced to the numbers the dynamics needs.

Fields

field type descriptor
part_id str
axle str
effective_radius_m f64
piston_area_m2 f64
clamp_force_n f64
corner_torque_nm f64
axle_force_n f64
disc_mass_kg f64
disc_thermal_capacity_j_k f64

The contact-patch model the dynamics reads, evaluated against live corner loads.

Fields

field type descriptor
part_id str
unloaded_radius_m f64
rolling_radius_m f64
rated_load_n f64
friction_reference_load_n f64
peak_friction f64
slide_friction f64
load_sensitivity f64
cornering_stiffness_per_n f64
rolling_coefficient f64
rolling_speed_coefficient f64

The traction pack, summed from the declared cell out to the terminals.

Fields

field type descriptor
part_id str
cell_count int
series_count int
parallel_count int
energy_kwh f64
nominal_voltage_v f64
resistance_ohm f64
charge_power_kw f64
discharge_power_kw f64
mass_kg f64

One declared part, checked against the specification that claims to describe it.

Fields

field type descriptor
part_id str
resolved bool
declared_count int
expected_count int
declared_mass_kg f64
derived_mass_kg f64
mass_error_fraction f64
tolerance_fraction f64
derivation str
passed bool

Everything one axle’s 20 Hz longitudinal solve needs, pre-solved into flat scalars.

Nothing on this struct depends on the live vehicle state, so it is built once with the rest of the hardware. The loop is then left with only the arithmetic that genuinely varies: the friction circle on this axle’s live vertical load, the fade factor at its live disc temperature, and the comparison that decides which of the two is binding. Keeping it flat also keeps it cheap to pass around: the interpreter copies a struct argument by value, so the per-step helpers must never be handed anything that carries the panel basis or a pre-sampled table.

Fields

field type descriptor
axle str
brake_axle_force_n f64
drive_force_per_throttle_n f64
power_force_numerator_w f64
power_constraint str
peak_friction f64
slide_friction f64
load_sensitivity f64
friction_reference_load_n f64
cornering_stiffness_per_n f64
fade_onset_temp_c f64
fade_rate_per_k f64
fade_floor_fraction f64
disc_thermal_capacity_j_k f64

Everything physics.sema used to hardcode, derived from the declared components.

Fields

field type descriptor
front_motor MotorLimits
rear_motor MotorLimits
front_brake BrakeAxleLimits
rear_brake BrakeAxleLimits
tire TireLimits
pack PackLimits
front_axle AxleHardware
rear_axle AxleHardware
final_drive_ratio f64
gearbox_efficiency f64
motor_efficiency f64
inverter_efficiency f64
max_motor_torque_nm f64
max_drive_power_kw f64
max_brake_force_n f64
max_regenerative_force_n f64
max_regenerative_power_kw f64
regenerative_front_share f64
max_line_pressure_pa f64
pad_friction f64
fade_onset_temp_c f64
fade_rate_per_k f64
fade_floor_fraction f64
disc_cooling_base_w_per_k f64
disc_cooling_speed_w_per_k_mps f64
driveline_top_speed_mps f64
def front_motor_spec()

Front machine: smaller rotor, more turns, sized for the lighter front axle duty.

def rear_motor_spec()

Rear machine: longer, wider rotor and roughly twice the current for the primary drive axle.

def front_inverter_spec()

Front inverter: standalone subframe housing, rated above the machine it feeds.

def rear_inverter_spec()

Rear inverter: shares the drive-unit casting, and is the binding current limit of that axle.

def front_brake_spec()

Front corner: 355 mm vented disc, four-piston fixed caliper.

def rear_brake_spec()

Rear corner: 330 mm vented disc, two-piston caliper carrying the park-brake actuator.

def tire_spec()

255/45 R21 summer tyre on all four corners.

def pack_spec()

Twenty-four 9s2p prismatic NMC modules wired in series into one 216s2p traction pack.

def gearbox_spec()

Two helical stages per drive unit; their pitch diameters are what make the 9.1:1 final drive.

def motor_limits(motor: TractionMotorSpec, inverter: InverterSpec) !{}

Parameters

name type
motor TractionMotorSpec
inverter InverterSpec

Effects !{}

Solve one machine at its own current limit, then at whichever limit its inverter imposes.

Torque is the interior-permanent-magnet expression: magnet torque plus the reluctance term the declared saliency provides at the declared current angle. The base speed is where the stator flux linkage the same operating point produces can no longer be driven at the space-vector voltage ceiling, and the constant-power region runs from there to the declared maximum speed. Power factor comes out of the corner-point voltage and current phasors rather than being declared, so the inverter’s apparent-power rating and the machine’s shaft power stay consistent.

def brake_axle_limits(corner: BrakeCornerSpec, rolling_radius_m: f64) !{}

Parameters

name type
corner BrakeCornerSpec
rolling_radius_m f64

Effects !{}

Pressure, clamp force, corner torque and the wheel force both corners of one axle can make.

The effective radius is the uniform-pressure integral over the swept annulus, and the disc thermal mass is the vented swept volume in cast iron. That thermal mass is what turns half m v squared into a disc temperature, so a heavier car heats the same disc further on the same stop.

def tire_limits()

Turn the moulded tyre size into the radius, rated load and grip model the dynamics reads.

def pack_limits()

Sum 24 declared modules of cells into pack energy, voltage, resistance, power and mass.

def final_drive_ratio()

The 9.1:1 reduction is the product of the two declared stage pitch-diameter ratios.

def gear_blank_mass(stage: GearStageSpec)

Parameters

name type
stage GearStageSpec

Both gear blanks of one stage as webbed steel discs of their pitch diameter and face width.

def derived_part_mass(part_id: str) !{}

Parameters

name type
part_id str

Effects !{}

Mass the declared specification implies for one part, summed over its declared instances.

def declared_part(part_id: str)

Parameters

name type
part_id str

The declared part this specification claims to describe. Fails loudly on an unknown id.

def part_derivation(part_id: str)

Parameters

name type
part_id str

One sentence naming what the derived mass for this part is actually built from.

def reconcile_part(part_id: str, expected_count: int) !{}

Parameters

name type
part_id str
expected_count int

Effects !{}

Check one specification against the part it claims to describe: identity, count and mass.

def part_reconciliation() !{}

Effects !{}

Every specification checked against vehicle_parts(), in the order the drive chain runs.

def net_current_limit(net_id: str)

Parameters

name type
net_id str

Declared maximum current of one high-voltage net, read out of vehicle_nets().

def generating_power(motor: MotorLimits)

Parameters

name type
motor MotorLimits

Electrical power one machine can regenerate: its continuous torque at its own base speed.

A braking burst is short, but the machines still recover at their continuous rating rather than their peak: peak current is a traction-side thermal allowance the pad-and-disc side never needs.

def axle_hardware(axle: str, motor: MotorLimits, brake: BrakeAxleLimits, tire: TireLimits, ratio: f64, efficiency: f64)

Parameters

name type
axle str
motor MotorLimits
brake BrakeAxleLimits
tire TireLimits
ratio f64
efficiency f64

Collapse one axle’s declared machine, inverter, calipers and tyres into per-step scalars.

def hardware_limits() -> HardwareLimits !{}

Returns HardwareLimits

Effects !{}

Derive every performance limit physics.sema needs, and refuse to return a drifted set.

The reconciliation runs first. A specification whose part id no longer resolves, whose instance count no longer matches the declared count, or whose derived mass has drifted outside the stated tolerance stops the build here rather than quietly feeding a wrong limit into the dynamics.

def parameter_row(part_id: str, key: str, label: str, value: f64, unit: str, source: str, note: str)

Parameters

name type
part_id str
key str
label str
value f64
unit str
source str
note str

One declared scalar with the part it belongs to and how it was arrived at.

def motor_parameters(motor: TractionMotorSpec)

Parameters

name type
motor TractionMotorSpec

Provenance rows for one machine, read straight off the specification struct.

def inverter_parameters(inverter: InverterSpec)

Parameters

name type
inverter InverterSpec

Provenance rows for one inverter, read straight off the specification struct.

def brake_parameters(corner: BrakeCornerSpec)

Parameters

name type
corner BrakeCornerSpec

Provenance rows for one brake corner, read straight off the specification struct.

def shared_parameters()

Provenance rows for the constants shared across specifications and reconciliations.

def hardware_parameters()

The whole provenance ledger, built from the specification structs so it cannot drift.

def motor_export(limits: MotorLimits)

Parameters

name type
limits MotorLimits
def brake_export(limits: BrakeAxleLimits)

Parameters

name type
limits BrakeAxleLimits
def reconciliation_export(row: PartReconciliation)

Parameters

name type
row PartReconciliation
def hardware_export() -> dict[str, any] !{}

Returns dict[str, any]

Effects !{}

API payload: the derived limits, the declared parameters behind them, and the reconciliation.

def hardware_equations() -> list[dict[str, str]] !{}

Returns list[dict[str, str]]

Effects !{}

Manifest rows for the hardware model, in the same shape as physics.sema:dynamics_equations().

def contains_id(seen: list[str], value: str)

Parameters

name type
seen list[str]
value str
def ledger_covers(rows: list[dict[str, any]], part_id: str)

Parameters

name type
rows list[dict[str, any]]
part_id str

Automotive-native Sema HTTP service for CircuitFrame Lab.

Everything expensive is solved once when the service starts: the source-panel aerodynamic basis, both reduced-order surrogates, the mass and joint budget, and the four printed-circuit boards with their DC nodal solutions. The 20 Hz drive loop and the on-demand wind-tunnel measurement then reuse those caches.

def response(status: int, body: any) !{}

Parameters

name type
status int
body any

Effects !{}

def error_response(status: int, code: str, detail: str) !{}

Parameters

name type
status int
code str
detail str

Effects !{}

def physical_actions_allowed()
def evidence_export_allowed()
def initial_session(context: PhysicsContext)

Parameters

name type
context PhysicsContext
def cached_aero_basis(session: dict[str, any])

Parameters

name type
session dict[str, any]
def cached_pcb(session: dict[str, any])

Parameters

name type
session dict[str, any]
def cached_assembly(session: dict[str, any])

Parameters

name type
session dict[str, any]
def cached_hardware(session: dict[str, any])

Parameters

name type
session dict[str, any]

The derived component limits and their reconciliation. Solved once with the context.

def progress_report()
def contains(values: list[str], value: str)

Parameters

name type
values list[str]
value str
def equation_phase(id: str, live: bool)

Parameters

name type
id str
live bool

Where a body is evaluated: inside every 20 Hz step, once at startup, or only when asked.

def grouped_equations(rows: list[dict[str, str]], group: str, live: bool)

Parameters

name type
rows list[dict[str, str]]
group str
live bool

Tag each declared equation with the subsystem that owns it and when its body runs.

def initial_residual_history()
def pushed_residual_history(history: dict[str, any], residual: f64, score: f64, sequence: int, activated: bool) !{}

Parameters

name type
history dict[str, any]
residual f64
score f64
sequence int
activated bool

Effects !{}

Bounded ring of the surrogate-versus-solved residual and the detector score that tracks it.

def idle_sweep()
def sweep_command(index: int, speed_mps: f64)

Parameters

name type
index int
speed_mps f64

Wind speed and bearing that put the apparent wind at the scripted sideslip. Returns [m/s, deg].

The demonstration commands a sideslip, not a wind. A fixed crosswind that walks a parked car clear of the calm model’s window barely moves the apparent angle at motorway speed, so a fixed wind would quietly stop demonstrating anything above about 30 m/s. Solving the wind vector for the target angle keeps it meaningful at any road speed, and both commanded values are published every step so nothing about the operating point is hidden.

def advanced_sweep(sweep: dict[str, any], controls: ControlInput)

Parameters

name type
sweep dict[str, any]
controls ControlInput
def detector_body(session: dict[str, any])

Parameters

name type
session dict[str, any]
def residual_history_body(session: dict[str, any])

Parameters

name type
session dict[str, any]

The whole ring, served on status only; a step carries one new sample and the client appends.

def sweep_body(session: dict[str, any])

Parameters

name type
session dict[str, any]
def all_equations()
def status_body(session: dict[str, any])

Parameters

name type
session dict[str, any]
def number_field_valid(payload: dict[str, any], field: str, minimum: f64, maximum: f64)

Parameters

name type
payload dict[str, any]
field str
minimum f64
maximum f64
def measurement_body(forces: any)

Parameters

name type
forces any
def tunnel_response(payload: any, session: dict[str, any]) !{}

Parameters

name type
payload any
session dict[str, any]

Effects !{}

On-demand wind-tunnel measurement: the car is held, the stream runs, the loads are integrated.

def step_response(payload: any, session: dict[str, any]) !{}

Parameters

name type
payload any
session dict[str, any]

Effects !{}

def reset_session(session: dict[str, any])

Parameters

name type
session dict[str, any]
def reset_response(session: dict[str, any]) !{}

Parameters

name type
session dict[str, any]

Effects !{}

def candidate_response(session: dict[str, any]) !{}

Parameters

name type
session dict[str, any]

Effects !{}

def commit_response(payload: any, session: dict[str, any]) !{}

Parameters

name type
payload any
session dict[str, any]

Effects !{}

def assembly_step_response(payload: any, session: dict[str, any]) !{}

Parameters

name type
payload any
session dict[str, any]

Effects !{}

def sweep_state_body(session: dict[str, any])

Parameters

name type
session dict[str, any]
def regime_sweep_response(payload: any, session: dict[str, any]) !{}

Parameters

name type
payload any
session dict[str, any]

Effects !{}

Arm or disarm the bounded wind-yaw demonstration; it never advances the causal sequence itself.

Anything short of an explicit {"action": "start"} disarms, so an empty body, {}, an explicit stop or a cancel flag all reliably stop the demonstration.

def configuration_response(payload: any, session: dict[str, any]) !{}

Parameters

name type
payload any
session dict[str, any]

Effects !{}

def export_response(session: dict[str, any]) !{}

Parameters

name type
session dict[str, any]

Effects !{}

def authoring_response(payload: any) !{}

Parameters

name type
payload any

Effects !{}

def live_response(request: dict[str, any], session: dict[str, any]) !{}

Parameters

name type
request dict[str, any]
session dict[str, any]

Effects !{}

def serve_live(port: int) -> None !{net.listen}

Parameters

name type
port int

Returns None

Effects !{net.listen}

CircuitFrame Lab automotive digital-twin entrypoint.

def main() !{net.listen}

Effects !{net.listen}

Printed-circuit design and DC electrical analysis for the CircuitFrame boards.

Four boards are declared at component, pin, net, trace and via level and then analysed:

  • DC modified nodal analysis. Copper branch conductances come from geometry (R = rho * L / (w * t), rho corrected by the copper temperature coefficient), every net’s feed pin is held at its declared operating potential, declared loads inject current at their sink pin and return it at their return pin, and the reference net pin is the 0 V node. G v = i is solved with solve(matrix, rhs).
  • IPC-2221 external/internal conductor sizing plus the inverted temperature-rise relation, evaluated per trace against the layer’s copper weight.
  • IEC 60664-1 creepage screening on the 800 V board.
  • A geometric design-rule check (track width, annular ring, pads inside outline).

Every board is an engineered reconstruction published for the CircuitFrame research demo. None of it is an OEM design, an OEM netlist, or a released manufacturing data set. Part numbers name the device class the reconstruction is dimensioned against.

Variants

  • signal
  • plane
  • core
  • prepreg
  • soldermask
  • coverlay

Variants

  • hv
  • power
  • ground
  • signal
  • can
  • ethernet
  • analog

Variants

  • power
  • ground
  • signal
  • analog
  • thermal
  • nc

Fields

field type descriptor
id str
name str
function PcbLayerFunction
copper_um f64
dielectric_mm f64
dielectric_er f64
material str

Fields

field type descriptor
id str
name str
layers list[PcbLayer]
total_thickness_mm f64
ipc_class int
impedance_target_ohm f64

Fields

field type descriptor
refdes str
part_number str
description str
footprint str
pin_count int
x_mm f64
y_mm f64
rotation_deg f64
power_dissipation_w f64
mass_g f64

Fields

field type descriptor
id str
refdes str
number int
name str
net_id str
x_mm f64
y_mm f64
kind PcbPinKind

Fields

field type descriptor
id str
name str
net_class PcbNetClass
pin_ids list[str]
nominal_voltage_v f64
current_a f64
is_reference bool

Fields

field type descriptor
id str
net_id str
layer_id str
width_mm f64
length_mm f64
current_a f64
from_pin_id str
to_pin_id str
resistance_mohm f64
voltage_drop_mv f64
power_loss_mw f64
temperature_rise_c f64
ipc_min_width_mm f64
compliant bool

Fields

field type descriptor
id str
net_id str
drill_mm f64
pad_mm f64
from_layer_id str
to_layer_id str
current_a f64
resistance_mohm f64

Fields

field type descriptor
id str
name str
host_part_id str
stackup PcbStackup
components list[PcbComponent]
pins list[PcbPin]
nets list[PcbNet]
traces list[PcbTrace]
vias list[PcbVia]
outline_width_mm f64
outline_height_mm f64
zone str
substrate str

Fields

field type descriptor
board_id str
node_count int
branch_count int
source_node str
worst_net_id str
worst_voltage_drop_mv f64
worst_drop_fraction f64
total_copper_loss_w f64
max_temperature_rise_c f64
ipc_violations int
drc_violations int
isolation_violations int
solver_residual f64
kcl_residual_a f64

Fields

field type descriptor
net_id str
layer_id str
width_mm f64
topology str
via_count int
via_drill_mm f64
via_pad_mm f64
via_layer_id str

Fields

field type descriptor
id str
net_id str
layer_id str
width_mm f64
from_pin_id str
to_pin_id str
via_count int
via_drill_mm f64
via_pad_mm f64
via_layer_id str

Fields

field type descriptor
id str
board_id str
pin_id str
return_pin_id str
current_a f64
description str

Fields

field type descriptor
node_ids list[str]
node_voltages list[f64]
branch_currents list[f64]
fixed_count int
solver_residual f64
kcl_residual_a f64
source_current_a f64

Fields

field type descriptor
id str
board_id str
category str
from_pin_id str
to_pin_id str
working_voltage_v f64
required_mm f64
measured_mm f64
reinforced bool
compliant bool
def operating_resistivity() !{}

Effects !{}

def net_class_key(net_class: PcbNetClass)

Parameters

name type
net_class PcbNetClass
def layer_function_key(function: PcbLayerFunction)

Parameters

name type
function PcbLayerFunction
def pin_kind_key(kind: PcbPinKind)

Parameters

name type
kind PcbPinKind
def pcb_pin(refdes: str, number: int, name: str, net_id: str, x_mm: f64, y_mm: f64, kind: PcbPinKind)

Parameters

name type
refdes str
number int
name str
net_id str
x_mm f64
y_mm f64
kind PcbPinKind
def pcb_load(board_id: str, pin_id: str, return_pin_id: str, current_a: f64, description: str)

Parameters

name type
board_id str
pin_id str
return_pin_id str
current_a f64
description str
def route_points(x1: f64, y1: f64, x2: f64, y2: f64)

Parameters

name type
x1 f64
y1 f64
x2 f64
y2 f64
def polyline_length_mm(points: list[list[f64]])

Parameters

name type
points list[list[f64]]
def pin_index_map(pins: list[PcbPin]) -> dict[str, int]

Parameters

name type
pins list[PcbPin]

Returns dict[str, int]

def layer_depth_mm(stackup: PcbStackup, layer_id: str)

Parameters

name type
stackup PcbStackup
layer_id str
def layer_copper_um(stackup: PcbStackup, layer_id: str)

Parameters

name type
stackup PcbStackup
layer_id str
def layer_is_external(stackup: PcbStackup, layer_id: str)

Parameters

name type
stackup PcbStackup
layer_id str
def stackup_thickness_mm(layers: list[PcbLayer])

Parameters

name type
layers list[PcbLayer]
def ipc_min_width_mm(current_a: f64, copper_um: f64, external: bool) !{}

Parameters

name type
current_a f64
copper_um f64
external bool

Effects !{}

def ipc_temperature_rise_c(current_a: f64, width_mm: f64, copper_um: f64, external: bool) !{}

Parameters

name type
current_a f64
width_mm f64
copper_um f64
external bool

Effects !{}

def iec60664_creepage_mm(working_voltage_v: f64)

Parameters

name type
working_voltage_v f64
def route_plan_for(net: PcbNet, defaults: list[PcbRoutePlan], overrides: list[PcbRoutePlan])

Parameters

name type
net PcbNet
defaults list[PcbRoutePlan]
overrides list[PcbRoutePlan]
def build_segments(nets: list[PcbNet], defaults: list[PcbRoutePlan], overrides: list[PcbRoutePlan])

Parameters

name type
nets list[PcbNet]
defaults list[PcbRoutePlan]
overrides list[PcbRoutePlan]
def segment_polyline(segment: PcbSegment, pins: list[PcbPin], index_of: dict[str, int])

Parameters

name type
segment PcbSegment
pins list[PcbPin]
index_of dict[str, int]
def single_via_resistance_ohm(stackup: PcbStackup, segment: PcbSegment) !{}

Parameters

name type
stackup PcbStackup
segment PcbSegment

Effects !{}

def via_series_resistance_ohm(stackup: PcbStackup, segment: PcbSegment) !{}

Parameters

name type
stackup PcbStackup
segment PcbSegment

Effects !{}

def copper_resistances_ohm(stackup: PcbStackup, segments: list[PcbSegment], lengths_mm: list[f64]) !{}

Parameters

name type
stackup PcbStackup
segments list[PcbSegment]
lengths_mm list[f64]

Effects !{}

def solve_linear(matrix: list[list[f64]], rhs: list[f64])

Parameters

name type
matrix list[list[f64]]
rhs list[f64]
def solve_network(pins: list[PcbPin], nets: list[PcbNet], segments: list[PcbSegment], resistances_ohm: list[f64], loads: list[PcbLoad])

Parameters

name type
pins list[PcbPin]
nets list[PcbNet]
segments list[PcbSegment]
resistances_ohm list[f64]
loads list[PcbLoad]
def assemble_board(board_id: str, name: str, host_part_id: str, zone: str, substrate: str, stackup: PcbStackup, components: list[PcbComponent], pins: list[PcbPin], nets: list[PcbNet], defaults: list[PcbRoutePlan], overrides: list[PcbRoutePlan], loads: list[PcbLoad], outline_width_mm: f64, outline_height_mm: f64) !{}

Parameters

name type
board_id str
name str
host_part_id str
zone str
substrate str
stackup PcbStackup
components list[PcbComponent]
pins list[PcbPin]
nets list[PcbNet]
defaults list[PcbRoutePlan]
overrides list[PcbRoutePlan]
loads list[PcbLoad]
outline_width_mm f64
outline_height_mm f64

Effects !{}

def bms_slave_stackup()
def bms_slave_components()
def bms_slave_pins()
def bms_slave_nets()
def bms_slave_plans()
def bms_slave_overrides()
def bms_slave_loads()
def bms_slave_board() !{}

Effects !{}

def front_zone_stackup()
def front_zone_components()
def front_zone_pins()
def front_zone_12v_load_a()
def front_zone_5v_load_a()
def front_zone_3v3_load_a()
def front_zone_48v_load_a()
def front_zone_nets()
def front_zone_plans()
def front_zone_overrides()
def front_zone_loads()
def front_zone_board() !{}

Effects !{}

def hv_junction_stackup()
def hv_junction_components()
def hv_junction_pins()
def hv_junction_12v_load_a()
def hv_junction_5v_load_a()
def hv_junction_nets()
def hv_junction_plans()
def hv_junction_overrides()
def hv_junction_loads()
def hv_junction_board() !{}

Effects !{}

def sill_panel_stackup()
def sill_panel_components()
def sill_panel_pins()
def sill_panel_nets()
def sill_panel_plans()
def sill_panel_overrides()
def sill_panel_loads()
def sill_panel_board() !{}

Effects !{}

def board_loads(board_id: str)

Parameters

name type
board_id str
def trace_branch_resistance_ohm(board: PcbBoard, trace: PcbTrace)

Parameters

name type
board PcbBoard
trace PcbTrace
def board_solution(board: PcbBoard)

Parameters

name type
board PcbBoard
def node_voltage(solution: PcbSolution, pin_id: str)

Parameters

name type
solution PcbSolution
pin_id str
def net_drop_v(board: PcbBoard, solution: PcbSolution, net: PcbNet)

Parameters

name type
board PcbBoard
solution PcbSolution
net PcbNet
def board_drc_violations(board: PcbBoard)

Parameters

name type
board PcbBoard
def isolated_domain_nets(board_id: str)

Parameters

name type
board_id str
def isolation_required_mm(same_domain: bool, potential_difference_v: f64)

Parameters

name type
same_domain bool
potential_difference_v f64
def net_lookup(nets: list[PcbNet]) -> dict[str, int]

Parameters

name type
nets list[PcbNet]

Returns dict[str, int]

def pcb_isolation_checks(board: PcbBoard)

Parameters

name type
board PcbBoard
def analyze_solved(board: PcbBoard, field: PcbSolution, gates: list[PcbIsolationCheck])

Parameters

name type
board PcbBoard
field PcbSolution
gates list[PcbIsolationCheck]
def pcb_analyze(board: PcbBoard)

Parameters

name type
board PcbBoard
def pcb_netlist_text(board: PcbBoard)

Parameters

name type
board PcbBoard
def pcb_boards() !{}

Effects !{}

def component_kind(refdes: str)

Parameters

name type
refdes str
def component_extent(board: PcbBoard, refdes: str)

Parameters

name type
board PcbBoard
refdes str
def pad_extent(footprint: str)

Parameters

name type
footprint str
def layer_kind(layer: PcbLayer)

Parameters

name type
layer PcbLayer
def layer_color(index: int, layer: PcbLayer)

Parameters

name type
index int
layer PcbLayer
def substrate_kind(board_id: str)

Parameters

name type
board_id str
def viewer_net_class(net_class: PcbNetClass)

Parameters

name type
net_class PcbNetClass
def vehicle_net_for(net_id: str)

Parameters

name type
net_id str
def board_pours(board: PcbBoard)

Parameters

name type
board PcbBoard
def board_export(board: PcbBoard)

Parameters

name type
board PcbBoard
def pcb_export() -> dict[str, any] !{}

Returns dict[str, any]

Effects !{}

def pcb_equations() -> list[dict[str, str]] !{}

Returns list[dict[str, str]]

Effects !{}

Coupled electric-vehicle dynamics over the panel-solved aerodynamic field.

Aerodynamic loads come from aero.sema: a constant-strength source-panel solve with an image ground plane, closed by an empirical viscous and base-pressure model. Mass, centre of gravity and yaw inertia come from assembly.sema, summed from the declared part placements rather than typed in as literals. The thermal state is the eight-node coolant network from the same module, so heat moves between components instead of each one sinking independently to ambient.

Two reduced-order surrogates are fitted to the panel solve at startup and are continuously scored against it while the vehicle runs; that residual is what drives the model-adaptation lifecycle. The dynamics themselves always integrate the solved loads, never the surrogate.

Fields

field type descriptor
throttle f64
brake f64
steering f64
wind_mps f64
wind_yaw_deg f64

Fields

field type descriptor
time_s f64
distance_m f64
speed_mps f64
lateral_speed_mps f64
acceleration_mps2 f64
lateral_acceleration_mps2 f64
yaw_rate_rps f64
yaw_angle_rad f64
steering_angle_rad f64
battery_soc f64
thermal_temps_c list[f64]
battery_temp_c f64
motor_temp_c f64
inverter_temp_c f64
coolant_temp_c f64
cabin_temp_c f64
tire_temp_c f64
front_disc_temp_c f64
rear_disc_temp_c f64
motor_rpm f64
model_id str

Body-axis aerodynamic loads. Positive drag resists forward travel; positive lift is upward.

Fields

field type descriptor
air_speed_mps f64
sideslip_rad f64
dynamic_pressure_pa f64
cd f64
cy f64
cl f64
cmz f64
drag_n f64
side_n f64
lift_n f64
front_lift_n f64
rear_lift_n f64
yaw_moment_nm f64

Even-in-yaw drag and lift, odd-in-yaw side force and yaw moment, fitted to the panel solve.

Fields

field type descriptor
model_id str
cd0 f64
cd2 f64
cd4 f64
cy1 f64
cy3 f64
cl0 f64
cl2 f64
cl4 f64
cmz1 f64
cmz3 f64
fit_max_yaw_rad f64
fit_nodes int
fit_rms_cd f64

Inertial and geometric parameters derived from the declared assembly, not typed in.

Fields

field type descriptor
mass_kg f64
cg_to_front_axle_m f64
cg_to_rear_axle_m f64
cg_height_m f64
yaw_inertia_kg_m2 f64
wheelbase_m f64
front_mass_fraction f64
track_m f64

Fields

field type descriptor
active_training_error f64
candidate_training_error f64
active_validation_error f64
candidate_validation_error f64

One wheel station read out of the declared double-wishbone closure.

Fields

field type descriptor
vertical_load_n f64
wheel_travel_m f64
camber_deg f64
toe_deg f64
damper_length_m f64
spring_force_n f64
travel_utilisation f64

The four wheel stations, each solved at its own vertical load.

Lateral load transfer moves normal force onto the outside pair, so the closure returns different travel, camber and toe per side. With no lateral acceleration the two sides are identical.

Fields

field type descriptor
front_left CornerSuspension
front_right CornerSuspension
rear_left CornerSuspension
rear_right CornerSuspension

Corner closures pre-solved on a load grid, in strictly increasing load order.

Fields

field type descriptor
loads_n list[f64]
corners list[CornerSuspension]

One axle’s longitudinal state: what its hardware could do, and what the tyres allowed.

Both the brake and the drive side report which declared component set the number. At the tyre limit a stop is mass-independent; the moment the caliper is the binding constraint it is not, and the difference is visible here rather than folded into one force.

Fields

field type descriptor
axle str
normal_force_n f64
friction_coefficient f64
tire_limit_n f64
brake_capability_n f64
brake_force_n f64
brake_constraint str
motor_force_n f64
power_force_n f64
drive_force_n f64
drive_constraint str
cornering_stiffness_n_rad f64
disc_temp_c f64
fade_fraction f64

The live inputs one longitudinal solve needs, flattened out of the state and the aero solve.

Built once per step and handed to the four axle solves. Everything on it is a scalar so that the per-call copy the interpreter makes stays cheap.

Fields

field type descriptor
throttle f64
brake f64
power_limit f64
speed_mps f64
previous_acceleration_mps2 f64
front_disc_temp_c f64
rear_disc_temp_c f64
front_lift_n f64
rear_lift_n f64
lift_n f64
drag_n f64

Both axles solved together under one load transfer, with the resulting body acceleration.

Fields

field type descriptor
front AxleLongitudinal
rear AxleLongitudinal
rolling_force_n f64
brake_line_pressure_pa f64
brake_force_n f64
drive_force_n f64
motor_torque_nm f64
acceleration_mps2 f64
limiting_constraint str

Everything expensive is built once here: the panel basis, the mass budget and both surrogates.

Fields

field type descriptor
basis AeroBasis
parameters VehicleParameters
calm AeroSurrogate
crosswind AeroSurrogate
holdout AeroHoldout
suspension SuspensionTable
hardware HardwareLimits

Fields

field type descriptor
state VehicleState
controls ControlInput
sequence int
apparent_air_speed_mps f64
sideslip_rad f64
dynamic_pressure_pa f64
solved AeroLoads
surrogate AeroLoads
model_cd f64
reference_cd f64
cd_pressure f64
cd_base f64
cd_friction f64
cd_wheels f64
drag_force_n f64
lift_force_n f64
side_force_n f64
aero_yaw_moment_nm f64
rolling_force_n f64
brake_force_n f64
regenerative_force_n f64
drive_force_n f64
wheel_torque_nm f64
tire_slip_ratio f64
front_axle_normal_force_n f64
rear_axle_normal_force_n f64
tire_utilization_fraction f64
front_axle AxleLongitudinal
rear_axle AxleLongitudinal
limiting_constraint str
brake_line_pressure_pa f64
brake_fade_fraction f64
front_disc_temp_c f64
rear_disc_temp_c f64
suspension SuspensionCorners
mechanical_power_kw f64
electrical_power_kw f64
regenerative_power_kw f64
battery_power_kw f64
battery_loss_kw f64
motor_loss_kw f64
inverter_loss_kw f64
pack_open_circuit_voltage_v f64
pack_voltage_v f64
pack_current_a f64
pack_resistance_ohm f64
power_limit_fraction f64
power_residual_kw f64
aero_residual_fraction f64
reynolds_number f64
separation_x_m f64
cp_min f64
cp_max f64
base_pressure_coefficient f64
wake_width_m f64
wake_shedding_hz f64
turbulence_intensity f64
wake_velocity_deficit_fraction f64
wake_recirculation_length_m f64
dalembert_residual_cd f64
aero_solver_residual f64
aero_force_closure_residual f64
thermal_energy_residual_w f64
energy_residual_fraction f64
def vehicle_parameters()

Inertia and geometry summed from the declared part placements in assembly.sema.

def least_squares_pair(u: list[f64], y: list[f64]) !{}

Parameters

name type
u list[f64]
y list[f64]

Effects !{}

Fit y ~ au + bu^2 through the origin by the 2x2 normal equations.

def least_squares_single(u: list[f64], y: list[f64])

Parameters

name type
u list[f64]
y list[f64]

Fit y ~ a*u through the origin.

def yaw_fit_nodes(count: int, maximum_deg: f64)

Parameters

name type
count int
maximum_deg f64

Yaw stations in radians, evenly spaced from zero to the declared fit boundary.

def fit_surrogate(basis: AeroBasis, model_id: str, count: int, maximum_deg: f64, quartic: bool) !{}

Parameters

name type
basis AeroBasis
model_id str
count int
maximum_deg f64
quartic bool

Effects !{}

Least-squares fit of the panel solve over a bounded yaw window.

Drag and lift are even in sideslip, side force and yaw moment are odd, so the basis functions are beta^2/beta^4 and beta/beta^3 respectively. The zero-yaw intercepts are taken exactly from the solve so the calibrated C_d(0) is preserved without relying on the fit.

def surrogate_loads(surrogate: AeroSurrogate, air_speed_mps: f64, sideslip_rad: f64) !{}

Parameters

name type
surrogate AeroSurrogate
air_speed_mps f64
sideslip_rad f64

Effects !{}

Evaluate the reduced-order coefficients. Cheap enough to run inside any control loop.

def solved_loads(forces: AeroForces)

Parameters

name type
forces AeroForces
def surrogate_for(context: PhysicsContext, model_id: str)

Parameters

name type
context PhysicsContext
model_id str
def relative_drag_error(basis: AeroBasis, surrogate: AeroSurrogate, degrees: list[f64]) !{}

Parameters

name type
basis AeroBasis
surrogate AeroSurrogate
degrees list[f64]

Effects !{}

Mean relative drag-coefficient error of a surrogate against the panel solve.

def physics_context() -> PhysicsContext !{}

Returns PhysicsContext

Effects !{}

Build the panel basis, the mass budget and both surrogates. Call once at startup.

def aero_holdout_metrics(context: PhysicsContext) -> dict[str, f64] !{}

Parameters

name type
context PhysicsContext

Returns dict[str, f64]

Effects !{}

Training and held-out surrogate errors measured against the live panel solve.

def initial_vehicle_state() -> VehicleState !{}

Returns VehicleState

Effects !{}

def state_at(speed_mps: f64, battery_soc: f64)

Parameters

name type
speed_mps f64
battery_soc f64

A settled state at one road speed and state of charge, with cold discs and cold tyres.

def state_carrying(state: VehicleState, speed_mps: f64)

Parameters

name type
state VehicleState
speed_mps f64

The same thermal and electrical state brought back to speed, for a repeated-stop sequence.

Only the motion resets. Disc, tyre, pack and coolant temperatures carry over, which is the whole point: the heat a stop leaves behind is what the next one starts from.

def with_added_mass(context: PhysicsContext, added_kg: f64)

Parameters

name type
context PhysicsContext
added_kg f64

The same vehicle carrying more mass: everything else declared stays exactly as it is.

Only the inertial parameters move. The motors, calipers, discs and tyres are the ones the parts list declares, which is the whole point of the comparison: what does the declared hardware do when it has more mass to move and to stop.

def apparent_wind(state: VehicleState, controls: ControlInput)

Parameters

name type
state VehicleState
controls ControlInput

Apparent-wind magnitude and sideslip in the vehicle frame.

A head-on tunnel wind and forward road speed add; wind_yaw_deg rotates the tunnel wind around the vehicle, so a parked car in a 27.8 m/s stream reports 27.8 m/s of apparent wind at that yaw.

def corner_state(state: SuspensionState)

Parameters

name type
state SuspensionState

Narrow a solved corner to the fields the dynamics frame publishes.

def suspension_table()

Pre-solve the declared corner closure on a grid uniform in lower-arm rotation.

suspension_travel bisects the arm rotation against the polytropic air spring, which costs far more than the whole rest of a 20 Hz step. Sampling it once at startup and interpolating keeps the loop inside its budget without inventing a second suspension model. Rotation-uniform spacing puts the nodes where the linkage actually moves: the air spring stiffens so sharply that a load-uniform grid would collapse the working range into a couple of intervals. Rotations that clamp to the same load at a stop are dropped, so the lookup never meets a zero-width interval.

def interpolate_corner(table: SuspensionTable, vertical_load_n: f64)

Parameters

name type
table SuspensionTable
vertical_load_n f64

Read the pre-solved corner grid at one vertical wheel load, linearly between nodes.

def clamped_disc_temp(value: f64)

Parameters

name type
value f64

Hold an integrated disc temperature inside the range the state invariant declares.

def axle_longitudinal(hardware: AxleHardware, demand: LongitudinalDemand, normal_force_n: f64, disc_temp_c: f64) !{}

Parameters

name type
hardware AxleHardware
demand LongitudinalDemand
normal_force_n f64
disc_temp_c f64

Effects !{}

One axle solved against its own hardware and its own live vertical load.

The brake side walks the real chain: pedal demand sets line pressure, pressure and piston area set clamp force, pad friction and the effective radius set corner torque, and the rolling radius turns that into a wheel force. All of that is fixed by the declared components, so hardware arrives with it already solved and only the pedal fraction and the fade factor are applied here. The tyre side is the friction circle on this axle’s live load, with the declared load sensitivity, so a heavier corner returns less grip per newton. Whichever of the two is smaller is the force the axle makes, and which one it was is published.

def axle_pass(front_hardware: AxleHardware, rear_hardware: AxleHardware, demand: LongitudinalDemand, static_front_n: f64, static_rear_n: f64, transfer_n: f64) !{}

Parameters

name type
front_hardware AxleHardware
rear_hardware AxleHardware
demand LongitudinalDemand
static_front_n f64
static_rear_n f64
transfer_n f64

Effects !{}

Both axles solved at the load transfer one candidate acceleration implies.

def longitudinal_solution(parameters: VehicleParameters, hardware: HardwareLimits, demand: LongitudinalDemand) !{}

Parameters

name type
parameters VehicleParameters
hardware HardwareLimits
demand LongitudinalDemand

Effects !{}

Solve both axles and the body acceleration together, since each one sets the other.

Load transfer depends on the acceleration, and the acceleration depends on the per-axle limits that the transferred loads produce. One predictor from the previous step’s acceleration and one corrector from this step’s is enough at 20 Hz: the transfer moves by well under a percent over the second pass, and a fixed point is not worth the extra arithmetic inside the loop.

Nothing larger than a flat scalar struct crosses these call boundaries. The interpreter copies a struct argument by value, and PhysicsContext carries the whole panel basis, so handing it to a helper that runs four times a step costs milliseconds out of a fifty-millisecond budget.

def step_vehicle(state: VehicleState, controls: ControlInput, duration_s: f64, sequence: int, model_id: str, context: PhysicsContext) -> DynamicsFrame !{}

Parameters

name type
state VehicleState
controls ControlInput
duration_s f64
sequence int
model_id str
context PhysicsContext

Returns DynamicsFrame

Effects !{}

One full-pedal stop, integrated at the same 20 Hz the live loop runs at.

Fields

field type descriptor
distance_m f64
duration_s f64
mean_deceleration_mps2 f64
front_constraint str
rear_constraint str
front_disc_temp_c f64
rear_disc_temp_c f64
peak_brake_force_n f64

One full-throttle launch to a target speed, integrated at the same 20 Hz.

Fields

field type descriptor
duration_s f64
distance_m f64
launch_constraint str
final_constraint str
peak_drive_force_n f64
def measured_stop(context: PhysicsContext, initial_speed_mps: f64)

Parameters

name type
context PhysicsContext
initial_speed_mps f64

Brake from one speed to rest at full pedal and report what the vehicle actually did.

def measured_launch(context: PhysicsContext, target_speed_mps: f64)

Parameters

name type
context PhysicsContext
target_speed_mps f64

Accelerate from rest to one speed at full throttle and report what the hardware allowed.

def dynamics_equations() -> list[dict[str, str]] !{}

Returns list[dict[str, str]]

Effects !{}

Automotive-native major-assembly vehicle, E/E, and manufacturing definition.

def variant_value(configuration_variant: str, conventional: str, circuitframe: str)

Parameters

name type
configuration_variant str
conventional str
circuitframe str
def vehicle_identity() -> VehicleIdentity !{}

Returns VehicleIdentity

Effects !{}

def vehicle_parts() -> list[PartDefinition] !{}

Returns list[PartDefinition]

Effects !{}

def vehicle_nets(configuration_variant: str) -> list[NetDefinition] !{}

Parameters

name type
configuration_variant str

Returns list[NetDefinition]

Effects !{}

def net_routing() -> list[dict[str, any]]

Returns list[dict[str, any]]

Per-net conductor routing for both configurations.

Lengths, terminations and splice counts are bounded engineered assumptions derived from the packaging of the reconstructed vehicle; they are not measured production harness data. The CircuitFrame column routes through the structure, so it is shorter and needs fewer separable interfaces, which is the claim this comparison exists to quantify.

def conductor_variant(net: NetDefinition, route: list[f64], density_a_mm2: f64, jacket_factor: f64, conductor: str, route_text: str) !{}

Parameters

name type
net NetDefinition
route list[f64]
density_a_mm2 f64
jacket_factor f64
conductor str
route_text str

Effects !{}

Size one conductor run from its declared current and report the resulting electrical loads.

def vehicle_net_comparison() -> list[dict[str, any]] !{}

Returns list[dict[str, any]]

Effects !{}

Conventional harness versus CircuitFrame structural conductors over the same nine nets.

def manufacturing_operations(configuration_variant: str)

Parameters

name type
configuration_variant str
def validation_cases()
def evidence_records()
def source_records()
def source_bindings()
def source_gate() -> dict[str, any] !{}

Returns dict[str, any]

Effects !{}

def completeness_report(configuration_variant: str) -> CompletenessReport !{}

Parameters

name type
configuration_variant str

Returns CompletenessReport

Effects !{}

def vehicle_manifest(configuration_variant: str) -> dict[str, any] !{}

Parameters

name type
configuration_variant str

Returns dict[str, any]

Effects !{}