Source code for suboptimumg.vehicle.vehicle

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)