import math
from ..constants import *
from ..models import SimulationState
from . import (
accumulator,
aerodynamics,
chassis,
driver_interface,
powertrain,
suspension,
)
from .vehicle_models import *
[docs]
class Car:
def __init__(self, vehicle_model: VehicleModel):
self.params = vehicle_model
# Construct all subsystems from vehicle_model
self.tires = suspension.Tires(vehicle_model=vehicle_model)
# Construct suspension based on type
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 tire normal forces
self.total_normal = 0
fl, fr, bl, br = self.car_weight_distributor(acc=0, lat_acc=0)
self.tires.initialize_tires(fl, fr, bl, br)
# 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=20, v_low=0, v_high=50):
"""binary searches what velocity drag and rolling resistance equals max motor force"""
self.car_reset()
for _ in range(max_steps):
# Midpoint velocity
v_current = (v_low + v_high) / 2.0
# Update forces at this velocity
self.calculate_norm_and_aero(
acc=0, lat_acc=0, roll=0, pitch=0, vel=v_current
)
rolling_resistance = self.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
self.car_reset()
return (v_low + v_high) / 2.0
[docs]
def convert_rotating_mass_to_linear_mass(self):
# m_equiv = J / r^2
# begin by solving lump sum J
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=False, acc=None, lat_acc=None):
"""does weight transfer, returns normal force of tires in N"""
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_norm_and_aero(
self,
acc,
lat_acc,
roll,
pitch,
vel,
backwards_pass=False,
):
"""
Calculates the aero moment based on current velocity, frontal area, and coefficient of lift.
Returns:
float: Aero moment.
"""
# Update normal forces and calculate quarter lift
(
self.tires.fl.normal,
self.tires.fr.normal,
self.tires.bl.normal,
self.tires.br.normal,
) = self.car_weight_distributor(
backwards_pass, acc=acc, lat_acc=lat_acc
) # normal force on tires
# Calculate lift forces on individual tires
aero_fl, aero_fr, aero_bl, aero_br = self.aero.tire_lift_forces(
vel, lat_acc, roll=roll, pitch=pitch
)
self.tires.fl.normal += aero_fl
self.tires.fr.normal += aero_fr
self.tires.bl.normal += aero_bl
self.tires.br.normal += aero_br
# Calculate total normal force
self.total_normal = sum(tire.normal for tire in self.tires.list)
[docs]
def calculate_and_update_lateral_forces(self, lat_acc):
"""
Calculates the lateral force exerted on each tire based on the normal force and the centripetal force acting on the vehicle.
If the front or rear axle is completely off the ground, the correponding tire lateral forces will be evaluated as 0.
"""
if self.total_normal == 0:
raise ValueError("Total normal force cannot be zero.")
# Each axle must exert some amount of force such that net yaw acceleration equals zero.
# F_y,f = m_f * a = F_c * self.w_distr_f
# F_y,r = m_r * a = F_c * self.w_distr_r
f_lat_front = self.params.w_distr_front * self.params.mass * lat_acc
f_lat_back = self.params.w_distr_b * self.params.mass * lat_acc
f_lat_maxs = []
for tire in self.tires.list:
mu_lat, _ = tire.calc_mu(
self.tires.params.mu_long_lat_ratio,
self.tires.params.mu_lat_zero_load,
)
f_lat_maxs.append(mu_lat * tire.normal)
# Now we do the split based on relative f_lat_maxs w/r/t each axle
# Calc the max possible load across the axle
f_lat_max_f = f_lat_maxs[0] + f_lat_maxs[1]
f_lat_max_b = f_lat_maxs[2] + f_lat_maxs[3]
# The lateral force requested by each tire should be a share of the force requested by its axle.
# The tire's share of force requested by its axle should be the same as the ratio of its max possible force to the force requested by the axle.
self.tires.fl.lateral = max(
f_lat_front * (f_lat_maxs[0] / (f_lat_max_f + 1e-6)), 0
)
self.tires.fr.lateral = max(
f_lat_front * (f_lat_maxs[1] / (f_lat_max_f + 1e-6)), 0
)
self.tires.bl.lateral = max(
f_lat_back * (f_lat_maxs[2] / (f_lat_max_b + 1e-6)), 0
)
self.tires.br.lateral = max(
f_lat_back * (f_lat_maxs[3] / (f_lat_max_b + 1e-6)), 0
)
[docs]
def check_all_tires_valid_lateral_force(self):
for tire in self.tires.list:
if not tire.is_current_lateral_force_valid(
self.params.tires.mu_long_lat_ratio,
self.params.tires.mu_lat_zero_load,
):
return False
return True
[docs]
def calculate_and_return_longitudinal_forces(self):
long_forces = []
for tire in self.tires.list:
# Calculate the longitudinal force budget left for that tire
f_long = tire.long_remain(
self.params.tires.mu_long_lat_ratio,
self.params.tires.mu_lat_zero_load,
)
long_forces.append(f_long)
return long_forces
[docs]
def try_accelerate_forwards_for_max_speed_calc(
self, radius, state: SimulationState
):
# Invalid state
if not isinstance(state, SimulationState):
raise ValueError(
"'state' needs to be a SimulationState object, representing the current state of the car"
)
# Set state variables acceleration
v_current = state.v
self.car_reset() # coconut.jpg
# pull in state variables
pitch = state.pitch
roll = state.roll
lat_acc = v_current * v_current / radius
# Because of problematic acceleration/longitudinal weight transfer behavior, we reset
# the longitudinal acceleration to zero, making the approximation of a completely steady
# state weight transfer. This should be removed once weight transfer is completely investigate
# NOTE: Modifies tire normals
self.calculate_norm_and_aero(
acc=0, lat_acc=lat_acc, roll=roll, pitch=pitch, vel=v_current
)
self.calculate_and_update_lateral_forces(lat_acc)
# First, we check if each tire can actually satisfy its demands.
if self.check_all_tires_valid_lateral_force():
# Then, we check if the longitudinal force the tires provide exceeds the resistive force
long_forces = self.calculate_and_return_longitudinal_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 = self.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
):
"""
Redesigned Move function that determines how the car should accelerate.
Args:
radius (float): Radius of the current corner
Returns:
tuple: (dt, p, acc) time step [s], power [W], and longitudinal acceleration [m/s^2] respectively
"""
# Invalid state
if not isinstance(state, SimulationState):
raise ValueError(
"'state' needs to be a SimulationState object, representing the current state of the car"
)
# Set state variables
v_current = state.v
self.car_reset() # clean slate
lat_acc = v_current * v_current / radius
# Because of problematic acceleration/longitudinal weight transfer behavior, we reset
# the longitudinal acceleration to zero, making the approximation of a completely steady
# state weight transfer. This should be removed once weight transfer is completely investigate
# NOTE: Modifies tire normals
self.calculate_norm_and_aero(
acc=state.acc,
lat_acc=lat_acc,
roll=state.roll,
pitch=state.pitch,
vel=v_current,
)
# NOTE: to prevent unexpected behavior, state should never be passed,
# even though it would work. Explicitly pass what you want to use,
# so you always know what's going on.
self.calculate_and_update_lateral_forces(lat_acc)
# Resistive forces
rolling_resistance = self.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():
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()
# 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
):
# Invalid state
if not isinstance(state, SimulationState):
raise ValueError(
"'state' needs to be a SimulationState object, representing the current state of the car"
)
# Set state variables
v_current = state.v
self.car_reset()
if state is None:
print("PREVIOUS STATE WAS NOT PASSED TO MOVE")
raise KeyError(
"The use of implicit parameter passing between (un)move() calls is deprecated. Ensure you pass a State dict with the required parameters."
)
lat_acc = v_current * v_current / radius
# Because of problematic acceleration/longitudinal weight transfer behavior, we reset
# the longitudinal acceleration to zero, making the approximation of a completely steady
# state weight transfer. This should be removed once weight transfer is completely investigate
# NOTE: Modifies tire normals
self.calculate_norm_and_aero(
acc=state.acc,
lat_acc=lat_acc,
roll=state.roll,
pitch=state.pitch,
vel=v_current,
backwards_pass=True,
)
self.calculate_and_update_lateral_forces(lat_acc)
# Resistive forces help with braking
rolling_resistance = self.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():
# 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()
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
):
"""
Lifts and coasts for endurance.
Args:
radius (float): Corner radius of the current corner.
Returns:
tuple: A tuple containing the time step (dt), power (p), and acceleration (acc).
"""
# Invalid state
if not isinstance(state, SimulationState):
raise ValueError(
"'state' needs to be a SimulationState object, representing the current state of the car"
)
# Set state variables
v_current = state.v
self.car_reset()
if state is None:
print("PREVIOUS STATE WAS NOT PASSED TO MOVE")
raise KeyError(
"The use of implicit parameter passing between (un)move() calls is deprecated. Ensure you pass a State dict with the required parameters."
)
lat_acc = v_current * v_current / radius
# Because of problematic acceleration/longitudinal weight transfer behavior, we reset
# the longitudinal acceleration to zero, making the approximation of a completely steady
# state weight transfer. This should be removed once weight transfer is completely investigate
# NOTE: Modifies tire normals
self.calculate_norm_and_aero(
acc=state.acc,
lat_acc=lat_acc,
roll=state.roll,
pitch=state.pitch,
vel=v_current,
)
self.calculate_and_update_lateral_forces(lat_acc)
# Check that we can make the corner
sliding = not self.check_all_tires_valid_lateral_force()
# Allow resistive forces to slow car down.
rolling_resistance = self.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=20,
resolution=0.008,
stabilize_steps=1,
):
"""
Experimentally binary searches for the maximum speed at which the car can safely navigate a corner.
DO NOT use this function in the middle of a simulated lap! This calculation will involve
resetting the car's state variables.
Note that self.acc does not necessarily need to be near-zero when the solution is converged to be valid. (i.e., an understeery car)
Note that stabilize_steps must be >1 (probably >5) for accurate results when roll/pitch/etc. sensitivity is used.
Args:
radius (float): The radius of the corner [m]
converge_steps (int): The maximum number of solution iterations. Default: 20.
resolution (float): Tolerance of final velocity solution. Default: 0.008 [m/s]
stabilize_steps (int): How many times to iterate each velocity check. Necessary for roll/pitch/etc. Default: 1
Returns:
float: The maximum stable cornering speed [m/s]
"""
if radius > 10000:
return float("inf")
v_min = 0
v_max = 50
i = 0
last_valid_v = 0
while i < converge_steps:
self.car_reset()
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:
self.car_reset()
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):
"""
Modifies a car parameter given its name and new value.
Parameters
----------
var_name : str
The name of the parameter to modify (e.g., 'pwrtn.motor.max_rpm').
new_var_value : any
The new value to set for the specified parameter.
"""
attrs = var_name.split(".")
try:
if len(attrs) > 1:
obj = self.params
for attr in attrs[:-1]:
obj = getattr(obj, attr)
setattr(obj, attrs[-1], new_var_value)
else:
setattr(self.params, attrs[0], new_var_value)
except AttributeError:
raise AttributeError(
f"{var_name} is not a car parameter. Example parameter name: 'pwrtn.motor.max_rpm'"
)
# 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):
"""
Retrieves the current value of a car parameter given its name.
Parameters
----------
var_name : str
The name of the parameter to retrieve (e.g., 'pwrtn.motor.max_rpm').
Returns
-------
any
The current value of the specified parameter.
"""
attrs = var_name.split(".")
try:
obj = 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'"
)
[docs]
def car_reset(self):
"""Resets the car's state variables to their initial values for a fresh start."""
self.total_normal = 0
fl, fr, bl, br = self.car_weight_distributor(acc=0, lat_acc=0)
self.tires.reset_tires(fl, fr, bl, br)