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 |