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)