Source code for suboptimumg.vehicle.powertrain.powertrain

import numpy as np
from numpy.typing import NDArray

from ...constants import circumference, in_to_m, rpm_to_rad_s
from ..vehicle_models import VehicleModel


[docs] class Motor: """ Models motor torque output, efficiency, and power limits. Notes ----- The efficiency map, rpm/torque normalization parameters, and the power-limit grids are precomputed once at construction time (when ``use_efficiency_lut`` is set) so that the runtime hot path only needs a single ``np.interp`` lookup instead of a bisection loop. Call ``build_power_limit_table`` again if motor parameters are mutated at runtime. """ def __init__( self, vehicle_model: VehicleModel, ) -> None: """ Build a motor model from the vehicle's motor parameters. Parameters ---------- vehicle_model : VehicleModel Full vehicle parameter model, used to access the motor parameters and other vehicle parameters (e.g. tire radius). """ self.vehicle_model = vehicle_model self.params = vehicle_model.pwrtn.motor self.efficiency_map: NDArray[np.float64] | None = None self.rpm_norm_params: tuple[float, float] | None = None self.torque_norm_params: tuple[float, float] | None = None # _pow_lim_rpm_grid/_pow_lim_tau_grid together form a precomputed # inverse of the DC-power constraint: _pow_lim_tau_grid[k] is the max # motor torque at _pow_lim_rpm_grid[k] such that DC bus power # tau * omega / eta_elec(rpm, tau) does not exceed pow_lim. self._pow_lim_rpm_grid: NDArray[np.float64] | None = None self._pow_lim_tau_grid: NDArray[np.float64] | None = None if self.params.use_efficiency_lut: self.fit_efficiency_data(self.params.efficiency_data, 4, 4) self.build_power_limit_table()
[docs] def get_torque_at_rpm(self, motor_rpm: float) -> float: """ Get motor torque at a given RPM using piecewise function. The torque curve has three regions: 1. Flat region (0 to fw_rpm): constant max torque 2. Linear decay (fw_rpm to max_rpm): torque decreases linearly 3. Zero region (above max_rpm): no torque Parameters ---------- motor_rpm : float Motor RPM Returns ------- float Motor torque at the given RPM (Nm) """ if motor_rpm < self.params.fw_rpm: # Pre-field weakening: constant max torque return self.params.max_torque elif motor_rpm <= self.params.max_rpm: # Field weakening: linear decrease from max_torque to fw_torque. # Handle edge case where fw_rpm == max_rpm (no field weakening region). if self.params.max_rpm == self.params.fw_rpm: return self.params.max_torque slope = (self.params.max_torque - self.params.fw_torque) / ( self.params.max_rpm - self.params.fw_rpm ) return self.params.max_torque - slope * (motor_rpm - self.params.fw_rpm) else: # Above max RPM: no torque return 0.0
[docs] def calculate_max_ground_force_and_motor_power( self, v: float, ratio: float ) -> tuple[float, float]: """ Calculates force and power output by the motor. Parameters ---------- v : float Velocity of the vehicle (m/s) ratio : float Gear ratio Returns ------- ground_force : float Force at the ground (N) motor_power : float Motor shaft power output (W) Notes ----- The torque the motor can deliver is constrained by two things: the static torque curve tau_curve(rpm), and the DC-bus power limit pow_lim, via ``tau * omega / eta_elec(rpm, tau) <= pow_lim``, which is circular in tau because eta_elec depends on tau. When use_efficiency_lut is True this is resolved by an inverse lookup built once at init in `build_power_limit_table`. Only chain/diff efficiencies sit between shaft and wheel, so the wheel torque uses `mechanical_efficiency` (not the full chain). The inverter and motor losses are upstream of the shaft and affect battery draw, not wheel torque. """ motor_rpm = self.v_to_rpm(v, ratio) omega = rpm_to_rad_s(motor_rpm) tau_curve = self.get_torque_at_rpm(motor_rpm) rpm_grid = self._pow_lim_rpm_grid tau_grid = self._pow_lim_tau_grid if self.params.use_efficiency_lut and rpm_grid is not None and tau_grid is not None: # DC-bus power constraint, precomputed inverse. tau_pow = float(np.interp(motor_rpm, rpm_grid, tau_grid)) else: # Scalar-efficiency fallback: eta_elec is constant in tau, so closed form. eta_elec = self.electrical_efficiency(motor_rpm, 0) tau_pow = self.params.pow_lim * eta_elec / max(omega, 1e-6) motor_torque = min(tau_curve, tau_pow) # Shaft -> wheel: only mechanical components act here. wheel_torque = motor_torque * ratio * self.mechanical_efficiency() ground_force = wheel_torque / in_to_m(self.vehicle_model.tires.tire_radius) return ground_force, motor_torque * omega
[docs] def v_to_rpm(self, v: float, ratio: float) -> float: """ Convert vehicle velocity to motor RPM given the gear ratio. Parameters ---------- v : float Velocity of the vehicle (m/s) ratio : float Gear ratio Returns ------- float Motor RPM """ tire_circumference = circumference(in_to_m(self.vehicle_model.tires.tire_radius)) rotations_per_sec = v / tire_circumference wheel_rpm = rotations_per_sec * 60 return wheel_rpm * ratio
[docs] def mechanical_efficiency(self) -> float: """ Loss factor between motor shaft and wheel. Returns ------- float Combined chain and differential efficiency (0.0 to 1.0) Notes ----- Only the components that physically sit downstream of the shaft act here: the chain/belt and the diff. The inverter and motor losses are electrical and do not reduce wheel torque; they show up as extra DC current draw and are accounted for in `electrical_efficiency`. """ return self.params.chain_efficiency * self.params.diff_efficiency
[docs] def electrical_efficiency(self, rpm: float, torque: float) -> float: """ Loss factor between DC bus and motor shaft. Parameters ---------- rpm : float Motor RPM (used if LUT is enabled) torque : float Motor torque (used if LUT is enabled) Returns ------- float Motor efficiency * MOC efficiency Notes ----- Covers the inverter (moc_efficiency) and the motor itself. When an efficiency LUT is in use and both rpm and torque are supplied, the motor's contribution is pulled from the (rpm, torque) map; otherwise the scalar motor_efficiency is used. """ if self.params.use_efficiency_lut: eta_motor = self.eval_efficiency_map(rpm, torque) else: eta_motor = self.params.motor_efficiency return self.params.moc_efficiency * eta_motor
[docs] def powertrain_efficiency(self, rpm: float, torque: float) -> float: """ Full DC-bus-to-ground efficiency, used by callers converting between battery power and wheel power (or vice versa). Parameters ---------- rpm : float Motor RPM (used if efficiency_method is 'indiv') torque : float Motor torque (used if efficiency_method is 'indiv') Returns ------- float Overall powertrain efficiency (0.0 to 1.0) Notes ----- Equals electrical_efficiency * mechanical_efficiency when in 'indiv' mode. In 'sys' mode the lumped system_efficiency parameter is used directly. Diff efficiency is currently fixed at 100% (TODO). """ if self.params.efficiency_method == "sys": return self.params.system_efficiency elif self.params.efficiency_method == "indiv": return self.electrical_efficiency(rpm, torque) * self.mechanical_efficiency()
[docs] def build_power_limit_table(self, n_rpm: int = 200) -> None: """ Precompute the DC-power-limited max motor torque at each rpm. Parameters ---------- n_rpm : int Number of RPM points to precompute between 0 and max_rpm. Notes ----- At a given rpm, the binding DC constraint is ``tau * omega / eta_elec(rpm, tau) <= pow_lim``, which is implicit in tau because eta_elec depends on tau via the LUT. This inverts the constraint once by bisecting tau in [0, tau_curve(rpm)] at each rpm in a fixed grid, then looks the result up with `np.interp` at runtime. At rpm=0 there is no power binding (omega=0 implies P_DC=0 for any tau), so the static torque curve dominates. Where the static curve already fits under pow_lim, no inversion is needed (this also provides a cheap early-out when full torque is already under the power limit at a given rpm). The bisection itself relies on P_DC(tau) being non-decreasing in tau, because eta_elec is bounded (`eval_efficiency_map` clips to [0.66, 0.99]), so the tau term dominates as tau approaches 0. Rebuild this table if pow_lim, moc_efficiency, or the LUT itself changes after construction. """ rpms = np.linspace(0.0, float(self.params.max_rpm), n_rpm) tau_pow = np.empty_like(rpms) pow_lim = self.params.pow_lim for k, rpm in enumerate(rpms): tau_curve = self.get_torque_at_rpm(float(rpm)) omega = rpm_to_rad_s(float(rpm)) if omega < 1e-6: tau_pow[k] = tau_curve continue eta_at_curve = self.electrical_efficiency(float(rpm), tau_curve) if tau_curve * omega / eta_at_curve <= pow_lim: tau_pow[k] = tau_curve continue lo, hi = 0.0, tau_curve for _ in range(40): mid = 0.5 * (lo + hi) eta_mid = self.electrical_efficiency(float(rpm), mid) if mid * omega / eta_mid > pow_lim: hi = mid else: lo = mid tau_pow[k] = 0.5 * (lo + hi) self._pow_lim_rpm_grid = rpms self._pow_lim_tau_grid = tau_pow
[docs] def fit_efficiency_data( self, points: list[list[float]], degv: int, degt: int ) -> NDArray[np.float64]: """ Fit a 2D polynomial z = f(x, y) to data points [[x, y, z], ...]. Parameters ---------- points : list of lists Each inner list is [rpm, torque, efficiency] for a data point. degv : int Degree of the polynomial in the rpm (x) direction. degt : int Degree of the polynomial in the torque (y) direction. Returns ------- NDArray[np.float64] Coefficient matrix C[i,j] for x^i y^j. Notes ----- If the efficiency surface has an especially unusual shape, a higher fit degree may be needed. Keep degrees even, and as low as possible, to limit evaluation complexity. """ pts = np.asarray(points, float) rpm, torque, z = pts.T self.rpm_norm_params = (rpm.min(), rpm.max()) self.torque_norm_params = (torque.min(), torque.max()) # Normalize to [0, 1] for numerical stability. x = (rpm - self.rpm_norm_params[0]) / (self.rpm_norm_params[1] - self.rpm_norm_params[0]) y = (torque - self.torque_norm_params[0]) / ( self.torque_norm_params[1] - self.torque_norm_params[0] ) terms = [(x**i) * (y**j) for i in range(degv + 1) for j in range(degt + 1)] A = np.column_stack(terms) coeffs, *_ = np.linalg.lstsq(A, z, rcond=None) self.efficiency_map = coeffs.reshape(degv + 1, degt + 1) return self.efficiency_map
[docs] def eval_efficiency_map(self, rpm: float, torque: float) -> float: """ Evaluate the 2D efficiency map at a given (rpm, torque) point. Parameters ---------- rpm : float Motor RPM torque : float Motor torque Returns ------- float Motor efficiency (0.66 to 0.99) Notes ----- Inputs outside the fitted rpm/torque range are clamped to the nearest in-range value before evaluating the polynomial, and the result is scaled down by an exponential penalty based on how far outside the range the original inputs were. """ if ( self.efficiency_map is None or self.rpm_norm_params is None or self.torque_norm_params is None ): print("WARNING: EFFICIENCY MAP IS EVALUATING BUT DOES NOT EXIST") return self.params.motor_efficiency C = self.efficiency_map rpm_min, rpm_max = self.rpm_norm_params torque_min, torque_max = self.torque_norm_params rpm_range = rpm_max - rpm_min torque_range = torque_max - torque_min # Distance outside bounds (normalized) rpm_dist = max(rpm_min - rpm, rpm - rpm_max, 0) / rpm_range torque_dist = max(torque_min - torque, torque - torque_max, 0) / torque_range total_dist = (rpm_dist**2 + torque_dist**2) ** 0.5 # Clamp inputs to valid range rpm_clamped = max(rpm_min, min(rpm_max, rpm)) torque_clamped = max(torque_min, min(torque_max, torque)) # Normalize clamped inputs x = (rpm_clamped - rpm_min) / rpm_range y = (torque_clamped - torque_min) / torque_range # Horner's method evaluation degx = C.shape[0] - 1 degy = C.shape[1] - 1 tmp = [0.0] * (degx + 1) for i in range(degx + 1): eff = C[i, degy] for j in range(degy - 1, -1, -1): eff = eff * y + C[i, j] tmp[i] = eff eff = tmp[degx] for i in range(degx - 1, -1, -1): eff = eff * x + tmp[i] # Apply distance penalty (exponential decay) if total_dist > 0: penalty = 2.71828 ** (-total_dist * 0.3) # ~0.74 at dist=1.0 eff = eff * penalty # Floor at 66%, cap at 99% return max(0.66, min(0.99, eff))
[docs] class Powertrain: """Models the full powertrain, composed of a motor.""" def __init__( self, vehicle_model: VehicleModel, ) -> None: """ Build a powertrain model from the vehicle's powertrain parameters. Parameters ---------- vehicle_model : VehicleModel Full vehicle parameter model, used to access the powertrain parameters and construct the motor. """ self.vehicle_model = vehicle_model self.params = vehicle_model.pwrtn self.motor = Motor(vehicle_model=vehicle_model)