import math
from pydantic import ValidationError
from ..constants import G, in_to_m
from ..models import SimulationState, TireForce, TireForces
from . import (
accumulator,
aerodynamics,
chassis,
driver_interface,
powertrain,
suspension,
)
from .subsystem_models import DriveSetup
from .vehicle_models import ComplexSuspensionModel, SimpleSuspensionModel, VehicleModel
[docs]
class Car:
"""Composes all vehicle subsystems and simulates the car moving through a corner."""
def __init__(self, vehicle_model: VehicleModel) -> None:
"""Build all vehicle subsystems from a validated vehicle model.
Parameters
----------
vehicle_model : VehicleModel
Full vehicle configuration.
"""
self.params = vehicle_model
# Construct all subsystems from vehicle_model
self.tires = suspension.Tires(vehicle_model=vehicle_model)
# Construct suspension based on type
self.sus: suspension.Suspension
match vehicle_model.sus:
case SimpleSuspensionModel():
self.sus = suspension.SimpleSuspension(vehicle_model=vehicle_model)
case ComplexSuspensionModel():
self.sus = suspension.ComplexSuspension(vehicle_model=vehicle_model)
self.aero = aerodynamics.Aero(vehicle_model=vehicle_model)
self.pwrtn = powertrain.Powertrain(vehicle_model=vehicle_model)
self.motor = self.pwrtn.motor # Alias for convenience
self.accum = accumulator.Accumulator(vehicle_model=vehicle_model)
self.dri = driver_interface.DriverInterface(vehicle_model=vehicle_model)
self.chass = chassis.Chassis(vehicle_model=vehicle_model)
# initialize longitudinal-mass-equivalent of MOI
self.moi_mass_equiv = self.convert_rotating_mass_to_linear_mass()
[docs]
def calculate_top_speed(
self, max_steps: int = 20, v_low: float = 0, v_high: float = 50
) -> float:
"""Binary search for the velocity where drag and rolling resistance equal max motor force.
Parameters
----------
max_steps : int
Maximum number of binary search iterations.
v_low : float
Lower bound of the search interval (m/s).
v_high : float
Upper bound of the search interval (m/s).
Returns
-------
float
Estimated top speed (m/s).
"""
for _ in range(max_steps):
# Midpoint velocity
v_current = (v_low + v_high) / 2.0
# Update forces at this velocity
forces = self.calculate_tire_forces(acc=0, lat_acc=0, roll=0, pitch=0, vel=v_current)
rolling_resistance = forces.total_normal * self.params.rolling_coeff
drag = self.aero.get_drag(v_current, 0)
motor_f, _ = self.motor.calculate_max_ground_force_and_motor_power(
v_current, self.params.pwrtn.ratio
)
total_resist = rolling_resistance + drag
# Binary search adjustment
if motor_f > total_resist:
v_low = v_current # motor can overcome resistance, try higher speed
else:
v_high = v_current # resistance too high, try lower speed
return (v_low + v_high) / 2.0
[docs]
def convert_rotating_mass_to_linear_mass(self) -> float:
"""Compute the linear-mass equivalent of all rotating inertia (tires, rims, motor, gearbox).
Returns
-------
float
Equivalent linear mass (kg), i.e. rotating inertia J divided by
tire radius squared, summed across all rotating components.
"""
r2 = in_to_m(self.params.tires.tire_radius) ** 2
# Tire+rim mass equiv. Each param specs one tire/rim
m_eq_tire = self.params.tires.moi_tire / r2
m_eq_rim = self.params.tires.moi_rim / r2
# Powertrain mass-equiv
m_eq_motor = self.params.pwrtn.motor.moi_motor * self.params.pwrtn.ratio**2 / r2
# motor MOI must be reflected through gear ratio
m_eq_gb = self.params.pwrtn.moi_gb / r2
m_eq_pwrtn = m_eq_motor + m_eq_gb
# Multiply by 4 if AWD
if self.params.pwrtn.motor.setup == DriveSetup.FOUR_WHEEL_DRIVE:
m_eq_pwrtn *= 4
# Combine
m_eq_total = m_eq_pwrtn + 4 * (m_eq_tire + m_eq_rim)
return m_eq_total
[docs]
def car_weight_distributor(
self,
backwards_pass: bool = False,
*,
acc: float,
lat_acc: float,
) -> tuple[float, float, float, float]:
"""Compute weight transfer and return each tire's normal force.
Parameters
----------
backwards_pass : bool
Whether this is a backwards (braking) integration pass; flips the
sign of the longitudinal acceleration used for weight transfer.
acc : float
Longitudinal acceleration (m/s^2).
lat_acc : float
Lateral acceleration (m/s^2).
Returns
-------
tuple of float
Normal forces (N) for the front-left, front-right, rear-left,
and rear-right tires.
"""
long_weight_f_delta, long_weight_r_delta = self.sus.longitudinal_weight_transfer(
wheelbase=self.params.wb,
total_mass=self.params.mass,
cg_height=self.params.cg_h,
long_acc_z=-acc if backwards_pass else acc,
)
lat_weight_f_left_delta, lat_weight_f_right_delta = self.sus.lateral_weight_transfer(
axle="front",
axle_track=self.params.front_track,
m_front=self.params.w_distr_front * self.params.mass,
m_rear=self.params.w_distr_b * self.params.mass,
cg_height=self.params.cg_h,
lat_acc_x=lat_acc,
)
lat_weight_r_left_delta, lat_weight_r_right_delta = self.sus.lateral_weight_transfer(
axle="rear",
axle_track=self.params.rear_track,
m_front=self.params.w_distr_front * self.params.mass,
m_rear=self.params.w_distr_b * self.params.mass,
cg_height=self.params.cg_h,
lat_acc_x=lat_acc,
)
# Calculate tire normal forces
fl = fr = self.params.mass * G * (self.params.w_distr_front) / 2
bl = br = self.params.mass * G * (self.params.w_distr_b) / 2
# Apply weight transfer to tire normal forces.
# The longitudinal helper returns the inter-axle transfer (m·a·h/L) —
# i.e. the total load shifted off the front axle / onto the rear axle —
# so each of the two tires on an axle gets half. The lateral helper
# already returns a per-tire delta (one tire per side per axle), so no
# split is needed there.
fr += long_weight_f_delta / 2 + lat_weight_f_right_delta
fl += long_weight_f_delta / 2 + lat_weight_f_left_delta
br += long_weight_r_delta / 2 + lat_weight_r_right_delta
bl += long_weight_r_delta / 2 + lat_weight_r_left_delta
return fl, fr, bl, br
[docs]
def calculate_tire_forces(
self,
acc: float,
lat_acc: float,
roll: float,
pitch: float,
vel: float,
backwards_pass: bool = False,
) -> TireForces:
"""Compute the forces on each tire from weight transfer, aero lift and cornering.
Parameters
----------
acc : float
Longitudinal acceleration (m/s^2).
lat_acc : float
Lateral acceleration (m/s^2).
roll : float
Roll angle (rad).
pitch : float
Pitch angle (rad).
vel : float
Vehicle velocity (m/s).
backwards_pass : bool
Whether this is a backwards (braking) integration pass.
Returns
-------
TireForces
Normal and lateral forces on all four tires.
"""
# Normal force on each tire, from weight transfer plus aero lift
weight_fl, weight_fr, weight_bl, weight_br = self.car_weight_distributor(
backwards_pass, acc=acc, lat_acc=lat_acc
)
lift_fl, lift_fr, lift_bl, lift_br = self.aero.tire_lift_forces(
vel, lat_acc, roll=roll, pitch=pitch
)
normal_fl = float(weight_fl + lift_fl)
normal_fr = float(weight_fr + lift_fr)
normal_bl = float(weight_bl + lift_bl)
normal_br = float(weight_br + lift_br)
if sum((normal_fl, normal_fr, normal_bl, normal_br)) == 0:
raise ValueError("Total normal force cannot be zero.")
lateral_fl, lateral_fr = self._share_axle_lateral_force(
normal_left=normal_fl,
normal_right=normal_fr,
axle_demand=self.params.w_distr_front * self.params.mass * lat_acc,
)
lateral_bl, lateral_br = self._share_axle_lateral_force(
normal_left=normal_bl,
normal_right=normal_br,
axle_demand=self.params.w_distr_b * self.params.mass * lat_acc,
)
return TireForces(
front_left=TireForce(normal=normal_fl, lateral=lateral_fl),
front_right=TireForce(normal=normal_fr, lateral=lateral_fr),
back_left=TireForce(normal=normal_bl, lateral=lateral_bl),
back_right=TireForce(normal=normal_br, lateral=lateral_br),
)
def _share_axle_lateral_force(
self, normal_left: float, normal_right: float, axle_demand: float
) -> tuple[float, float]:
"""Split one axle's lateral force demand between its two tires by available grip.
Parameters
----------
normal_left : float
Normal force on the left tire of the axle (N).
normal_right : float
Normal force on the right tire of the axle (N).
axle_demand : float
Lateral force the axle must exert (N).
Returns
-------
tuple of float
Lateral force (N) on the left and right tire.
Notes
-----
Each axle must exert enough lateral force that net yaw acceleration is
zero, i.e. F_y = m_axle * a = F_c * w_distr_axle. Each tire's share
matches the ratio of its own maximum available force to the axle's
total, so a completely unloaded axle produces 0 on both tires.
"""
max_left = self.tires.calc_mu(normal_left)[0] * normal_left
max_right = self.tires.calc_mu(normal_right)[0] * normal_right
axle_max = max_left + max_right
return (
max(axle_demand * (max_left / (axle_max + 1e-6)), 0),
max(axle_demand * (max_right / (axle_max + 1e-6)), 0),
)
[docs]
def check_all_tires_valid_lateral_force(self, forces: TireForces) -> bool:
"""Check whether every tire's lateral force is within its friction limit.
Parameters
----------
forces : TireForces
Tire forces to check.
Returns
-------
bool
True if all tires satisfy their lateral force limit.
"""
return (
self.tires.is_lateral_force_valid(forces.front_left)
and self.tires.is_lateral_force_valid(forces.front_right)
and self.tires.is_lateral_force_valid(forces.back_left)
and self.tires.is_lateral_force_valid(forces.back_right)
)
[docs]
def calculate_and_return_longitudinal_forces(self, forces: TireForces) -> list[float]:
"""Compute the remaining longitudinal force budget for each tire.
Parameters
----------
forces : TireForces
Tire forces to compute the remaining grip from.
Returns
-------
list of float
Longitudinal force budget (N) for each tire, in
front-left, front-right, rear-left, rear-right order.
"""
return [
self.tires.long_remain(forces.front_left),
self.tires.long_remain(forces.front_right),
self.tires.long_remain(forces.back_left),
self.tires.long_remain(forces.back_right),
]
[docs]
def try_accelerate_forwards_for_max_speed_calc(
self, radius: float, state: SimulationState
) -> bool:
"""Check whether the car can hold the given speed through a corner of the given radius.
Parameters
----------
radius : float
Corner radius (m).
state : SimulationState
Current simulation state.
Returns
-------
bool
True if the powertrain and tires can sustain this speed through
the corner.
Notes
-----
Approximates a steady-state weight transfer by zeroing longitudinal
acceleration for this check (a simplification pending further
investigation of longitudinal weight transfer behavior).
"""
v_current = state.v
pitch = state.pitch
roll = state.roll
lat_acc = v_current * v_current / radius
forces = self.calculate_tire_forces(
acc=0, lat_acc=lat_acc, roll=roll, pitch=pitch, vel=v_current
)
# First, we check if each tire can actually satisfy its demands.
if self.check_all_tires_valid_lateral_force(forces):
# Then, we check if the longitudinal force the tires provide exceeds the resistive force
long_forces = self.calculate_and_return_longitudinal_forces(forces)
# Check for motor force
motor_f, _ = self.motor.calculate_max_ground_force_and_motor_power(
v_current, self.params.pwrtn.ratio
)
# Calculate f_long_ptrain based on drive setup
if self.params.pwrtn.motor.setup == DriveSetup.FOUR_WHEEL_DRIVE:
# AWD logic: sum the minimum of motor force and each tire's
# force. ASSUME ALL GEAR RATIOS EQUAL.
f_long_ptrain = sum(min(motor_f, f) for f in long_forces[:])
# LUT-aware powertrain efficiency at this operating point so
# the F->P->F clip is consistent with the LUT path used inside
# calculate_max_ground_force_and_motor_power. Steady-state
# corner check, so v_current is the right velocity here (no
# v_avg, unlike the dx-step accelerate function).
# Recover per-motor shaft torque from total ground force:
# T_motor = (f_total / 4) * r / (ratio * eta_mech)
motor_rpm = self.motor.v_to_rpm(v_current, self.params.pwrtn.ratio)
tire_radius_m = in_to_m(self.params.tires.tire_radius)
eta_mech = self.motor.mechanical_efficiency()
per_motor_torque = (
f_long_ptrain * tire_radius_m / (4 * self.params.pwrtn.ratio * eta_mech)
)
eta_full = self.motor.powertrain_efficiency(motor_rpm, per_motor_torque)
# Clip total DC bus power
p_predicted = f_long_ptrain * v_current / eta_full
if p_predicted > self.accum.params.total_pow_lim:
f_long_ptrain = self.accum.params.total_pow_lim * eta_full / v_current
else:
# RWD logic: see accelerate_forwards_dx() for explanation
# TL;DR approximate an LSD
f_long_net = (
2 * min(long_forces[2], long_forces[3]) + max(long_forces[2], long_forces[3])
) * 0.666666
f_long_ptrain = min(motor_f, f_long_net)
rolling_resistance = forces.total_normal * self.params.rolling_coeff
drag = self.aero.get_drag(v_current, lat_acc)
return f_long_ptrain > rolling_resistance + drag
else:
return False
[docs]
def accelerate_forwards_dx(
self, radius: float, distance_step: float, state: SimulationState
) -> SimulationState:
"""Advance the car forward by one distance step while accelerating.
Parameters
----------
radius : float
Radius of the current corner (m).
distance_step : float
Distance to advance (m).
state : SimulationState
Current simulation state.
Returns
-------
SimulationState
Updated simulation state after the step.
"""
# Set state variables
v_current = state.v
lat_acc = v_current * v_current / radius
forces = self.calculate_tire_forces(
acc=state.acc,
lat_acc=lat_acc,
roll=state.roll,
pitch=state.pitch,
vel=v_current,
)
# Resistive forces
rolling_resistance = forces.total_normal * self.params.rolling_coeff
drag = self.aero.get_drag(v_current, lat_acc)
# First, we check if each tire can actually satisfy its demands.
if self.check_all_tires_valid_lateral_force(forces):
sliding = False
# Set the force to be the least grippy tire's f_long times two (open diff)
long_forces = self.calculate_and_return_longitudinal_forces(forces)
# in the case of rear wheel drive, it's very magic#-y to
# exactly model a diff. Instead, we will assume a weighted avg
# of open vs closed diff; still magic#-y but very simple.
f_long_net = (
2 * min(long_forces[2], long_forces[3]) + max(long_forces[2], long_forces[3])
) * 0.666666
# f_long_net = 2 * min(long_forces[2], long_forces[3])
# Restrict the acceleration with the motor force limit at this speed
motor_f, _ = self.motor.calculate_max_ground_force_and_motor_power(
v_current, self.params.pwrtn.ratio
) # in the case of awd, motor_f should be the max force a single motor can apply
f_long_ptrain = min(motor_f, f_long_net)
if self.params.pwrtn.motor.setup == DriveSetup.FOUR_WHEEL_DRIVE:
# ASSUME ALL GEAR RATIOS EQUAL AND PERFECT TV.
# Start grip-limited: each motor's force is clipped to that
# corner's available longitudinal grip, then summed.
f_long_ptrain = sum(min(motor_f, f) for f in long_forces[:])
# Power is integrated over the step, so the 80 kW system
# constraint must be checked at v_avg, not state.v. Estimate
# v_avg from the grip-limited force, refine with one pass.
acc_est = (f_long_ptrain - drag - rolling_resistance) / (
self.params.mass + self.moi_mass_equiv
)
v_final_est_sq = max(v_current * v_current + 2 * acc_est * distance_step, 0.0)
v_avg_est = 0.5 * (v_current + math.sqrt(v_final_est_sq))
# LUT-aware powertrain efficiency. The per-motor force calc
# above already used the LUT internally; the F->P->F clip must
# use the same eta to stay self-consistent. Recover per-motor
# shaft torque from total ground force assuming equal split:
# f_ground = T_motor * ratio * eta_mech / r_tire (per motor)
# so T_motor = (f_total / 4) * r / (ratio * eta_mech).
# TODO: per-wheel v differs in a corner (inner/outer); this
# still assumes all four wheels travel at vehicle speed.
motor_rpm = self.motor.v_to_rpm(v_current, self.params.pwrtn.ratio)
tire_radius_m = in_to_m(self.params.tires.tire_radius)
eta_mech = self.motor.mechanical_efficiency()
per_motor_torque = (
f_long_ptrain * tire_radius_m / (4 * self.params.pwrtn.ratio * eta_mech)
)
eta_full = self.motor.powertrain_efficiency(motor_rpm, per_motor_torque)
# Clip total DC bus power
# First-order rescale: shrinking f also shrinks per_motor_torque
# which slightly shifts eta_full, but the change is small over
# a single rescale so one pass converges in practice.
p_predicted = f_long_ptrain * v_avg_est / eta_full
if p_predicted > self.accum.params.total_pow_lim:
f_long_ptrain = self.accum.params.total_pow_lim * eta_full / v_avg_est
acc_new = (f_long_ptrain - drag - rolling_resistance) / (
self.params.mass + self.moi_mass_equiv
)
# if the longitudinal grip cannot sustain resistive forces, say we are sliding
# NOTE: try disabling this and seeing what happens... Maybe it's unnecessary
if acc_new < 0:
sliding = True
f_long_ptrain = rolling_resistance + drag
acc_new = 0
else: # if the tires are invalid
sliding = True
# We cannot go any faster, so let the applied motor force be whatever force matches our losses
# We don't do validity checks here. Implicitly, with small step sizes, and if we always accelerate
# towards larger radii, we are guaranteed to be close to validity. This approach is better than setting
# f_long_ptrain to zero and seeing large instantaneous fluctuations in power consumption.
f_long_ptrain = rolling_resistance + drag
acc_new = 0
# v_f^2 = v_i^2 + 2 * acc * dx
v_final = math.sqrt(v_current * v_current + 2 * acc_new * distance_step)
v_avg = (v_current + v_final) * 0.5
# LUT-aware DC bus power for lap_powers. RWD: all wheel torque flows
# through 1 motor. AWD: 4 motors split it (model assumption).
motor_rpm_avg = self.motor.v_to_rpm(v_avg, self.params.pwrtn.ratio)
n_motors = 4 if self.params.pwrtn.motor.setup == DriveSetup.FOUR_WHEEL_DRIVE else 1
per_motor_torque_avg = (
f_long_ptrain
* in_to_m(self.params.tires.tire_radius)
/ (n_motors * self.params.pwrtn.ratio * self.motor.mechanical_efficiency())
)
p = (
f_long_ptrain
* v_avg
/ self.motor.powertrain_efficiency(motor_rpm_avg, per_motor_torque_avg)
)
# Per-motor shaft torque actually delivered (already setup-conditional
# via n_motors). Positive: motor is driving.
motor_torque = per_motor_torque_avg
dt = distance_step / v_avg
# Update state variables
if sliding:
dt = state.dt
v_final = state.v
p = state.p
motor_torque = state.motor_torque
acc_new = 0
# NOTE: once roll/pitch sensitivities are implemented,
# r/p should be set to the last good value here.
state_new = SimulationState(
sliding=sliding,
dt=dt,
v=v_final,
p=p,
acc=acc_new,
motor_torque=motor_torque,
# NOTE: uncomment once sensitivities are implemented
# roll=roll_new,
# pitch=pitch_new,
)
return state_new
[docs]
def brake_backwards_dx(
self, radius: float, distance_step: float, state: SimulationState
) -> SimulationState:
"""Integrate the car backward by one distance step while braking.
Parameters
----------
radius : float
Radius of the current corner (m).
distance_step : float
Distance to integrate backward (m).
state : SimulationState
Current simulation state.
Returns
-------
SimulationState
Updated simulation state after the step.
"""
# Set state variables
v_current = state.v
lat_acc = v_current * v_current / radius
forces = self.calculate_tire_forces(
acc=state.acc,
lat_acc=lat_acc,
roll=state.roll,
pitch=state.pitch,
vel=v_current,
backwards_pass=True,
)
# Resistive forces help with braking
rolling_resistance = forces.total_normal * self.params.rolling_coeff
drag = self.aero.get_drag(v_current, lat_acc)
# First, we check if each tire can actually satisfy its demands.
if self.check_all_tires_valid_lateral_force(forces):
# Set the force to be the least grippy tire's f_long times two (single brake line)
long_forces = self.calculate_and_return_longitudinal_forces(forces)
f_long_net_front = 2 * min(long_forces[0], long_forces[1])
f_long_net_rear = 2 * min(long_forces[2], long_forces[3])
# Recalculate driver interface for brake bias if available, otherwise use unmodified values.
f_long_biased_front, f_long_biased_rear = self.dri.update_forces(
f_long_net_front, f_long_net_rear
)
f_brake = f_long_biased_front + f_long_biased_rear
# NOTE: we don't need to do the same check as in the forward, since f_brake will always be > 0.
else:
# If we are out of grip on the reverse pass, we want to stop attempting to accelerate backwards.
# Even though contributions from resistive forces accelerate the car backwards independently of
# tires, it is unphysical to let the car reach a speed beyond this limit.
dt = state.dt
sliding_state = SimulationState(
sliding=True,
acc=0.0,
v=state.v,
dt=state.dt,
p=0.0,
motor_torque=0.0,
)
return sliding_state
acc_new = (f_brake + drag + rolling_resistance) / (self.params.mass + self.moi_mass_equiv)
# v_f^2 = v_i^2 + 2 * acc * dx
v_final = math.sqrt(v_current * v_current + 2 * acc_new * distance_step)
v_avg = (v_current + v_final) * 0.5
# Which brake force is routed through motors (and thus recoverable as
# regen) depends on drive setup:
# RWD: only the rear axle has a motor, so only the rear-axle brake
# force is routed to a motor. 1 motor regens.
# AWD: all 4 wheels have motors, so the entire braking effort
# (front + rear) is routed to motors. 4 motors regen.
# In regen, mech losses act wheel->shaft (opposite of driving), so:
# T_shaft_per_motor = (F_brake_regen / n_motors) * r * eta_mech / ratio
# Per-motor torque is used for the LUT efficiency lookup. The total
# regen power formula F_brake_regen * v * eta_full works for both 1- and
# n-motor cases under the model assumption of equal per-axle torque
# split (open-diff brake distribution within each axle).
motor_rpm = self.motor.v_to_rpm(v_current, self.params.pwrtn.ratio)
tire_radius_m = in_to_m(self.params.tires.tire_radius)
eta_mech = self.motor.mechanical_efficiency()
if self.params.pwrtn.motor.setup == DriveSetup.FOUR_WHEEL_DRIVE:
f_brake_regen = f_long_biased_front + f_long_biased_rear
n_regen_motors = 4
else:
f_brake_regen = f_long_biased_rear
n_regen_motors = 1
per_motor_regen_torque = (
f_brake_regen * tire_radius_m * eta_mech / (n_regen_motors * self.params.pwrtn.ratio)
)
eta_full_regen = self.motor.powertrain_efficiency(motor_rpm, per_motor_regen_torque)
current_regen = f_brake_regen * v_current * eta_full_regen * self.params.pwrtn.regen_percent
applied_regen = min(current_regen, self.params.pwrtn.max_regen)
p = -1 * applied_regen
# Per-motor shaft torque absorbed by the motors during braking (signed
# negative). per_motor_regen_torque is the shaft torque for the FULL
# brake force routed through the motors; the motors only actually
# absorb the regenerated fraction, so scale by regen_percent and any
# max_regen capping. This is 0 when regen is disabled (the friction
# brakes carry the load and the motors are electrically idle).
capping = applied_regen / current_regen if current_regen > 1e-9 else 0.0
motor_torque = -per_motor_regen_torque * self.params.pwrtn.regen_percent * capping
dt = distance_step / v_avg
state_new = SimulationState(
sliding=False,
dt=dt,
v=v_final,
p=p,
acc=acc_new,
motor_torque=motor_torque,
# NOTE: uncomment once sensitivities are implemented
# roll=roll_new,
# pitch=pitch_new,
)
return state_new
[docs]
def coast_forwards_dx(
self, radius: float, distance_step: float, state: SimulationState
) -> SimulationState:
"""Lift off the throttle and coast (with regen) for one distance step.
Parameters
----------
radius : float
Radius of the current corner (m).
distance_step : float
Distance to advance (m).
state : SimulationState
Current simulation state.
Returns
-------
SimulationState
Updated simulation state after the step.
"""
# Set state variables
v_current = state.v
lat_acc = v_current * v_current / radius
forces = self.calculate_tire_forces(
acc=state.acc,
lat_acc=lat_acc,
roll=state.roll,
pitch=state.pitch,
vel=v_current,
)
# Check that we can make the corner
sliding = not self.check_all_tires_valid_lateral_force(forces)
# Allow resistive forces to slow car down.
rolling_resistance = forces.total_normal * self.params.rolling_coeff
drag = self.aero.get_drag(v_current, lat_acc)
# Apply regenerative forces from motor. We regen
# as much as possible (up to our controlled limit).
regen_power = 0
if self.params.pwrtn.regen_percent > 0:
regen_power = self.params.pwrtn.max_regen
# Backsolve brake force from target regen power: P_regen = F * v * eta.
# eta depends on per-motor regen torque (which depends on F), so do
# one refinement pass with the LUT. All driven motors regen during
# coasting: 4 for AWD, 1 for RWD.
n_motors = 4 if self.params.pwrtn.motor.setup == DriveSetup.FOUR_WHEEL_DRIVE else 1
motor_rpm = self.motor.v_to_rpm(v_current, self.params.pwrtn.ratio)
tire_radius_m = in_to_m(self.params.tires.tire_radius)
eta_mech = self.motor.mechanical_efficiency()
# Initial scalar-eta guess
regen_force = (
regen_power
/ v_current
/ (
self.motor.mechanical_efficiency()
* self.motor.params.moc_efficiency
* self.motor.params.motor_efficiency
)
)
# Refine using LUT-aware eta at this operating point. In regen,
# mech losses act wheel->shaft, so T_shaft = F * r * eta_mech / (n * ratio).
per_motor_regen_torque = (
regen_force * tire_radius_m * eta_mech / (n_motors * self.params.pwrtn.ratio)
)
eta_full_regen = self.motor.powertrain_efficiency(motor_rpm, per_motor_regen_torque)
regen_force = regen_power / v_current / eta_full_regen
# Sum resistive forces
acc_new = -(rolling_resistance + drag + regen_force) / (
self.params.mass + self.moi_mass_equiv
)
# v_f^2 = v_i^2 + 2 * acc * dx
v_final = math.sqrt(v_current * v_current + 2 * acc_new * distance_step)
v_avg = (v_current + v_final) * 0.5
dt = distance_step / v_avg
if sliding:
return SimulationState(
sliding=sliding,
acc=state.acc,
v=state.v,
dt=state.dt,
p=0.0,
motor_torque=0.0,
)
# Per-motor shaft torque absorbed during the coast regen (signed
# negative). per_motor_regen_torque is derived from regen_force, which
# is already 0 when regen is disabled, so this collapses to 0 there.
motor_torque = -per_motor_regen_torque
return SimulationState(
sliding=sliding,
dt=dt,
v=v_final,
p=-regen_power,
acc=acc_new,
motor_torque=motor_torque,
# NOTE: uncomment once sensitivities are implemented
# roll=roll_new,
# pitch=pitch_new,
)
[docs]
def max_stable_speed(
self,
radius: float,
converge_steps: int = 20,
resolution: float = 0.008,
stabilize_steps: int = 1,
) -> float:
"""Binary search for the maximum speed at which the car can safely navigate a corner.
Parameters
----------
radius : float
Radius of the corner (m).
converge_steps : int
Maximum number of solution iterations.
resolution : float
Tolerance of the final velocity solution (m/s).
stabilize_steps : int
How many times to iterate each velocity check. Needed for
roll/pitch/etc. sensitivity (should be >1, ideally >5, when used).
Returns
-------
float
The maximum stable cornering speed (m/s).
Notes
-----
The acceleration does not need to be near zero when converged (e.g.
for an understeery car).
"""
if radius > 10000:
return float("inf")
v_min = 0.0
v_max = 50.0
i = 0
last_valid_v = 0.0
while i < converge_steps:
v_mid = (v_min + v_max) / 2
state = SimulationState(sliding=False, acc=0.0, v=v_mid, dt=0.0, p=0.0)
for _ in range(stabilize_steps):
can_successfully_acc = self.try_accelerate_forwards_for_max_speed_calc(
radius, state
)
if not can_successfully_acc:
break
if not can_successfully_acc:
v_max = v_mid
continue
# v_mid is a valid velocity
# If the change relative to the last valid velocity is sufficiently small,
# we have converged
diff = abs(v_mid - last_valid_v)
if diff < resolution:
return v_mid
# Update binary search parameters
last_valid_v = v_mid
v_min = v_mid
i += 1
raise ValueError(
f"Failed to find the maximum stable speed for corner radius {radius}.\n"
f"Search progress: "
f"v_min = {v_min} v_max = {v_max} v_mid = {v_mid} last_good_v = {last_valid_v}"
)
[docs]
def modify_params(self, var_name: str, new_var_value: object) -> None:
"""Modify a car parameter given its dotted name and new value.
Parameters
----------
var_name : str
The name of the parameter to modify (e.g., 'pwrtn.motor.max_rpm').
new_var_value : object
The new value to set for the specified parameter.
Raises
------
AttributeError
If `var_name` does not name a car parameter.
ValidationError
If the value is rejected by the parameter's own validation.
Notes
-----
The parameter models use `validate_assignment`, so an unknown leaf
name surfaces as a Pydantic `ValidationError` rather than an
`AttributeError`. That case is translated here so a bad name always
raises `AttributeError`, whichever part of the path is wrong, while
a genuinely invalid value keeps its original validation error.
"""
attrs = var_name.split(".")
obj: object = self.params
try:
for attr in attrs[:-1]:
obj = getattr(obj, attr)
except AttributeError:
raise AttributeError(
f"{var_name} is not a car parameter. Example parameter name: 'pwrtn.motor.max_rpm'"
)
try:
setattr(obj, attrs[-1], new_var_value)
except AttributeError:
raise AttributeError(
f"{var_name} is not a car parameter. Example parameter name: 'pwrtn.motor.max_rpm'"
)
except ValidationError as error:
if any(detail["type"] == "no_such_attribute" for detail in error.errors()):
raise AttributeError(
f"{var_name} is not a car parameter. "
f"Example parameter name: 'pwrtn.motor.max_rpm'"
)
raise
# Rebuild any dependent values after modifying parameters
self.moi_mass_equiv = self.convert_rotating_mass_to_linear_mass()
if self.pwrtn.motor.params.use_efficiency_lut:
self.pwrtn.motor.build_power_limit_table()
[docs]
def get_current_params(self, var_name: str) -> object:
"""Retrieve the current value of a car parameter given its dotted name.
Parameters
----------
var_name : str
The name of the parameter to retrieve (e.g., 'pwrtn.motor.max_rpm').
Returns
-------
object
The current value of the specified parameter.
Raises
------
AttributeError
If `var_name` does not name a car parameter.
Notes
-----
Parameters hold values of many types, so the value is returned as-is.
Callers that need a number are responsible for asserting that the
value is numeric, which is application-specific.
"""
attrs = var_name.split(".")
try:
obj: object = self.params
for attr in attrs:
obj = getattr(obj, attr)
return obj
except AttributeError:
raise AttributeError(
f"{var_name} is not a car parameter. Example parameter name: 'pwrtn.motor.max_rpm'"
)