Skip to content

How to add a throttle controller

A throttle is the motor-driven orifice between the airbox and the intake manifold. In aeolus1d it's modeled as an inlet BC of kind throttle — a stagnation reservoir on the airbox side, an orifice whose effective area is the open-fraction × A_max × Cd (Phase E1). The open fraction defaults to 1.0 (wide-open) at build time and is mutable at runtime through a small Throttle state object.

The fully-built-in closed-loop story (a [[controller]] kind = "throttle" block writing the open fraction from a manifold-pressure setpoint) is not in place today — it's a BACKLOG follow-up. This tutorial walks through the part that does work now: dropping a throttle BC into a case and mutating its open fraction either statically (one value at build time) or dynamically (from a small Python control script running alongside the solver).

Open the demo case

The bundled examples/72_throttle_demo.toml is a minimal pipe network with a throttle BC + a mass-flow extraction at the downstream end — the kind of layout a control script would exercise without engine baggage.

aeolus1d run examples/72_throttle_demo.toml --out /tmp/throttle.h5 --trace-dt 1e-3

Open the trace in the GUI (aeolus1d-gui /tmp/throttle.h5) and plot the manifold pipe's mid-pipe pressure time series. At WOT (open=1.0), the manifold sits near atmospheric — the 0.02 kg/s extraction at the right end produces only small pressure-wave dynamics.

Step 1 — Static partial-throttle via the build-time API

The throttle's open fraction lives on a Throttle dataclass (defined in aeolus1d.bc.throttle) that build_network records in the aeolus1d.io.build._THROTTLES registry under the (pipe_id, end) key. The public accessor is get_throttles_registry() in the same module. To start partially closed, build the case in Python and write the open fraction before the timeloop starts:

from aeolus1d.io.case import load_case
from aeolus1d.io.build import build_network, get_throttles_registry
from aeolus1d.solver.network_timeloop import advance_network

case = load_case("examples/72_throttle_demo.toml")
pipes, junctions, far_bcs = build_network(case)
throttles = get_throttles_registry()

# Set the throttle to 50 % open before any solver step runs.
throttles[("intake_manifold", "left")].open = 0.5

advance_network(
    list(pipes.values()), junctions, far_bcs,
    t_end=case.case.t_end, cfl=case.case.cfl,
)

The half-open throttle restricts mass flow — the manifold pressure drops noticeably below atmospheric for the same 0.02 kg/s draw, then recovers when the wave bounces.

Step 2 — Dynamic control via the on_step hook

For a closed-loop demo, pass an on_step(t) callback to advance_network that reads the manifold pressure and writes the throttle open fraction:

throttle = throttles[("intake_manifold", "left")]
manifold = pipes["intake_manifold"]
P_TARGET = 60_000.0   # 60 kPa
KP, KI = 1.0e-6, 5.0e-4
integ = 0.0

def control(t):
    global integ
    # pipe.primitives() -> (rho, u, p) arrays over the interior;
    # sample mid-pipe pressure.
    _, _, p = manifold.primitives()
    p_mid = float(p[manifold.N // 2])
    err = P_TARGET - p_mid
    integ += err * 1e-4   # rough dt — pull from the actual step in real code
    new_open = max(0.05, min(1.0, 0.5 + KP * err + KI * integ))
    throttle.open = new_open

advance_network(
    list(pipes.values()), junctions, far_bcs,
    t_end=case.case.t_end, cfl=case.case.cfl,
    on_step=control,
)

This shape — solver thread + Python control callback — mirrors how the boost-pressure controller works internally; it'll move into a first-class [[controller]] kind = "throttle" block once the BACKLOG follow-up lands.

Step 3 — When the closed-loop controller block ships

A future [[controller]] entry will look like:

[[controller]]
id               = "throttle_ctrl"
kind             = "throttle"      # not yet implemented; tracked on BACKLOG
throttle_pipe_id = "intake_manifold"
throttle_end     = "left"
probe_pipe_id    = "intake_manifold"
p_target         = 60.0e3
Kp               = 1.0e-6
Ki               = 5.0e-4
open_min         = 0.05
open_max         = 1.0

The schema follows the same pattern as egr_valve / fuel_injector / spark_timing — drop in the block, run, the controller owns the throttle's open fraction for the rest of the case.

Reference

  • examples/72_throttle_demo.toml — the runnable case this tutorial walks through.
  • aeolus1d.bc.throttle — the Phase-E1 throttle BC + Throttle state dataclass (make_throttle_bc).
  • aeolus1d.io.build.get_throttles_registry() / ._THROTTLES — the (pipe, end) → Throttle runtime registry populated by build_network (cleared at the start of each build).
  • See Closures — reference table for the full BC kind list.
  • See How to model EGR with O₂ feedback for a worked closed-loop example using a controller kind that IS in place today.