Source code for suboptimumg.vehicle.vehicle_models

from __future__ import annotations

from typing import Annotated, Literal, TypedDict

import numpy as np
from numpy.typing import NDArray
from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    TypeAdapter,
    computed_field,
    field_validator,
)

from .subsystem_models import (
    AccumulatorModel,
    AeroModel,
    ChassisModel,
    ComplexSuspensionModel,
    DriverInterfaceModel,
    PowertrainModel,
    SimpleSuspensionModel,
    TireModel,
)


[docs] class FittedCurve: """Polynomial fit of tabulated [x, y] data with Horner evaluation. Parameters ---------- data : list of [x, y] pairs Raw tabulated input data. poly_order : int Polynomial degree for the least-squares fit. Attributes ---------- x_raw, y_raw : NDArray[np.float64] Original input arrays. poly_order : int coeffs : NDArray[np.float64] Polynomial coefficients in **descending** degree order ``[c_n, c_{n-1}, ..., c_1, c_0]``, matching ``np.polyfit`` output and the order Horner's method consumes directly. """ def __init__(self, data: list[list[float]], poly_order: int) -> None: self.x_raw: NDArray[np.float64] = np.array([p[0] for p in data], dtype=np.float64) self.y_raw: NDArray[np.float64] = np.array([p[1] for p in data], dtype=np.float64) self.poly_order = poly_order self.coeffs: NDArray[np.float64] = np.polyfit(self.x_raw, self.y_raw, poly_order) def __call__(self, x: float | NDArray[np.float64]) -> np.float64 | NDArray[np.float64]: """Evaluate the fitted polynomial (alias for `evaluate`). Parameters ---------- x : float | NDArray[np.float64] Input value(s) at which to evaluate. Returns ------- np.float64 | NDArray[np.float64] Evaluated polynomial value(s). """ return self.evaluate(x)
[docs] def evaluate(self, x: float | NDArray[np.float64]) -> np.float64 | NDArray[np.float64]: """Evaluate the fitted polynomial via Horner's method. Parameters ---------- x : float | NDArray[np.float64] Input value(s) at which to evaluate. Returns ------- np.float64 | NDArray[np.float64] Evaluated polynomial value(s). """ x_arr = np.asarray(x, dtype=np.float64) result = self.coeffs[0] for c in self.coeffs[1:]: result = result * x_arr + c return result
[docs] class VehicleModel(BaseModel): """Full vehicle configuration composed of all subsystem models.""" model_config = ConfigDict(validate_assignment=True) type: Literal["base"] = "base" # Vehicle mass and geometry mass: float = Field(gt=0, description="Total vehicle mass (kg)") w_distr_b: float = Field(gt=0, lt=1, description="Rear weight distribution percentage (0-1)") cg_h: float = Field(gt=0, description="Center of gravity height (m)") wb: float = Field(gt=0, description="Wheelbase (m)") front_track: float = Field(gt=0, description="Front track width (m)") rear_track: float = Field(gt=0, description="Rear track width (m)") rolling_coeff: float = Field(ge=0, description="Rolling resistance coefficient (unitless)") @computed_field # type: ignore[prop-decorator] # pydantic's mypy plugin recognizes @computed_field over @property, but the # plugin currently errors out under the installed mypy version, so mypy # flags this valid Pydantic V2 pattern as unsupported decorator stacking. @property def w_distr_front(self) -> float: return 1 - self.w_distr_b @computed_field # type: ignore[prop-decorator] @property def track(self) -> float: return (self.front_track + self.rear_track) / 2 # Subsystems aero: AeroModel = Field(description="Aerodynamics configuration") pwrtn: PowertrainModel = Field(description="Powertrain configuration") accum: AccumulatorModel = Field(description="Accumulator configuration") sus: SimpleSuspensionModel | ComplexSuspensionModel = Field( discriminator="type", description="Suspension configuration" ) tires: TireModel = Field(description="Tire parameters") dri: DriverInterfaceModel = Field(description="Driver interface configuration") chass: ChassisModel | None = Field(None, description="Chassis configuration")
[docs] @field_validator("rolling_coeff") def validate_rolling_coeff(cls, v: float) -> float: if v != 0.02: raise ValueError("Currently only rolling coefficient of 0.02 is considered valid") return v
[docs] class AlignmentModel(BaseModel): """Wheel alignment configuration.""" model_config = ConfigDict(validate_assignment=True) camber_front_deg: float = Field( description="Front camber angle (degrees, negative = cambered in)" ) camber_rear_deg: float = Field(description="Rear camber angle (degrees)") toe_front_deg: float = Field(description="Front toe angle (degrees, positive = toe-in)") toe_rear_deg: float = Field(description="Rear toe angle (degrees)")
[docs] class FittedCurveData(TypedDict): """Raw YAML shape for a `FittedCurve` before it is constructed.""" data: list[list[float]] poly_order: int
[docs] class SteeringSetupModel(BaseModel): """Steering geometry and ackermann curve configuration.""" model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) ackermann: FittedCurve = Field( description="Ackermann steering curve: steering wheel deg -> tire angle deg" )
[docs] @field_validator("ackermann", mode="before") @classmethod def coerce_ackermann(cls, v: FittedCurve | FittedCurveData) -> FittedCurve | FittedCurveData: if isinstance(v, dict): return FittedCurve(data=v["data"], poly_order=v["poly_order"]) return v
[docs] class SuspensionSetupModel(BaseModel): """Real-world suspension setup parameters.""" model_config = ConfigDict(validate_assignment=True) roll_stiffness_front_Nm_per_rad: float = Field( gt=0, description="Front roll stiffness (Nm/rad)" ) roll_stiffness_rear_Nm_per_rad: float = Field(gt=0, description="Rear roll stiffness (Nm/rad)") heave_stiffness_front_N_per_m: float = Field(gt=0, description="Front heave stiffness (N/m)") heave_stiffness_rear_N_per_m: float = Field(gt=0, description="Rear heave stiffness (N/m)") anti_dive_pct: float = Field(ge=0, le=1, description="Anti-dive percentage (0-1)") anti_squat_pct: float = Field(ge=0, le=1, description="Anti-squat percentage (0-1)") motion_ratio_front: float = Field( gt=0, description="Front motion ratio (wheel travel / shock travel)" ) motion_ratio_rear: float = Field( gt=0, description="Rear motion ratio (wheel travel / shock travel)" )
[docs] class DynamicsPriorBoundsModel(BaseModel): """Soft +/- percentage bounds for the yaw-response bicycle priors. Used by an LPV output-error fit to build the optimizer's parameter box around ``Ca_front``, ``Ca_rear`` and ``Izz``. """ model_config = ConfigDict(validate_assignment=True) Ca_front_pct: float = Field(ge=0, description="Front cornering-stiffness bound (+/- percent)") Ca_rear_pct: float = Field(ge=0, description="Rear cornering-stiffness bound (+/- percent)") Izz_pct: float = Field(ge=0, description="Yaw-inertia bound (+/- percent)")
[docs] class DynamicsSetupModel(BaseModel): """ Linear-region bicycle-model parameters used as soft priors for a LPV yaw-response output-error fit Backsolve from four cornering operating points (skidpad / steady turns) using SAE bicycle algebra: delta_tire = L/R + (m/L)(l_r/Ca_f - l_f/Ca_r) V^2/R beta = l_r/R - (m a_y l_f) / (L Ca_r) Ca_r is the multi-point consensus from three well-conditioned points. Ca_f comes from the lowest-a_y point only (front tires saturate above ~1g, biasing fits low). Izz should be an FSAE rule-of-thumb (~0.7 m wb track / 4). """ model_config = ConfigDict(validate_assignment=True) Ca_front_N_per_rad: float = Field( gt=0, description="Per-axle front cornering stiffness prior (N/rad)" ) Ca_rear_N_per_rad: float = Field( gt=0, description="Per-axle rear cornering stiffness prior (N/rad)" ) Izz_kg_m2: float = Field(gt=0, description="Yaw inertia about the CG (kg.m^2)") prior_bounds: DynamicsPriorBoundsModel = Field( description="Soft +/- percentage bounds for the optimizer" )
[docs] class ExtendedVehicleModel(VehicleModel): """VehicleModel extended with real-world session setup parameters. Set ``type: irl_setup`` in the YAML to trigger this model. Includes alignment, steering ackermann, suspension setup, and yaw-response dynamics priors on top of the base car parameters. """ model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) # Narrowing the discriminator Literal in a subclass is the standard # Pydantic V2 discriminated-union pattern; mypy treats it as an unsound # field override without the (currently incompatible) pydantic plugin. type: Literal["irl_setup"] = "irl_setup" # type: ignore[assignment] alignment: AlignmentModel = Field(description="Wheel alignment configuration") steering_setup: SteeringSetupModel = Field(description="Steering geometry and ackermann curve") suspension_setup: SuspensionSetupModel = Field( description="Real-world suspension setup parameters" ) dynamics_setup: DynamicsSetupModel = Field( description="Linear-bicycle priors for yaw-response identification" )
AnyVehicleModel: TypeAdapter[VehicleModel | ExtendedVehicleModel] = TypeAdapter( Annotated[VehicleModel | ExtendedVehicleModel, Field(discriminator="type")] )