Source code for suboptimumg.vehicle.vehicle

import math

from pydantic import ValidationError

from ..constants import G, in_to_m
from ..models import SimulationState, TireForce, TireForces
from . import (
    accumulator,
    aerodynamics,
    chassis,
    driver_interface,
    powertrain,
    suspension,
)
from .subsystem_models import DriveSetup
from .vehicle_models import ComplexSuspensionModel, SimpleSuspensionModel, VehicleModel


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