Skip to content

Python assembly: engines

Part of the pure-Python assembly ladder. A cylinder is the one component where hand-assembly stops being the obvious route — a running engine needs a crank, two valve ports with cam windows, a combustion model, wall heat, and a clock that all four agree on. Building that from scratch is a page of boilerplate before anything turns.

So the engine path starts from a scaffold and takes the objects apart.


The scaffold gives you the objects, not a black box

from aeolus1d.scaffold.engine import scaffold_single_cylinder_engine

engine = scaffold_single_cylinder_engine(rpm=1800.0, compression_ratio=10.0)
type      : SingleCylinderEngine
pipes     : ['<pipe>', '<pipe>']
cylinder  : V=5.02655e-05 m^3  p=100000 Pa
ports     : 2  far_bcs=2
crank     : omega=188.4956 rad/s  theta_init=0 deg  bore=0.08 stroke=0.09 m
combustion: WiebeCombustion start=360 deg

SingleCylinderEngine is a dataclass whose docstring says what it is for: "Every sub-object is exposed so the caller can hot-patch fields or hand-attach controllers before launching the timeloop." Its members are ordinary components:

Member What it is
engine.intake, engine.exhaust Pipe objects
engine.cylinder Cylinder — .volume, .ports, .combustion, .heat_transfer, .omega_crank
engine.clock the shared simulation clock (.t)
engine.far_bcs the (pipe, end, callable) list from the ladder's step 1
engine.pipes, engine.components convenience lists for advance_network

ω = 188.4956 rad/s is 1800 rpm, and V = 5.02655e-05 m³ is the clearance volume for a 80 × 90 mm bore/stroke at CR 10 — the scaffold derives them, so they are worth reading back rather than assuming.

Hot-patching, then running

Everything is a plain attribute, so changing spark timing is an assignment:

engine.cylinder.combustion.theta_start_deg = 355.0

Then drive it with the same advance_network as any other network — with one extra argument, clock, because the cylinder's crank angle is a function of simulation time and the timeloop must share the clock the ports read:

from aeolus1d.solver.network_timeloop import advance_network

t = advance_network(engine.pipes, engine.components, engine.far_bcs,
                    t_end=2.0e-3, cfl=0.4, clock=engine.clock)
advanced  : t=0.002 s  theta=21.60 deg  p_cyl=1.0084 bar  xb=0.0000

Read that output: 2 ms at 1800 rpm is 21.6 crank degrees, which is what last_theta_deg reports — the arithmetic checks out (1800 rpm = 10800 deg/s; 0.002 s × 10800 = 21.6). Burned fraction xb is 0, correctly: combustion starts at 355° and the crank has only reached 21.6°.

Pass the clock, or the crank never turns

The cylinder's valve windows and combustion phasing are functions of crank angle, which comes from simulation time via the clock. Omitting clock= leaves the timeloop with its own local clock, and the symptom is not a crash — the valves simply behave as if t never advanced. The same class of bug was a real defect once: under the Strang split the inner advance_network calls reset their clock to 0, so a ramp-from-zero urea schedule injected nothing for an entire run (test_strang_urea_samples_advancing_clock pins it).

Diagnostics the cylinder records

The cylinder writes its per-step state to last_* attributes, which is the cheapest instrumentation available — no on_step hook needed:

Attribute Meaning
last_theta_deg crank angle at the last step
last_xb burned mass fraction
last_pdV_power, last_wall_heat_power piston work, wall heat
last_knock_integral, last_knock_fired Livengood–Wu integral and its trip
last_port_open, last_port_area_frac per-port valve state

Pair them with on_step to build a trace, or read them after the run for a final-state summary.

Going further

For a multi-cylinder engine with a turbo and aftertreatment, the declarative route is the practical one. examples/python/89_epw_diesel.py is the generated builder for the six-cylinder EPW diesel and the biggest of the 79 at 721 lines; examples/python/39_4cyl_engine_turbo_scr.py is the four-cylinder SI engine at 540. Build one, patch the fields you care about, and run:

import runpy
ns = runpy.run_path("examples/python/89_epw_diesel.py")
case = ns["build_case"]()
for cyl in case.cylinders:
    cyl.combustion.theta_start_deg -= 5.0    # advance every cylinder 5°

Not every engine case has a combustion block

CylinderSpec.combustion holds a WiebeSpec — a prescribed burn — and it is None on cases that do not use one. Of the bundled engine examples only six carry it (52, 63, 71, 88, 89, 91). 39_4cyl_engine_turbo_scr is not among them: it has combustion = None and ignition = 'spark', releasing heat through the injector schedule (inject_cad / inject_rate) instead. Check before you reach for it —

if cyl.combustion is not None:
    cyl.combustion.theta_start_deg -= 5.0

There is no cyl.wiebe; the attribute is combustion.

Where to go next

Next Page
Turbochargers and shafts Turbo and control
Every [[cylinder]] field Cylinder block reference
The same engine as a TOML walkthrough Engine simulation end-to-end
Turning a run into IMEP / heat release Post-processing walkthrough