Recipe: wall temperature, burn shape, and fuelling limits¶
Three models that each replace a constant you had to guess. Every snippet below runs as written — copy it into a file and execute it.
The theory, and why each is shaped the way it is, is in Theory: wall temperature, burn shape, and fuelling limits.
All three are opt-in
Defaults are unchanged. Turning one on changes results — that is the point — so nothing here is switched on for you.
1. Let the wall find its own temperature¶
Before: a constant you guessed¶
from aeolus1d.components.cylinder import Cylinder
cyl = Cylinder(bore=0.130, stroke=0.160, T_wall=450.0) # a guess
450.0 is right at one operating point and wrong at every other.
After: solve it¶
from aeolus1d.closures.cylinder_wall_thermal import (
CylinderWallThermal,
liner_friction_power,
)
wall = CylinderWallThermal(
bore=0.130,
stroke=0.160,
T_coolant=363.0, # 90 °C
T_oil=373.0, # 100 °C
tau_cycles=12.0, # metal lag, in cycles
)
Feed it every solver step, then close the cycle:
# --- once per solver step, inside your march ---
wall.accumulate(
dt=dt,
T_gas=T_gas,
h_head=h, h_piston=h, h_liner=h, # from your h correlation
)
# --- once per cycle ---
wall.add_friction_power(
liner_friction_power(
fmep_pa=1.2e5,
displacement_m3=2.12e-3,
cycles_per_s=rpm / 120.0,
)
)
surfaces = wall.update()
print(surfaces.T_head, surfaces.T_piston, surfaces.T_liner)
update() returns the surfaces for the next cycle and resets the
integrals. Hand them to the cylinder's zone temperatures.
A complete, runnable demonstration¶
import math
from aeolus1d.closures.cylinder_wall_thermal import CylinderWallThermal
def run(T_gas_peak: float, n_cycles: int = 400) -> float:
wall = CylinderWallThermal(bore=0.130, stroke=0.160, tau_cycles=12.0)
for _ in range(n_cycles):
for i in range(720): # 1° steps
theta = math.radians(i)
# crude stand-ins: hot and high-h near TDC
hot = math.exp(-((i - 370) / 40.0) ** 2)
wall.accumulate(
dt=1.0e-5,
T_gas=400.0 + T_gas_peak * hot,
h_head=200.0 + 2500.0 * hot,
h_piston=200.0 + 2500.0 * hot,
h_liner=150.0 + 1500.0 * hot,
)
wall.update()
return wall.surfaces.T_head
for peak in (1200.0, 1800.0, 2400.0):
print(f"peak gas {peak:6.0f} K -> head {run(peak):6.1f} K")
The head temperature rises with load, which a constant cannot do. That is the whole point of the model.
Start with friction alone¶
Before wiring the full network, test the cheap hypothesis:
from aeolus1d.closures.cylinder_wall_thermal import liner_friction_power
q = liner_friction_power(fmep_pa=1.2e5, displacement_m3=2.12e-3,
cycles_per_s=12.5)
print(f"{q / 1000.0:.2f} kW into the liner, per cylinder")
If adding that to your existing constant-wall closure does not move a Q_WHT
shortfall in the right direction and with the right load trend, the
resistance network will not rescue it either. Cheaper to find out now.
fraction_to_liner=0.25 is a starting value
It is the older literature's ring-friction split, unverified here. Measure it before trusting it.
2. Make the burn shape follow the ignition delay¶
One field¶
from aeolus1d.closures.diesel_predictive_combustion import (
DieselPredictiveCombustion,
)
model = DieselPredictiveCombustion(
soi_deg=346.0,
inj_duration_deg=18.0,
m_fuel=1.2e-4,
burn_law="watson", # <- was "wiebe"
)
That is the whole change. burn_law="wiebe" (the default) keeps a fixed
premixed duration; "watson" evaluates SAE 800029 directly, so the premixed
sharpness follows τ_id · N and the diffusion rate follows φ.
Seeing it respond¶
import math
from aeolus1d.closures.diesel_predictive_combustion import (
DieselPredictiveCombustion,
)
omega = 2.0 * math.pi * 1400.0 / 60.0
common = dict(m_charge=2.7e-3, omega_crank=omega, mean_piston_speed=8.0)
model = DieselPredictiveCombustion(soi_deg=346.0, burn_law="watson")
for cycle, (p, T, label) in enumerate(
[(90e5, 1000.0, "hot charge"), (35e5, 680.0, "cold charge")], start=1
):
model.advance(1e-5, 360.0, cycle=cycle, p=p, T=T, **common)
print(f"{label:12s} delay {model.last_tau_id_deg:5.2f}°CA "
f"K1 {model._msw.shape.K1:.3f} beta {model.last_premixed_fraction:.3f}")
The colder charge delays ignition, and K1 follows it. A fixed premixed
duration cannot express that, and phases wrongly as soon as load moves the
delay.
Using the law on its own¶
from aeolus1d.closures.watson_burn_law import (
watson_burn_rate, watson_burnt_fraction, watson_shape,
)
shape = watson_shape(tau_id_ms=1.0, rpm=1500.0, phi=0.5)
for tau in (0.0, 0.02, 0.05, 0.2, 0.5, 1.0):
print(f"tau {tau:4.2f} x_b {watson_burnt_fraction(tau, 0.4, shape):.4f}"
f" dx/dtau {watson_burn_rate(tau, 0.4, shape):8.3f}")
tau_id_ms is milliseconds
Passing seconds does not raise — it collapses K1 to 2.0 and silently
disables the correlation. The difference is only about 1.5×, which is why
it is easy to miss.
When the injection rate matters, use Chmela instead¶
Watson cannot see the injector. A pilot, a post and a ramped needle all give
it the same curve, because the correlation's only inputs are the delay and φ.
burn_law="chmela" reads an actual injection profile:
from aeolus1d.closures.chmela_mcc import InjectionRate
from aeolus1d.closures.diesel_predictive_combustion import (
DieselPredictiveCombustion,
)
dps = 1400.0 * 6.0 # deg/s at 1400 rpm
m_fuel = 1.2e-4
pilot_and_main = InjectionRate(
theta_deg=(339.9, 340.0, 343.0, 343.1, 345.9, 346.0, 364.0, 364.1),
mdot=(0.0, 0.25 * m_fuel * dps / 3.0, 0.25 * m_fuel * dps / 3.0, 0.0,
0.0, 0.75 * m_fuel * dps / 18.0, 0.75 * m_fuel * dps / 18.0, 0.0),
)
model = DieselPredictiveCombustion(
soi_deg=346.0, inj_duration_deg=18.0, m_fuel=m_fuel,
burn_law="chmela",
injection_rate=pilot_and_main,
)
Without injection_rate it falls back to a rectangle built from
soi_deg/inj_duration_deg — it runs, but a rectangle has no rate shape, so
it gives up most of what Chmela offers over Watson.
Seeing it respond:
import math
from aeolus1d.closures.chmela_mcc import ChmelaMCC, rectangular_injection
OMEGA = 2.0 * math.pi * 1500.0 / 60.0
M = 1.2e-4
def burn(duration_deg: float, hole_m: float = 1.6e-4) -> tuple[float, float]:
inj = rectangular_injection(soi_deg=350.0, duration_deg=duration_deg,
m_fuel=M, omega_crank=OMEGA)
m = ChmelaMCC(injection=inj, m_fuel=M, hole_diameter_m=hole_m)
m.reset_cycle()
dps, n = OMEGA * 180.0 / math.pi, 4000
dth = 140.0 / n
dt, theta, peak = dth / dps, 340.0, 0.0
for _ in range(n):
V = 8e-5 + 9e-4 * (1.0 - math.cos(math.radians(theta - 360.0)))
peak = max(peak, m.advance(dt, theta, V=max(V, 8e-5),
omega_crank=OMEGA))
theta += dth
return m.burnt_fraction, peak / 1e3
for label, kw in (("20 deg, 0.16 mm", {}),
("10 deg, 0.16 mm", {"duration_deg": 10.0}),
("20 deg, 0.12 mm", {"hole_m": 1.2e-4})):
f, pk = burn(kw.pop("duration_deg", 20.0), **kw)
print(f"{label:18s} burnt {f:.3f} peak {pk:7.1f} kW")
A shorter injection and a smaller nozzle both raise the peak, through the spray turbulence they generate. Nothing in Watson can express either.
The constants are calibration, and two were not verified
C_mod and C_rate are calibration constants in every published use of
this model. C_turb and C_diss govern the turbulence sub-model and
were not verified against the source paper — they are exposed as
parameters rather than buried for exactly that reason. Calibrate against
measured heat release.
It integrates; the others sample
Chmela carries released heat and spray kinetic energy across the cycle.
Sampling the same crank angle twice does not give the same answer, as
it would for a Wiebe or Watson curve. reset_cycle() starts a fresh
cycle; the closure does that for you on a new cycle index.
3. Cap fuelling at what the air can burn¶
The chain¶
import math
from aeolus1d.control import SpeedGovernor, SmokeLimiter, governed_fuelling
governor = SpeedGovernor(
m_fuel_max=2.0e-4, # kg/cycle/cylinder at the stop
omega_set=2.0 * math.pi * 1500.0 / 60.0,
droop=0.05, # 5 % droop
)
limiter = SmokeLimiter(lambda_min=1.4, afr_stoich=14.5)
Then once per cycle:
result = governed_fuelling(
governor, limiter,
omega=omega_now,
dt=cycle_time_s,
m_air_trapped=m_charge * (1.0 - burnt_fraction), # AIR, not charge
)
print(f"demand {result.m_fuel_demand:.3e} -> fuelled {result.m_fuel:.3e}"
f" limited={result.limited} lambda={result.lambda_actual:.2f}")
A load step, where it earns its place¶
import math
from aeolus1d.control import SpeedGovernor, SmokeLimiter, governed_fuelling
omega_set = 2.0 * math.pi * 1500.0 / 60.0
governor = SpeedGovernor(m_fuel_max=2.0e-4, omega_set=omega_set, droop=0.05)
limiter = SmokeLimiter(lambda_min=1.4)
# the turbo spools over ~1 s; trapped air follows it
for i in range(6):
m_air = 1.0e-3 + i * 1.4e-3
r = governed_fuelling(governor, limiter, omega=omega_set * 0.93,
dt=0.04, m_air_trapped=m_air)
flag = "LIMITED" if r.limited else "free"
print(f"air {m_air * 1e3:5.2f} g fuel {r.m_fuel * 1e6:6.1f} mg "
f"lambda {r.lambda_actual:5.2f} {flag}")
Early on the governor wants full fuel and the air refuses; as boost arrives the cap lifts. Without the limiter every one of those cycles would burn fuel that has no oxygen.
Feed it air, not charge
Recirculated exhaust is mass and is not oxygen. Passing total charge mass at a high-EGR point licenses exactly the over-fuelling the limiter exists to prevent — worst where it matters most.
4. Share a component between cases¶
A part lives in its own file with no [case] block. Bundled parts live under
examples/parts/, kept out of the case lineup on purpose: a part has no
[case] block and cannot be run, so aeolus1d examples does not list one
and nothing counts it as a bundled case.
# examples/parts/std_pipe.toml
length = 0.75
n_cells = 200
area = 0.0035
[init]
p = 1.0e5
T = 300.0
u = 0.0
A case names it and overrides only what differs:
[[pipe]]
id = "tube"
ref = "parts/std_pipe"
length = 1.25 # n_cells, area and [init] come from the part
from aeolus1d.io.parsers import load_case
case = load_case("examples/92_component_library.toml")
print(case.pipes[0].length) # 1.25 — the override
print(case.pipes[0].n_cells) # 200 — from the part
print(case.component_refs) # {'pipe[0]': 'parts/std_pipe'}
Local keys always win. The part supplies defaults; the case overrides them. The reverse would let a re-measured part silently change a case that had deliberately overridden it.
Refs resolve against the case directory first, then library_roots, then
$AEOLUS_COMPONENT_LIBRARY (a PATH-style list) — so a case can ship a local
override of a shared part without touching the library:
export AEOLUS_COMPONENT_LIBRARY=/srv/parts:/home/me/parts
Nested tables merge; arrays are replaced wholesale. A part may itself carry
ref, so a part can be a variant of another part. Cycles are refused with
the chain named.
Saving flattens the reference
The GUI writer emits every field explicitly, so saving a case built from
a ref inlines the part and loses the link. It still reloads to an
identical case, but the next re-measurement of that part will no longer
reach it. save_case warns when this happens — keep the original TOML as
the source of truth.
Run the bundled example:
aeolus1d run examples/92_component_library.toml
Where to go next¶
- Theory — why each model is shaped this way, and the numerics that matter.
- Recipe: EGR with O₂ feedback — the smoke limiter pairs naturally with EGR control.
- Engine end-to-end — where these plug into a full case.