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")]
)