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 .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)