Vehicle system and drive cycle¶
The [vehicle] / [driver] / [drive_cycle] case that aeolus1d drive-cycle
reads: a closed-loop PI driver follows a speed-time profile, a one-degree-of-
freedom longitudinal vehicle integrates the road load, and the run ends in
fuel and energy totals. It is a separate path from the gas-dynamics case
(aeolus1d run): it does not touch io.schema, io.build or the solver, and
the bundled examples 85 (conventional) and 86 (parallel hybrid) are the two
cases that use it. The contracts of every function below are in their
docstrings (the standard is Function contracts and flow charts).
Two things the chart makes plain. First, the engine here is a number, not a
map: fuel comes from one brake-specific fuel consumption ([report]
bsfc_g_per_kwh) times the positive crank work, so there is no engine map, no
gearbox and no torque converter on this path. Second, driveline.py,
transmission_shift.py, planetary.py, emotor.py, energy_management.py,
engine_oil.py and coolant.py are stand-alone models: nothing in
src/ or the GUI imports them except one another (hybrid.py imports
battery_ecm.py, energy_management.py imports emotor.py and
battery_ecm.py, transmission_shift.py imports driveline.py); only the unit
tests and examples/plots/ call them. They are drawn in the last section so
the boundary is visible.
Case to run¶
What enters: a TOML file. What leaves: a VehicleCase, then a printed report
and, with --out, a CSV trace. Each refusal is a ValueError naming its block
and key. aeolus1d drive-cycle prints each as one error: <case>: <reason>
line on stderr and exits 2, malformed TOML included (a TOMLDecodeError is a
ValueError); a missing file also exits 2.
flowchart TD
IN["case TOML: [vehicle] [driver] [drive_cycle] [report] [powertrain]"] --> LD{"load_vehicle_case"}
LD -- "file missing" --> XF["FileNotFoundError: error line, exit 2"]
LD -- "invalid TOML" --> XT["TOMLDecodeError: error line, exit 2"]
LD --> V{"_check_keys [vehicle], [driver]"}
V -- "not a table, or an unknown key" --> X1["ValueError naming the block and the allowed keys"]
V --> P{"mass, rotating factor, frontal area, wheel radius, gear ratio, rho, g, eta all positive and finite?"}
P -- no --> X2["ValueError: [vehicle] name = value must be positive"]
P --> CY{"build_cycle: source"}
CY -- "builtin" --> B1["urban_illustrative or highway_illustrative"]
CY -- "builtin, a regulatory name" --> X3["ValueError: not bundled, supply a csv"]
CY -- "csv" --> B2["DriveCycle.from_csv: path relative to the case file"]
CY -- "segments" --> B3["DriveCycle.from_segments: Segment list"]
CY -- "dt_s not positive and finite, an unknown source, a leftover key" --> X4["ValueError naming [drive_cycle]"]
B1 --> DC["DriveCycle: strictly increasing finite time, finite non-negative speed [m/s]"]
B2 --> DC
B3 --> DC
DC -- "invalid profile" --> X5["ValueError, from a csv it names the file, and a bad cell its line"]
DC --> R{"[report] keys: bsfc_g_per_kwh, fuel_density_kg_l, fuel_co2_g_per_g"}
R -- "unknown key" --> X6["ValueError: [report] has unknown key(s)"]
R --> H{"[powertrain] present?"}
H -- yes --> HB["build_hybrid: _check_keys on motor, battery, controller"]
HB -- "unknown sub-table or key" --> X7["ValueError naming powertrain.sub"]
HB --> CASE
H -- no --> CASE["VehicleCase: vehicle, driver, cycle, dt_s, grade_rad, report, hybrid or None"]
CASE --> CMD{"_cmd_drive_cycle: case.hybrid is not None?"}
CMD -- no --> RUN1["run_vehicle_case (second chart)"]
CMD -- yes --> RUN2["run_electrified_case (third chart)"]
[driver] and cd are not range-checked, and a Battery capacity, SoC
or a controller motor_fraction outside its physical range is accepted.
dt_s defaults to 0.1 s so the driver's discrete pole 1 - kp*dt stays inside
the unit circle at the default kp = 2.
The closed loop: driver, vehicle, trace¶
follow_cycle resamples the cycle to dt_s, starts the vehicle at the
first target speed (no launch from rest unless the cycle starts at 0), and
for each sample asks the driver for a torque and a brake force, records the
speed before stepping, then steps the plant. It steps the plant n - 1
times for n samples, so distance_m covers the cycle's duration and no more;
the last sample's demand is recorded but not applied (until 2026-10-11 it made
n steps).
flowchart TD
CY["DriveCycle, VehicleParameters, DriverParameters, dt_s, grade_rad"] --> WR{"warn_if_driver_rings: kp*dt_s >= 1?"}
WR -- yes --> W1["UserWarning: the speed trace will ring, kp*dt_s >= 2 diverges"]
WR --> RS{"DriveCycle.resample(dt_s)"}
RS -- "dt_s <= 0 or NaN" --> X1["ValueError: dt_s must be > 0"]
RS -- "dt_s longer than the cycle" --> X2["ValueError: needs at least 2 points"]
RS --> ST["state: v = first target speed, x = 0"]
ST --> LP{"sample i of n"}
LP --> DM{"Driver.demand(v_target, state, dt, grade)"}
DM -- "error, dt or grade NaN or infinite" --> X3["ValueError, integrator untouched"]
DM --> REC["record torque, brake and the speed before the step"]
REC --> LST{"i = n - 1?"}
LST -- "no" --> SV["step_vehicle: forward Euler, position from the new speed"]
SV --> LP
LST -- "yes: last demand recorded, not applied" --> OUT["CycleFollowResult: t, v_target, v_actual [m/s], engine torque [N m], brake [N], distance_m, tracking_rmse_mps"]
Inside Driver.demand the law is a PI on the speed error plus the road-load
feed-forward; the torque and the brake are what is left after the engine's
envelope. step_vehicle is m*rotating_mass_factor * dv/dt = F_drive - F_roll
- F_aero - F_grade - F_brake, with F_roll and the brake force zero in the
|v| <= 1e-6 dead band and a brake that would carry the speed through zero
stopping the vehicle instead. Since 2026-10-09 the same clamp covers a
negative crank torque (engine braking, regen), the rolling resistance and the
drag: none of them takes a vehicle that was not going backwards backwards; only
the grade can (test_engine_braking_stops_a_vehicle_and_cannot_reverse_it).
Before that the clamp covered the friction brake only, and in examples 85 and
86 the speed trace was negative for 166 and 146 of 1501 samples (down to -0.28
and -0.023 m/s).
flowchart TD
IN["v_target, v, dt, grade"] --> A["err = v_target - v, integral += err*dt, clamped to +-i_clamp/ki when ki > 0"]
A --> B["a_cmd = kp*err + ki*integral [m/s^2]"]
B --> C["F = m*rotating_mass_factor*a_cmd"]
C --> FF{"feedforward?"}
FF -- yes --> D["F += rolling + aero at max(v, 0), + m g sin(grade)"]
FF -- no --> E
D --> E["T = engine_torque_for_wheel_torque(F*r): losses on the side the power flows from"]
E --> S{"T against the envelope"}
S -- "T above engine_torque_max_Nm" --> S1["T = max, this step integral is backed out"]
S -- "T below engine_torque_min_Nm" --> S2["T = min (engine braking), brake = the rest of the force, in [0, brake_force_max_N], integral backed out"]
S -- inside --> S3["T as computed, brake 0"]
S1 --> OUT["(engine torque [N m], brake force [N])"]
S2 --> OUT
S3 --> OUT
The electrified run¶
run_electrified_case repeats the loop on a copy of the hybrid (so the
case's battery starts every run at its declared SoC), with the crank speed
omega = max(v, 0)/r * gear_ratio [rad/s]. The driver's torque demand goes
through ParallelHybrid.step; the plant advances on the sum of the engine
and motor torques. Like follow_cycle it steps the battery and the vehicle
n - 1 times for n samples: the last sample's split is taken on a throwaway
copy and not applied (until 2026-10-11 both were stepped n times).
flowchart TD
IN["case with hybrid, the driver's demand T [N m], omega_crank [rad/s], dt"] --> NH{"hybrid is None?"}
NH -- yes --> X0["ValueError: needs a [powertrain] block"]
NH --> SP{"HybridController.split(T)"}
SP -- "unknown strategy, for any demand" --> X1["ValueError: unknown strategy"]
SP -- "T below 0 and regen off, or engine_only" --> N0["(0, 0): the braking request is dropped"]
SP -- "T below 0, regen on" --> N1["(0, T) to the motor"]
SP -- "T >= 0" --> N2["engine_only, motor_only, fixed_fraction, or load_following: motor keeps engine_min_T, engine takes the excess"]
N0 --> LK
N1 --> LK
N2 --> LK{"SoC lock-outs"}
LK -- "motor drive demand and SoC <= 0.001" --> L1["the drive share moves to the engine"]
LK -- "motor regen demand and SoC >= 0.999" --> L2["the regen share is dropped: it goes nowhere"]
LK --> MD{"EMotor.deliverable_torque(T_motor, omega)"}
L1 --> MD
L2 --> MD
MD -- "NaN request or speed" --> X2["ValueError"]
MD --> EL["electrical_power_W: P/eta_drive driving, P*eta_regen regenerating"]
EL --> BT{"Battery.step(P, dt): current_from_power from OCV and R"}
BT -- "NaN power or step" --> X3["ValueError"]
BT --> SOC["SoC -= I*dt/3600/capacity_Ah, clamped to [0, 1]"]
SOC --> OUT["(T_engine, T_motor), the vehicle steps on T_engine + T_motor"]
A clipped motor request is not made up by the engine, and the driver closes the loop on speed, so a shortfall shows up as tracking error, not as an exception.
Fuel and energy totals¶
The totals are reductions of the finished trace; nothing inside the loop
accumulates fuel. The two paths define the work differently, and both divide
the energy by 3.6e6 to go from J to kWh.
flowchart TD
TR["CycleFollowResult: t, v_actual, engine torque, distance"] --> KP{"conventional: cycle_kpis"}
TR --> EK{"electrified: run_electrified_case"}
KP --> K1["positive wheel work = sum over the N-1 intervals of max(eta*GR*T_i*v_i/r, 0) * (t[i+1] - t[i]) / 3.6e6 [kWh], the work the plant applied"]
K1 --> K2{"bsfc_g_per_kwh given and distance > 0?"}
K2 -- no --> K3["fuel and CO2 stay None, the CLI prints a hint"]
K2 -- yes --> K4["engine work = wheel work / eta, fuel_g = bsfc * engine work, L/100km = fuel_g/1000/rho/km*100, CO2 g/km = fuel_g*co2/km"]
KP -- "empty trace" --> XE["ValueError: the result holds no samples"]
EK --> E1["electrical kWh = sum of the positive steps of dSoC * capacity_Ah * OCV / 1000 (GROSS discharge), regen kWh = the negative steps"]
EK --> E2["engine crank work = sum over the n-1 applied steps of max(T_engine*omega_wheel*GR, 0) * mean(dt) / 3.6e6 [kWh]"]
E2 --> E3{"bsfc given and distance > 0?"}
E3 -- yes --> E4["fuel_g = bsfc * crank work (no efficiency: it is already crank work), the same L/100km and CO2 g/km"]
E3 -- no --> E5["engine fuel and CO2 stay None"]
K4 --> OUT["printed report, optional CSV trace"]
E1 --> OUT
E4 --> OUT
Units on the path: speeds are m/s inside (Segment.target_kmh and a CSV in
kmh are converted by /3.6; the KPI report converts back to km/h); torques
N m, forces N, speeds of rotation rad/s (omega_crank, not rpm); SoC a
fraction (the report prints it as a percentage); battery energy
SoC * Ah * V is in Wh and reported in kWh; the OCV used is the one before
the step, so the energy is the cell's internal energy, not the terminal
energy (the I^2R heat is not counted).
The stand-alone models¶
These are not reached by any case or command. They share the conventions above
(SI units, SoC as a fraction) and are exercised by their own unit tests and by
examples/plots/ (plot_70, plot_84).
flowchart TD
GB["Gearbox: ratios, current_gear -1..N"] --> RG{"recommended_gear / auto_shift_step: closest to the target rpm in the band"}
RG --> AM["AMTShiftController: DISENGAGE, SYNC, ENGAGE, one phase change per update"]
AM --> CL["Clutch.transmit: clamped engagement fraction"]
CL --> TC["TorqueConverter.torque_ratio: 0 below the pump floor, K_stall blending to 1 at lockup"]
TC --> DL["Driveline.wheel_state_from_engine: torque times the total ratio, no efficiency"]
DL --> VI["vehicle_torque_input: fed to step_vehicle, which applies its own efficiency again"]
MM["MappedEMotor: bilinear efficiency map, clip_torque, electrical_power_W"] --> SH{"SeriesHybrid.step: motor, ThermostatEMS genset, RCNetworkBattery"}
EC["ECMSGenset.split: a linear cost, so the engine is off or at full power"] --> SH
RC["RCNetworkBattery.step_power: current_from_power, RC overpotentials"] --> SH
SH --> FU["fuel_kg = P_genset / (eta*LHV) * dt"]
PG["PlanetaryGear: lever relations, torque split k/(1+k) to the ring"]
OL["OilThermalState and FMEPViscosityModel: forward Euler, Walther viscosity, FMEP scale"]
CO["CoolantState: consumers' heat in minus the radiator's heat out"]
Functions¶
src/aeolus1d/cli/commands/drive_cycle.py::_cmd_drive_cyclesrc/aeolus1d/cli/commands/drive_cycle.py::_write_drive_cycle_tracesrc/aeolus1d/io/vehicle_case.py::load_vehicle_casesrc/aeolus1d/io/vehicle_case.py::_check_keyssrc/aeolus1d/io/vehicle_case.py::build_cyclesrc/aeolus1d/io/vehicle_case.py::build_hybridsrc/aeolus1d/io/vehicle_case.py::run_vehicle_casesrc/aeolus1d/io/vehicle_case.py::run_electrified_casesrc/aeolus1d/analysis/vehicle_tasks.py::cycle_kpissrc/aeolus1d/drive_cycles.py::DriveCycle.__post_init__src/aeolus1d/drive_cycles.py::DriveCycle.resamplesrc/aeolus1d/drive_cycles.py::DriveCycle.from_csvsrc/aeolus1d/drive_cycles.py::DriveCycle.from_segmentssrc/aeolus1d/drive_cycles.py::urban_illustrativesrc/aeolus1d/drive_cycles.py::highway_illustrativesrc/aeolus1d/driver.py::follow_cyclesrc/aeolus1d/driver.py::warn_if_driver_ringssrc/aeolus1d/driver.py::Driver.demandsrc/aeolus1d/vehicle.py::step_vehiclesrc/aeolus1d/vehicle.py::engine_torque_for_wheel_torquesrc/aeolus1d/vehicle.py::wheel_torque_from_enginesrc/aeolus1d/hybrid.py::HybridController.splitsrc/aeolus1d/hybrid.py::ParallelHybrid.stepsrc/aeolus1d/hybrid.py::EMotor.deliverable_torquesrc/aeolus1d/hybrid.py::EMotor.electrical_power_Wsrc/aeolus1d/hybrid.py::Battery.stepsrc/aeolus1d/battery_ecm.py::current_from_powersrc/aeolus1d/driveline.py::recommended_gearsrc/aeolus1d/driveline.py::auto_shift_stepsrc/aeolus1d/driveline.py::Clutch.transmitsrc/aeolus1d/driveline.py::TorqueConverter.torque_ratiosrc/aeolus1d/driveline.py::Driveline.wheel_state_from_enginesrc/aeolus1d/driveline.py::vehicle_torque_inputsrc/aeolus1d/transmission_shift.py::AMTShiftController.updatesrc/aeolus1d/emotor.py::MappedEMotor.clip_torquesrc/aeolus1d/emotor.py::MappedEMotor.electrical_power_Wsrc/aeolus1d/energy_management.py::SeriesHybrid.stepsrc/aeolus1d/energy_management.py::ThermostatEMS.updatesrc/aeolus1d/energy_management.py::ECMSGenset.splitsrc/aeolus1d/battery_ecm.py::RCNetworkBattery.step_powersrc/aeolus1d/planetary.py::PlanetaryGear.split_carrier_torquesrc/aeolus1d/engine_oil.py::OilThermalState.stepsrc/aeolus1d/engine_oil.py::FMEPViscosityModel.fmep_scalesrc/aeolus1d/coolant.py::CoolantState.step