Skip to content

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_cycle
  • src/aeolus1d/cli/commands/drive_cycle.py::_write_drive_cycle_trace
  • src/aeolus1d/io/vehicle_case.py::load_vehicle_case
  • src/aeolus1d/io/vehicle_case.py::_check_keys
  • src/aeolus1d/io/vehicle_case.py::build_cycle
  • src/aeolus1d/io/vehicle_case.py::build_hybrid
  • src/aeolus1d/io/vehicle_case.py::run_vehicle_case
  • src/aeolus1d/io/vehicle_case.py::run_electrified_case
  • src/aeolus1d/analysis/vehicle_tasks.py::cycle_kpis
  • src/aeolus1d/drive_cycles.py::DriveCycle.__post_init__
  • src/aeolus1d/drive_cycles.py::DriveCycle.resample
  • src/aeolus1d/drive_cycles.py::DriveCycle.from_csv
  • src/aeolus1d/drive_cycles.py::DriveCycle.from_segments
  • src/aeolus1d/drive_cycles.py::urban_illustrative
  • src/aeolus1d/drive_cycles.py::highway_illustrative
  • src/aeolus1d/driver.py::follow_cycle
  • src/aeolus1d/driver.py::warn_if_driver_rings
  • src/aeolus1d/driver.py::Driver.demand
  • src/aeolus1d/vehicle.py::step_vehicle
  • src/aeolus1d/vehicle.py::engine_torque_for_wheel_torque
  • src/aeolus1d/vehicle.py::wheel_torque_from_engine
  • src/aeolus1d/hybrid.py::HybridController.split
  • src/aeolus1d/hybrid.py::ParallelHybrid.step
  • src/aeolus1d/hybrid.py::EMotor.deliverable_torque
  • src/aeolus1d/hybrid.py::EMotor.electrical_power_W
  • src/aeolus1d/hybrid.py::Battery.step
  • src/aeolus1d/battery_ecm.py::current_from_power
  • src/aeolus1d/driveline.py::recommended_gear
  • src/aeolus1d/driveline.py::auto_shift_step
  • src/aeolus1d/driveline.py::Clutch.transmit
  • src/aeolus1d/driveline.py::TorqueConverter.torque_ratio
  • src/aeolus1d/driveline.py::Driveline.wheel_state_from_engine
  • src/aeolus1d/driveline.py::vehicle_torque_input
  • src/aeolus1d/transmission_shift.py::AMTShiftController.update
  • src/aeolus1d/emotor.py::MappedEMotor.clip_torque
  • src/aeolus1d/emotor.py::MappedEMotor.electrical_power_W
  • src/aeolus1d/energy_management.py::SeriesHybrid.step
  • src/aeolus1d/energy_management.py::ThermostatEMS.update
  • src/aeolus1d/energy_management.py::ECMSGenset.split
  • src/aeolus1d/battery_ecm.py::RCNetworkBattery.step_power
  • src/aeolus1d/planetary.py::PlanetaryGear.split_carrier_torque
  • src/aeolus1d/engine_oil.py::OilThermalState.step
  • src/aeolus1d/engine_oil.py::FMEPViscosityModel.fmep_scale
  • src/aeolus1d/coolant.py::CoolantState.step