import math
from ..constants import *
from ..models import SimulationState
from . import (
accumulator,
aerodynamics,
chassis,
driver_interface,
powertrain,
suspension,
)
from .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)
[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 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
fr += long_weight_f_delta + lat_weight_f_right_delta
fl += long_weight_f_delta + lat_weight_f_left_delta
br += long_weight_r_delta + lat_weight_r_right_delta
bl += long_weight_r_delta + 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.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[:])
# Apply power limit constraint
current_predicted_power = (
f_long_ptrain
* state.v # assume all tires approx = speed (wrong)
# TODO: don't assume this
/ self.motor.powertrain_efficiency()
)
# self.motor.pow_lim is that motor specifically, so for now
# hardcode 80kW limit
current_predicted_power = min(
current_predicted_power,
80000,
)
# unwrap power back into force
f_long_ptrain = (
current_predicted_power
/ v_current
* self.motor.powertrain_efficiency()
)
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.setup == DriveSetup.FOUR_WHEEL_DRIVE:
# ASSUME ALL GEAR RATIOS EQUAL AND PERFECT TV
# in awd config, motor_f is max f by single motor. ~30kW lim
# similar, slightly lower torque than RWD config.
# begin by limiting each motor_f to the grip available & sum
f_long_ptrain = sum(min(motor_f, f) for f in long_forces[:])
# this will give us some effective power that may be above 80kW
# so we must limit this power. Could use p from motor_f call,
# but we've clipped that force now so it's easier to recalc
# calc applied power and invert efficiency calc for accum power
# calc the power output by accumulator P = F*v/E
current_predicted_power = (
f_long_ptrain
* state.v # assume all tires approx = speed (wrong)
# TODO: don't assume this
/ self.motor.powertrain_efficiency()
)
# clip the power to the power limit.
# self.motor.pow_lim is that motor specifically, so for now
# hardcode 80kW limit
current_predicted_power = min(
current_predicted_power,
80000,
# TODO: maybe make this a config item?
)
# now we undo the power output calc F = E*P/v
# don't worry about which tire will supply this force, we know
# that we have enough grip b/c F_L_P can only decrease
# as a result of these min() operations
f_long_ptrain = (
current_predicted_power
/ state.v
* self.motor.powertrain_efficiency()
)
acc_new = (f_long_ptrain - drag - rolling_resistance) / self.params.mass
# 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
p = f_long_ptrain * v_avg / self.pwrtn.motor.powertrain_efficiency()
dt = distance_step / v_avg
# Update state variables
if sliding:
dt = state.dt
v_final = state.v
p = state.p
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,
# 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
)
return sliding_state
acc_new = (f_brake + drag + rolling_resistance) / self.params.mass
# 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
current_regen = (
f_long_biased_rear # only use rear axle because we are a RWD car. For 4WD, use both axles
* v_current
* self.motor.powertrain_efficiency()
* self.params.pwrtn.regen_percent
)
p = -1 * min(current_regen, self.params.pwrtn.max_regen)
dt = distance_step / v_avg
state_new = SimulationState(
sliding=False,
dt=dt,
v=v_final,
p=p,
acc=acc_new,
# 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 force
regen_force = regen_power / v_current / self.motor.powertrain_efficiency()
# Sum resistive forces
acc_new = -(rolling_resistance + drag + regen_force) / self.params.mass
# 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
)
return SimulationState(
sliding=sliding,
dt=dt,
v=v_final,
p=-regen_power,
acc=acc_new,
# 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 i 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'"
)
self.car_reset()
[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)