From ae7ca126ee842ab5b7c60220d1dd5b14485b10b0 Mon Sep 17 00:00:00 2001 From: zolaski333 Date: Thu, 22 Oct 2026 20:00:43 +0000 Subject: [PATCH 01/12] knock: the end gas as its own zone, and a control that steps The end gas the flame has not reached is followed from the seal, isentropic with a real cp(T), against Douaud-Eyzat's delay integrated from the spark, with its prefactor doubled: at 17.68 ms every catalogue engine with a knock limit, NA ones included, held 4-13 deg of retard behind its own map. The knock's intensity is the pressure the end gas adds. The ECU's knock control pulls the spark back in steps and gives it back at 1 deg/s. Boost no longer speeds the flame. The Audi I5 gets its intercooler, and the catalogue gate's absorber reaches 35 bar of BMEP. Woschni's combustion term, measured with this work, is backed out: it stalls lugged and launched engines the tests hold (journal 2026-10-23). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FBmqGMLt8GmUG1aPYHCEVw --- .claude/CLAUDE.md | 15 +- CHANGELOG.md | 15 ++ docs/ecu-tuning.md | 16 ++- docs/journal.md | 21 +++ docs/simulation-model.md | 61 ++++++-- engines/05_audi_i5_like.engine.yaml | 13 +- .../calibration/EcuCalibrationKeys.hpp | 4 +- src/calibration/src/EcuCalibration.cpp | 4 +- .../include/enginelab/ecu/SimpleEcuModel.hpp | 7 +- src/ecu/src/SimpleEcuModel.cpp | 19 ++- .../enginelab/foundation/EngineTypes.hpp | 6 + .../enginelab/physics/EndGasKnockModel.hpp | 61 +++++++- src/physics/src/EndGasKnockModel.cpp | 135 ++++++++++++++---- src/physics/src/FlamePhysicsModel.cpp | 7 +- src/simulation/src/EngineSimulator.cpp | 27 +++- tests/ActiveLimitsTests.cpp | 20 ++- tests/CoreTests.cpp | 86 +++++++++-- tests/KnockControlTests.cpp | 62 +++++--- tests/LiveTuningTests.cpp | 14 +- tools/ComparisonHarness.cpp | 19 ++- tools/DynoSweepHarness.cpp | 39 ++++- 21 files changed, 528 insertions(+), 123 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 224f580d..6e5d9d0b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -31,7 +31,11 @@ without the owner's explicit agreement. 4. **Knock.** Most engines, even the biggest, survive a boost plus an advance that would destroy a real one. Fix the knock PHYSICS, not the damage model. The designer's ignition is a template because there is no knock limit to - calibrate MBT against, so this item unblocks that one. + calibrate MBT against, so this item unblocks that one. The end gas knocks + since 2026-10-22, a stepped control holds the limit, and Douaud-Eyzat's + prefactor (x2) puts the catalogue's maps at their limit (2026-10-23). Open: + the I5 holds 8-12 deg of retard at its maker's boost; Woschni's combustion + term (right for the TDI's BSFC, it stalls lugged engines) was backed out. 5. **Editing on the engine**: compression ratio by clicking the engine, every turbo visible when two or more are fitted, carburettor parameters by clicking (if one is added), and two exhaust paths joinable into one (a @@ -225,10 +229,11 @@ Each measurement is in `docs/journal.md` or the docs archive (tag fail, 5 more trade the lean first cycle for rich or misfires); seeding the high-load correction cell from the idle cell (saves a start at 4 s but stalls 10 engines of 14 after 25 s of idle) and an asynchronous pulse on tip-in. -- **Knock, one input at a time** (journal 2026-10-13): the end-gas integral - reads the MEAN chamber temperature with a delay 2-5 x slow; isentropic end gas - never knocks; plus Douaud-Eyzat it over-knocks (1,000-1,200 K); integral - control pulls 12 deg for nothing. +- **Knock** (journal 2026-10-13, -22): end gas at the MEAN temperature, or at + constant cv (1,000-1,200 K); proportional or integral control; Kalghatgi's + octane index (boost gains 1-3 octane); an ECU boost cut on knock (I5 363 + N.m); the I5 at 2.1 bar. A weaker Douaud-Eyzat pressure exponent has no + source (pressure matters more in the NTC band). - **The other simulator's ingredients, one at a time** (journal 2026-10-13/14): output stage, pressure-release outlet, valve-area noise, mild room IR -- none kept (blind listening or measurement). Its fine mesh is not shown to matter. diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f1159a..226e73ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,21 @@ file formats, always with an in-memory migration). 125 cc cylinders a 16.6 mm bore on a single and 33.2 mm on a four. Singles gain 18-29 % of peak torque; triples, fours and V4s lose the 6-9 % their oversized bores gave them; twins are unchanged. +- **Engines knock.** A boost and an advance that would destroy a real engine + now knock: the end gas the flame has not reached is followed as its own + zone, compressed from the seal, against an autoignition delay fitted on + reference fuels in a knocking engine (Douaud-Eyzat), and the knock's + intensity is the pressure that gas adds. The delay's prefactor is + calibrated so that the catalogue's own spark maps sit at their knock limit, + as a production map does: 10 deg of advance over a map knocks, and a 2JZ + run that way at full load with knock control off knocks heavily. The knock + control now works as a production one: it pulls the spark back in steps of + about a degree while the engine knocks and gives it back at 1 deg/s, so an + engine settles at its knock limit instead of sitting in light knock. The + 2JZ, EJ25 and Audi I5 get the intercoolers their real engines have. +- Boost no longer burns the charge faster: the flame's turbulence follows the + piston, not the intake pressure, so a boosted cylinder burns like a full + unboosted one. ### Changed diff --git a/docs/ecu-tuning.md b/docs/ecu-tuning.md index 34fdf5e6..20456c1e 100644 --- a/docs/ecu-tuning.md +++ b/docs/ecu-tuning.md @@ -30,11 +30,17 @@ neutral at 0. The spark slider adds a trim in degrees to the table. Cranking, temperature, transient enrichment, knock and rev-limiter logic apply after that. -Knock control retards the spark by `knock.retard_deg_per_intensity` degrees -per unit of knock intensity (0 to 1), 12° by default, read with -`knockRetardGainDegrees()`. Zero turns it off: the knock then runs free. The -control is proportional and continuous; a real ECU retards in steps per knock -event and recovers slowly, which is not modelled. Diesels have no entry. +Knock control works as a production ECU's does: while the knock intensity is +above 0.1 (about 1 bar of oscillation, where measurements start calling a +cycle knocking) it pulls the spark back fast, 20 x the excess per second as a +share of its authority: a heavy event (0.5, decaying at 45/s) pulls about 1.3°, +within the 1-3° production controls take per knocking cycle. In a clean stretch it +gives the advance back at 1°/s. The retard therefore settles at the knock-limited +advance instead of needing knock to hold it. Its authority, the most it may +pull, is `knock.retard_deg_per_intensity` (12° by default; the key keeps the +name of the proportional control it replaced), read with +`knockRetardGainDegrees()`. Past that much retard the knock is no longer held. +Zero turns it off: the knock then runs free. Diesels have no entry. Launch control (`limits.launch_rpm`, off by default) moves the limiter's cut down to that speed while the vehicle stands in gear (under 5 km/h) with the diff --git a/docs/journal.md b/docs/journal.md index e926b8ca..7d62236e 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -5,6 +5,27 @@ measured, how, and what was not. Anything that becomes a lasting rule goes into `.claude/CLAUDE.md`. Earlier history lives in the `archive/docs-2026-09` git tag (`docs/archive/`). +## 2026-10-23 — Knock: Douaud-Eyzat's prefactor doubled; Woschni's combustion term backed out + +- Linux, `linux-gcc-release`. The 2026-10-22 knock work failed 8 ctest gates its baseline (91c04d3) passes. Ablation by switches, one ingredient each. +- Woschni's combustion term plus the 4V/B liner stalled the RD350's standing start (min 552 -> 260 rpm), the lugged I4 of CombustionPhasing and moved CameraMicrophone's click metric; either one off fixed them. Backed out with the TDI refit it forced (280 mm2, 0.028 law): still the TDI's 171 g/kWh, an open lead. +- I5 at 2,000 rpm, 2.48 bar, map 8.7 deg, control off: CA50 ~8 ATDC, end gas 955-965 K at 96-100 bar, 13-33 % unburned at onset, tau 0.37 ms. Seal 377 K at 2.28 bar, manifold 64 degC: plausible inputs. +- Retard held behind the map (8 engines, 2-9.5 krpm): x1 delay K20 7-12 deg, LS3 5-9, 2JZ 5-12, EJ25 10-12, I5 saturated; x2: NA 0-2, 2JZ 0-4.6, EJ25 0-5.4, I5 8-13 at light knock; x3 all ~0. Same with Woschni off. Kept x2 (35.4 ms): a production map sits 0-3 deg from its limit. +- x2, control off: map clean; +5 deg light (K20 0.28 at 3,500); +10 deg K20 0.23-0.39, 2JZ 0.80 at 5,000 (all frames heavy). +- KnockControl/LiveTuning now advance the 2JZ map 8 deg to reach a limit. CatalogPhysics' absorber stopped at 25 bar BMEP, below the I5's ~26: now 35 bar, gains x25/35. +- Sources reachable here are search snippets only: Chen et al. 2014 (D-E ignores EGR and lambda), an Energy & Fuels study, doi 10.1021/acs.energyfuels.4c04775 (D-E over-predicts knock as intake pressure rises). Not measured: the sound; Windows. + +## 2026-10-22 — Knock: the end gas, a stepped control, Woschni's combustion term + +- End gas as its own zone from the seal, isentropic with real cp(T); Douaud-Eyzat delay; Livengood-Wu from the spark; intensity = the constant-volume pressure the unburned share adds, over 30 bar. ECU knock control now steps (20 x excess/s of a 12 deg authority, ~1.3 deg per heavy event, back at 1 deg/s). +- 2JZ at full load, control off: knock 0.83 mean, damage 0.0065; on: 0.08 mean at 4.7 deg, no damage. Catalogue WOT: NA engines at most 0.10, 2JZ <= 0.08, EJ25 0.40 at 5,000 rpm. +- It took three more changes: the flame's load term capped at 1 (boost burned a charge up to 36 % faster); intercoolers 0.7 on 2JZ/EJ25/I5; Woschni's combustion term and the 4V/B liner (TDI BSFC 171/181/199 -> 212/203/215 g/kWh at 2/3/4,000 rpm; petrol WOT 227-310). TDI throat 280 -> 220 mm2: 285 N.m at 2,000 rpm. +- Maker gate (before the TDI refit): all pass but the TDI at 2,000 rpm; 2JZ +9.6 %, EJ25 +12.8 %, I5 +7.0 % torque. +- **Open: the model over-knocks under boost.** Implied knock-limited spark vs calibration practice: ~6 deg early NA, ~11 at 1.9 bar, ~17 at 2.45. The I5 knocks 0.78-0.90 at every rpm with the authority saturated, 0.56 at 2,000 rpm at the -10 deg floor; at 2.3/2.2/2.1 bar still heavy (2.1: 0.47-0.83); direct injection (end gas -15 K) and CR 10 neither cure it. +- Refuted: Kalghatgi's octane index (the zone is 150-200 K colder than his K fit, boost gains 1-3 octane); an ECU boost cut on knock (I5 155 kPa / 363 N.m). +- Leads: the intensity scale (constant-volume rise vs MAPO, a load-scaled threshold); Douaud-Eyzat used at 90-100 bar onsets, beyond its fit; the DI preset going lean above 5,000 rpm (lambda 1.07-1.18); the RD350's 437-1,582 g/kWh. +- Not measured: the sound before/after; CatalogReference and CatalogBoostHold after the TDI refit; the full ctest. + ## 2026-10-22 — Two-stroke: a carburettor, and the bore it sits in - Added: a slide carburettor per intake path (`CarburettorModel`), two circuits on their own depressions -- the main on the venturi's 1/2 rho V^2, the pilot on the bowl-to-crankcase drop -- with jets in their stamped numbers (cc/min under 1 m of head). Its fuel enters the crankcase with the air; the injector is commanded nothing on a carburetted path. The designer fits one to every two-stroke and sizes the jets from the engine: an RD350-sized design gets #287 main / #41 pilot against the bike's 260-290 / 30-40, fitted to neither. diff --git a/docs/simulation-model.md b/docs/simulation-model.md index c8cf93ed..4acd0bef 100644 --- a/docs/simulation-model.md +++ b/docs/simulation-model.md @@ -312,7 +312,10 @@ rev limiter apply after that. A spark is scheduled per cylinder. Before the kernel is born, an ignition delay depends on pressure, temperature, equivalence ratio and residuals. The laminar speed follows a Metghalchi-Keck type correlation; a closure based on mean -piston speed adds turbulence, while dilution reduces speed and efficiency. +piston speed adds turbulence, while dilution reduces speed and efficiency. The +turbulence term grows with load up to a full cylinder and no further: the +piston stirs the charge, not the intake pressure, so boost does not burn it +faster. The current progression geometry is a cylindrical effective volume: @@ -325,13 +328,36 @@ chamber height. It is neither an ellipsoidal front nor a resolved 3D surface. The geometric fraction drives an absolute number of moles to react; the released energy uses the fuel's LHV and stoichiometry. -Knock uses a Livengood-Wu integral on the end gas. When its threshold is -reached, a share of the remainder really auto-ignites in the cell, raises the -pressure and feeds the telemetry. The ECU then pulls advance in proportion to -the knock level, so at WOT engines sit at a light, constant knock (0.1-0.4) -rather than stepping back and holding as a real knock control does. Only knock -above 0.45 (the diagnostics' "severe") adds damage, which lowers efficiency for -good. This global correlation is not multi-species chemical kinetics. +Knock follows the end gas, the charge the flame has not reached yet. From the +seal it is its own zone, compressed isentropically by the cylinder pressure +with real heat capacities (air, products and gasoline vapour, fitted to the +ideal-gas tables). Its autoignition delay is Douaud and Eyzat's, fitted on +primary reference fuels in a knocking engine, with their prefactor doubled: + +```text +tau = 35.4 ms x (ON/100)^3.402 x p[atm]^-1.7 x exp(3800 / T_unburned) +``` + +The prefactor is the constant users of this correlation recalibrate to their +engine. With Douaud and Eyzat's 17.68 ms every catalogue engine with a knock +limit, naturally aspirated ones included, held 4-13 deg of knock retard behind +its own spark map, where a production map sits 0-3 deg from its limit; with +35.4 ms the maps hold 0-5 deg and 10 deg of advance over a map knocks. + +A Livengood-Wu integral of dt/tau runs from the spark. When it reaches 1 while +the flame burns, everything the flame has not reached autoignites at once and +really raises the pressure in the cell. The pressure that gas adds burning at +constant volume, (gamma - 1) x unburned share x charge energy / V, is the scale +of the oscillation it starts; the knock intensity is that over 30 bar. The +ECU's knock control retards in steps and gives the advance back slowly +([ecu-tuning.md](ecu-tuning.md)), so at full load an engine settles at its +knock limit. Only knock above 0.45 (the diagnostics' "severe") adds damage, +which lowers efficiency for good. + +The correlation is global, not multi-species kinetics, and it over-predicts +knock under boost: the Audi I5 knocks heavily at its maker's 2.5 bar even at +the ECU's -10 deg floor, where the real engine (10:1, direct injection) holds +it on 98 RON (journal 2026-10-22). Each cylinder's firing peak is tracked at every physics sub-step and published once per cycle, when the cylinder opens into its gas exchange @@ -340,8 +366,9 @@ the engine's structural limit (`peak_pressure_limit_bar`; zero takes 120 bar naturally aspirated, 140 boosted, 200 for a diesel, the upper end of production designs) it adds damage as the square of the excess: 10 % over takes about 17 minutes to "damaged", 30 % about 2 minutes. The simulated full-load peaks of -the boosted catalogue engines run above real ones (Audi I5 175 bar, TDI 231 bar), -so those two carry their own limit 10 % over their stock peak. +the boosted catalogue engines ran above real ones (Audi I5 175 bar before the +knock control held it, TDI 231 bar), so those two carry their own limit 10 % +over that stock peak. ## Forced induction @@ -377,10 +404,11 @@ to the engine's draw. A turbocharger fed by fewer cylinders spins slower and passes less of the air, without any special rule. `intercooler_effectiveness` (0 to 0.95, default 0) takes that share of the -compressor's temperature rise back out of the charge. No catalogue engine -authors one: with a real one (0.7) the petrol turbo engines make 19-29 % more -torque than their makers' figures, so the model is optimistic at boost by -about that much and the missing cooler hides it (journal 2026-10-15). +compressor's temperature rise back out of the charge. The 2JZ, EJ25 and I5 +author 0.7, as their real engines have one. Before the end gas could knock it +gave them 19-29 % more torque than their makers' figures (journal 2026-10-15); +now the knock control spends that margin and they land 7-13 % over (journal +2026-10-22). The turbine lives in the exhaust gas network, as a restriction face (the same primitive as an individual throttle) on the single outgoing face of the first @@ -431,7 +459,10 @@ Without a graph, the historical geometry is first expanded into a compatible physical DAG. See [custom-exhaust.md](custom-exhaust.md). Cylinders use Woschni's instantaneous convective correlation rather than a -constant conductance. Intake ducts and every cell of the exhaust network have a +constant conductance, its motored term only: the combustion term would bring +the TDI's 171 g/kWh at 2,000 rpm to a real diesel's 212, but it stalls lugged +and launched engines the tests hold (journal 2026-10-23). Intake ducts and every +cell of the exhaust network have a metal wall with finite heat capacity. Internal exchange is integrated analytically and conserves gas + wall energy; only the explicitly accounted external convection rejects energy to the ambient. The model therefore never diff --git a/engines/05_audi_i5_like.engine.yaml b/engines/05_audi_i5_like.engine.yaml index 00e6c798..eada0899 100644 --- a/engines/05_audi_i5_like.engine.yaml +++ b/engines/05_audi_i5_like.engine.yaml @@ -7,10 +7,10 @@ engine: firing_order: [1, 2, 4, 5, 3] idle_rpm: 780 redline_rpm: 7100 - # Simulated stock firing pressure reaches 175 bar at 3,000 rpm full load, - # above the 140 bar of a typical boosted petrol engine: the limit sits 10 % - # over this engine's own stock peak so the stock calibration does no harm - # (journal 2026-10-15). Not a manufacturer figure. + # Simulated stock firing pressure reached 175 bar at 3,000 rpm full load + # before the end gas could knock (journal 2026-10-15); the knock control now + # holds it at 136-156 bar. The limit stays 10 % over the old peak. Not a + # manufacturer figure. peak_pressure_limit_bar: 195 rotating_inertia_kg_m2: 0.31 friction_coefficient: 0.13 @@ -45,6 +45,11 @@ engine: shaft_inertia_kg_m2: 0.00013 wastegate_pressure_ratio: 2.45 charge_temperature_rise_c: 43 + # The real engine has an air-to-air intercooler. Without one the charge + # reached the runners at ~130 degC at full boost and the engine knocked + # heavily at the maker's boost; with it the knock control holds it 8-12 + # deg behind its map, in light knock (journal 2026-10-23). + intercooler_effectiveness: 0.7 # Family-level acoustic estimates; replace with measured turbo geometry. compressor_blade_count: 7 turbine_blade_count: 9 diff --git a/src/calibration/include/enginelab/calibration/EcuCalibrationKeys.hpp b/src/calibration/include/enginelab/calibration/EcuCalibrationKeys.hpp index 5dea6d98..edf9d0ec 100644 --- a/src/calibration/include/enginelab/calibration/EcuCalibrationKeys.hpp +++ b/src/calibration/include/enginelab/calibration/EcuCalibrationKeys.hpp @@ -39,8 +39,8 @@ inline constexpr double minimumIgnitionAdvanceDegrees = -10.0; inline constexpr double maximumIgnitionAdvanceDegrees = 55.0; inline constexpr double minimumRevLimitRpm = 600.0; inline constexpr double maximumRevLimitRpm = 25'000.0; -// Knock control: spark retard per unit of knock intensity (0 = control off, -// the knock runs free). 12 deg is the gain the ECU always used. +// Knock control: the most spark it may pull (0 = control off, the knock runs +// free). The key keeps its first name, from when the retard was proportional. inline constexpr double minimumKnockRetardGainDegrees = 0.0; inline constexpr double maximumKnockRetardGainDegrees = 40.0; inline constexpr double defaultKnockRetardGainDegrees = 12.0; diff --git a/src/calibration/src/EcuCalibration.cpp b/src/calibration/src/EcuCalibration.cpp index b2e2ccae..59cf0453 100644 --- a/src/calibration/src/EcuCalibration.cpp +++ b/src/calibration/src/EcuCalibration.cpp @@ -174,8 +174,8 @@ CalibrationDraft makeDefaultEcuCalibration(const EngineConfig& config) { } if (!diesel) { draft.set(ScalarCalibration { - metadata(std::string(keys::knockRetardGain), "Knock retard gain", - "Spark retard per unit of knock intensity. Zero turns knock control off: the knock then runs free and severe knock damages the engine.", + metadata(std::string(keys::knockRetardGain), "Knock retard limit", + "The most spark the knock control may pull. It retards fast while the engine knocks and gives the advance back at about a degree per second, so it settles at the knock limit; past this much retard the knock is no longer held. Zero turns knock control off: the knock then runs free and severe knock damages the engine.", CalibrationUnit::degreeCrankshaft, ecuLimits::minimumKnockRetardGainDegrees, ecuLimits::maximumKnockRetardGainDegrees, 1), ecuLimits::defaultKnockRetardGainDegrees diff --git a/src/ecu/include/enginelab/ecu/SimpleEcuModel.hpp b/src/ecu/include/enginelab/ecu/SimpleEcuModel.hpp index 46ecf547..9b8328fa 100644 --- a/src/ecu/include/enginelab/ecu/SimpleEcuModel.hpp +++ b/src/ecu/include/enginelab/ecu/SimpleEcuModel.hpp @@ -13,8 +13,8 @@ namespace enginelab { * here rather than from the authored config. */ [[nodiscard]] double activeRevLimitRpm(const EngineConfig& config, const calibration::CalibrationSnapshot& calibration) noexcept; -/** The ECU's knock control gain: spark retard per unit of knock intensity, - * from the calibration's `knock.retard_deg_per_intensity` (12 deg when the +/** The ECU's knock control authority: the most spark it may pull, from the + * calibration's `knock.retard_deg_per_intensity` (12 deg when the * calibration has none). Zero turns knock control off. */ [[nodiscard]] double knockRetardGainDegrees(const calibration::CalibrationSnapshot& calibration) noexcept; /** Launch control's rev limit (calibration `limits.launch_rpm`); 0 = off. */ @@ -60,6 +60,7 @@ class SimpleEcuModel final : public IEcuModel { driverRevvedSinceStart_.store(false, std::memory_order_relaxed); afterfireOverrunWindow_.store(false, std::memory_order_relaxed); afterfirePulseEpochSeconds_.store(0.0, std::memory_order_relaxed); + knockRetardShare_.store(0.0, std::memory_order_relaxed); } /** * Read-only view of the idle governor's internal state, for diagnostics. @@ -108,6 +109,8 @@ class SimpleEcuModel final : public IEcuModel { mutable std::atomic limiterLatched_ { false }; mutable std::atomic decelerationFuelCutLatched_ { false }; mutable std::atomic decelerationFuelResume_ { 1.0 }; + /** The knock control's retard as a share of its authority, 0-1. */ + mutable std::atomic knockRetardShare_ { 0.0 }; mutable std::atomic lambdaLearningHoldSeconds_ { 0.0 }; mutable std::atomic limiterReleaseTime_ { 0.0 }; mutable std::atomic previousThrottle_ { 0.0 }; diff --git a/src/ecu/src/SimpleEcuModel.cpp b/src/ecu/src/SimpleEcuModel.cpp index 1f67a595..69d2f47c 100644 --- a/src/ecu/src/SimpleEcuModel.cpp +++ b/src/ecu/src/SimpleEcuModel.cpp @@ -455,7 +455,24 @@ EcuCommand SimpleEcuModel::evaluate(const EngineConfig& config, const EngineStat mappedAfr = std::clamp(mappedAfr - std::max(0.0, state.coolantTemperatureC - 108.0) * 0.025, minimumMappedAfr, maximumMappedAfr); - const auto knockRetardDegrees = state.knockLevel * knockRetardGainDegrees(*calibrationSnapshot); + // Knock control as an ECU does it: knock the sensor hears pulls the spark + // back fast, a clean stretch gives it back slowly, so the retard settles + // where knock just stops (the knock-limited advance). A retard + // proportional to the knock it removes could only hold by keeping knock. + // The threshold is ~1 bar of oscillation, where measurements start + // calling a cycle knocking. A heavy event (0.5, decaying at 45/s) pulls + // about 1.3 deg, within the 1-3 deg production controls take per knocking + // cycle; the recovery is 1 deg/s at 12 deg of authority. + constexpr double knockDetectionLevel = 0.1; + constexpr double knockAttackPerSecond = 20.0; + constexpr double knockRecoveryPerSecond = 0.08; + auto knockRetardShare = knockRetardShare_.load(std::memory_order_relaxed); + knockRetardShare = std::clamp(state.knockLevel > knockDetectionLevel + ? knockRetardShare + (state.knockLevel - knockDetectionLevel) * knockAttackPerSecond * idleDt + : knockRetardShare - knockRecoveryPerSecond * idleDt, + 0.0, 1.0); + knockRetardShare_.store(knockRetardShare, std::memory_order_relaxed); + const auto knockRetardDegrees = knockRetardShare * knockRetardGainDegrees(*calibrationSnapshot); mappedAdvance = std::clamp(mappedAdvance - knockRetardDegrees - std::max(0.0, controls.shiftSparkRetardDegrees) - std::max(0.0, state.coolantTemperatureC - 108.0) * 0.20, diff --git a/src/foundation/include/enginelab/foundation/EngineTypes.hpp b/src/foundation/include/enginelab/foundation/EngineTypes.hpp index 83b8cb37..a4bc0945 100644 --- a/src/foundation/include/enginelab/foundation/EngineTypes.hpp +++ b/src/foundation/include/enginelab/foundation/EngineTypes.hpp @@ -1449,6 +1449,12 @@ struct CylinderState final { * command, for example while DFCO resume waits for a complete port-injector * window. Event/audio consumers must not label that clean cut a misfire. */ bool combustionCommandAvailable { true }; + /** The end gas: the hottest the unburned charge got in the last completed + * cycle, compressed from the moment the cylinder sealed (EndGasKnockModel), + * and the pressure the last knock event's end gas released. Spark ignition + * only; zero on a diesel. */ + double endGasPeakTemperatureK { 0.0 }; + double knockPressureRiseBar { 0.0 }; }; enum class DynoMode : std::uint8_t { diff --git a/src/physics/include/enginelab/physics/EndGasKnockModel.hpp b/src/physics/include/enginelab/physics/EndGasKnockModel.hpp index da8f0894..42e58fa6 100644 --- a/src/physics/include/enginelab/physics/EndGasKnockModel.hpp +++ b/src/physics/include/enginelab/physics/EndGasKnockModel.hpp @@ -2,35 +2,92 @@ namespace enginelab { +/** + * The end gas of one cylinder: the unburned charge, followed from the moment + * the cylinder seals until it opens again. + * + * The chamber is one well-mixed cell, and its mean temperature is not the + * end gas's: once the flame has started it mixes burned gas at 2,000-2,800 K + * with an unburned charge near 800-1,000 K. So the unburned zone is carried + * here as its own temperature, compressed isentropically by the chamber + * pressure from the seal on, with the heat capacities of the unburned + * mixture at its own temperature (Heywood, *Internal Combustion Engine + * Fundamentals*, 9.2: the unburned zone of a two-zone model). + */ struct EndGasKnockState final { double inductionIntegral { 0.0 }; double filteredLevel { 0.0 }; bool autoIgnitedThisCycle { false }; + /** True from the seal to the next opening; false resets the zone. */ + bool sealed { false }; + double unburnedTemperatureK { 0.0 }; + double lastPressureBar { 0.0 }; + /** Mole fractions of the unburned zone, frozen when the flame starts: + * from then on the cell's own composition is the burned zone's too. */ + double fuelMoleFraction { 0.0 }; + double burnedMoleFraction { 0.0 }; + bool compositionFrozen { false }; + /** This cycle's hottest end gas, and the last completed cycle's. */ + double peakUnburnedTemperatureK { 0.0 }; + double lastCyclePeakUnburnedTemperatureK { 0.0 }; + /** The pressure the last knock event's end gas released, bar. */ + double lastKnockPressureRiseBar { 0.0 }; }; struct EndGasConditions final { double pressureBar { 1.0 }; + /** The chamber's mean temperature: the end gas's only before the flame. */ double temperatureK { 293.15 }; double equivalenceRatio { 1.0 }; double burnedFraction { 0.0 }; double octaneRating { 95.0 }; + /** Both valves (a two-stroke's ports) shut. */ + bool sealed { false }; + /** A flame is burning in this cylinder this cycle. */ bool combustionActive { false }; + /** Live mole fractions of the chamber gas. */ + double fuelMoleFraction { 0.0 }; + double burnedMoleFraction { 0.0 }; + double chamberVolumeM3 { 0.0 }; + /** The heat the whole charge releases when burned, J. */ + double chargeFuelEnergyJoules { 0.0 }; + double heatCapacityRatio { 1.3 }; }; struct EndGasKnockResult final { double level { 0.0 }; double autoIgnitedFuelFraction { 0.0 }; double autoIgnitionDelaySeconds { 1.0 }; + double unburnedTemperatureK { 0.0 }; + /** This event's end-gas pressure rise, bar; zero without an event. */ + double knockPressureRiseBar { 0.0 }; bool autoIgnited { false }; }; -/** Livengood-Wu end-gas auto-ignition integral driven by actual chamber P/T. */ +/** + * Livengood-Wu end-gas auto-ignition integral, from ignition, over the + * Douaud-Eyzat delay of the unburned zone. When it reaches one, the gas the + * flame has not reached burns at once; the pressure that would add at + * constant volume, (gamma - 1) x_u Q / V, is the knock's intensity. + */ class EndGasKnockModel final { public: [[nodiscard]] static EndGasKnockResult advance(EndGasKnockState& state, const EndGasConditions& conditions, double dtSeconds) noexcept; - [[nodiscard]] static double autoIgnitionDelaySeconds(const EndGasConditions& conditions) noexcept; + /** Douaud and Eyzat (SAE 780080), for the unburned zone's temperature. */ + [[nodiscard]] static double autoIgnitionDelaySeconds(double pressureBar, + double unburnedTemperatureK, + double octaneRating) noexcept; + /** Molar cp of an unburned fuel-air-residual mixture, J/(mol K). */ + [[nodiscard]] static double unburnedMolarHeatCapacityCp(double temperatureK, + double fuelMoleFraction, + double burnedMoleFraction) noexcept; + /** The end-gas pressure rise, bar, at which `level` reads 1: about the + * heaviest conventional knock, whose oscillation reaches ~10 bar (a cycle + * is called knocking from ~1 bar). The ECU hears knock from 3 bar of + * rise, and the diagnostics call it severe from 13.5 (0.45). */ + static constexpr double fullScaleKnockPressureRiseBar = 30.0; }; } // namespace enginelab diff --git a/src/physics/src/EndGasKnockModel.cpp b/src/physics/src/EndGasKnockModel.cpp index 292369dd..56e0b32c 100644 --- a/src/physics/src/EndGasKnockModel.cpp +++ b/src/physics/src/EndGasKnockModel.cpp @@ -5,52 +5,129 @@ namespace enginelab { -double EndGasKnockModel::autoIgnitionDelaySeconds(const EndGasConditions& conditions) noexcept { - const auto temperatureK = std::clamp(conditions.temperatureK, 450.0, 1'500.0); - const auto pressureBar = std::clamp(conditions.pressureBar, 1.0, 200.0); - const auto octane = std::clamp(conditions.octaneRating, 60.0, 130.0); - const auto phi = std::clamp(conditions.equivalenceRatio, 0.45, 1.55); - const auto temperatureFactor = std::exp(3'800.0 * (1.0 / temperatureK - 1.0 / 850.0)); - const auto pressureFactor = std::pow(20.0 / pressureBar, 1.2); - const auto octaneFactor = std::pow(octane / 95.0, 2.35); - const auto mixtureFactor = 1.0 + 2.2 * (phi - 1.0) * (phi - 1.0); - return std::clamp(0.018 * temperatureFactor * pressureFactor * octaneFactor - * mixtureFactor, 0.00008, 1.0); +namespace { +constexpr double universalGasConstant = 8.31446261815324; +} + +double EndGasKnockModel::unburnedMolarHeatCapacityCp(double temperatureK, + double fuelMoleFraction, + double burnedMoleFraction) noexcept { + const auto t = std::clamp(temperatureK, 250.0, 1'500.0); + const auto fuel = std::clamp(fuelMoleFraction, 0.0, 0.2); + const auto burned = std::clamp(burnedMoleFraction, 0.0, 1.0 - fuel); + // Straight-line fits over 300-1,200 K to the ideal-gas tables, within 1 % + // for air (3.50 R at 300 K, 3.98 R at 1,000 K) and the stoichiometric + // products (N2, CO2, H2O: 3.69 R at 300 K, 4.49 R at 1,200 K); a quadratic + // for gasoline vapour, taken as iso-octane (189 J/(mol K) at 298 K, ~390 + // at 800 K), within 5 %. The simulator's own gas holds air at 5/2 R of cv + // at every temperature, right at 300 K and 14 % short at 900 K. + const auto air = (3.305 + 6.58e-4 * t) * universalGasConstant; + const auto products = (3.44 + 8.36e-4 * t) * universalGasConstant; + const auto above = t - 298.0; + const auto vapour = 188.9 + 0.48 * above - 1.5e-4 * above * above; + return (1.0 - fuel - burned) * air + fuel * vapour + burned * products; +} + +double EndGasKnockModel::autoIgnitionDelaySeconds(double pressureBar, + double unburnedTemperatureK, + double octaneRating) noexcept { + // Douaud and Eyzat, fitted on primary reference fuels in a knocking + // engine: tau = 17.68 ms (ON/100)^3.402 p[atm]^-1.7 exp(3800/T). Its + // prefactor is the constant users recalibrate to their engine; here it is + // doubled. At 17.68 ms every catalogue engine with a knock limit held 4-13 + // deg of knock retard behind its own spark map, the NA K20 and LS3 + // included, where a production map sits 0-3 deg from the limit. At 35.4 + // the maps run at 0-5 deg of retard and 10 deg over the map knocks + // (journal 2026-10-23). + const auto temperatureK = std::clamp(unburnedTemperatureK, 300.0, 1'500.0); + const auto pressureAtm = std::clamp(pressureBar, 0.5, 250.0) / 1.01325; + const auto octane = std::clamp(octaneRating, 60.0, 130.0); + return std::clamp(35.4e-3 * std::pow(octane / 100.0, 3.402) + * std::pow(pressureAtm, -1.7) * std::exp(3'800.0 / temperatureK), 1.0e-6, 10.0); } EndGasKnockResult EndGasKnockModel::advance(EndGasKnockState& state, const EndGasConditions& conditions, double dtSeconds) noexcept { EndGasKnockResult result; - if (!conditions.combustionActive || dtSeconds <= 0.0 || !std::isfinite(dtSeconds)) { + const auto dt = std::isfinite(dtSeconds) ? std::max(0.0, dtSeconds) : 0.0; + const auto pressureBar = std::max(0.01, conditions.pressureBar); + if (!conditions.sealed) { + // An open cylinder has no end gas. The cycle's peak is kept for + // whoever reads it while the next charge comes in. + if (state.sealed) + state.lastCyclePeakUnburnedTemperatureK = state.peakUnburnedTemperatureK; + state.sealed = false; state.inductionIntegral = 0.0; - state.filteredLevel *= std::exp(-std::max(0.0, dtSeconds) * 120.0); state.autoIgnitedThisCycle = false; + state.compositionFrozen = false; + state.peakUnburnedTemperatureK = 0.0; + state.unburnedTemperatureK = conditions.temperatureK; + state.lastPressureBar = pressureBar; + state.filteredLevel *= std::exp(-dt * 45.0); result.level = state.filteredLevel; + result.unburnedTemperatureK = state.unburnedTemperatureK; return result; } + if (!state.sealed) { + // The seal: the whole charge is unburned and at the mean temperature. + state.sealed = true; + state.inductionIntegral = 0.0; + state.autoIgnitedThisCycle = false; + state.compositionFrozen = false; + state.unburnedTemperatureK = conditions.temperatureK; + state.lastPressureBar = pressureBar; + state.peakUnburnedTemperatureK = conditions.temperatureK; + } else { + if (!state.compositionFrozen) { + state.fuelMoleFraction = conditions.fuelMoleFraction; + state.burnedMoleFraction = conditions.burnedMoleFraction; + state.compositionFrozen = conditions.combustionActive; + } + // dT/T = (R/cp) dp/p, with cp at the zone's own temperature. + const auto cp = unburnedMolarHeatCapacityCp(state.unburnedTemperatureK, + state.fuelMoleFraction, state.burnedMoleFraction); + state.unburnedTemperatureK *= std::exp(universalGasConstant / cp + * std::log(pressureBar / state.lastPressureBar)); + state.lastPressureBar = pressureBar; + state.peakUnburnedTemperatureK = std::max(state.peakUnburnedTemperatureK, + state.unburnedTemperatureK); + } + result.unburnedTemperatureK = state.unburnedTemperatureK; + result.autoIgnitionDelaySeconds = autoIgnitionDelaySeconds( + pressureBar, state.unburnedTemperatureK, conditions.octaneRating); + + // The integral runs from ignition. Douaud and Eyzat fitted their delay + // to knock onsets, in the end gas the flame compresses; read below that, + // its 3,800 K activation credits the compression stroke (500-750 K) with + // chemistry gasoline does not have there, two-stage as it is. From the + // seal, the boosted catalogue knocked heavily at its makers' boost. + if (!conditions.combustionActive) + state.inductionIntegral = 0.0; + else if (!state.autoIgnitedThisCycle) + state.inductionIntegral += dt / result.autoIgnitionDelaySeconds; + const auto unburnedFraction = std::clamp(1.0 - conditions.burnedFraction, 0.0, 1.0); - result.autoIgnitionDelaySeconds = autoIgnitionDelaySeconds(conditions); - if (unburnedFraction > 0.015 && !state.autoIgnitedThisCycle) - state.inductionIntegral += dtSeconds / result.autoIgnitionDelaySeconds; - else - state.inductionIntegral = std::max(0.0, state.inductionIntegral - dtSeconds * 80.0); - - if (state.inductionIntegral >= 1.0 && unburnedFraction > 0.015 - && !state.autoIgnitedThisCycle) { - const auto thermalSeverity = std::clamp((conditions.temperatureK - 820.0) / 330.0, 0.0, 1.0); - const auto pressureSeverity = std::clamp((conditions.pressureBar - 18.0) / 82.0, 0.0, 1.0); - const auto severity = std::clamp(0.18 + thermalSeverity * 0.45 - + pressureSeverity * 0.37, 0.0, 1.0); - result.autoIgnitedFuelFraction = unburnedFraction - * std::clamp(0.22 + severity * 0.68, 0.0, 0.95); + if (state.inductionIntegral >= 1.0 && conditions.combustionActive + && unburnedFraction > 0.015 && !state.autoIgnitedThisCycle) { + // Knock: whatever the flame has not reached ignites at once. The + // pressure that gas adds burning at constant volume is the scale of + // the oscillation it starts, so it is the intensity, and it grows + // with how early the end gas went: the unburned share at onset. + const auto volumeM3 = std::max(1.0e-9, conditions.chamberVolumeM3); + const auto riseBar = std::max(0.0, conditions.heatCapacityRatio - 1.0) + * unburnedFraction * std::max(0.0, conditions.chargeFuelEnergyJoules) + / volumeM3 * 1.0e-5; + result.autoIgnitedFuelFraction = unburnedFraction; + result.knockPressureRiseBar = riseBar; result.autoIgnited = true; state.autoIgnitedThisCycle = true; + state.lastKnockPressureRiseBar = riseBar; state.filteredLevel = std::max(state.filteredLevel, - std::clamp(severity * std::sqrt(unburnedFraction), 0.0, 1.0)); + std::clamp(riseBar / fullScaleKnockPressureRiseBar, 0.0, 1.0)); } else { - state.filteredLevel *= std::exp(-dtSeconds * 45.0); + state.filteredLevel *= std::exp(-dt * 45.0); } result.level = state.filteredLevel; return result; diff --git a/src/physics/src/FlamePhysicsModel.cpp b/src/physics/src/FlamePhysicsModel.cpp index db60c5ff..c101395f 100644 --- a/src/physics/src/FlamePhysicsModel.cpp +++ b/src/physics/src/FlamePhysicsModel.cpp @@ -30,10 +30,15 @@ double FlamePhysicsModel::turbulentFlameSpeedMps(const FuelConfig& fuel, const FlameConditions& conditions) noexcept { const auto laminar = laminarFlameSpeedMps(fuel, conditions.equivalenceRatio, conditions.temperatureK, conditions.pressurePa); + // The load term slows a throttled flame; it stops at a full cylinder. + // Turbulence intensity follows the piston, not the intake pressure, so + // boost does not stir the charge harder: letting the term run to a load + // of 1.5 burned a boosted charge up to 36 % faster than the same engine + // unboosted, and the earlier, higher peak pressure knocked. const auto turbulence = std::max(0.0, conditions.meanPistonSpeedMps) * std::clamp(conditions.chamberTurbulenceIntensityRatio, 0.1, 4.0) * std::clamp(fuel.turbulenceFlameSpeedGain, 0.0, 8.0) - * (0.28 + 0.72 * std::clamp(conditions.load, 0.0, 1.5)); + * (0.28 + 0.72 * std::clamp(conditions.load, 0.0, 1.0)); const auto dilutionAttenuation = std::clamp(1.0 - conditions.residualDilutionSensitivity * std::clamp(conditions.burnedGasFraction, 0.0, 0.85), 0.25, 1.0); // In a running SI engine the integral turbulent flame speed scales with diff --git a/src/simulation/src/EngineSimulator.cpp b/src/simulation/src/EngineSimulator.cpp index 5f098034..e9d4e717 100644 --- a/src/simulation/src/EngineSimulator.cpp +++ b/src/simulation/src/EngineSimulator.cpp @@ -3037,7 +3037,6 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co .cycleVariationCorrelation, combustionVariationRandomState_[cylinderIndex], combustionVariationNormalisedState_[cylinderIndex]); - endGasKnockStates_[cylinderIndex] = {}; meteredFuelMolesLastCycle_[cylinderIndex] = injectedFuelMolesThisCycle_[cylinderIndex]; const auto chamberFuelMoles = cylinderGas_[cylinderIndex].mixture().fuelMoles; deliveredFuelMolesLastCycle_[cylinderIndex] = chamberFuelMoles; @@ -3357,6 +3356,18 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co reaction.releasedEnergyJoules; } + // The end gas exists from the moment the cylinder seals: both + // valves shut, or a two-stroke's transfer and exhaust ports. + const auto cylinderSealed = twoStroke + ? portOpenAreaMm2(cylinder, config_.twoStroke.transferPortOpenDegrees, + transferPortWidthMm(config_.twoStroke, cylinder), + config_.twoStroke.portDischargeCoefficient, cyclePhase) <= 0.0 + && portOpenAreaMm2(cylinder, config_.twoStroke.exhaustPortOpenDegrees, + exhaustPortWidthMm(config_.twoStroke, cylinder), + config_.twoStroke.portDischargeCoefficient, cyclePhase) <= 0.0 + : intakeLift <= 0.0 && exhaustLift <= 0.0; + const auto& chamberMixture = cylinderGas_[cylinderIndex].mixture(); + const auto chamberMoles = std::max(1.0e-15, chamberMixture.totalMoles()); const auto endGas = EndGasKnockModel::advance( endGasKnockStates_[cylinderIndex], { cylinderGas_[cylinderIndex].pressureKpa() / 100.0, @@ -3364,9 +3375,17 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co flameConditions.equivalenceRatio, flameEvents_[cylinderIndex].burnedFraction, config_.octaneRating, + cylinderSealed, cylinderSparkCombustionAvailable && flameEvents_[cylinderIndex].active - && !cylinderMisfires_[cylinderIndex] }, + && !cylinderMisfires_[cylinderIndex], + chamberMixture.fuelMoles / chamberMoles, + chamberMixture.burnedMoles / chamberMoles, + cylinderGas_[cylinderIndex].volumeM3(), + flameEvents_[cylinderIndex].initialBurnableFuelMoles + * config_.fuelProperties.molarMassGramsPerMole * 0.001 + * config_.fuelProperties.lowerHeatingValueMjPerKg * 1.0e6, + cylinderGas_[cylinderIndex].heatCapacityRatioEffective() }, combustionDt); if (endGas.autoIgnited && endGas.autoIgnitedFuelFraction > 0.0) { @@ -5458,6 +5477,10 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co highLoadTrimBlend); cylinderState.residualGasFractionAtSpark = residualGasFractionAtSpark_[index]; cylinderState.equivalenceRatioAtSpark = equivalenceRatioAtSpark_[index]; + cylinderState.endGasPeakTemperatureK = compressionIgnition + ? 0.0 : endGasKnockStates_[index].lastCyclePeakUnburnedTemperatureK; + cylinderState.knockPressureRiseBar = compressionIgnition + ? 0.0 : endGasKnockStates_[index].lastKnockPressureRiseBar; cylinderState.pistonTravelMm = kinematics.pistonTravelMm; cylinderState.pistonPositionMm = kinematics.pistonPositionMm; cylinderState.pistonVelocityMps = kinematics.pistonVelocityMps; diff --git a/tests/ActiveLimitsTests.cpp b/tests/ActiveLimitsTests.cpp index 8da6e845..f4436d97 100644 --- a/tests/ActiveLimitsTests.cpp +++ b/tests/ActiveLimitsTests.cpp @@ -80,14 +80,20 @@ void checkEcu() { "at the tuned limit the ECU cuts and says the rev limiter holds the engine"); state.rpm = 1'800.0; - state.simulationTimeSeconds = 5.0; - state.knockLevel = 0.2; - (void)ecu.evaluate(config, state, controls); - const auto knocking = ecu.evaluate(config, state, controls); - require(hasActiveLimit(knocking.activeLimits, ActiveLimit::knockRetard), + // Knock control integrates: 0.05 s at knock 0.2 pulls 1.2 deg, and clean + // running gives back a degree a second. + const auto holdKnock = [&](double knock, double seconds) { + enginelab::EcuCommand command; + for (auto step = 0; step < static_cast(seconds * 1'000.0); ++step) { + state.simulationTimeSeconds += 1.0e-3; + state.knockLevel = knock; + command = ecu.evaluate(config, state, controls); + } + return command; + }; + require(hasActiveLimit(holdKnock(0.2, 0.05).activeLimits, ActiveLimit::knockRetard), "knock control pulling spark is reported"); - state.knockLevel = 0.01; - require(!hasActiveLimit(ecu.evaluate(config, state, controls).activeLimits, ActiveLimit::knockRetard), + require(!hasActiveLimit(holdKnock(0.01, 0.75).activeLimits, ActiveLimit::knockRetard), "a retard below half a degree is not reported"); state.knockLevel = 0.0; diff --git a/tests/CoreTests.cpp b/tests/CoreTests.cpp index 1d36c961..649bad3e 100644 --- a/tests/CoreTests.cpp +++ b/tests/CoreTests.cpp @@ -657,19 +657,71 @@ int main() { "simultaneous valve transaction must conserve species and total energy"); } { - enginelab::EndGasKnockState coolState; - enginelab::EndGasKnockState hotState; - bool hotAutoIgnited = false; - for (int step = 0; step < 300; ++step) { - const auto cool = enginelab::EndGasKnockModel::advance(coolState, - { 12.0, 650.0, 1.0, 0.25, 98.0, true }, 0.00005); - const auto hot = enginelab::EndGasKnockModel::advance(hotState, - { 72.0, 1'060.0, 1.0, 0.25, 90.0, true }, 0.00005); - require(!cool.autoIgnited, "cool low-pressure end gas must not knock"); - hotAutoIgnited = hotAutoIgnited || hot.autoIgnited; + using enginelab::EndGasKnockModel; + // Douaud and Eyzat give 8.2 ms at 850 K, 20 bar, RON 95; the model + // doubles their prefactor, calibrated against the catalogue's spark + // maps (journal 2026-10-23), and keeps their dependences. + require(std::abs(EndGasKnockModel::autoIgnitionDelaySeconds(20.0, 850.0, 95.0) - 2.0 * 8.15e-3) < 0.1e-3, + "the end-gas delay is Douaud and Eyzat's with twice their prefactor"); + require(EndGasKnockModel::autoIgnitionDelaySeconds(20.0, 850.0, 105.0) + > 1.3 * EndGasKnockModel::autoIgnitionDelaySeconds(20.0, 850.0, 95.0), + "ten octane numbers lengthen the delay by (105/95)^3.4"); + // Ideal-gas tables: air 3.50 R at 300 K and 3.98 R at 1,000 K. + constexpr double r = 8.31446261815324; + require(std::abs(EndGasKnockModel::unburnedMolarHeatCapacityCp(300.0, 0.0, 0.0) / r - 3.50) < 0.03 + && std::abs(EndGasKnockModel::unburnedMolarHeatCapacityCp(1'000.0, 0.0, 0.0) / r - 3.98) < 0.04, + "the end gas's air has the tables' heat capacity, not 7/2 R at every temperature"); + + // The zone follows the pressure from the seal: a stoichiometric + // charge at 350 K compressed 30 times ends near 790 K (an average + // gamma of 1.31), where 7/2 R would put it at 925 K. + enginelab::EndGasKnockState zone; + enginelab::EndGasConditions closed { 1.0, 350.0, 1.0, 0.0, 98.0, true, false, 0.0165, 0.04 }; + (void)EndGasKnockModel::advance(zone, closed, 1.0e-5); + for (int step = 1; step <= 300; ++step) { + closed.pressureBar = std::pow(30.0, step / 300.0); + (void)EndGasKnockModel::advance(zone, closed, 1.0e-9); } - require(hotAutoIgnited && hotState.filteredLevel > 0.0, - "hot high-pressure end gas must auto-ignite through the Livengood-Wu integral"); + require(zone.unburnedTemperatureK > 760.0 && zone.unburnedTemperatureK < 820.0, + "the unburned zone is compressed with its own heat capacities"); + + // Intensity is the end gas left when it goes: the same knock with + // 40 % unburned is four times the one with 10 %, and an open cylinder + // has no end gas at all. + const auto knockAt = [](double burnedFraction) { + enginelab::EndGasKnockState state; + enginelab::EndGasConditions hot { 60.0, 1'000.0, 1.0, burnedFraction, 90.0, true, true, + 0.0165, 0.05, 1.0e-4, 1'000.0, 1.3 }; + enginelab::EndGasKnockResult result; + for (int step = 0; step < 2'000 && !result.autoIgnited; ++step) + result = EndGasKnockModel::advance(state, hot, 1.0e-5); + return result; + }; + const auto early = knockAt(0.6); + const auto late = knockAt(0.9); + require(early.autoIgnited && late.autoIgnited, "a hot, high-pressure end gas knocks"); + require(std::abs(early.knockPressureRiseBar / late.knockPressureRiseBar - 4.0) < 1.0e-9 + && early.level > late.level, + "knock intensity is the unburned share at onset"); + enginelab::EndGasKnockState open; + for (int step = 0; step < 2'000; ++step) + require(!EndGasKnockModel::advance(open, { 60.0, 1'000.0, 1.0, 0.5, 90.0, false, true, + 0.0165, 0.05, 1.0e-4, 1'000.0, 1.3 }, 1.0e-5).autoIgnited, + "an open cylinder has no end gas to knock"); + } + { + // The integral starts at ignition: a hot charge sealed for 20 ms of + // compression has not used up its delay when the flame starts. + using enginelab::EndGasKnockModel; + enginelab::EndGasKnockState state; + enginelab::EndGasConditions compressed { 60.0, 1'000.0, 1.0, 0.0, 90.0, true, false, + 0.0165, 0.05, 1.0e-4, 1'000.0, 1.3 }; + for (int step = 0; step < 2'000; ++step) + (void)EndGasKnockModel::advance(state, compressed, 1.0e-5); + compressed.combustionActive = true; + compressed.burnedFraction = 0.05; + require(!EndGasKnockModel::advance(state, compressed, 1.0e-6).autoIgnited, + "the compression stroke does not count toward the end gas's delay"); } { enginelab::GasCell highPressure; @@ -820,6 +872,16 @@ int main() { * highSpeedPentRoof.temperatureK) + 1.0e-12, "high-speed pent-roof combustion must retain calibrated tumble " "while remaining a subsonic deflagration"); + // Turbulence follows the piston, not the intake pressure: a boosted + // charge burns no faster than a full unboosted one. + auto boosted = highSpeedPentRoof; + boosted.load = 1.5; + boosted.temperatureK = 600.0; + auto unboosted = boosted; + unboosted.load = 1.0; + require(enginelab::FlamePhysicsModel::turbulentFlameSpeedMps(fuel, boosted) + == enginelab::FlamePhysicsModel::turbulentFlameSpeedMps(fuel, unboosted), + "boost must not stir the charge harder than a full cylinder"); enginelab::FlameEvent event; enginelab::FlameConditions conditions { 0.086, 0.000055, 700.0, 900'000.0, 1.05, 0.04, 12.0, 0.9 }; diff --git a/tests/KnockControlTests.cpp b/tests/KnockControlTests.cpp index 4bdf9b68..73c9e127 100644 --- a/tests/KnockControlTests.cpp +++ b/tests/KnockControlTests.cpp @@ -1,6 +1,8 @@ -// Knock control is a calibration: the ECU retards the spark by a gain per unit -// of knock intensity, `knock.retard_deg_per_intensity` (12 deg by default, the -// gain it always used). Zero turns the control off and the knock runs free. +// Knock control is a calibration: `knock.retard_deg_per_intensity` is the most +// spark the ECU may pull (12 deg by default). Knock the sensor hears pulls the +// spark back fast, clean running gives it back at a degree per second, so the +// retard settles at the knock limit. Zero turns the control off and the knock +// runs free. #include #include @@ -43,8 +45,9 @@ void publishKnockGain(SimpleEcuModel& ecu, double degreesPerIntensity) { ecu.beginFrame(); } -/** The ECU alone: the retard is the gain times the knock, and the knock-retard - * limit is reported only when the control actually retards. */ +/** The ECU alone: knock above the detection level (0.1) retards at 20 x its + * excess per second, a share of the authority; clean running recovers 0.08 + * of it per second. The knock-retard limit is reported while it retards. */ void ecuAppliesTheGain() { const auto config = makeDefaultInlineFour(); SimpleEcuModel ecu; @@ -52,7 +55,7 @@ void ecuAppliesTheGain() { ecu.beginFrame(); require(knockRetardGainDegrees(*ecu.calibrationStore()->snapshot()) == calibration::ecuLimits::defaultKnockRetardGainDegrees, - "the baseline gain is the ECU's former 12 deg"); + "the baseline authority is the ECU's former 12 deg"); EngineState state; state.rpm = 3'000.0; @@ -62,28 +65,40 @@ void ecuAppliesTheGain() { EngineControls controls; controls.ignitionEnabled = true; controls.throttle = 1.0; - const auto advanceAt = [&](double knock) { - state.knockLevel = knock; - return ecu.evaluate(config, state, controls); + // Evaluated every millisecond, as the solver's substeps call it. + const auto runFor = [&](double knock, double seconds) { + EcuCommand command; + for (auto step = 0; step < static_cast(std::lround(seconds * 1'000.0)); ++step) { + state.simulationTimeSeconds += 1.0e-3; + state.knockLevel = knock; + command = ecu.evaluate(config, state, controls); + } + return command; }; - const auto clean = advanceAt(0.0).ignitionAdvanceDegrees; - const auto knocking = advanceAt(0.4); - require(std::abs(clean - knocking.ignitionAdvanceDegrees - 4.8) < 1.0e-9, - "at the default gain, knock 0.4 retards 4.8 deg"); + const auto clean = runFor(0.0, 0.01).ignitionAdvanceDegrees; + require(std::abs(clean - runFor(0.08, 1.0).ignitionAdvanceDegrees) < 1.0e-9, + "knock below the detection level does not move the spark"); + const auto knocking = runFor(0.2, 0.1); + require(std::abs(clean - knocking.ignitionAdvanceDegrees - 2.4) < 0.05, + "0.1 s at knock 0.2 pulls 20 x 0.1 x 0.1 = a fifth of 12 deg"); require(hasActiveLimit(knocking.activeLimits, ActiveLimit::knockRetard), "a retarding knock control is reported"); + require(std::abs(clean - runFor(0.0, 1.0).ignitionAdvanceDegrees - 1.44) < 0.05, + "a clean second gives back 0.08 x 12 = 0.96 deg"); + + publishKnockGain(ecu, 25.0); + require(std::abs(clean - runFor(0.0, 0.001).ignitionAdvanceDegrees - 0.12 * 25.0) < 0.05, + "the retard is a share of the authority, so 25 deg scales it"); + require(std::abs(clean - runFor(0.6, 1.0).ignitionAdvanceDegrees - 25.0) < 1.0e-9, + "sustained heavy knock pulls the whole authority and no more"); publishKnockGain(ecu, 0.0); - const auto off = advanceAt(0.4); + const auto off = runFor(0.4, 0.5); require(std::abs(off.ignitionAdvanceDegrees - clean) < 1.0e-9, - "with the gain at zero the knock does not move the spark"); + "with the authority at zero the knock does not move the spark"); require(!hasActiveLimit(off.activeLimits, ActiveLimit::knockRetard), "a control that is off does not report a knock retard"); - publishKnockGain(ecu, 25.0); - require(std::abs(clean - advanceAt(0.4).ignitionAdvanceDegrees - 10.0) < 1.0e-9, - "a gain of 25 deg retards 10 deg at knock 0.4"); - auto diesel = config; diesel.fuel = FuelType::diesel; SimpleEcuModel dieselEcu; @@ -154,16 +169,19 @@ struct HeldRun final { return config; } -/** The 2JZ at full load, knock control on and off. */ +/** The 2JZ at full load with its spark map advanced 8 deg, past its knock + * limit (its stock map runs clean), knock control on and off. */ void knockRunsFreeWithoutControl() { - const auto config = catalogueEngine("2JZ"); + auto config = catalogueEngine("2JZ"); + for (auto& sample : config.ignition.timingCurve) sample.advanceDegrees += 8.0; const auto on = heldFullLoad(config, calibration::ecuLimits::defaultKnockRetardGainDegrees, 12.0, 6.0); const auto off = heldFullLoad(config, 0.0, 12.0, 6.0); for (const auto& [name, run] : { std::pair { "on ", on }, std::pair { "off", off } }) std::cout << " 2JZ held at full load, knock control " << name << ": knock mean " << run.meanKnock << ", peak " << run.peakKnock << ", advance " << run.meanAdvance << " deg, torque " << run.meanTorque << " N.m, damage " << run.damage << '\n'; - require(on.meanKnock > 0.1, "the 2JZ knocks lightly at full load (else this proves nothing)"); + require(on.peakKnock > 0.1 && on.meanKnock < 0.2, + "the over-advanced 2JZ reaches its knock limit at full load and the control holds it there"); require(off.meanAdvance > on.meanAdvance + 2.0, "without knock control the spark stays where the map puts it"); require(off.meanKnock > on.meanKnock + 0.05, "without knock control the knock grows"); } diff --git a/tests/LiveTuningTests.cpp b/tests/LiveTuningTests.cpp index f3ba88c3..76f1e588 100644 --- a/tests/LiveTuningTests.cpp +++ b/tests/LiveTuningTests.cpp @@ -463,12 +463,13 @@ void realisticTemperaturesFollowHeatAndAir() { require(!aircooledFan, "an air-cooled engine has no radiator fan"); } -/** Light knock does not wear the engine: the 2JZ held at full load knocks - * lightly the whole time, as every catalogue engine does at WOT, and ends with - * no damage. Counted linearly, it lost 0.3-0.5 % of power for good per dyno - * pass. */ +/** Light knock does not wear the engine: the 2JZ held at full load with its + * spark map advanced 8 deg runs at its knock limit, where the knock control + * holds it, and ends with no damage. Counted linearly, it lost 0.3-0.5 % of + * power for good per dyno pass. */ void lightKnockDoesNotWear() { - const auto config = catalogueEngine("2JZ"); + auto config = catalogueEngine("2JZ"); + for (auto& sample : config.ignition.timingCurve) sample.advanceDegrees += 8.0; Rig rig(config); constexpr auto frames = 10 * 240; double rpm = 0.0; @@ -485,7 +486,8 @@ void lightKnockDoesNotWear() { const auto damage = rig.simulator->state().damage; std::cout << " 2JZ at full load for 8 s: knock mean " << meanKnock << ", peak " << peakKnock << ", damage " << damage << '\n'; - require(meanKnock > 0.1 && peakKnock < 0.45, "the 2JZ knocks lightly at full load (else this proves nothing)"); + require(peakKnock > 0.1 && peakKnock < 0.45, + "the 2JZ knocks lightly at full load, where its control holds it (else this proves nothing)"); require(damage < 1.0e-9, "light knock does not damage the engine"); } diff --git a/tools/ComparisonHarness.cpp b/tools/ComparisonHarness.cpp index b4b8d64d..56eba377 100644 --- a/tools/ComparisonHarness.cpp +++ b/tools/ComparisonHarness.cpp @@ -472,6 +472,13 @@ bool runCatalogAcceptance(const std::filesystem::path& root, const std::string& std::array sampledInvalidBoundaries {}; double previousPressureSampleTime = 0.0; double dynoLoadIntegral = 0.0; + // The absorber brakes up to 35 bar of BMEP. The simulator's legacy + // load stops at 25, about what a real 2.5 TFSI makes (500 N.m), so a + // faithful boosted engine outran it. The gains are scaled so that + // below 25 bar the torque per rpm of error is what it always was. + const auto absorberCapacityNm = 3'500'000.0 + * enginelab::engineDisplacementLitres(config) * 0.001 / (4.0 * std::numbers::pi); + constexpr double absorberGainShare = 25.0 / 35.0; const auto dynoTargetRpm = std::max(config.idleRpm * 1.55, config.ignition.revLimitRpm * 0.65); for (int step = 0; step < static_cast(7.0 / dt); ++step) { @@ -481,11 +488,15 @@ bool runCatalogAcceptance(const std::filesystem::path& root, const std::string& const auto speedError = (simulator.state().rpm - dynoTargetRpm) / std::max(1.0, dynoTargetRpm); dynoLoadIntegral = std::clamp( - dynoLoadIntegral + speedError * dt * 1.20, 0.0, 0.92); - dynoLoad = std::clamp(dynoLoadIntegral + speedError * 0.70, 0.0, 1.0); + dynoLoadIntegral + speedError * dt * 1.20 * absorberGainShare, 0.0, 0.92); + dynoLoad = std::clamp(dynoLoadIntegral + speedError * 0.70 * absorberGainShare, 0.0, 1.0); } - const auto frame = simulator.step(dt, - { true, time < 1.5, 0.72, dynoLoad }); + enginelab::EngineControls controls; + controls.ignitionEnabled = true; + controls.starterEngaged = time < 1.5; + controls.throttle = 0.72; + controls.dynamometerTorqueNm = dynoLoad * absorberCapacityNm; + const auto frame = simulator.step(dt, controls); const auto& state = frame.state; enginelab::CylinderPressureSample pressureSample; while (simulator.tryPopCylinderPressureSample(pressureSample)) { diff --git a/tools/DynoSweepHarness.cpp b/tools/DynoSweepHarness.cpp index d55d8a3b..7b128f93 100644 --- a/tools/DynoSweepHarness.cpp +++ b/tools/DynoSweepHarness.cpp @@ -37,7 +37,7 @@ #include namespace { -constexpr std::array dynoColumnNames { +constexpr std::array dynoColumnNames { "engine", "cylinders", "target_rpm", "actual_rpm", "torque_nm", "power_kw", "power_ps", "ve", "lambda", "imep_bar", "peak_cyl_bar", "egt_c", "map_kpa", "air_mg", "fuel_g_s", "pmep_bar", @@ -46,6 +46,8 @@ constexpr std::array dynoColumnNames { "rpm_min", "rpm_max", "rpm_stddev", "rpm_drift", "held_rpm_min", "held_rpm_max", "held_rpm_stddev", "held_rpm_drift", "brake_nm_mean", "brake_nm_min", "brake_nm_max", + "knock_mean", "knock_peak", "heavy_knock_share", "advance_deg", + "end_gas_k", }; struct Sample final { @@ -82,6 +84,15 @@ struct Sample final { double meanBrakeTorqueNm {}; double minimumBrakeTorqueNm {}; double maximumBrakeTorqueNm {}; + /** `knockLevel`, the intensity the ECU retards on: window mean and peak, + * and the share of frames above the 0.45 the diagnostics call severe. */ + double meanKnock {}; + double peakKnock {}; + double heavyKnockShare {}; + /** The spark the ECU commanded, knock retard included; window mean. */ + double advanceDegrees {}; + /** The end gas's peak temperature per cycle, over cylinders and window. */ + double endGasPeakTemperatureK {}; }; [[nodiscard]] std::array numericColumns( @@ -101,6 +112,8 @@ struct Sample final { sample.heldRpmStandardDeviation, sample.heldRpmDrift, sample.meanBrakeTorqueNm, sample.minimumBrakeTorqueNm, sample.maximumBrakeTorqueNm, + sample.meanKnock, sample.peakKnock, sample.heavyKnockShare, + sample.advanceDegrees, sample.endGasPeakTemperatureK, }; } @@ -231,6 +244,8 @@ Sample holdPoint(enginelab::EngineSimulator& simulator, auto secondHalfHeldRpmAcc = 0.0; auto firstHalfCount = 0.0; auto secondHalfCount = 0.0; + auto knockAcc = 0.0, peakKnock = 0.0, heavyKnockAcc = 0.0, advanceAcc = 0.0; + auto endGasAcc = 0.0; auto sampleStartTimeSeconds = std::numeric_limits::infinity(); for (int step = 0; step < settleSteps + sampleSteps; ++step) { const auto absorberOutput = absorber.advance( @@ -304,6 +319,15 @@ Sample holdPoint(enginelab::EngineSimulator& simulator, exhaustAcc += frame.state.exhaustPressureKpa; airAcc += frame.state.airMassMgPerCycle; fuelAcc += frame.state.fuelFlowGramsPerSecond; + knockAcc += frame.state.knockLevel; + peakKnock = std::max(peakKnock, frame.state.knockLevel); + if (frame.state.knockLevel > 0.45) heavyKnockAcc += 1.0; + advanceAcc += frame.state.ignitionAdvanceDegrees; + auto endGasPeak = 0.0; + for (std::size_t c = 0; c < frame.state.cylinderStateCount; ++c) + endGasPeak += frame.state.cylinderStates[c].endGasPeakTemperatureK; + endGasAcc += endGasPeak / std::max(1.0, + static_cast(frame.state.cylinderStateCount)); // The simulator's per-sub-step cycle peak: a per-frame sample of // the chamber pressure misses the firing peak. peak = std::max(peak, frame.state.peakCylinderPressureBar); @@ -360,6 +384,11 @@ Sample holdPoint(enginelab::EngineSimulator& simulator, acc.minimumBrakeTorqueNm = std::isfinite(minimumBrakeTorque) ? minimumBrakeTorque : acc.meanBrakeTorqueNm; acc.maximumBrakeTorqueNm = maximumBrakeTorque; + acc.meanKnock = knockAcc / d; + acc.peakKnock = peakKnock; + acc.heavyKnockShare = heavyKnockAcc / d; + acc.advanceDegrees = advanceAcc / d; + acc.endGasPeakTemperatureK = endGasAcc / d; return acc; } @@ -588,6 +617,8 @@ int main(int argc, char** argv) { // Degrees added to the whole ignition curve: how far it is from the best // torque (journal 2026-10-19). auto advanceOffset = 0.0; + // Replaces every engine's fuel octane (RON); 0 keeps the catalogue's. + auto octaneRating = 0.0; // Seconds held at each point before sampling, and the length of the sample // window. The defaults are what every figure this tool has ever printed // used, so leaving them alone keeps old CSVs comparable. Raise the settle @@ -614,6 +645,8 @@ int main(int argc, char** argv) { else if (argument == "--add-turbo") addTurbo = true; else if (argument == "--advance-offset" && index + 1 < argc) advanceOffset = std::stod(argv[++index]); + else if (argument == "--octane" && index + 1 < argc) + octaneRating = std::stod(argv[++index]); else if (argument == "--settle" && index + 1 < argc) settleSeconds = std::stod(argv[++index]); else if (argument == "--sample" && index + 1 < argc) @@ -629,7 +662,7 @@ int main(int argc, char** argv) { " [--filter name-fragment] [--step rpm]" " [--reference-file csv] [--schema-only]" " [--well-mixed-junctions] [--boost-hold-margin ratio]" - " [--add-turbo] [--advance-offset deg]" + " [--add-turbo] [--advance-offset deg] [--octane ron]" " [--settle seconds] [--sample seconds]" " [--exhaust-cell-mm mm]" " [--crank-degrees deg] [--coupling-us us]\n"; @@ -691,6 +724,8 @@ int main(int argc, char** argv) { if (!engine.forcedInduction.enabled) engine = enginelab::withTurbocharger(engine); for (auto& engine : engines) for (auto& sample : engine.ignition.timingCurve) sample.advanceDegrees += advanceOffset; + if (octaneRating > 0.0) + for (auto& engine : engines) engine.octaneRating = octaneRating; auto boostHeld = true; for (const auto& engine : engines) { From 27bb9117dd89e03e8176a3bc8e2690515471a851 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:17:59 +0000 Subject: [PATCH 02/12] two-stroke: halve the idle governor's gains, and record the designs that flood The idle governor's gains were set on four-strokes. On the RD350, whose idle air passes the crankcase first, they hunt: a 5 s limit cycle, std 66 rpm with the lambda trim (which damps it) and 165 with it frozen. At half the proportional and integral gain the idle holds within 1,284-1,325 rpm. Designed carburetted 1-3 cylinder two-strokes do not idle at all: an open item-2 lead, recorded in the journal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FBmqGMLt8GmUG1aPYHCEVw --- .claude/CLAUDE.md | 5 +++-- CHANGELOG.md | 5 ++++- docs/journal.md | 7 +++++++ src/ecu/src/SimpleEcuModel.cpp | 10 ++++++++-- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6e5d9d0b..49223f02 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -25,8 +25,9 @@ without the owner's explicit agreement. chaotic and neither the behaviour nor the sound is a real two-stroke's. Free rein to add what a 2T needs (carburettor, reed and power valves) or to rework the ports deeply. Half the chaos was the scavenging model (fixed 2026-10-21) - and a carburettor exists (2026-10-22); open are reed and power valves and the - 3,500/7,000/7,500 rpm holding band, the engine's own and not the dyno's. + and a carburettor exists (2026-10-22); open are reed and power valves, the + 3,500/7,000/7,500 rpm holding band, the engine's own and not the dyno's, and + designed carburetted 1-3 cylinder two-strokes that do not idle (2026-10-23). 3. **Performance**: test duration, build time, real-time factor. 4. **Knock.** Most engines, even the biggest, survive a boost plus an advance that would destroy a real one. Fix the knock PHYSICS, not the damage model. diff --git a/CHANGELOG.md b/CHANGELOG.md index 226e73ba..eaf8ee5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,7 +85,10 @@ file formats, always with an in-memory migration). control now works as a production one: it pulls the spark back in steps of about a degree while the engine knocks and gives it back at 1 deg/s, so an engine settles at its knock limit instead of sitting in light knock. The - 2JZ, EJ25 and Audi I5 get the intercoolers their real engines have. + Audi I5 gets the intercooler its real engine has. +- A two-stroke's idle no longer hunts: the idle governor runs at half the + gain on two-strokes, whose idle air passes the crankcase first. The RD350 + held 1,175-1,398 rpm with a 5 s period; now 1,284-1,325. - Boost no longer burns the charge faster: the flame's turbulence follows the piston, not the intake pressure, so a boosted cylinder burns like a full unboosted one. diff --git a/docs/journal.md b/docs/journal.md index 7d62236e..65edebaf 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -5,6 +5,13 @@ measured, how, and what was not. Anything that becomes a lasting rule goes into `.claude/CLAUDE.md`. Earlier history lives in the `archive/docs-2026-09` git tag (`docs/archive/`). +## 2026-10-23 — Two-stroke idle: the governor's gains halved; designed carburetted twins flood + +- RD350 (injected) free idle, std over 16-40 s: the idle gate failed on drift 66 rpm (baseline 64.4, gate 65): a 5 s limit cycle, trim 0.68-1.16, air per cycle 34-49 mg. No end-gas event in it (the old model had 4). +- Lambda trim gain x0 / x0.5 / x1 / x2: std 165 / 125 / 66 / 16: the trim damps the hunt, it does not make it. The cycle-correct trim gain (360 deg, half the speed) made it worse (std 69, drift 78): not kept. +- Governor P,I x0.5: std 13.6 (trim on), 15.8 (trim frozen); D x2 helps only with the trim (16.7 vs 152). Kept for two-strokes: idle gate std 13.4, drift 0.8; WOT-blip recovery settles at 1,489 rpm (was 1,336). +- Designed carburetted 2T (sport, 54-64 x 54 mm): the 1-, 2- and 3-cylinder designs do not idle (stall by 4 s; twin AFR 4-6 read, metered 15-22, telemetry air ~4 g/s against a bypass that chokes near 1.6). The 4-cylinder idles 1,131 rpm, std 31. Open, item 2. + ## 2026-10-23 — Knock: Douaud-Eyzat's prefactor doubled; Woschni's combustion term backed out - Linux, `linux-gcc-release`. The 2026-10-22 knock work failed 8 ctest gates its baseline (91c04d3) passes. Ablation by switches, one ingredient each. diff --git a/src/ecu/src/SimpleEcuModel.cpp b/src/ecu/src/SimpleEcuModel.cpp index 69d2f47c..0528c962 100644 --- a/src/ecu/src/SimpleEcuModel.cpp +++ b/src/ecu/src/SimpleEcuModel.cpp @@ -236,8 +236,14 @@ EcuCommand SimpleEcuModel::evaluate(const EngineConfig& config, const EngineStat // gap during tip-in. const auto driverOverride = std::clamp(1.0 - state.throttle * 12.0, 0.0, 1.0); constexpr double feedForward = 0.42; - constexpr double proportionalGain = 0.90; - constexpr double integralGain = 0.55; + // The gains were set on four-strokes. On a two-stroke, whose idle air + // goes through the crankcase first, they hunt: the RD350 swung + // 1,006-1,565 rpm with its fuel trim frozen (std 165) and 1,175-1,398 + // with it; at half the gain, 1,271-1,349 and 1,279-1,325 (journal + // 2026-10-23). + const auto governorGainShare = config.cycle == EngineCycle::twoStroke ? 0.5 : 1.0; + const auto proportionalGain = 0.90 * governorGainShare; + const auto integralGain = 0.55 * governorGainShare; constexpr double derivativeGainSeconds = 0.12; constexpr double minimumIntegral = -0.20; if (effectiveThrottle > 0.02) { From e0bff3d8b27ae9319be60849069e02bc06ba47e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:48:59 +0000 Subject: [PATCH 03/12] journal: a pilot sized from the idle path's air does not crank a designed twin Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FBmqGMLt8GmUG1aPYHCEVw --- docs/journal.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/journal.md b/docs/journal.md index 65edebaf..b9712a32 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -10,7 +10,8 @@ measured, how, and what was not. Anything that becomes a lasting rule goes into - RD350 (injected) free idle, std over 16-40 s: the idle gate failed on drift 66 rpm (baseline 64.4, gate 65): a 5 s limit cycle, trim 0.68-1.16, air per cycle 34-49 mg. No end-gas event in it (the old model had 4). - Lambda trim gain x0 / x0.5 / x1 / x2: std 165 / 125 / 66 / 16: the trim damps the hunt, it does not make it. The cycle-correct trim gain (360 deg, half the speed) made it worse (std 69, drift 78): not kept. - Governor P,I x0.5: std 13.6 (trim on), 15.8 (trim frozen); D x2 helps only with the trim (16.7 vs 152). Kept for two-strokes: idle gate std 13.4, drift 0.8; WOT-blip recovery settles at 1,489 rpm (was 1,336). -- Designed carburetted 2T (sport, 54-64 x 54 mm): the 1-, 2- and 3-cylinder designs do not idle (stall by 4 s; twin AFR 4-6 read, metered 15-22, telemetry air ~4 g/s against a bypass that chokes near 1.6). The 4-cylinder idles 1,131 rpm, std 31. Open, item 2. +- Designed carburetted 2T (sport, 54-64 x 54 mm): the 1-, 2- and 3-cylinder designs do not idle; the 4-cylinder idles 1,131 rpm, std 31. The twin's pilot meters ~AFR 7 against the air its bypass can pass (it is sized for an idle delivery ratio of 0.35); `airFlowGramsPerSecond` reads trapped mass x cycle rate, ~4 g/s of a misfiring charge, not throttle flow. +- Refuted: sizing the pilot from the idle path's air at the governor's neutral 0.42: the single idles (1,241, std 60) but the twin cranks at AFR 30-100 on the 0.88 post-start air and never fires. The ECU's idle air moves 0.4-1.0 while the pilot cannot follow it: a carburettor's idle air must pass its own idle circuit. Open, item 2. ## 2026-10-23 — Knock: Douaud-Eyzat's prefactor doubled; Woschni's combustion term backed out From f4b76d36f59c29805f988121d0124acf6dd2e7d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:02:01 +0000 Subject: [PATCH 04/12] two-stroke: a designed carburetted engine idles The designer sized the pilot jet for an idle delivery ratio of 0.35, about eight times what a two-stroke idles on, so designed twins and triples flooded at idle and stalled. The pilot is now sized from the air the idle path passes at the crankcase's 20 kPa, and follows that path as the ECU's idle air (the slide's idle stop) opens, the way progression holes do; each carburettor's bypass goes with the cylinder it feeds, and the lumped pilot restriction is refitted on the same RD350 pair (0.026, a #35). Designed 1-4 cylinder engines idle at their 1,300 rpm. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FBmqGMLt8GmUG1aPYHCEVw --- .claude/CLAUDE.md | 3 +-- CHANGELOG.md | 6 ++++++ docs/journal.md | 4 ++-- docs/simulation-model.md | 21 ++++++++++++------- src/catalog/src/EngineDesign.cpp | 20 +++++++++++++++--- src/ecu/src/SimpleEcuModel.cpp | 2 +- .../enginelab/foundation/EngineTypes.hpp | 16 +++++++++----- .../enginelab/physics/CarburettorModel.hpp | 8 ++++++- src/physics/src/CarburettorModel.cpp | 6 ++++-- src/simulation/src/EngineSimulator.cpp | 16 +++++++++++++- tests/TwoStrokeTests.cpp | 10 +++++++-- 11 files changed, 85 insertions(+), 27 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 49223f02..0d19f742 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -26,8 +26,7 @@ without the owner's explicit agreement. rein to add what a 2T needs (carburettor, reed and power valves) or to rework the ports deeply. Half the chaos was the scavenging model (fixed 2026-10-21) and a carburettor exists (2026-10-22); open are reed and power valves, the - 3,500/7,000/7,500 rpm holding band, the engine's own and not the dyno's, and - designed carburetted 1-3 cylinder two-strokes that do not idle (2026-10-23). + 3,500/7,000/7,500 rpm holding band, the engine's own and not the dyno's. 3. **Performance**: test duration, build time, real-time factor. 4. **Knock.** Most engines, even the biggest, survive a boost plus an advance that would destroy a real one. Fix the knock PHYSICS, not the damage model. diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf8ee5d..87230813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,12 @@ file formats, always with an in-memory migration). about a degree while the engine knocks and gives it back at 1 deg/s, so an engine settles at its knock limit instead of sitting in light knock. The Audi I5 gets the intercooler its real engine has. +- A designed carburetted two-stroke idles: its pilot jet was sized for about + eight times the air a two-stroke idles on, so twins and triples flooded and + stalled. The pilot is now sized from the air the idle path passes and + follows it as the idle air opens, and each carburettor's idle bypass goes + with the cylinder it feeds. Designed 1-4 cylinder two-strokes idle at their + 1,300 rpm; an RD350-sized design keeps a 35 pilot. - A two-stroke's idle no longer hunts: the idle governor runs at half the gain on two-strokes, whose idle air passes the crankcase first. The RD350 held 1,175-1,398 rpm with a 5 s period; now 1,284-1,325. diff --git a/docs/journal.md b/docs/journal.md index b9712a32..b1334442 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -5,13 +5,13 @@ measured, how, and what was not. Anything that becomes a lasting rule goes into `.claude/CLAUDE.md`. Earlier history lives in the `archive/docs-2026-09` git tag (`docs/archive/`). -## 2026-10-23 — Two-stroke idle: the governor's gains halved; designed carburetted twins flood +## 2026-10-23 — Two-stroke idle: the governor's gains halved; designed carburetted engines idle - RD350 (injected) free idle, std over 16-40 s: the idle gate failed on drift 66 rpm (baseline 64.4, gate 65): a 5 s limit cycle, trim 0.68-1.16, air per cycle 34-49 mg. No end-gas event in it (the old model had 4). - Lambda trim gain x0 / x0.5 / x1 / x2: std 165 / 125 / 66 / 16: the trim damps the hunt, it does not make it. The cycle-correct trim gain (360 deg, half the speed) made it worse (std 69, drift 78): not kept. - Governor P,I x0.5: std 13.6 (trim on), 15.8 (trim frozen); D x2 helps only with the trim (16.7 vs 152). Kept for two-strokes: idle gate std 13.4, drift 0.8; WOT-blip recovery settles at 1,489 rpm (was 1,336). - Designed carburetted 2T (sport, 54-64 x 54 mm): the 1-, 2- and 3-cylinder designs do not idle; the 4-cylinder idles 1,131 rpm, std 31. The twin's pilot meters ~AFR 7 against the air its bypass can pass (it is sized for an idle delivery ratio of 0.35); `airFlowGramsPerSecond` reads trapped mass x cycle rate, ~4 g/s of a misfiring charge, not throttle flow. -- Refuted: sizing the pilot from the idle path's air at the governor's neutral 0.42: the single idles (1,241, std 60) but the twin cranks at AFR 30-100 on the 0.88 post-start air and never fires. The ECU's idle air moves 0.4-1.0 while the pilot cannot follow it: a carburettor's idle air must pass its own idle circuit. Open, item 2. +- Sizing the pilot from the idle path's air at the governor's neutral 0.42 alone: the twin cranks at AFR 30-100 on the 0.88 post-start air and never fires. With the pilot also following the idle path's opening (progression holes), the bypass per carburettor at 18 mm2 per litre of its cylinder (a four had 6.25 each) and the pilot factor refitted 0.14 -> 0.026 (RD350 #35): designed 1/2/3/4-cylinder engines idle 1,310/1,303/1,303/1,302 rpm, std 73/26/20/18, pilots #25/35/35/35. The rig's RD350 idles at delivery ratio 0.041. ## 2026-10-23 — Knock: Douaud-Eyzat's prefactor doubled; Woschni's combustion term backed out diff --git a/docs/simulation-model.md b/docs/simulation-model.md index 4acd0bef..5942afb8 100644 --- a/docs/simulation-model.md +++ b/docs/simulation-model.md @@ -199,14 +199,19 @@ spark, injection, the trapped-charge latch and the cycle telemetry follow it. of head, so flow is the number times the root of the head available. The designer sizes them from the engine and not from a table -- the fuel peak power needs at AFR 12.8 over the root of the venturi head there, and the - fuel the idle needs at AFR 13.5 over the root of the crankcase head -- and on - an RD350-sized design that returns a 287 main and a 41 pilot against the - bike's 260-290 and 30-40, neither of which was fitted to it. The main jet is - very nearly its circuit's only restriction so its number is used directly; - the pilot's is not, because the emulsion passage, the air screw's bleed and - the outlet all throttle it further, and `pilotCircuitFactor` is that lumped - restriction (0.14, the one value fitted to a real pair: an RD350's 35 pilot - idling at AFR 13 on about 21 kPa). + fuel the idle air needs at AFR 13.5 over the root of the crankcase head. The + idle air is what the idle path passes at that head (the slide's leak and the + bypass at the idle governor's neutral opening), and as the ECU opens or + shuts that path, standing in for the slide's idle stop, the pilot follows it + the way progression holes do: the idle mixture holds through cranking and + idle. On an RD350-sized design the main comes out at 287 against the bike's + 260-290. The main jet is very nearly its circuit's only restriction so its + number is used directly; the pilot's is not, because the emulsion passage, + the air screw's bleed and the outlet all throttle it further, and + `pilotCircuitFactor` is that lumped restriction (0.026, the one value fitted + to a real pair: an RD350's 35 pilot feeding its idle air at AFR 13.5 on about + 20 kPa). A pilot sized for an idle delivery ratio of 0.35, about eight times + what the model's RD350 idles on, flooded every designed twin. Two things a real carburettor has are deliberately absent, both of which act at the top of the range: there is no emulsion tube or air corrector, so the diff --git a/src/catalog/src/EngineDesign.cpp b/src/catalog/src/EngineDesign.cpp index 31cb4a47..239c6bd7 100644 --- a/src/catalog/src/EngineDesign.cpp +++ b/src/catalog/src/EngineDesign.cpp @@ -568,8 +568,14 @@ DesignedEngine designEngine(const EngineSpec& spec, const PartsLibrary& parts) { std::max(minimumPlenumVolumeLitres, plenum), plenum < minimumPlenumVolumeLitres ? "per litre gives " + format(plenum) + ", raised to the simulator's floor" : std::string("per litre of displacement")); + // A two-stroke's carburettor feeds one cylinder, so its idle air goes + // with that cylinder: the RD350 runs 3 mm2 per carburettor on 175 cc. + // Per litre of the whole engine, a four's carburettors each had a + // twin's idle air and their pilots were sized to match (#63). intake.idleBypassAreaMm2 = derive("idle_bypass_area_mm2", "Idle bypass", "mm2", - diesel ? 0.0 : (twoStroke ? 9.0 : 12.0) * litres, diesel ? "none: a diesel is unthrottled" : "per litre"); + diesel ? 0.0 : twoStroke ? 18.0 * litres / std::max(1.0, countD) : 12.0 * litres, + diesel ? "none: a diesel is unthrottled" + : twoStroke ? "per litre of the cylinder its carburettor feeds" : "per litre"); // The plate-in-bore gap leaks in proportion to the bore's rim. Held at // the simulator's 0.6 mm2 on a small bore, the leak alone outran a // 50 cc engine's idle air: 2,042 rpm for 1,350; 0.1 mm2 gave 1,329 @@ -632,9 +638,17 @@ DesignedEngine designEngine(const EngineSpec& spec, const PartsLibrary& parts) { // Pilot circuit, at idle: the depression is the whole drop from the // float bowl to a crankcase pumping about 20 kPa below ambient. - const auto idleAirKgPerSecond = displacementPerBoreM3 * 0.35 - * (std::max(1.0, config.idleRpm) / 60.0) * ambientDensityKgPerM3; + // The air it meters for is what the idle air path passes at that + // drop, the closed-slide leak and the bypass at the governor's + // neutral opening; the pilot then follows that path as it opens. + // Sized instead for a delivery ratio of 0.35 at idle, about three + // times what a two-stroke idles on, it fed a 350 cc twin AFR ~7 + // and the twin flooded (journal 2026-10-23). constexpr double idleCrankcaseDropPa = 20'000.0; + const auto idleAirAreaM2 = (intake.closedThrottleLeakageAreaMm2 * bores + + intake.idleBypassAreaMm2 * carburettorPilotSizingIdleOpening) * 1.0e-6; + const auto idleAirKgPerSecond = intake.throttleDischargeCoefficient * idleAirAreaM2 + * std::sqrt(2.0 * ambientDensityKgPerM3 * idleCrankcaseDropPa); const auto idleHeadM = idleCrankcaseDropPa / (fuelDensityKgPerM3 * gravityMPerS2); constexpr double idleAirFuelRatio = 13.5; diff --git a/src/ecu/src/SimpleEcuModel.cpp b/src/ecu/src/SimpleEcuModel.cpp index 0528c962..fd399065 100644 --- a/src/ecu/src/SimpleEcuModel.cpp +++ b/src/ecu/src/SimpleEcuModel.cpp @@ -235,7 +235,7 @@ EcuCommand SimpleEcuModel::evaluate(const EngineConfig& config, const EngineStat // integration step before the plate has opened and creates a real air // gap during tip-in. const auto driverOverride = std::clamp(1.0 - state.throttle * 12.0, 0.0, 1.0); - constexpr double feedForward = 0.42; + constexpr double feedForward = carburettorPilotSizingIdleOpening; // The gains were set on four-strokes. On a two-stroke, whose idle air // goes through the crankcase first, they hunt: the RD350 swung // 1,006-1,565 rpm with its fuel trim frozen (std 165) and 1,175-1,398 diff --git a/src/foundation/include/enginelab/foundation/EngineTypes.hpp b/src/foundation/include/enginelab/foundation/EngineTypes.hpp index a4bc0945..80977cac 100644 --- a/src/foundation/include/enginelab/foundation/EngineTypes.hpp +++ b/src/foundation/include/enginelab/foundation/EngineTypes.hpp @@ -210,6 +210,10 @@ struct ExhaustConfig final { double midpipeDiameterMm { 0.0 }; }; +/** The idle-air opening a carburettor's pilot jet is sized at: the idle + * governor's neutral command (its feed-forward), where a warm engine idles. */ +inline constexpr double carburettorPilotSizingIdleOpening = 0.42; + /** A slide carburettor: the fuel system that meters from the air itself. * * An injector is told how much fuel to deliver. A carburettor is not told @@ -256,11 +260,13 @@ struct CarburettorConfig final { * through a small outlet at the slide's edge, and all of that throttles it * further. Driving a bare 35 jet with the whole crankcase depression gives * AFR 1.5 at idle, so the lumping is not optional; this factor is the - * series restriction of everything after the jet. 0.14 is what an RD350's - * 35 pilot and its ~21 kPa idle depression need to idle at AFR 13, which - * is the one real pair the value is fitted to. A circuit resolved passage - * by passage would replace it. */ - double pilotCircuitFactor { 0.14 }; + * series restriction of everything after the jet. 0.026 is what an + * RD350's 35 pilot and its ~20 kPa idle depression need to feed the air + * its idle path passes at AFR 13.5, which is the one real pair the value + * is fitted to (0.14 belonged to an idle delivery ratio of 0.35, about + * three times a real one). A circuit resolved passage by passage would + * replace it. */ + double pilotCircuitFactor { 0.026 }; /** Needle clip position, 1 (needle highest, richest) to 5. The taper in * the needle jet is what meters between the pilot and the main. */ double needleClipPosition { 3.0 }; diff --git a/src/physics/include/enginelab/physics/CarburettorModel.hpp b/src/physics/include/enginelab/physics/CarburettorModel.hpp index 619d5ab7..253e953f 100644 --- a/src/physics/include/enginelab/physics/CarburettorModel.hpp +++ b/src/physics/include/enginelab/physics/CarburettorModel.hpp @@ -87,6 +87,11 @@ class CarburettorModel final { * `airMassFlowKgPerSecond` is the air actually crossing the bore, and the * fuel follows from the depression that moved it, so this is an amount per * step and never a gain on a flow it goes on to change. + * + * `idleCircuitOpening` is the idle air path's opening against its + * neutral one. Opening a slide's idle stop uncovers progression holes + * as it lets air by, so the pilot's fuel follows the idle air; 1 is the + * opening the pilot jet was sized at. */ [[nodiscard]] static CarburettorMetering meter(const CarburettorConfig& carburettor, const FuelConfig& fuel, @@ -97,7 +102,8 @@ class CarburettorModel final { double crankcasePressureKpa, double throttleDiameterMm, double throttleFraction, - double dtSeconds) noexcept; + double dtSeconds, + double idleCircuitOpening = 1.0) noexcept; }; } // namespace enginelab diff --git a/src/physics/src/CarburettorModel.cpp b/src/physics/src/CarburettorModel.cpp index 06dfa00d..982945c4 100644 --- a/src/physics/src/CarburettorModel.cpp +++ b/src/physics/src/CarburettorModel.cpp @@ -76,7 +76,8 @@ CarburettorMetering CarburettorModel::meter(const CarburettorConfig& carburettor double crankcasePressureKpa, double throttleDiameterMm, double throttleFraction, - double dtSeconds) noexcept { + double dtSeconds, + double idleCircuitOpening) noexcept { CarburettorMetering result; if (!(dtSeconds > 0.0) || !std::isfinite(dtSeconds)) return result; @@ -118,7 +119,8 @@ CarburettorMetering CarburettorModel::meter(const CarburettorConfig& carburettor // root so that a doubled bleed is a halved head rather than a halved flow. const auto airScrewFactor = std::sqrt(nominalAirScrewTurns / airScrewTurns); const auto pilotAreaM2 = jetAreaM2(carburettor.pilotJetFlowNumber, coefficient) - * std::max(0.0, carburettor.pilotCircuitFactor) * airScrewFactor; + * std::max(0.0, carburettor.pilotCircuitFactor) * airScrewFactor + * (std::isfinite(idleCircuitOpening) ? std::clamp(idleCircuitOpening, 0.0, 4.0) : 1.0); const auto pilotFlowKgPerSecond = orificeMassFlowKgPerSecond( pilotAreaM2, coefficient, fuelDensityKgPerM3, pilotDropPa - liftPressurePa); result.pilotMoles = pilotFlowKgPerSecond * dtSeconds / fuelMolarMassKg; diff --git a/src/simulation/src/EngineSimulator.cpp b/src/simulation/src/EngineSimulator.cpp index e9d4e717..e5f4570e 100644 --- a/src/simulation/src/EngineSimulator.cpp +++ b/src/simulation/src/EngineSimulator.cpp @@ -251,6 +251,19 @@ constexpr double exhaustExternalHeatTransferWPerM2K = 18.0; /** What feeds the plenum: the throttle, or with individual throttles the bores * held fully open, the plenum being the airbox and the plates in the runners. */ +/** A carburettor's idle air path against the one its pilot was sized at: + * the closed-slide leak plus the bypass at the idle governor's neutral + * opening. The ECU's idle air stands in for the slide's idle stop, and + * opening that stop uncovers the progression holes, so the pilot follows. */ +[[nodiscard]] double carburettorIdleCircuitOpening(const IntakeConfig& intake, + double idleAirOpening) noexcept { + const auto leak = closedThrottleLeakageAreaM2(intake); + const auto bypass = std::max(0.0, intake.idleBypassAreaMm2) * 1.0e-6; + const auto neutral = leak + bypass * carburettorPilotSizingIdleOpening; + if (!(neutral > 0.0)) return 1.0; + return (leak + bypass * std::clamp(idleAirOpening, 0.0, 1.0)) / neutral; +} + [[nodiscard]] double plenumInletAreaM2(const IntakeConfig& intake, double throttleAreaM2, double throttlePlateAreaM2) noexcept { @@ -2083,7 +2096,8 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co plenumInflowKg / subDt, boundaryDensityKgPerM3, intakeSourcePressureKpa, intakePlenumGas_[pathIndex].pressureKpa(), - intake.throttleDiameterMm, state_.throttle, subDt); + intake.throttleDiameterMm, state_.throttle, subDt, + carburettorIdleCircuitOpening(intake, ecuCommand.idleAirOpening)); if (metering.deliveredMoles > 0.0) intakePlenumGas_[pathIndex].injectFuelMoles( metering.deliveredMoles, diff --git a/tests/TwoStrokeTests.cpp b/tests/TwoStrokeTests.cpp index 17c3752b..3538abdc 100644 --- a/tests/TwoStrokeTests.cpp +++ b/tests/TwoStrokeTests.cpp @@ -672,8 +672,14 @@ void theDefaultJetsGiveTheRealEngineItsMixture() { std::cout << " full throttle, 8,000 rpm: AFR " << wideOpenAfr << '\n'; require(wideOpenAfr > 11.5 && wideOpenAfr < 14.5, "the stock main jet gives a full-load mixture a two-stroke can run"); - const auto idleAfr = rig.settledAirFuelRatio( - rig.airFlowKgPerSecond(1'300.0, 0.35), 80.0, 0.0); + // At idle the air is what the bike's idle path passes, not a share of + // what the cylinder could swallow: its 3 mm2 per carburettor, at the idle + // governor's neutral opening, plus the 26 mm slide's 0.26 mm2 leak, under + // the crankcase's 21 kPa. + const auto idleAirKgPerSecond = 0.72 + * (3.0 * carburettorPilotSizingIdleOpening + 0.26) * 1.0e-6 + * std::sqrt(2.0 * rig.airDensityKgPerM3 * (101.325 - 80.0) * 1'000.0); + const auto idleAfr = rig.settledAirFuelRatio(idleAirKgPerSecond, 80.0, 0.0); std::cout << " idle, 1,300 rpm: AFR " << idleAfr << '\n'; require(idleAfr > 11.0 && idleAfr < 17.0, "the stock pilot jet idles in the band a carburetted two-stroke idles in"); From fb6e7c48af1eee3f21f00c8758a7641d73574eaa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:51:03 +0000 Subject: [PATCH 05/12] two-stroke: the holding band was the 240 Hz frame's alias; hold the frame's mean speed A designed two-stroke twin could not be held at 3,500, 7,000 and 7,500 rpm. Those are where it fires at 117, 233 and 250 Hz against the 240 Hz frame: the absorber held EngineState::rpm, the speed at the frame's end with the firing ripple in it, which aliased into a 6-7 Hz wander the brake answered. The simulator now publishes frameAveragedRpm and the absorber holds that: held_rpm_stddev 2.3/6.6/2.6 -> 0.2/0.3/0.2 rpm, torque within 1 %. DynoSweepHarness --frame-hz moves the frame. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FBmqGMLt8GmUG1aPYHCEVw --- .claude/CLAUDE.md | 16 +++++++++------- CHANGELOG.md | 4 ++++ docs/journal.md | 7 +++++++ docs/tests-and-validation.md | 2 +- .../include/enginelab/foundation/EngineTypes.hpp | 5 +++++ src/simulation/src/DynoAbsorberController.cpp | 11 +++++++++-- src/simulation/src/EngineSimulator.cpp | 6 ++++++ tools/DynoSweepHarness.cpp | 15 +++++++++++---- 8 files changed, 52 insertions(+), 14 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0d19f742..fec2e3ed 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -25,8 +25,9 @@ without the owner's explicit agreement. chaotic and neither the behaviour nor the sound is a real two-stroke's. Free rein to add what a 2T needs (carburettor, reed and power valves) or to rework the ports deeply. Half the chaos was the scavenging model (fixed 2026-10-21) - and a carburettor exists (2026-10-22); open are reed and power valves, the - 3,500/7,000/7,500 rpm holding band, the engine's own and not the dyno's. + and a carburettor exists (2026-10-22); the 3,500/7,000/7,500 rpm holding + band was the 240 Hz frame's alias of the firing ripple (2026-10-23); open + are reed and power valves. 3. **Performance**: test duration, build time, real-time factor. 4. **Knock.** Most engines, even the biggest, survive a boost plus an advance that would destroy a real one. Fix the knock PHYSICS, not the damage model. @@ -129,9 +130,10 @@ masks everything); `EngineLabPhysicsPerfHarness` needs `--idle` beside `--trace 1` (`--trace ` alone is full-load lugging, not an idle). **Flags that separate a real effect from an artefact**: -`EngineLabDynoSweepHarness` `--crank-degrees` and `--coupling-us` resize the -mechanical substep and the exhaust flush clock, and a gas-dynamic feature stays -where it is when either changes; `EngineLabGeometrySensitivityHarness` says +`EngineLabDynoSweepHarness` `--crank-degrees`, `--coupling-us` and +`--frame-hz` resize the mechanical substep, the exhaust flush clock and the +240 Hz outer frame, and a gas-dynamic feature stays where it is when any one +changes; `EngineLabGeometrySensitivityHarness` says whether an exhaust change moved the timbre (`shape`) or only the level (`level`). @@ -212,8 +214,8 @@ Each measurement is in `docs/journal.md` or the docs archive (tag crankcase); a transit-vs-window scavenging race (monotonic in rpm by construction); a scavenging bias from ONE pressure sample at port opening (a hunting limit cycle over 4,500-6,250 rpm: integrate the front); "the trapped - residual is too low" (a unit error). The 3,500/7,000/7,500 rpm holding band - is none of pipe length, settle, step, crank step, flush clock or carburettor. + residual is too low" (a unit error). A holding band locked to crank speed is + first a frame-rate alias: `DynoSweepHarness --frame-hz` moves it. - **Intake**: four inertance formulations (unstable or out of phase); the charge bias as the MUSCL limiter, the finite plenum, wall heat or valve linearisation (it was the valve nozzle boundary, Courant-dependent); front-loaded or diff --git a/CHANGELOG.md b/CHANGELOG.md index 87230813..515f90b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,10 @@ file formats, always with an in-memory migration). about a degree while the engine knocks and gives it back at 1 deg/s, so an engine settles at its knock limit instead of sitting in light knock. The Audi I5 gets the intercooler its real engine has. +- A two-stroke twin no longer fails to hold 3,500, 7,000 and 7,500 rpm on the + dyno: the brake answered the crankshaft speed sampled at the end of each + 240 Hz frame, where a firing rate near 240 Hz (or 120) aliases into a slow + wander. The dyno now holds the speed averaged over the frame. - A designed carburetted two-stroke idles: its pilot jet was sized for about eight times the air a two-stroke idles on, so twins and triples flooded and stalled. The pilot is now sized from the air the idle path passes and diff --git a/docs/journal.md b/docs/journal.md index b1334442..660ea302 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -5,6 +5,13 @@ measured, how, and what was not. Anything that becomes a lasting rule goes into `.claude/CLAUDE.md`. Earlier history lives in the `archive/docs-2026-09` git tag (`docs/archive/`). +## 2026-10-23 — Two-stroke: the 3,500/7,000/7,500 rpm holding band is the 240 Hz frame's alias + +- Designed t2_twin_500 (sport, 0.5 L), DynoSweep step 250, settle 8, with a new `--frame-hz`. `held_rpm_stddev` at 240 Hz: 3,500 2.3, 7,000 6.6, 7,500 2.6, elsewhere 0.2-1.1; at 300 Hz the three are gone (0.2-0.4); at 200 Hz the band moves to 5,750-6,250 (2.7-3.1), where the twin fires at 200 Hz. A twin two-stroke fires at 233 Hz at 7,000 rpm, 117 at 3,500, 250 at 7,500. +- Mechanism: `EngineState::rpm` is the speed at the frame's end, with the firing ripple in it (+-50 rpm here). Sampled once a frame it beats at 6-7 Hz, which the absorber's 8 rad/s filter passes in part: the brake swung 40-58 N.m with the alias. Spark, AFR and trim were steady, so the engine itself held. +- Fix: the simulator publishes `frameAveragedRpm` (crank travel over the frame) and the absorber holds that. 240 Hz after: 3,500 0.2, 7,000 0.3, 7,500 0.2; torque within 1 %. Left: 1.4-1.8 rpm at 5,750-6,000, present at 200/240/300 Hz alike, so the engine's own. +- Not measured: the application's continuous-ramp dyno, which reads the same controller; Windows. + ## 2026-10-23 — Two-stroke idle: the governor's gains halved; designed carburetted engines idle - RD350 (injected) free idle, std over 16-40 s: the idle gate failed on drift 66 rpm (baseline 64.4, gate 65): a 5 s limit cycle, trim 0.68-1.16, air per cycle 34-49 mg. No end-gas event in it (the old model had 4). diff --git a/docs/tests-and-validation.md b/docs/tests-and-validation.md index 28281b11..8bfe130a 100644 --- a/docs/tests-and-validation.md +++ b/docs/tests-and-validation.md @@ -187,7 +187,7 @@ file's header says which question it answers and lists its options. | `DriveScenarioHarness` | a driving complaint as a timeline of pedals and gears (above) | | `DynoCurveHarness` | torque at established points, integrated over whole cycles once the engine has settled; writes per-cycle data and a provenance file next to its CSV | | `DynoRuntimeCurveHarness` | the application's own dyno run, captured from the runtime's public snapshots | -| `DynoSweepHarness` | torque and power over an engine-speed sweep, CSV export; `--advance-offset deg` shifts the whole ignition curve. The sweep is warm-started, so each point inherits its predecessor's state: `--settle s` is what separates a curve's shape from that memory, and `--step` cannot (it moves the grid, the first point and the path at once). `--sample s` widens the averaging window, `--exhaust-cell-mm` overrides the exhaust mesh (a convergence knob, not a setting). `--crank-degrees deg` and `--coupling-us us` resize the mechanical substep and the exhaust flush clock, which is how a gas-dynamic feature is told from a sampling artefact: a real one stays where it is when either changes | +| `DynoSweepHarness` | torque and power over an engine-speed sweep, CSV export; `--advance-offset deg` shifts the whole ignition curve. The sweep is warm-started, so each point inherits its predecessor's state: `--settle s` is what separates a curve's shape from that memory, and `--step` cannot (it moves the grid, the first point and the path at once). `--sample s` widens the averaging window, `--exhaust-cell-mm` overrides the exhaust mesh (a convergence knob, not a setting). `--crank-degrees deg` and `--coupling-us us` resize the mechanical substep and the exhaust flush clock, which is how a gas-dynamic feature is told from a sampling artefact: a real one stays where it is when either changes; `--frame-hz` moves the 240 Hz outer frame the absorber and controls step at, so a feature locked to the speed where the firing rate meets it is told from the engine's own | | `ExhaustFlowBench` | an authored exhaust under a steady mass flow, engine removed: is the back-pressure the pipes' or the pulses'? | | `ExhaustNetworkCostBench` | does the same geometry cost more solver time when authored as a designer network? | | `GeometrySensitivityHarness` | does an exhaust change alter the timbre (`shape`) or only the level? | diff --git a/src/foundation/include/enginelab/foundation/EngineTypes.hpp b/src/foundation/include/enginelab/foundation/EngineTypes.hpp index 80977cac..3f069807 100644 --- a/src/foundation/include/enginelab/foundation/EngineTypes.hpp +++ b/src/foundation/include/enginelab/foundation/EngineTypes.hpp @@ -1888,6 +1888,11 @@ struct EngineState final { bool airFuelRatioValid { false }; /** The electric radiator fan, realistic temperatures only. */ bool coolingFanOn { false }; + /** Crankshaft speed averaged over the last step: `rpm` is the speed at + * its end, which carries the firing ripple. Sampled once per 240 Hz + * frame, a ripple near that rate aliases into a slow wander; a + * two-stroke twin fires at 233 Hz at 7,000 rpm. Zero before a step. */ + double frameAveragedRpm { 0.0 }; }; struct EcuCommand final { diff --git a/src/simulation/src/DynoAbsorberController.cpp b/src/simulation/src/DynoAbsorberController.cpp index 499c6424..f29505a3 100644 --- a/src/simulation/src/DynoAbsorberController.cpp +++ b/src/simulation/src/DynoAbsorberController.cpp @@ -55,8 +55,15 @@ DynoAbsorberOutput DynoAbsorberController::advance( // A non-finite observation is not a zero-speed impulse. Hold the last // finite observation so one corrupt frame cannot inject an artificial // acceleration spike into the controller. - const auto measuredRpm = std::isfinite(engineState.rpm) - ? std::max(0.0, engineState.rpm) : filteredRpm_; + // The speed averaged over the frame, not the speed at its end: sampled + // once a frame, the firing ripple aliases into a slow wander the brake + // answered. A two-stroke twin, firing at 233 Hz at 7,000 rpm, swung + // +-50 rpm on a 240 Hz frame and could not be held there (nor at 3,500 + // or 7,500 rpm); on a 300 Hz frame it held (journal 2026-10-23). + const auto observedRpm = engineState.frameAveragedRpm > 0.0 + ? engineState.frameAveragedRpm : engineState.rpm; + const auto measuredRpm = std::isfinite(observedRpm) + ? std::max(0.0, observedRpm) : filteredRpm_; const auto previousFilteredRpm = filteredRpm_; const auto speedFilterAlpha = 1.0 - std::exp(-dt * 8.0); filteredRpm_ += (measuredRpm - filteredRpm_) diff --git a/src/simulation/src/EngineSimulator.cpp b/src/simulation/src/EngineSimulator.cpp index e5f4570e..b8efa3b4 100644 --- a/src/simulation/src/EngineSimulator.cpp +++ b/src/simulation/src/EngineSimulator.cpp @@ -1596,6 +1596,8 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co double frameMaximumAfterfireInductionIntegral = 0.0; double frameMinimumAfterfireInductionDelaySeconds = 0.0; double frameMaximumAfterfireInductionDelaySeconds = 0.0; + double frameTravelledDegrees = 0.0; + double frameIntegratedSeconds = 0.0; while (remainingStepSeconds > 0.0) { // Predict with this frame's known external/starter commands rather @@ -5141,6 +5143,8 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co state_.angularVelocityRadPerSecond = 0.0; } const auto subTravelled = (subPreviousRpm + state_.rpm) * 0.5 * 6.0 * subDt; + frameTravelledDegrees += subTravelled; + frameIntegratedSeconds += subDt; maximumIntegratedCrankStep = std::max(maximumIntegratedCrankStep, std::abs(subTravelled)); cycleNumericalFault_ = cycleNumericalFault_ || state_.solverResolutionLimited @@ -5834,6 +5838,8 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co | static_cast(ActiveLimit::coolantProtection) | static_cast(ActiveLimit::launchControl); state_.activeLimits = (state_.activeLimits & ecuLimitBits) | publishedSimulatorLimits(); + state_.frameAveragedRpm = frameIntegratedSeconds > 0.0 + ? frameTravelledDegrees / (6.0 * frameIntegratedSeconds) : state_.rpm; frame.state = state_; return frame; } diff --git a/tools/DynoSweepHarness.cpp b/tools/DynoSweepHarness.cpp index 7b128f93..d3049974 100644 --- a/tools/DynoSweepHarness.cpp +++ b/tools/DynoSweepHarness.cpp @@ -37,6 +37,11 @@ #include namespace { + +// The outer frame the absorber and the controls step at, 240 Hz as in the +// application. A feature locked to a crank speed whose firing frequency sits +// near this rate or its half can be its alias: `--frame-hz` moves it. +double frameSeconds = 1.0 / 240.0; constexpr std::array dynoColumnNames { "engine", "cylinders", "target_rpm", "actual_rpm", "torque_nm", "power_kw", "power_ps", "ve", "lambda", "imep_bar", "peak_cyl_bar", @@ -209,7 +214,7 @@ Sample holdPoint(enginelab::EngineSimulator& simulator, const enginelab::EngineConfig& config, double targetRpm, double settleSeconds, double sampleSeconds) { - constexpr double dt = 1.0 / 240.0; + const double dt = frameSeconds; const auto settleSteps = static_cast(settleSeconds / dt); const auto sampleSteps = static_cast(sampleSeconds / dt); enginelab::DynoAbsorberController absorber(config); @@ -436,7 +441,7 @@ SweepSummary sweepEngine(const enginelab::EngineConfig& baseConfig, double stepR enginelab::EngineSimulator simulator( config, ecu, physics, events, exhaust, simulatorOptions); - constexpr double dt = 1.0 / 240.0; + const double dt = frameSeconds; // Cold start and idle briefly before the first hold point. for (int step = 0; step < static_cast(2.0 / dt); ++step) { const auto time = static_cast(step) * dt; @@ -525,7 +530,7 @@ bool validateReferencePoints(const std::filesystem::path& catalogRoot, !useWellMixedExhaustJunctions; enginelab::EngineSimulator simulator( config, ecu, physics, events, exhaust, simulatorOptions); - constexpr double dt = 1.0 / 240.0; + const double dt = frameSeconds; for (int step = 0; step < static_cast(2.0 / dt); ++step) { const auto time = static_cast(step) * dt; (void)simulator.step(dt, { true, time < 1.5, 0.55, 0.0 }); @@ -657,6 +662,8 @@ int main(int argc, char** argv) { crankDegreesPerStep = std::stod(argv[++index]); else if (argument == "--coupling-us" && index + 1 < argc) couplingMicroseconds = std::stod(argv[++index]); + else if (argument == "--frame-hz" && index + 1 < argc) + frameSeconds = 1.0 / std::clamp(std::stod(argv[++index]), 30.0, 4'000.0); else { std::cerr << "usage: EngineLabDynoSweepHarness [--catalog-root dir]" " [--filter name-fragment] [--step rpm]" @@ -665,7 +672,7 @@ int main(int argc, char** argv) { " [--add-turbo] [--advance-offset deg] [--octane ron]" " [--settle seconds] [--sample seconds]" " [--exhaust-cell-mm mm]" - " [--crank-degrees deg] [--coupling-us us]\n"; + " [--crank-degrees deg] [--coupling-us us] [--frame-hz hz]\n"; return EXIT_FAILURE; } } From 1ff54cc97d83117ef7aae2423aae43e02609f7ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 05:38:53 +0000 Subject: [PATCH 06/12] two-stroke: a power valve, and the JSON scavenging loop A power valve in the exhaust port's roof (YPVS and its kind): shut, the port opens later and is lower; a servo lifts it between two speeds. `two_stroke.power_valve_*` in YAML, JSON, the catalogue and scripts; EngineState::powerValveOpening publishes it. The designer fits one to sport two-strokes (12 deg later shut, lifting from 65 to 90 % of peak power speed): a designed 500 cc twin gains 20-39 % between 3,250 and 5,750 rpm and keeps its top end. The JSON reader now reads the scavenging loop length the writer already saved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FBmqGMLt8GmUG1aPYHCEVw --- .claude/CLAUDE.md | 4 +- CHANGELOG.md | 6 ++ docs/engine-scripting.md | 6 +- docs/journal.md | 7 +++ docs/simulation-model.md | 9 +++ src/catalog/src/EngineCatalog.cpp | 4 ++ src/catalog/src/EngineDesign.cpp | 15 +++++ .../enginelab/foundation/EngineTypes.hpp | 12 ++++ .../enginelab/foundation/TwoStrokePorts.hpp | 21 +++++++ src/foundation/src/EngineTypes.cpp | 7 +++ src/scripting/src/EngineScriptCompiler.cpp | 3 + .../src/JsonEngineSerializer.cpp | 13 +++- .../src/YamlEngineSerializer.cpp | 15 ++++- .../enginelab/simulation/EngineSimulator.hpp | 3 + src/simulation/src/EngineSimulator.cpp | 18 ++++-- tests/TwoStrokeTests.cpp | 62 +++++++++++++++++++ 16 files changed, 194 insertions(+), 11 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index fec2e3ed..76ade7d3 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -26,8 +26,8 @@ without the owner's explicit agreement. rein to add what a 2T needs (carburettor, reed and power valves) or to rework the ports deeply. Half the chaos was the scavenging model (fixed 2026-10-21) and a carburettor exists (2026-10-22); the 3,500/7,000/7,500 rpm holding - band was the 240 Hz frame's alias of the firing ripple (2026-10-23); open - are reed and power valves. + band was the 240 Hz frame's alias of the firing ripple, and a power valve + exists (2026-10-23); open is the reed (a perfect check valve today). 3. **Performance**: test duration, build time, real-time factor. 4. **Knock.** Most engines, even the biggest, survive a boost plus an advance that would destroy a real one. Fix the knock PHYSICS, not the damage model. diff --git a/CHANGELOG.md b/CHANGELOG.md index 515f90b1..9e796d30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,12 @@ file formats, always with an in-memory migration). about a degree while the engine knocks and gives it back at 1 deg/s, so an engine settles at its knock limit instead of sitting in light knock. The Audi I5 gets the intercooler its real engine has. +- **Two-strokes can have a power valve** (`two_stroke.power_valve_*`): it + lowers the exhaust port at low speed and lifts as the pipe comes in. The + designer fits one to sport two-strokes; a designed 500 cc twin gains 20-39 % + of torque between 3,250 and 5,750 rpm and keeps its top end. +- A two-stroke saved as JSON keeps its scavenging loop length (it came back + at the default). - A two-stroke twin no longer fails to hold 3,500, 7,000 and 7,500 rpm on the dyno: the brake answered the crankshaft speed sampled at the end of each 240 Hz frame, where a firing rate near 240 Hz (or 120) aliases into a slow diff --git a/docs/engine-scripting.md b/docs/engine-scripting.md index bb7e1efd..17a32140 100644 --- a/docs/engine-scripting.md +++ b/docs/engine-scripting.md @@ -150,7 +150,11 @@ timings (exhaust 92 deg, transfers 118 deg after top dead centre). The ports are set with `two_stroke.exhaust_port_open_deg`, `two_stroke.transfer_port_open_deg` (angles after top dead centre), `two_stroke.exhaust_port_width_mm`, `two_stroke.transfer_port_width_mm` (0: from the bore), -`two_stroke.port_discharge_coefficient` and `two_stroke.crankcase_compression_ratio`. +`two_stroke.port_discharge_coefficient`, `two_stroke.crankcase_compression_ratio` +and `two_stroke.scavenge_loop_length_ratio`. A power valve is +`two_stroke.power_valve_closed_exhaust_port_open_deg` (the exhaust's opening +with the valve shut; 0, none), `two_stroke.power_valve_opening_start_rpm` and +`two_stroke.power_valve_fully_open_rpm`. The final validation applies the two-stroke rules (petrol, one cylinder per intake path). diff --git a/docs/journal.md b/docs/journal.md index 660ea302..99279420 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -5,6 +5,13 @@ measured, how, and what was not. Anything that becomes a lasting rule goes into `.claude/CLAUDE.md`. Earlier history lives in the `archive/docs-2026-09` git tag (`docs/archive/`). +## 2026-10-23 — Two-stroke: a power valve + +- Added: `two_stroke.power_valve_*` (the exhaust's opening with the valve shut, the speeds it lifts between, 0.25 s servo lag); the port's roof and so its area follow the valve. The designer fits one to sport designs: 12 deg later shut, lifting from 65 to 90 % of peak power speed. +- Designed t2_twin_500 (EPO 82), DynoSweep step 250 / settle 8, valve shut at 90 / 94 / 98 deg (5,500-7,500 rpm): +18-29 % / +22-39 % / +24-47 % over 3,000-5,750 rpm, within 1 % from 7,250 up; 2,000 rpm -4 / -7 / -9 %. The designer's (94, 5,031-6,966): 34-42 N.m from 2,000 to 5,000 rpm where the valveless port gave 29.5-37, top end unchanged, held within 1.5 rpm. +- Found on the way: the JSON reader dropped `scavenge_loop_length_ratio`, so a saved two-stroke lost an authored loop length and came back at the default 1.8. Fixed and tested. +- Not measured: a real YPVS engine's curve against the model's (no data here); the sound; the RD350LC keeps no valve, as the LC had none. + ## 2026-10-23 — Two-stroke: the 3,500/7,000/7,500 rpm holding band is the 240 Hz frame's alias - Designed t2_twin_500 (sport, 0.5 L), DynoSweep step 250, settle 8, with a new `--frame-hz`. `held_rpm_stddev` at 240 Hz: 3,500 2.3, 7,000 6.6, 7,500 2.6, elsewhere 0.2-1.1; at 300 Hz the three are gone (0.2-0.4); at 200 Hz the band moves to 5,750-6,250 (2.7-3.1), where the twin fires at 200 Hz. A twin two-stroke fires at 233 Hz at 7,000 rpm, 117 at 3,500, 250 at 7,500. diff --git a/docs/simulation-model.md b/docs/simulation-model.md index 5942afb8..d9603f63 100644 --- a/docs/simulation-model.md +++ b/docs/simulation-model.md @@ -122,6 +122,15 @@ spark, injection, the trapped-charge latch and the cycle telemetry follow it. volume. The throttle is the carburettor bore with a reed check valve (no flow out of the crankcase), and the runner is the transfer duct. The piston's underside sees the crankcase pressure, not the ambient. +- **Power valve** (optional): a valve in the exhaust port's roof, Yamaha's + YPVS and its kind. Shut, it lowers the roof, so the port opens + `power_valve_closed_exhaust_port_open_deg` after top dead centre instead of + `exhaust_port_open_deg` and is that much lower; a servo lifts it between + `power_valve_opening_start_rpm` and `power_valve_fully_open_rpm` (a smooth + schedule, 0.25 s of lag). The designer fits one to sport two-strokes, 12 deg + later shut, lifting from 65 to 90 % of peak power speed: on a 500 cc twin it + fills the 30 N.m trough at 4,000-4,750 rpm (+20-39 % from 3,250 to 5,750) + and leaves the top end as it was. `EngineState::powerValveOpening` publishes it. - **Trapping**: the charge is trapped when the transfers close (symmetric about bottom dead centre); short-circuit losses come out of the same 1-D exhaust flow that scavenges the cylinder. There is no pumping loop in the diff --git a/src/catalog/src/EngineCatalog.cpp b/src/catalog/src/EngineCatalog.cpp index d2ef32a6..c9bbdc87 100644 --- a/src/catalog/src/EngineCatalog.cpp +++ b/src/catalog/src/EngineCatalog.cpp @@ -991,6 +991,10 @@ void applyCrankOffsets(EngineConfig& config) { assignIfPresent(ports, "port_discharge_coefficient", config.twoStroke.portDischargeCoefficient); assignIfPresent(ports, "crankcase_compression_ratio", config.twoStroke.crankcaseCompressionRatio); assignIfPresent(ports, "scavenge_loop_length_ratio", config.twoStroke.scavengeLoopLengthRatio); + assignIfPresent(ports, "power_valve_closed_exhaust_port_open_deg", + config.twoStroke.powerValveClosedExhaustPortOpenDegrees); + assignIfPresent(ports, "power_valve_opening_start_rpm", config.twoStroke.powerValveOpeningStartRpm); + assignIfPresent(ports, "power_valve_fully_open_rpm", config.twoStroke.powerValveFullyOpenRpm); } if (const auto calibration = engine["combustion_calibration"]) { assignIfPresent(calibration, "base_ignition_delay_s", config.combustionCalibration.baseIgnitionDelaySeconds); diff --git a/src/catalog/src/EngineDesign.cpp b/src/catalog/src/EngineDesign.cpp index 239c6bd7..a6e494b9 100644 --- a/src/catalog/src/EngineDesign.cpp +++ b/src/catalog/src/EngineDesign.cpp @@ -786,6 +786,21 @@ DesignedEngine designEngine(const EngineSpec& spec, const PartsLibrary& parts) { config.twoStroke.scavengeLoopLengthRatio = derive("scavenge_loop_length_ratio", "Scavenging loop length", "x bore", 1.8, "a loop-scavenged cylinder: up the far wall, over the head, back to the port"); + // A sport port's long exhaust window throws the charge out at low + // speed: on a designed 500 cc twin, 30 N.m at 4,000-4,750 rpm + // against 46-50 from 7,000. A power valve lowers the port there and + // lifts as the pipe comes in; 12 deg later shut (82 -> 94, 196 deg + // of window -> 172) gave +20-39 % from 3,250 to 5,750 rpm and the + // same top end (journal 2026-10-23). + if (sport) { + config.twoStroke.powerValveClosedExhaustPortOpenDegrees = derive( + "power_valve_closed_exhaust_port_open_deg", "Power valve shut: exhaust opens", "deg ATDC", + config.twoStroke.exhaustPortOpenDegrees + 12.0, "12 deg later than the open port"); + config.twoStroke.powerValveOpeningStartRpm = derive("power_valve_opening_start_rpm", + "Power valve starts to open", "rpm", 0.65 * peakPowerRpm, "65 % of peak power speed"); + config.twoStroke.powerValveFullyOpenRpm = derive("power_valve_fully_open_rpm", + "Power valve fully open", "rpm", 0.90 * peakPowerRpm, "90 % of peak power speed"); + } config.combustionCalibration.chamberTurbulenceIntensityRatio = derive("chamber_turbulence_ratio", "Chamber turbulence", "", 2.0, "a squish head, as the RD350LC's"); diff --git a/src/foundation/include/enginelab/foundation/EngineTypes.hpp b/src/foundation/include/enginelab/foundation/EngineTypes.hpp index 3f069807..57835451 100644 --- a/src/foundation/include/enginelab/foundation/EngineTypes.hpp +++ b/src/foundation/include/enginelab/foundation/EngineTypes.hpp @@ -1147,6 +1147,16 @@ struct TwoStrokeConfig final { * cylinder the simulator had before the model existed. See * `TwoStrokeScavenging.hpp` for what the length buys. */ double scavengeLoopLengthRatio { 1.8 }; + /** An exhaust power valve (Yamaha's YPVS and its kind): a blade or drum + * in the port's roof that lowers it at low speed, so the port opens later + * and the exhaust keeps more of the stroke, and lifts out of the way as + * the speed rises. The opening angle with the valve shut, later than + * `exhaustPortOpenDegrees`; zero is no valve. */ + double powerValveClosedExhaustPortOpenDegrees { 0.0 }; + /** The speeds where the servo starts to lift the valve and where it is + * fully open. */ + double powerValveOpeningStartRpm { 0.0 }; + double powerValveFullyOpenRpm { 0.0 }; }; /** Crank degrees of one thermodynamic cycle: 720 for a four-stroke, 360 for @@ -1893,6 +1903,8 @@ struct EngineState final { * frame, a ripple near that rate aliases into a slow wander; a * two-stroke twin fires at 233 Hz at 7,000 rpm. Zero before a step. */ double frameAveragedRpm { 0.0 }; + /** A two-stroke's exhaust power valve, 0 shut to 1 open; 1 without one. */ + double powerValveOpening { 1.0 }; }; struct EcuCommand final { diff --git a/src/foundation/include/enginelab/foundation/TwoStrokePorts.hpp b/src/foundation/include/enginelab/foundation/TwoStrokePorts.hpp index fce56a34..aab4eaf1 100644 --- a/src/foundation/include/enginelab/foundation/TwoStrokePorts.hpp +++ b/src/foundation/include/enginelab/foundation/TwoStrokePorts.hpp @@ -26,6 +26,27 @@ namespace enginelab { return ports.transferPortWidthMm > 0.0 ? ports.transferPortWidthMm : 1.30 * cylinder.boreMm; } +[[nodiscard]] inline bool hasPowerValve(const TwoStrokeConfig& ports) noexcept { + return ports.powerValveClosedExhaustPortOpenDegrees > ports.exhaustPortOpenDegrees + && ports.powerValveFullyOpenRpm > ports.powerValveOpeningStartRpm; +} + +/** Where a power valve's servo is told to be at `rpm`, 0 shut to 1 open: + * a smooth lift between its start and fully-open speeds; 1 without one. */ +[[nodiscard]] inline double powerValveTargetOpening(const TwoStrokeConfig& ports, double rpm) noexcept { + if (!hasPowerValve(ports)) return 1.0; + const auto t = std::clamp((rpm - ports.powerValveOpeningStartRpm) + / (ports.powerValveFullyOpenRpm - ports.powerValveOpeningStartRpm), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +/** The exhaust port's opening angle at a valve opening, 0 shut to 1 open. */ +[[nodiscard]] inline double exhaustPortOpenDegreesAt(const TwoStrokeConfig& ports, double valveOpening) noexcept { + if (!hasPowerValve(ports)) return ports.exhaustPortOpenDegrees; + return ports.powerValveClosedExhaustPortOpenDegrees + std::clamp(valveOpening, 0.0, 1.0) + * (ports.exhaustPortOpenDegrees - ports.powerValveClosedExhaustPortOpenDegrees); +} + /** * The effective open area, mm^2 (discharge coefficient included), of a port * whose top edge the piston uncovers `openDegrees` after top dead centre, at diff --git a/src/foundation/src/EngineTypes.cpp b/src/foundation/src/EngineTypes.cpp index bd099f30..23f0f005 100644 --- a/src/foundation/src/EngineTypes.cpp +++ b/src/foundation/src/EngineTypes.cpp @@ -677,6 +677,13 @@ std::optional validateEngineConfig(const EngineConfig& config) { return "Two-stroke port widths are 5-500 mm (0: from the bore), their discharge coefficient 0.3-1, " "the crankcase compression ratio 1.1-2.5, the scavenging loop 0.5-4 x bore " "(0: a perfectly mixed cylinder)"; + if (ports.powerValveClosedExhaustPortOpenDegrees != 0.0 + && (!inRange(ports.powerValveClosedExhaustPortOpenDegrees, ports.exhaustPortOpenDegrees + 1.0, + ports.transferPortOpenDegrees - 4.0) + || !inRange(ports.powerValveOpeningStartRpm, 0.0, 30'000.0) + || !inRange(ports.powerValveFullyOpenRpm, ports.powerValveOpeningStartRpm + 100.0, 30'000.0))) + return "A two-stroke power valve opens the exhaust port later than the port itself and at least " + "4 deg before the transfers, and is fully open at least 100 rpm above where it starts"; for (const auto& path : config.intakePaths) { if (path.cylinderIds.size() != 1) return "Each two-stroke cylinder has its own crankcase: one intake path per cylinder"; diff --git a/src/scripting/src/EngineScriptCompiler.cpp b/src/scripting/src/EngineScriptCompiler.cpp index db3e3cbc..2db827f4 100644 --- a/src/scripting/src/EngineScriptCompiler.cpp +++ b/src/scripting/src/EngineScriptCompiler.cpp @@ -471,6 +471,9 @@ const GlobalNumericProperty* findGlobalNumericProperty(std::string_view path) no { "two_stroke.port_discharge_coefficient", Dimension::dimensionless, [](auto& c, double v) { c.twoStroke.portDischargeCoefficient = v; } }, { "two_stroke.crankcase_compression_ratio", Dimension::dimensionless, [](auto& c, double v) { c.twoStroke.crankcaseCompressionRatio = v; } }, { "two_stroke.scavenge_loop_length_ratio", Dimension::dimensionless, [](auto& c, double v) { c.twoStroke.scavengeLoopLengthRatio = v; } }, + { "two_stroke.power_valve_closed_exhaust_port_open_deg", Dimension::angle, [](auto& c, double v) { c.twoStroke.powerValveClosedExhaustPortOpenDegrees = v; } }, + { "two_stroke.power_valve_opening_start_rpm", Dimension::engineSpeed, [](auto& c, double v) { c.twoStroke.powerValveOpeningStartRpm = v; } }, + { "two_stroke.power_valve_fully_open_rpm", Dimension::engineSpeed, [](auto& c, double v) { c.twoStroke.powerValveFullyOpenRpm = v; } }, { "forced_induction.turbine_efficiency", Dimension::dimensionless, [](auto& c, double v) { c.forcedInduction.turbineEfficiency = v; } }, { "forced_induction.shaft_inertia_kg_m2", Dimension::inertia, [](auto& c, double v) { c.forcedInduction.shaftInertiaKgM2 = v; } }, { "forced_induction.wastegate_pressure_ratio", Dimension::dimensionless, [](auto& c, double v) { c.forcedInduction.wastegatePressureRatio = v; } }, diff --git a/src/serialization/src/JsonEngineSerializer.cpp b/src/serialization/src/JsonEngineSerializer.cpp index ced55d5f..56e9970e 100644 --- a/src/serialization/src/JsonEngineSerializer.cpp +++ b/src/serialization/src/JsonEngineSerializer.cpp @@ -663,8 +663,15 @@ std::string JsonEngineSerializer::encode(const EngineConfig& config) const { document["engine"]["audio_voicing"] = { {"family", config.audioVoicingFamily}, {"key", config.audioVoicingKey} }; // Written for a two-stroke only, so a four-stroke file is unchanged. - if (config.cycle == EngineCycle::twoStroke) + if (config.cycle == EngineCycle::twoStroke) { document["engine"]["two_stroke"] = { {"exhaust_port_open_deg", config.twoStroke.exhaustPortOpenDegrees}, {"transfer_port_open_deg", config.twoStroke.transferPortOpenDegrees}, {"exhaust_port_width_mm", config.twoStroke.exhaustPortWidthMm}, {"transfer_port_width_mm", config.twoStroke.transferPortWidthMm}, {"port_discharge_coefficient", config.twoStroke.portDischargeCoefficient}, {"crankcase_compression_ratio", config.twoStroke.crankcaseCompressionRatio}, {"scavenge_loop_length_ratio", config.twoStroke.scavengeLoopLengthRatio} }; + if (config.twoStroke.powerValveClosedExhaustPortOpenDegrees != 0.0) { + auto& ports = document["engine"]["two_stroke"]; + ports["power_valve_closed_exhaust_port_open_deg"] = config.twoStroke.powerValveClosedExhaustPortOpenDegrees; + ports["power_valve_opening_start_rpm"] = config.twoStroke.powerValveOpeningStartRpm; + ports["power_valve_fully_open_rpm"] = config.twoStroke.powerValveFullyOpenRpm; + } + } return document.dump(2); } @@ -782,6 +789,10 @@ EngineDecodeResult JsonEngineSerializer::decode(std::string_view text) const noe config.twoStroke.transferPortWidthMm = ports.value("transfer_port_width_mm", config.twoStroke.transferPortWidthMm); config.twoStroke.portDischargeCoefficient = ports.value("port_discharge_coefficient", config.twoStroke.portDischargeCoefficient); config.twoStroke.crankcaseCompressionRatio = ports.value("crankcase_compression_ratio", config.twoStroke.crankcaseCompressionRatio); + config.twoStroke.scavengeLoopLengthRatio = ports.value("scavenge_loop_length_ratio", config.twoStroke.scavengeLoopLengthRatio); + config.twoStroke.powerValveClosedExhaustPortOpenDegrees = ports.value("power_valve_closed_exhaust_port_open_deg", config.twoStroke.powerValveClosedExhaustPortOpenDegrees); + config.twoStroke.powerValveOpeningStartRpm = ports.value("power_valve_opening_start_rpm", config.twoStroke.powerValveOpeningStartRpm); + config.twoStroke.powerValveFullyOpenRpm = ports.value("power_valve_fully_open_rpm", config.twoStroke.powerValveFullyOpenRpm); } if (engine.contains("combustion_calibration")) { const auto& calibration = engine.at("combustion_calibration"); diff --git a/src/serialization/src/YamlEngineSerializer.cpp b/src/serialization/src/YamlEngineSerializer.cpp index 928be090..bdb6b0bc 100644 --- a/src/serialization/src/YamlEngineSerializer.cpp +++ b/src/serialization/src/YamlEngineSerializer.cpp @@ -777,7 +777,7 @@ std::string YamlEngineSerializer::encode(const EngineConfig& config) const { << YAML::EndMap; out << YAML::EndSeq; // Written for a two-stroke only, so a four-stroke file is unchanged. - if (config.cycle == EngineCycle::twoStroke) + if (config.cycle == EngineCycle::twoStroke) { out << YAML::Key << "two_stroke" << YAML::Value << YAML::BeginMap << YAML::Key << "exhaust_port_open_deg" << YAML::Value << config.twoStroke.exhaustPortOpenDegrees << YAML::Key << "transfer_port_open_deg" << YAML::Value << config.twoStroke.transferPortOpenDegrees @@ -785,8 +785,14 @@ std::string YamlEngineSerializer::encode(const EngineConfig& config) const { << YAML::Key << "transfer_port_width_mm" << YAML::Value << config.twoStroke.transferPortWidthMm << YAML::Key << "port_discharge_coefficient" << YAML::Value << config.twoStroke.portDischargeCoefficient << YAML::Key << "crankcase_compression_ratio" << YAML::Value << config.twoStroke.crankcaseCompressionRatio - << YAML::Key << "scavenge_loop_length_ratio" << YAML::Value << config.twoStroke.scavengeLoopLengthRatio - << YAML::EndMap; + << YAML::Key << "scavenge_loop_length_ratio" << YAML::Value << config.twoStroke.scavengeLoopLengthRatio; + if (config.twoStroke.powerValveClosedExhaustPortOpenDegrees != 0.0) + out << YAML::Key << "power_valve_closed_exhaust_port_open_deg" << YAML::Value + << config.twoStroke.powerValveClosedExhaustPortOpenDegrees + << YAML::Key << "power_valve_opening_start_rpm" << YAML::Value << config.twoStroke.powerValveOpeningStartRpm + << YAML::Key << "power_valve_fully_open_rpm" << YAML::Value << config.twoStroke.powerValveFullyOpenRpm; + out << YAML::EndMap; + } out << YAML::EndMap << YAML::EndMap; return out.c_str(); } @@ -894,6 +900,9 @@ EngineDecodeResult YamlEngineSerializer::decode(std::string_view text) const noe config.twoStroke.portDischargeCoefficient = ports["port_discharge_coefficient"].as(config.twoStroke.portDischargeCoefficient); config.twoStroke.crankcaseCompressionRatio = ports["crankcase_compression_ratio"].as(config.twoStroke.crankcaseCompressionRatio); config.twoStroke.scavengeLoopLengthRatio = ports["scavenge_loop_length_ratio"].as(config.twoStroke.scavengeLoopLengthRatio); + config.twoStroke.powerValveClosedExhaustPortOpenDegrees = ports["power_valve_closed_exhaust_port_open_deg"].as(config.twoStroke.powerValveClosedExhaustPortOpenDegrees); + config.twoStroke.powerValveOpeningStartRpm = ports["power_valve_opening_start_rpm"].as(config.twoStroke.powerValveOpeningStartRpm); + config.twoStroke.powerValveFullyOpenRpm = ports["power_valve_fully_open_rpm"].as(config.twoStroke.powerValveFullyOpenRpm); } if (const auto calibration = engine["combustion_calibration"]) { config.combustionCalibration.baseIgnitionDelaySeconds = calibration["base_ignition_delay_s"].as(config.combustionCalibration.baseIgnitionDelaySeconds); diff --git a/src/simulation/include/enginelab/simulation/EngineSimulator.hpp b/src/simulation/include/enginelab/simulation/EngineSimulator.hpp index 48bff9cf..a29e6389 100644 --- a/src/simulation/include/enginelab/simulation/EngineSimulator.hpp +++ b/src/simulation/include/enginelab/simulation/EngineSimulator.hpp @@ -764,6 +764,9 @@ class EngineSimulator final : public IEngineSimulation { std::array compressionIgnitionResults_ {}; std::array injectionStates_ {}; std::array endGasKnockStates_ {}; + /** A two-stroke's exhaust power valve, 0 shut to 1 open, behind its + * servo's lag. */ + double powerValveOpening_ { 1.0 }; std::array indicatedWorkStates_ {}; std::array valveTrainStates_ {}; std::array valveTrainResults_ {}; diff --git a/src/simulation/src/EngineSimulator.cpp b/src/simulation/src/EngineSimulator.cpp index b8efa3b4..f62cd85d 100644 --- a/src/simulation/src/EngineSimulator.cpp +++ b/src/simulation/src/EngineSimulator.cpp @@ -1598,6 +1598,14 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co double frameMaximumAfterfireInductionDelaySeconds = 0.0; double frameTravelledDegrees = 0.0; double frameIntegratedSeconds = 0.0; + // A power valve's servo follows its speed schedule with a lag: YPVS-type + // servos take a few tenths of a second to sweep the valve. + { + constexpr double powerValveServoSeconds = 0.25; + powerValveOpening_ += (powerValveTargetOpening(config_.twoStroke, state_.rpm) + - powerValveOpening_) * (1.0 - std::exp(-dt / powerValveServoSeconds)); + } + const auto exhaustPortOpenDegrees = exhaustPortOpenDegreesAt(config_.twoStroke, powerValveOpening_); while (remainingStepSeconds > 0.0) { // Predict with this frame's known external/starter commands rather @@ -3266,7 +3274,7 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co const auto exhaustCenterPhase = std::fmod(360.0 - cams.config->exhaustCenterlineDegrees - valveTrain.exhaustAdvanceDegrees + 720.0, 720.0); const auto exhaustOpenPhase = twoStroke - ? config_.twoStroke.exhaustPortOpenDegrees + ? exhaustPortOpenDegrees : std::fmod(exhaustCenterPhase - cams.exhaustDuration() * 0.5 + 720.0, 720.0); const auto distanceToExhaustOpen = std::fmod( exhaustOpenPhase - previousPhase + cycleDegrees, cycleDegrees); @@ -3378,7 +3386,7 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co ? portOpenAreaMm2(cylinder, config_.twoStroke.transferPortOpenDegrees, transferPortWidthMm(config_.twoStroke, cylinder), config_.twoStroke.portDischargeCoefficient, cyclePhase) <= 0.0 - && portOpenAreaMm2(cylinder, config_.twoStroke.exhaustPortOpenDegrees, + && portOpenAreaMm2(cylinder, exhaustPortOpenDegrees, exhaustPortWidthMm(config_.twoStroke, cylinder), config_.twoStroke.portDischargeCoefficient, cyclePhase) <= 0.0 : intakeLift <= 0.0 && exhaustLift <= 0.0; @@ -3460,7 +3468,7 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co cylinder.intakeValveCount, cylinder.intakeValveDiameterMm, valveTrain.intakeDischargeCoefficient, 0.40, 0.50, 0.58); const auto exhaustArea = twoStroke - ? portOpenAreaMm2(cylinder, config_.twoStroke.exhaustPortOpenDegrees, + ? portOpenAreaMm2(cylinder, exhaustPortOpenDegrees, exhaustPortWidthMm(config_.twoStroke, cylinder), config_.twoStroke.portDischargeCoefficient, cyclePhase) : effectiveValveAreaMm2(cylinder.boreMm, exhaustLift, @@ -5714,7 +5722,7 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co pressureSample.exhaustValveOpening[index] = static_cast(std::clamp( twoStroke ? exhaustValveAreaM2[index] * 1.0e6 / std::max(1.0e-9, portOpenAreaMm2(ported, - config_.twoStroke.exhaustPortOpenDegrees, exhaustPortWidthMm(config_.twoStroke, ported), + exhaustPortOpenDegrees, exhaustPortWidthMm(config_.twoStroke, ported), config_.twoStroke.portDischargeCoefficient, 180.0)) : valveTrainResults_[index].exhaustLiftMm / maximumExhaustLiftMm, 0.0, 1.0)); pressureSample.exhaustPathIndex[index] = @@ -5838,6 +5846,7 @@ SimulationFrame EngineSimulator::step(double dtSeconds, const EngineControls& co | static_cast(ActiveLimit::coolantProtection) | static_cast(ActiveLimit::launchControl); state_.activeLimits = (state_.activeLimits & ecuLimitBits) | publishedSimulatorLimits(); + state_.powerValveOpening = powerValveOpening_; state_.frameAveragedRpm = frameIntegratedSeconds > 0.0 ? frameTravelledDegrees / (6.0 * frameIntegratedSeconds) : state_.rpm; frame.state = state_; @@ -6308,6 +6317,7 @@ void EngineSimulator::reset() noexcept { compressionIgnitionResults_.fill({}); injectionStates_.fill({}); endGasKnockStates_.fill({}); + powerValveOpening_ = powerValveTargetOpening(config_.twoStroke, 0.0); indicatedWorkStates_.fill({}); valveTrainStates_.fill({}); valveTrainResults_.fill({}); diff --git a/tests/TwoStrokeTests.cpp b/tests/TwoStrokeTests.cpp index 3538abdc..d99c5fc6 100644 --- a/tests/TwoStrokeTests.cpp +++ b/tests/TwoStrokeTests.cpp @@ -113,6 +113,67 @@ void missingCrankOffsetsFollowTheCycle() { } } +/** A power valve lowers the exhaust port's roof at low speed, so the port + * opens later and is lower, and lifts as the speed rises. Off, a file and + * the port are what they were. */ +void thePowerValveLowersThePortAtLowSpeed() { + auto config = catalogueTwoStroke(); + require(!hasPowerValve(config.twoStroke) + && exhaustPortOpenDegreesAt(config.twoStroke, 0.0) == config.twoStroke.exhaustPortOpenDegrees + && powerValveTargetOpening(config.twoStroke, 0.0) == 1.0, + "without a power valve the exhaust port is the one authored, at every speed"); + config.twoStroke.powerValveClosedExhaustPortOpenDegrees = config.twoStroke.exhaustPortOpenDegrees + 12.0; + config.twoStroke.powerValveOpeningStartRpm = 5'000.0; + config.twoStroke.powerValveFullyOpenRpm = 7'000.0; + require(!validateEngineConfig(config), "a power valve that lowers the port is valid"); + require(powerValveTargetOpening(config.twoStroke, 4'000.0) == 0.0 + && powerValveTargetOpening(config.twoStroke, 7'500.0) == 1.0 + && std::abs(powerValveTargetOpening(config.twoStroke, 6'000.0) - 0.5) < 1.0e-12, + "the valve is shut below its start, open above its end, half open between"); + const auto& cylinder = config.cylinders.front(); + const auto bottom = [&](double opening) { + return portOpenAreaMm2(cylinder, exhaustPortOpenDegreesAt(config.twoStroke, opening), + exhaustPortWidthMm(config.twoStroke, cylinder), 0.72, 180.0); + }; + require(exhaustPortOpenDegreesAt(config.twoStroke, 0.0) == config.twoStroke.exhaustPortOpenDegrees + 12.0 + && bottom(0.0) < bottom(0.5) && bottom(0.5) < bottom(1.0), + "shut, the port opens 12 deg later and is lower: its area grows as the valve lifts"); + + auto early = config; + early.twoStroke.powerValveClosedExhaustPortOpenDegrees = config.twoStroke.exhaustPortOpenDegrees - 2.0; + require(validateEngineConfig(early).has_value(), "a power valve that would open the port earlier is refused"); + auto pastTransfers = config; + pastTransfers.twoStroke.powerValveClosedExhaustPortOpenDegrees = config.twoStroke.transferPortOpenDegrees; + require(validateEngineConfig(pastTransfers).has_value(), + "a power valve that would open the exhaust with the transfers is refused"); + + config.twoStroke.scavengeLoopLengthRatio = 2.1; + const JsonEngineSerializer json; + const YamlEngineSerializer yaml; + for (const auto* serializer : { static_cast(&json), + static_cast(&yaml) }) { + const auto decoded = serializer->decode(serializer->encode(config)); + require(decoded + && decoded.config->twoStroke.powerValveClosedExhaustPortOpenDegrees + == config.twoStroke.powerValveClosedExhaustPortOpenDegrees + && decoded.config->twoStroke.powerValveOpeningStartRpm == 5'000.0 + && decoded.config->twoStroke.powerValveFullyOpenRpm == 7'000.0 + && decoded.config->twoStroke.scavengeLoopLengthRatio == 2.1, + "a file keeps the power valve and the scavenging loop"); + } + + // At idle the servo holds the valve shut. + auto rig = std::make_unique(config); + EngineState last; + for (auto frame = 0; frame < 6 * 240; ++frame) { + EngineControls controls; + controls.ignitionEnabled = true; + controls.starterEngaged = frame * frameSeconds < 1.5; + last = rig->simulator->step(frameSeconds, controls).state; + } + require(last.rpm > 600.0 && last.powerValveOpening < 1.0e-6, "idling, the power valve is shut"); +} + struct Held final { double torqueNm { 0.0 }; double lambda { 0.0 }; @@ -801,6 +862,7 @@ int main(int argc, char** argv) { portsFollowThePiston(); configurationIsChecked(); missingCrankOffsetsFollowTheCycle(); + thePowerValveLowersThePortAtLowSpeed(); loadIsTheDeliveredCharge(); catalogueTwinIdles(); catalogueTwinMakesItsTorque(); From 6b6f239aa3f5cad087145167a2ee477f3f6dcf31 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 06:33:15 +0000 Subject: [PATCH 07/12] build: compile JUCE once, in EngineLabJuce JUCE's modules are interface libraries that carry their sources, and the audio library linked four of them PUBLIC, so every executable linking audio compiled JUCE again: 130 of the tree's 390 compile units. They are now compiled once into EngineLabJuce, linked PRIVATE with their include directories (SYSTEM) and definitions re-exported, JUCE's own pattern for a shared module library; every target links it. Clean Linux build on 4 cores at -j 4: 14 min 46 s -> 6 min 08 s wall, 53 min 40 s -> 20 min 50 s CPU, 255 compile units. The per-unit JUCE warning exemptions go: no JUCE unit compiles under our warnings any more. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FBmqGMLt8GmUG1aPYHCEVw --- .claude/CLAUDE.md | 3 ++- CHANGELOG.md | 2 ++ CMakeLists.txt | 20 ++++++++++++++++++++ cmake/CompilerWarnings.cmake | 25 ------------------------- docs/journal.md | 8 ++++++++ src/app/CMakeLists.txt | 2 +- src/audio/CMakeLists.txt | 5 +---- tests/CMakeLists.txt | 2 +- tools/CMakeLists.txt | 2 +- 9 files changed, 36 insertions(+), 33 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 76ade7d3..23e93c0d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -27,7 +27,8 @@ without the owner's explicit agreement. the ports deeply. Half the chaos was the scavenging model (fixed 2026-10-21) and a carburettor exists (2026-10-22); the 3,500/7,000/7,500 rpm holding band was the 240 Hz frame's alias of the firing ripple, and a power valve - exists (2026-10-23); open is the reed (a perfect check valve today). + exists (2026-10-23). The reed stays a perfect check valve until a measured + gap asks for its petals (VISION.md's decision rule). 3. **Performance**: test duration, build time, real-time factor. 4. **Knock.** Most engines, even the biggest, survive a boost plus an advance that would destroy a real one. Fix the knock PHYSICS, not the damage model. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e796d30..40529e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,8 @@ file formats, always with an in-memory migration). ### Changed +- The build compiles JUCE once instead of once per executable: a clean + Linux build takes 6 minutes instead of 15 on four cores. - Engines with eight cylinders or more simulate their cylinders in parallel, with exactly the same result: 22-33 % more real-time headroom (a designed 6 L V12 at 7,500 rpm 0.71 -> 0.93, the LS3 1.17 -> 1.43, the Merlin diff --git a/CMakeLists.txt b/CMakeLists.txt index caf1e8f0..87d201db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -131,6 +131,26 @@ if(NOT MSVC) enginelab_vendor_headers_are_system(${enginelab_vendor_targets}) endif() +# JUCE's modules arrive as interface sources, so every target that linked one +# compiled JUCE again: about 130 of the Linux tree's 390 compile units were +# JUCE, six to fifteen per executable. They are compiled once here, linked +# PRIVATE so their sources stay in this library, and their include directories +# and definitions are re-exported (JUCE's own pattern for a shared code +# library). Our targets link this, never a `juce::` module directly. +add_library(EngineLabJuce STATIC) +target_link_libraries(EngineLabJuce + PRIVATE + juce::juce_audio_basics juce::juce_audio_devices juce::juce_audio_formats + juce::juce_audio_utils juce::juce_dsp juce::juce_gui_extra juce::juce_opengl + juce::juce_cryptography + juce::juce_recommended_config_flags) +target_compile_definitions(EngineLabJuce + PUBLIC JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0 + INTERFACE $) +target_include_directories(EngineLabJuce SYSTEM + INTERFACE $) +set_target_properties(EngineLabJuce PROPERTIES POSITION_INDEPENDENT_CODE TRUE) + add_subdirectory(src/foundation) add_subdirectory(src/calibration) add_subdirectory(src/events) diff --git a/cmake/CompilerWarnings.cmake b/cmake/CompilerWarnings.cmake index b4fc469d..beb246b1 100644 --- a/cmake/CompilerWarnings.cmake +++ b/cmake/CompilerWarnings.cmake @@ -1,27 +1,3 @@ -# JUCE's module units are compiled INTO the targets that link them (they arrive -# as interface sources), so they would inherit the warning set below. They are -# not our code and they do not build clean under -Wconversion, which drowned -# EngineLab's own 125 diagnostics in ~400 of JUCE's on the first Linux build. -# The categories JUCE trips are turned off for those units only; our own -# sources keep the full set. JUCE's module units are one level deep inside -# `modules/` by construction, so a single glob finds them. -function(enginelab_quiet_vendor_units) - if(MSVC OR NOT juce_SOURCE_DIR) - return() - endif() - get_property(units GLOBAL PROPERTY ENGINELAB_VENDOR_UNITS) - if(NOT units) - file(GLOB units "${juce_SOURCE_DIR}/modules/*/*.cpp") - set_property(GLOBAL PROPERTY ENGINELAB_VENDOR_UNITS "${units}") - endif() - if(units) - # A source property resolves in the directory that defines the target, - # which is this function's caller: a function is not a new scope. - set_source_files_properties(${units} PROPERTIES COMPILE_OPTIONS - "-Wno-conversion;-Wno-float-conversion;-Wno-missing-field-initializers;-Wno-shadow;-Wno-comment") - endif() -endfunction() - # A vendored *header* is compiled as part of whatever includes it, so no source # property can reach it. One line of JUCE's `juce_UMPUtils.h` broke fourteen of # our translation units on the first Linux build with warnings as errors, and @@ -66,6 +42,5 @@ function(enginelab_set_warnings target) if(ENGINELAB_ENABLE_WARNINGS_AS_ERRORS) target_compile_options(${target} PRIVATE -Werror) endif() - enginelab_quiet_vendor_units() endif() endfunction() diff --git a/docs/journal.md b/docs/journal.md index 99279420..8123f92e 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -5,6 +5,14 @@ measured, how, and what was not. Anything that becomes a lasting rule goes into `.claude/CLAUDE.md`. Earlier history lives in the `archive/docs-2026-09` git tag (`docs/archive/`). +## 2026-10-23 — Build: JUCE compiled once + +- JUCE's modules are interface libraries carrying their sources, and `EngineLabAudio` linked four of them PUBLIC: every executable that linked audio compiled JUCE again (6 units each, 15 for the app); 130 of the tree's 390 compile units were JUCE. +- `EngineLabJuce` (top-level `CMakeLists.txt`) links every module PRIVATE and re-exports their include directories (as SYSTEM) and definitions; all targets link it. +- Linux, 4 cores, clean configure + build at `-j 4`, `linux-gcc-release` flags: 14 min 46 s wall / 53 min 40 s CPU (390 units) -> 6 min 08 s / 20 min 50 s (255 units). Full ctest green on the new tree. +- Profile of a CP2 dyno sweep for the next step: `pow` 9 % (open ends, `GasCell::dynamicPressureKpa`, valve fluxes), the FV duct (cache, Riemann flux, primitive recovery) ~20 %. Not changed: each costs bit-exactness. +- Not measured: Windows (the reference) build time; incremental builds. + ## 2026-10-23 — Two-stroke: a power valve - Added: `two_stroke.power_valve_*` (the exhaust's opening with the valve shut, the speeds it lifts between, 0.25 s servo lag); the port's roof and so its area follow the valve. The designer fits one to sport designs: 12 deg later shut, lifting from 65 to 90 % of peak power speed. diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 59af6298..6c010e60 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -33,7 +33,7 @@ target_include_directories(EngineLabApp PRIVATE include) target_link_libraries(EngineLabApp PRIVATE EngineLab::Audio EngineLab::Runtime EngineLab::Diagnostics EngineLab::Serialization EngineLab::Catalog EngineLab::Calibration EngineLab::Render EngineLab::Scripting EngineLabFonts - juce::juce_audio_utils juce::juce_gui_extra juce::juce_opengl + EngineLabJuce PUBLIC juce::juce_recommended_config_flags juce::juce_recommended_lto_flags) target_compile_definitions(EngineLabApp PRIVATE JUCE_WEB_BROWSER=0 diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index e4cff68a..5b2036e9 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -12,9 +12,6 @@ add_library(EngineLabAudio STATIC src/StructuralModalRadiator.cpp) add_library(EngineLab::Audio ALIAS EngineLabAudio) target_include_directories(EngineLabAudio PUBLIC include) -target_link_libraries(EngineLabAudio PUBLIC EngineLab::Runtime - juce::juce_audio_basics juce::juce_audio_devices juce::juce_audio_formats - juce::juce_dsp +target_link_libraries(EngineLabAudio PUBLIC EngineLab::Runtime EngineLabJuce PRIVATE nlohmann_json::nlohmann_json) -target_compile_definitions(EngineLabAudio PUBLIC JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0) enginelab_set_warnings(EngineLabAudio) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 58412525..3bd05c64 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -357,7 +357,7 @@ target_include_directories(EngineLabAudioWorkshopTests PRIVATE "${CMAKE_SOURCE_DIR}/src/app/include") target_link_libraries(EngineLabAudioWorkshopTests PRIVATE EngineLab::Audio EngineLab::Catalog - juce::juce_gui_extra + EngineLabJuce juce::juce_recommended_config_flags) target_compile_definitions(EngineLabAudioWorkshopTests PRIVATE ENGINELAB_CATALOG_ROOT="${CMAKE_SOURCE_DIR}" diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index 846f6ffa..5436085e 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -194,7 +194,7 @@ add_executable(EngineLabAbClipRenderer AbClipRenderer.cpp) target_link_libraries(EngineLabAbClipRenderer PRIVATE EngineLab::Audio EngineLab::Simulation EngineLab::Foundation EngineLab::Physics EngineLab::Ecu EngineLab::Events EngineLab::Exhaust EngineLab::Catalog - juce::juce_cryptography nlohmann_json::nlohmann_json) + EngineLabJuce nlohmann_json::nlohmann_json) target_compile_definitions(EngineLabAbClipRenderer PRIVATE ENGINELAB_CATALOG_ROOT="${CMAKE_SOURCE_DIR}") if(MSVC) target_link_options(EngineLabAbClipRenderer PRIVATE /STACK:8388608) From 2606c3d7afd29a773f1c088c5fd1e94a364d9bd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 08:21:19 +0000 Subject: [PATCH 08/12] build: compile EngineLabJuce two units at a time on MSVC MSBuild compiles one project's units in series, so moving JUCE into one project put its heavy units on the Windows critical path: the CI build went from 25 to 32 min there while Linux went from 18 to 4.5. /MP2 on that project only, beside CI's two MSBuild nodes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FBmqGMLt8GmUG1aPYHCEVw --- CMakeLists.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 87d201db..5f4240dc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -150,6 +150,14 @@ target_compile_definitions(EngineLabJuce target_include_directories(EngineLabJuce SYSTEM INTERFACE $) set_target_properties(EngineLabJuce PROPERTIES POSITION_INDEPENDENT_CODE TRUE) +# MSBuild runs projects in parallel but compiles one project's units one after +# another, so a single JUCE project put its fifteen heavy units in series on +# the critical path: the CI build went 25 to 32 min on Windows while Linux +# went 18 to 4.5. Two compilers inside this one project, on top of the two +# MSBuild nodes CI runs (more ran out of compiler heap, C1060). +if(MSVC) + target_compile_options(EngineLabJuce PRIVATE /MP2) +endif() add_subdirectory(src/foundation) add_subdirectory(src/calibration) From 11a632ae8f5dd6dd9d74f08664d33bec47d4d646 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 09:20:45 +0000 Subject: [PATCH 09/12] Revert "build: compile EngineLabJuce two units at a time on MSVC" This reverts commit 2606c3d7afd29a773f1c088c5fd1e94a364d9bd2. --- CMakeLists.txt | 8 -------- docs/journal.md | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5f4240dc..87d201db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -150,14 +150,6 @@ target_compile_definitions(EngineLabJuce target_include_directories(EngineLabJuce SYSTEM INTERFACE $) set_target_properties(EngineLabJuce PROPERTIES POSITION_INDEPENDENT_CODE TRUE) -# MSBuild runs projects in parallel but compiles one project's units one after -# another, so a single JUCE project put its fifteen heavy units in series on -# the critical path: the CI build went 25 to 32 min on Windows while Linux -# went 18 to 4.5. Two compilers inside this one project, on top of the two -# MSBuild nodes CI runs (more ran out of compiler heap, C1060). -if(MSVC) - target_compile_options(EngineLabJuce PRIVATE /MP2) -endif() add_subdirectory(src/foundation) add_subdirectory(src/calibration) diff --git a/docs/journal.md b/docs/journal.md index 8123f92e..00d822cf 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -11,7 +11,7 @@ measured, how, and what was not. Anything that becomes a lasting rule goes into - `EngineLabJuce` (top-level `CMakeLists.txt`) links every module PRIVATE and re-exports their include directories (as SYSTEM) and definitions; all targets link it. - Linux, 4 cores, clean configure + build at `-j 4`, `linux-gcc-release` flags: 14 min 46 s wall / 53 min 40 s CPU (390 units) -> 6 min 08 s / 20 min 50 s (255 units). Full ctest green on the new tree. - Profile of a CP2 dyno sweep for the next step: `pow` 9 % (open ends, `GasCell::dynamicPressureKpa`, valve fluxes), the FV duct (cache, Riemann flux, primitive recovery) ~20 %. Not changed: each costs bit-exactness. -- Not measured: Windows (the reference) build time; incremental builds. +- CI, Build step: Linux 17 min 47 s -> 4 min 33 s; Windows 25 min 21 s -> 32 min 05 s: MSBuild compiles one project's units in series and CI runs two nodes, so the one JUCE project sits on the critical path. `/MP2` on that project failed the build with C1060 (out of heap) on two harnesses beside the app's LTCG link: reverted. Not measured: a local many-core Windows build, where JUCE no longer compiles ~20 times. ## 2026-10-23 — Two-stroke: a power valve From 8245897ef8b0b682a192e1f4cc4e68bfa653651a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 10:18:03 +0000 Subject: [PATCH 10/12] journal: what the Linux box can say about performance; LTO refuted Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FBmqGMLt8GmUG1aPYHCEVw --- .claude/CLAUDE.md | 8 ++++++-- docs/journal.md | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 23e93c0d..54645861 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -29,7 +29,10 @@ without the owner's explicit agreement. band was the 240 Hz frame's alias of the firing ripple, and a power valve exists (2026-10-23). The reed stays a perfect check valve until a measured gap asks for its petals (VISION.md's decision rule). -3. **Performance**: test duration, build time, real-time factor. +3. **Performance**: test duration, build time, real-time factor. JUCE compiles + once since 2026-10-23 (Linux CI build 18 -> 4.5 min, Windows 25 -> 32: one + serial MSBuild project). Open, and measurable only on the reference machine: + the V10-V16 designs below real time (pool spin vs park, duct numerics). 4. **Knock.** Most engines, even the biggest, survive a boost plus an advance that would destroy a real one. Fix the knock PHYSICS, not the damage model. The designer's ignition is a template because there is no knock limit to @@ -201,7 +204,8 @@ Each measurement is in `docs/journal.md` or the docs archive (tag - **Performance**: fork-join per cylinder with woken threads (the resident pool runs the pass since 2026-10-20); AVX/AVX2 (dependent-division latency); a telemetry cache for listening; state-variable valve-flow reconstruction - against clicks. + against clicks; whole-project LTO (bit-identical, within noise); `/MP2` on + the JUCE project in CI (C1060 beside the app's LTCG link). - **Two-stroke**: a 50 mm mesh; coupling the exhaust every substep beyond BDC-to-transfer-close, a characteristic valve, finer transfer coupling (none of them real time); un-gating exhaust oxidation before scavenging is right diff --git a/docs/journal.md b/docs/journal.md index 00d822cf..1138af78 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -5,6 +5,13 @@ measured, how, and what was not. Anything that becomes a lasting rule goes into `.claude/CLAUDE.md`. Earlier history lives in the `archive/docs-2026-09` git tag (`docs/archive/`). +## 2026-10-23 — Performance: what this Linux box can and cannot say + +- Refuted: whole-project link-time optimisation (`CMAKE_INTERPROCEDURAL_OPTIMIZATION`, GCC 13): bit-identical CSVs; CP2 sweep 6.65 -> 6.42 s best of 3, LS3+K20+Hayabusa 40.8 -> 39.1 s, within the run-to-run spread (39.1-42.1 for both). +- Test duration is simulation CPU: 3,485 s of tests packed into 1,742-1,803 s at `-j 2`, the ideal. The ten slowest (IdleStabilityRegression 382 s, LiveCylinderResize 274, IntakeTuning 258...) are half of it; splitting them cannot help at two-wide. +- Designed 6 L V12 (`PhysicsPerfHarness`, one run): 2,256 / 4,580 / 7,129 us per 240 Hz frame at 990 / 4,345 / 7,110 rpm (budget 4,167). perf: 24 % self time in `CylinderWorkerPool` workers plus 6.5 % in `attachNetworkParallelFor` (spin, then yield, then park, by design); FV duct + Riemann + primitive recovery ~14 %; `pow` 3 %. +- Not changed: the pool's spin/park balance and the duct numerics. Both move the real-time factor, which is valid only on the reference Windows machine, interleaved (`.claude/CLAUDE.md`); Linux scheduling differs by construction. + ## 2026-10-23 — Build: JUCE compiled once - JUCE's modules are interface libraries carrying their sources, and `EngineLabAudio` linked four of them PUBLIC: every executable that linked audio compiled JUCE again (6 units each, 15 for the app); 130 of the tree's 390 compile units were JUCE. From e68a6224dddd5735af6b678fda37b9dec6ceae5a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 10:39:50 +0000 Subject: [PATCH 11/12] ci: resume the Windows build on one node after a C1060 Run 98 ran out of compiler heap on two MSBuild nodes with run 96's green tree: the runner's memory, not the code. The build now resumes on one node when the log shows C1060, as .claude/CLAUDE.md prescribes, which rebuilds only what failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FBmqGMLt8GmUG1aPYHCEVw --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a43fbb6d..e7d7752b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,11 +22,18 @@ jobs: run: cmake --preset windows-vs2022 # Two parallel MSBuild nodes: the hosted runner runs out of compiler heap - # (C1060) with the default one-node-per-core when JUCE is compiling. + # (C1060) with the default one-node-per-core when JUCE is compiling. Two + # can still run out (run 98, on run 96's green tree): the build then + # resumes on one node, as .claude/CLAUDE.md says to, rebuilding only + # what failed. - name: Build shell: pwsh run: | cmake --build --preset windows-release -- /m:2 /nologo /verbosity:minimal *>&1 | Tee-Object -FilePath build.log + if ($LASTEXITCODE -ne 0 -and (Select-String -Path build.log -Pattern 'C1060' -Quiet)) { + Write-Output "::warning::C1060 (compiler out of heap) on two nodes; resuming on one." + cmake --build --preset windows-release -- /m:1 /nologo /verbosity:minimal *>&1 | Tee-Object -FilePath build.log + } if ($LASTEXITCODE -ne 0) { Select-String -Path build.log -Pattern 'error [A-Z]+[0-9]+|fatal error' | Select-Object -First 30 | From f13e4dbefcfcd7532bd84009ce0dcfb53a04157c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 11:51:45 +0000 Subject: [PATCH 12/12] Edit on the engine: every turbo drawn, compression ratio, carburettor, 6-into-1 - Every turbocharger is drawn, each on the collector of the path that drives it, and each opens its own inspector. - A liner or piston edits the compression ratio beside bore and stroke (setCompressionRatio; a chamber-geometry engine moves its deck), taken live. - A carburetted path's throttle is drawn as a carburettor (slide tower, float bowl) and its inspector jets every carburettor alike: main and pilot jets, needle clip, air screw (tuneCarburettor), taken live. Carburetted cylinders no longer draw or offer injectors. - The exhaust ports or a junction offer Joined: joinExhaustPaths copies the kept path's primary run for each cylinder of the other path into its collector, so a boxer six's banks become one 6-into-1 (restart). - Designer, checked on screen for the first time: its real-time check cranked carburetted two-strokes at full throttle, where they never catch; it now cranks shut until the engine runs. The name follows the engine until typed; the table's columns read Quantity and Value. All 89 tests pass on Linux; each change was checked in the app under Xvfb (journal 2026-10-23). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FBmqGMLt8GmUG1aPYHCEVw --- .claude/CLAUDE.md | 10 +- CHANGELOG.md | 25 ++++ docs/architecture.md | 44 +++++-- docs/journal.md | 9 ++ docs/tests-and-validation.md | 6 +- .../include/enginelab/app/EngineViewport.hpp | 2 +- src/app/src/EngineDesignerWindow.cpp | 36 +++++- src/app/src/EngineViewport.cpp | 118 ++++++++++++++++-- .../exhaust/ExhaustComponentResize.hpp | 15 +++ src/exhaust/src/ExhaustComponentResize.cpp | 99 +++++++++++++++ .../enginelab/foundation/CylinderResize.hpp | 14 +++ .../enginelab/foundation/IntakeResize.hpp | 23 ++++ src/foundation/src/CylinderResize.cpp | 22 ++++ src/foundation/src/IntakeResize.cpp | 44 +++++-- .../include/enginelab/render/DuctLayout3D.hpp | 16 ++- .../enginelab/render/EngineModel3D.hpp | 20 ++- src/render/src/DuctLayout3D.cpp | 55 +++++--- src/render/src/EngineModel3D.cpp | 57 ++++++--- src/runtime/src/RealtimeCapacityProbe.cpp | 8 ++ tests/EngineDesignTests.cpp | 17 +++ tests/EngineModel3DTests.cpp | 93 +++++++++++++- tests/LiveChangeTests.cpp | 67 ++++++++++ tests/LiveIntakeTests.cpp | 82 ++++++++++++ 23 files changed, 796 insertions(+), 86 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 54645861..c028f27e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -41,11 +41,11 @@ without the owner's explicit agreement. prefactor (x2) puts the catalogue's maps at their limit (2026-10-23). Open: the I5 holds 8-12 deg of retard at its maker's boost; Woschni's combustion term (right for the TDI's BSFC, it stalls lugged engines) was backed out. -5. **Editing on the engine**: compression ratio by clicking the engine, every - turbo visible when two or more are fitted, carburettor parameters by - clicking (if one is added), and two exhaust paths joinable into one (a - boxer six cannot be made 6-into-1 today). The designer window built on - 2026-10-19 has never been checked on screen: do that here. +5. **Editing on the engine** -- done 2026-10-23, each checked on screen + (journal): compression ratio on the liner/piston, every turbo drawn, the + carburettor drawn and jetted by clicking, two exhaust paths joined into one + (*Joined* on the ports or a junction), and the designer window. Open: the + designer lists valve and injector rows a two-stroke does not use. **Paused: milestone 1, the sound of the CP2** (MT-07, Arrow exhaust): `docs/recording-protocol-cp2.md` holds the method, and the two unverified leads diff --git a/CHANGELOG.md b/CHANGELOG.md index 40529e23..debbcb1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,16 @@ file formats, always with an in-memory migration). ### Fixed +- **The designer's real-time check failed every carburetted two-stroke** + ("did not hold 5000 rpm"): it cranked the engine at full throttle, where a + carburettor cannot start it. It now cranks with the throttle shut until the + engine runs. +- The designer names the engine from what it is (*Two-stroke Inline Twin*) + until a name is typed, instead of keeping *Inline Seven*, and its table's + columns are headed *Quantity* and *Value*. +- A carburetted engine no longer draws, or offers to edit, injectors it does + not have. + - A designed V or W engine shares one crank pin between the cylinders of a row, as real ones do; each had its own pin. A 90 deg V-twin now fires 270-450 and a 45 deg one 315-405. Where a shared pin cannot fire six @@ -86,6 +96,21 @@ file formats, always with an in-memory migration). about a degree while the engine knocks and gives it back at 1 deg/s, so an engine settles at its knock limit instead of sitting in light knock. The Audi I5 gets the intercooler its real engine has. +- **The compression ratio is edited by clicking the engine**: a cylinder + liner or piston offers it beside the bore and stroke, and the running + engine takes it (at once or over the ramp). +- **A carburettor is drawn and tuned in the 3-D view**: on a carburetted + engine the throttle body becomes a carburettor (slide tower, float bowl), + and clicking it offers its main and pilot jets, needle clip and air screw, + taken by the running engine. +- **Two exhaust paths can be joined into one**: the exhaust ports or a + junction offer *Joined*, which gives the other path's cylinders a copy of + this path's primary into its collector (a boxer six's two banks become a + 6-into-1) and restarts the engine. +- **Every turbocharger is drawn.** With two or more units the 3-D view drew + only the first; now each unit an exhaust path drives sits on that path's + collector (a flat six's two banks get one each), and clicking one opens its + own inspector, titled with its number and its path. - **Two-strokes can have a power valve** (`two_stroke.power_valve_*`): it lowers the exhaust port at low speed and lifts as the pipe comes in. The designer fits one to sport two-strokes; a designed 500 cc twin gains 20-39 % diff --git a/docs/architecture.md b/docs/architecture.md index 06813f05..dc3b365d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -223,11 +223,14 @@ peak-power speed. Silencers, gearboxes and vehicles come from the family parts An engine file may hold `design:` (the spec, same keys) instead of `engine:`; the catalogue loads it through the same function, so every harness runs a designed engine. The window (`EngineDesignerWindow`) shows the derived values -and their rules as the spec changes; *Advanced* adds firing order, crank +and their rules as the spec changes, and names it from its cycle, fuel, +layout and cylinder count until a name is typed; *Advanced* adds firing order, crank pins, crank offsets, W bank angles and setting any value by hand. *Build and run* first runs the design off-screen for a few seconds with the live engine paused (`measureRealtimeCapacity()`, `runtime/RealtimeCapacityProbe.hpp`: the -physics-only factor at the realtime harness's speed) and asks before building +physics-only factor at the realtime harness's speed; the engine is cranked +with the throttle shut until it runs, at most 5 s simulated, then held on the +dyno, since a carburetted two-stroke never catches at full throttle) and asks before building below `realtimeCapacityMargin` (1.14: the sound and the 3-D view cost 3-12 % on top). Cost grows with cylinders and speed: a 12-cylinder sport car is at the limit on the reference machine, a quad-turbo W16 at half. Then it saves the @@ -280,7 +283,11 @@ piston-to-deck clearance and its compression ratio follows. `resizeConnectingRods` gives every cylinder one rod length (at least 1.2 strokes; a radial's articulated rods are refused) and keeps the compression ratio: one authored by chamber geometry keeps its piston-to-deck clearance, -its deck moving by the added length. +its deck moving by the added length. `setCompressionRatio` gives every +cylinder one compression ratio (5 to 20): an engine authored by ratio takes +it as is; one authored by chamber geometry keeps its head chamber, gasket and +piston and moves its deck (a decked or shimmed block), refused when the +crown would have to rise past the gasket. `EngineRuntime::applyLiveCylinderResize` hands the sizes to the simulation thread, where `EngineSimulator::beginCylinderResize` applies them one crank journal at a time, at the gas-exchange TDC (phase 360) of the first of its @@ -496,10 +503,12 @@ issues as JSON, to look at them outside the app. **Turbocharger.** The gas solver's turbine is a restriction face on the outgoing face of the first junction where all of a path's cylinders have met (see [simulation-model.md](simulation-model.md#forced-induction)). The view -draws it at the same place (`TurboPlacement`), in the first path that has -one. The view draws one turbo, so every catalogue turbo engine has a single -path that all its cylinders exhaust into (`EngineLab.EngineModel3D` checks -it). Gas bends out of the collector, enters the volute +draws one turbo per unit at the same place (`TurboPlacement`, `turbos()`): +each unit on the collector of the first path that drives it, the path's own +unit or its index modulo the count, as the simulator assigns them. A unit no +path drives gets no gas and is not drawn; two units on a flat six's two banks +are both drawn, each clickable with its own inspector (`EngineLab.EngineModel3D` +checks it). Gas bends out of the collector, enters the volute tangentially and leaves the exducer along the trunk, where the path resumes; the shaft runs along the crankshaft, the compressor on the far side of the bearing housing, far enough out to clear the collector. Wheel sizes come from @@ -626,7 +635,9 @@ reads. A stepper press reaches the running engine 250 ms after the last one had when it was selected. The camera stays and the part stays selected. A cylinder liner or piston offers the engine's bore and stroke the same way -(1 mm, 0.1 mm with Shift), with a choice between a 3 s ramp (the default) and +(1 mm, 0.1 mm with Shift), and its compression ratio (0.5, 0.1 with Shift; +only the values edited are applied, so a chamber-geometry engine's ratio +still follows its bore and stroke), with a choice between a 3 s ramp (the default) and the next cycle (a live bore and stroke change, see above). The view shows the target size at once. A connecting rod offers the rods' length the same way (5 mm, 0.5 mm with Shift). @@ -637,7 +648,22 @@ The other parts edit the same way, each through `MainComponent::applyEngineEdit` - an intake runner (length and diameter, its own if the cylinder has one, else the path's, which every runner on it shares), a plenum (volume) and a throttle (bore): `resizeIntakePart` (foundation), which mirrors a single - path into `EngineConfig::intake` and the legacy fields; + path into `EngineConfig::intake` and the legacy fields. On a path metered + by a carburettor the throttle is drawn as one (the slide's tower above the + bore, the float bowl under it) and named *Carburettor*: it also offers the + main and pilot jets as stamped flow numbers, the needle clip (1 richest to + 5) and the air screw's turns out (`tuneCarburettor`, mirrored the same + way), which the running engine takes as an intake edit; +- the exhaust ports of a cylinder, and a junction, when the engine has + another exhaust path that can join this one: a `choice` between + *Separate* and *Joined* that restarts the engine with + `joinExhaustPaths` (exhaust library). Each cylinder of the next path gets + a copy of this path's first primary run (its components up to the first + junction several pipes meet at), feeding that junction; the other path's + pipes, silencers and outlet go, and banks that fed it feed this one. A + boxer six's two 3-into-1 banks so become one 6-into-1; the collector keeps + its size. Refused (not offered) when the primary meets no other pipe + before its outlet, or when a turbocharger would be left without a path; - the injectors, on each drawn injector (`PartRole::injector`: in the runner out of the head for port injection, in the chamber's roof on the intake side for direct injection), and also on the intake ports or the combustion: flow diff --git a/docs/journal.md b/docs/journal.md index 1138af78..c3a3e51e 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -5,6 +5,15 @@ measured, how, and what was not. Anything that becomes a lasting rule goes into `.claude/CLAUDE.md`. Earlier history lives in the `archive/docs-2026-09` git tag (`docs/archive/`). +## 2026-10-23 — Editing on the engine: checked on screen (Linux, Xvfb) + +- Compression ratio on a 2JZ piston, 8.7 -> 9.5 by the stepper: taken live (the engine shows modified, the field reads it back). `LiveCylinderResize`: K20A +1.5 held at 60 % redline settles like one built so, with more torque. +- Aircooled flat six, *Joined* on its exhaust ports: one path, six primaries into one collector, one tailpipe; it idles at 744 rpm. The ports are one part for all cylinders (no cylinder to name a path), so the first path is offered the next. +- The designer window (built 2026-10-19), first time on screen: it works. Fixed: the names' column was headed "Value" and the values' none; the name stayed "Inline Seven" for any engine. Not fixed: a two-stroke lists "Valves per side" and "Injector flow", which it does not use. +- Designed two-stroke twin (64 x 54): its real-time check failed ("did not hold 5000 rpm; mean 673"): cranked at full throttle, a carburettor's pilot is dead and its venturi draws nothing, so it never catches. The probe now cranks shut until it runs (<= 5 s simulated): 1.90 x real time here (test fails without, 1,397 rpm mean). +- The same twin catches only after ~3.1 s of cranking at closed throttle (lambda 6.8 -> 1.4 first), against ~1 s for an injected engine. A real cold two-stroke is started on its choke, which the model has not: not changed, recorded. +- Carburettor jetting live: main #288 -> #431 at ~5,800 rpm full throttle, lambda 0.80 -> 0.53. Its injectors were still drawn (and offered) on a carburetted engine; both removed. + ## 2026-10-23 — Performance: what this Linux box can and cannot say - Refuted: whole-project link-time optimisation (`CMAKE_INTERPROCEDURAL_OPTIMIZATION`, GCC 13): bit-identical CSVs; CP2 sweep 6.65 -> 6.42 s best of 3, LS3+K20+Hayabusa 40.8 -> 39.1 s, within the run-to-run spread (39.1-42.1 for both). diff --git a/docs/tests-and-validation.md b/docs/tests-and-validation.md index 8bfe130a..6598b6a4 100644 --- a/docs/tests-and-validation.md +++ b/docs/tests-and-validation.md @@ -87,12 +87,12 @@ The main registered tests are: | `EngineLab.VehicleDynamics` | tyre friction circle and load transfer for front, rear and all-wheel drive, forward and reverse; catalogue layouts, persistence and diagnostics; a stalled engine left in gear does not move the car; a car coasting in neutral comes to rest | | `EngineLab.RenderSnapshot` | bounded scene, 3D layout and angle interpolation | | `EngineLab.SavedEngine` | saving an engine as it is: every catalogue engine, with bores 1 mm smaller and runners 40 mm longer, saved and read back runs bit for bit as before (2.5 s from cranking to 70 % throttle) and re-encodes to the same JSON; every catalogue engine saved and loaded renders the same audio through the shipping path, heard through its own voicing; tables retarded by 6° are saved, read back and run bit for bit; the "edited" baseline (the ECU's default tables are not an edit, retarded tables and resized cylinders are); a damaged table file fails the read; saving without tables removes stale ones; safe file names, catalogue and empty names refused; the folder listing sorted, without table files, a broken file reported; a running engine renamed without a restart names the next dyno run | -| `EngineLab.EngineModel3D` | 3-D meshes and poses of every catalogue engine (rod length, wrist pin on axis, crown below deck), the ducts laid out from the configuration (every exhaust component drawn once, an outlet with no authored length drawn as an open end, no pipe shorter than authored, runners at their length, plenum and airbox volumes), the route check over the catalogue (clashes, self-clashes, engine clashes, tight bends and stretched pipes no more than measured), the router on two crossing pipes and a box (they part, keep their length, bend no tighter than one diameter; a pipe that fits stays put), a tube with flat open ends, the turbo (drawn exactly on the four catalogue turbochargers, fed by its collector, on the path every cylinder exhausts into, the exhaust resuming at its outlet, clear of the engine, two wheels turning about the shaft and nothing else), the supercharger (drawn on the Merlin only, the airbox or inlet duct feeding its eye, its charge pipe ending on the throttle, clear of the engine, the impeller turning about its axis at the drive ratio times the crank), the gearbox (drawn on every catalogue engine with gears, one pair per gear with its ratio's teeth within 6 %, the teeth of every pair in mesh at any input angle, the collar against the engaged gear and between first and second in neutral, the output shaft turning with the engaged gear), the gas field (every drawn exhaust component, runner and plenum bound to its solver element; capture within one sub-step of the requested angle; capturing leaves the simulation bit-identical; a wave over a 200 kPa mean still shows both colours, and every intake runner and plenum shows its wave below ambient on the same scale; the pulsation strength holds still while the live wave moves, warm where the gas pulses and grey where it does not; a never-started intake stays at ambient and, once a running CP2 or LS3 is switched off and stops, its runners stand at their plenum's pressure), the oscilloscope probe (a cycle fills every crank bin, every element and cell reads what the gas field shows, the simulation stays bit-identical), the exhaust resize from the inspector (the size shown is the one drawn; on every engine and on a scalar-geometry copy, a component 80 mm longer and 4 mm wider is valid and drawn so (the width checked on pipes), the others unchanged; a length on a collector or an outlet, a 4 mm diameter and a silencer narrowed to its outlet are refused), the injectors (one per cylinder, named with its cylinder by a click, its nozzle ending at its tip, on the intake side; a port injector out of the head spraying at the intake valves, a direct one in the chamber's roof within the bore spraying into the cylinder; both kinds checked), the display crank clock (steady, accelerating, slow motion, freeze, pause) and the camera microphone's frame (distances from the origin kept, up stays up, a camera 4 m behind the drawn tailpipe on the first outlet's acoustic axis for at least 12 catalogue engines) and its sound sources (on every catalogue engine and a scalar-geometry copy, every compiled exhaust outlet finds its drawn tip and axis, every intake path one drawn mouth away from the origin, an inlet duct sounding from its open end, a drawn turbo or supercharger its place) | +| `EngineLab.EngineModel3D` | 3-D meshes and poses of every catalogue engine (rod length, wrist pin on axis, crown below deck), the ducts laid out from the configuration (every exhaust component drawn once, an outlet with no authored length drawn as an open end, no pipe shorter than authored, runners at their length, plenum and airbox volumes), the route check over the catalogue (clashes, self-clashes, engine clashes, tight bends and stretched pipes no more than measured), the router on two crossing pipes and a box (they part, keep their length, bend no tighter than one diameter; a pipe that fits stays put), a tube with flat open ends, the turbo (drawn exactly on the four catalogue turbochargers, fed by its collector, on the path every cylinder exhausts into, the exhaust resuming at its outlet, clear of the engine, two wheels turning about the shaft and nothing else), the supercharger (drawn on the Merlin only, the airbox or inlet duct feeding its eye, its charge pipe ending on the throttle, clear of the engine, the impeller turning about its axis at the drive ratio times the crank), the gearbox (drawn on every catalogue engine with gears, one pair per gear with its ratio's teeth within 6 %, the teeth of every pair in mesh at any input angle, the collar against the engaged gear and between first and second in neutral, the output shaft turning with the engaged gear), the gas field (every drawn exhaust component, runner and plenum bound to its solver element; capture within one sub-step of the requested angle; capturing leaves the simulation bit-identical; a wave over a 200 kPa mean still shows both colours, and every intake runner and plenum shows its wave below ambient on the same scale; the pulsation strength holds still while the live wave moves, warm where the gas pulses and grey where it does not; a never-started intake stays at ambient and, once a running CP2 or LS3 is switched off and stops, its runners stand at their plenum's pressure), the oscilloscope probe (a cycle fills every crank bin, every element and cell reads what the gas field shows, the simulation stays bit-identical), the exhaust resize from the inspector (the size shown is the one drawn; on every engine and on a scalar-geometry copy, a component 80 mm longer and 4 mm wider is valid and drawn so (the width checked on pipes), the others unchanged; a length on a collector or an outlet, a 4 mm diameter and a silencer narrowed to its outlet are refused), the injectors (one per cylinder, named with its cylinder by a click, its nozzle ending at its tip, on the intake side; a port injector out of the head spraying at the intake valves, a direct one in the chamber's roof within the bore spraying into the cylinder; both kinds checked), the display crank clock (steady, accelerating, slow motion, freeze, pause) and the camera microphone's frame (distances from the origin kept, up stays up, a camera 4 m behind the drawn tailpipe on the first outlet's acoustic axis for at least 12 catalogue engines) and its sound sources (on every catalogue engine and a scalar-geometry copy, every compiled exhaust outlet finds its drawn tip and axis, every intake path one drawn mouth away from the origin, an inlet duct sounding from its open end, a drawn turbo or supercharger its place), and the Aircooled's two banks joined into one 6-into-1 (six primaries meeting at one collector, every drawn piece on the kept path, bound to its solver, idling; a path joined to itself, a twin-turbo's paths and the Big Twin's separate pipes refused) | | `EngineLab.RealtimeRegression` | timing, audio rate, buffers, path isolation and DAG transmission; the starter keeps its level whatever the mechanical fader | | `EngineLab.LiveSettings` | live injector and turbo changes: taking the running settings leaves the 2JZ and CP2 bit-identical; another injection mode, forced-induction type or blade count is refused; a 2JZ wastegate cut to 1.42 (boost ratio 1.94 -> 1.68) and CP2 injectors of 120 g/min (torque 60 -> 46 Nm at 5,900 rpm) taken at 6 s settle within 10 % of the effect of an engine built that way, without a stall; the runtime refuses an invalid or other machine and keeps its configuration; edits are sorted into exhaust, cylinders, settings and other; the audio turbo layer takes the same settings bit-identically, hears an edited turbo, refuses another machine or a sound that would start or stop | -| `EngineLab.LiveIntake` | live intake sizes: a plenum resized keeps its pressure and temperature; swapping in the running intake leaves CP2 and LS3 bit-identical; another airbox, throttle count or path layout is refused; CP2, K20A and LS3 held at 60 % of redline take runners +120 mm, plenums x1.6 and throttles +6 mm at 6 s, settle within 25 % of the effect of an engine built that way (CP2 60 -> 78 Nm), without a stall and with no frame-to-frame speed step beyond 1.5 times the engine's own plus the shift between the two equilibria; every catalogue engine keeps idling through a smaller change; edits are sorted, an edit of one of two paths included; the intake sound's swap of the same intake stays 60 dB below the engine during and 90 dB after, a resized one is heard without a sample step beyond the engine's own | +| `EngineLab.LiveIntake` | live intake sizes: a plenum resized keeps its pressure and temperature; swapping in the running intake leaves CP2 and LS3 bit-identical; another airbox, throttle count or path layout is refused; CP2, K20A and LS3 held at 60 % of redline take runners +120 mm, plenums x1.6 and throttles +6 mm at 6 s, settle within 25 % of the effect of an engine built that way (CP2 60 -> 78 Nm), without a stall and with no frame-to-frame speed step beyond 1.5 times the engine's own plus the shift between the two equilibria; every catalogue engine keeps idling through a smaller change; a designed two-stroke's carburettor jetting is read and written where the engine meters it (an injected engine's refused, a clip out of range refused), and a main jet 1.5 times larger fitted live richens full throttle; edits are sorted, an edit of one of two paths included; the intake sound's swap of the same intake stays 60 dB below the engine during and 90 dB after, a resized one is heard without a sample step beyond the engine's own | | `EngineLab.LiveTuning` | live tuning: a K20A driveline in first gear at 2,000 rpm takes a final drive x1.3 and +200 kg at 1 s and settles at the road speed of one built with it (3.08 against 3.09 m/s, 4.00 untouched), another gear count refused; CP2 and 2JZ held at 60 % of redline take the air of 2,000 m (79.5 kPa) at 35 degC at 6 s and settle within 15 % of the effect of an engine built there (CP2 70.8 -> 51.6 Nm, 2JZ 455 -> 362 Nm), an ambient edit being a settings edit; the 2JZ held at 60 % of redline keeps its gauge boost within 8 % at 79.5 kPa (90.2 kPa both); a regulated CP2 stays at 90 degC through 10 s of full load, from the start and once regulated while running, an unregulated one starts at 22 degC and warms up, the switch being a settings edit; the realistic thermal model of the CP2 warms past 80 degC at idle within 30 min (9.8), its fan holding it at 100 degC in still air, overheats at rated heat standing, holds below 100 degC at 30 m/s and stops its fan at 95 degC, the Aircooled having no fan; the 2JZ held at full load for 8 s knocks lightly (mean 0.34) and takes no damage; a flywheel edit is a settings edit; another crankshaft ratio is refused, by the simulator and the runtime; CP2 and 2JZ given a flywheel twice as heavy at idle (3 s) free-rev from 4 s to 75 % of redline within 15 % of the effect of an engine built with it (CP2 0.525 -> 0.94 s), still idling; taking the running camshafts leaves CP2 and K20A bit-identical, at once and over a ramp; a cam edit is a camshaft edit, a profile switch added is refused by the simulator and the runtime; CP2 and K20A held at 60 % of redline take cams 40 degrees shorter with 30 % less lift at 6 s, at once and over 2 s, and settle within 15 % of the effect of an engine built with them (CP2 70.8 -> 55.2 Nm, K20A 223 -> 142 Nm), the duration half-way at half the ramp | -| `EngineLab.LiveCylinderResize` | live bore, stroke and rod changes: resizing to the running size leaves the simulation bit-identical (at once and over a ramp); each journal of a K20A changes at its own gas-exchange TDC; CP2, K20A and LS3 held at 60 % of redline, +3 mm bore and stroke at once and over 2 s, settle to the brake torque of an engine built that way, and resized back to the original's, the cycle torque leaving the band of its cycle-to-cycle variability for at most one cycle at once (the cycle fuelled for the old cylinder) and none over the ramp, and the bore half-way at half the ramp; every catalogue engine keeps running through a 2 mm change at idle (smaller for the Merlin, at the 20 litre ceiling); CP2 and K20A rods 0.3 stroke longer keep the compression ratio and, at once or over 2 s, settle to the brake torque of an engine built with them (within a quarter of the effect or 0.5 %), the rod half-way at half the ramp; a radial's articulated rods are refused | +| `EngineLab.LiveCylinderResize` | live bore, stroke and rod changes: resizing to the running size leaves the simulation bit-identical (at once and over a ramp); each journal of a K20A changes at its own gas-exchange TDC; CP2, K20A and LS3 held at 60 % of redline, +3 mm bore and stroke at once and over 2 s, settle to the brake torque of an engine built that way, and resized back to the original's, the cycle torque leaving the band of its cycle-to-cycle variability for at most one cycle at once (the cycle fuelled for the old cylinder) and none over the ramp, and the bore half-way at half the ramp; every catalogue engine keeps running through a 2 mm change at idle (smaller for the Merlin, at the 20 litre ceiling); CP2 and K20A rods 0.3 stroke longer keep the compression ratio and, at once or over 2 s, settle to the brake torque of an engine built with them (within a quarter of the effect or 0.5 %), the rod half-way at half the ramp; a radial's articulated rods are refused; a K20A's compression ratio raised 1.5 while held settles to the brake torque of an engine built with it, above the untouched one's, and a chamber-geometry copy takes a higher ratio by lowering its deck while a ratio its chamber cannot reach is refused, changing nothing | | `EngineLab.LiveChange` | live exhaust changes: swapping in the same exhaust leaves the simulation bit-identical and the audio more than 60 dB below the engine during the crossfade (90 dB after); a resized exhaust (CP2, K20A, LS3, held at 60 % of redline) settles to the brake torque of an engine built with it, with no speed step beyond the second before and the walls as hot as before; the running exhaust remeshed from 360 to 180 mm has finer cells and settles to the brake torque of an engine started with them (on the LS3, where the mesh moves it, closer to that than to the 360 mm one); a resized exhaust in the audio changes the sound with no larger sample step than before | | `EngineLab.CameraMicrophone` | the camera microphone: a microphone moved to its own place is bit-identical; twice as far is 6.02 dB quieter for a tone, the CP2's block, exhaust and intake and the 2JZ's turbo; a glide shifts the pitch by 2 % at most and does not click; an immediate move jumps; a microphone in the source is kept 0.25 m away; at the authored positions or switched off the CP2 renders bit for bit the default sound, and switched off after 3 s twice as far it is bit for bit the default sound again within 2.5 s; an exhaust and an intake swapped in live are heard from the moved microphones; an opening moved to its own place is bit-identical, moved halfway to the listener it is louder as 1/r says, moved back bit-identical again; the CP2's outlets and mouths placed 1 m before the microphones (after they moved) are about 12 dB louder, bit for bit the default sound when the microphones are not moved or switched off, and kept by a live swap; the 2JZ's turbo placed halfway is heard at its 1/r level | | `EngineLab.CatalogPhysics` | deterministic scenarios and loaded holds governed by a PI brake across the whole catalogue | diff --git a/src/app/include/enginelab/app/EngineViewport.hpp b/src/app/include/enginelab/app/EngineViewport.hpp index 431e6cc4..9e9615f6 100644 --- a/src/app/include/enginelab/app/EngineViewport.hpp +++ b/src/app/include/enginelab/app/EngineViewport.hpp @@ -367,7 +367,7 @@ class EngineViewport final : public juce::Component, private juce::OpenGLRendere void updateScope(); void applyEdit(const std::vector& values, bool gradual); void applyExhaustEdit(double lengthMm, double diameterMm); - void applyCylinderEdit(double boreMm, double strokeMm, bool gradual); + void applyCylinderEdit(const std::vector& values, bool gradual); void applyRodEdit(double rodMm, bool gradual); /** A valve's cam or the heads' cam timing, through the ramped editor. */ void applyCamshaftEdit(const std::vector& values, bool gradual); diff --git a/src/app/src/EngineDesignerWindow.cpp b/src/app/src/EngineDesignerWindow.cpp index cc01644e..f3841a02 100644 --- a/src/app/src/EngineDesignerWindow.cpp +++ b/src/app/src/EngineDesignerWindow.cpp @@ -68,6 +68,11 @@ class EngineDesignerWindow::DesignerContent final : public juce::Component, } addField(name_, "Name", "Inline Seven"); + // A typed name stays; cleared, the name follows the engine again. + name_.editor.onTextChange = [this] { + nameTyped_ = name_.editor.getText().trim().isNotEmpty(); + startTimer(250); + }; addChoice(cycle_, "Cycle", { "Four-stroke", "Two-stroke" }); addChoice(fuel_, "Fuel", { "Petrol", "Diesel" }); addChoice(use_, "Built for", @@ -95,8 +100,8 @@ class EngineDesignerWindow::DesignerContent final : public juce::Component, table_.setModel(this); table_.setRowHeight(24); - table_.getHeader().addColumn("Value", 1, 210); - table_.getHeader().addColumn("", 2, 90); + table_.getHeader().addColumn("Quantity", 1, 210); + table_.getHeader().addColumn("Value", 2, 90); table_.getHeader().addColumn("Set by", 3, 70); table_.getHeader().addColumn("Rule", 4, 420); table_.getHeader().setStretchToFitActive(true); @@ -328,7 +333,33 @@ class EngineDesignerWindow::DesignerContent final : public juce::Component, return spec; } + /** A name from what the engine is ("Two-stroke Inline Twin"), until one + is typed. */ + void suggestName() { + if (nameTyped_) return; + static constexpr std::array counts { "Single", "Twin", "Three", "Four", "Five", "Six", + "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen" }; + static constexpr std::array layoutNames { "Inline", "V", "Flat", "Radial", "W" }; + const auto count = numberOf(cylinders_.editor.getText()).value_or(0.0); + juce::String name; + if (fuel_.combo.getSelectedItemIndex() == 1) name << "Diesel "; + if (cycle_.combo.getSelectedItemIndex() == 1) name << "Two-stroke "; + if (count == 1.0) { + name << "Single"; + } else { + name << layoutNames[static_cast( + std::clamp(layout_.combo.getSelectedItemIndex(), 0, static_cast(layoutNames.size()) - 1))] + << " "; + if (count >= 2.0 && count <= 16.0 && count == std::round(count)) + name << counts[static_cast(count) - 1U]; + else + name << juce::String(juce::roundToInt(count)); + } + name_.editor.setText(name, false); + } + void redesign() { + suggestName(); designed_ = {}; juce::String error; if (!partsError_.empty()) error = utf8("The parts library did not load: ") + utf8(partsError_.c_str()); @@ -511,6 +542,7 @@ class EngineDesignerWindow::DesignerContent final : public juce::Component, static constexpr const char* utf8Times = "\xc3\x97"; std::filesystem::path catalogRoot_; + bool nameTyped_ { false }; BuildCallback buildCallback_; PauseCallback pauseLive_; std::function close_; diff --git a/src/app/src/EngineViewport.cpp b/src/app/src/EngineViewport.cpp index 4dad66ef..1f9c6af8 100644 --- a/src/app/src/EngineViewport.cpp +++ b/src/app/src/EngineViewport.cpp @@ -1164,7 +1164,7 @@ void EngineViewport::applyEdit(const std::vector& values, bool gradual) && scene_->ducts()[static_cast(identity.duct)].kind == render::DuctKind::exhaustComponent) applyExhaustEdit(first, second); else if (role == render::PartRole::liner || role == render::PartRole::piston) - applyCylinderEdit(first, second, gradual); + applyCylinderEdit(values, gradual); else if (role == render::PartRole::rod) applyRodEdit(first, gradual); else if (partEditor_ && (role == render::PartRole::head || role == render::PartRole::intakeValve @@ -1214,10 +1214,19 @@ void EngineViewport::applyPartChoice(int index) { } } -void EngineViewport::applyCylinderEdit(double boreMm, double strokeMm, bool gradual) { - if (!scene_ || !cylinderEditor_ || selectedPart_ < 0) return; +void EngineViewport::applyCylinderEdit(const std::vector& values, bool gradual) { + if (!scene_ || !cylinderEditor_ || selectedPart_ < 0 || values.size() < 3U) return; auto edited = scene_->config(); - const auto error = resizeCylinders(edited, boreMm, strokeMm); + const auto boreMm = values[0]; + const auto strokeMm = values[1]; + const auto ratio = values[2]; + // Only what was edited: a chamber-geometry engine's ratio follows its + // bore and stroke, and the field still holds the ratio from before. + std::string error; + if (const auto size = cylinderSize(edited); size && (boreMm != size->boreMm || strokeMm != size->strokeMm)) + error = resizeCylinders(edited, boreMm, strokeMm); + if (error.empty() && ratio != scene_->config().cylinders.front().compressionRatio) + error = setCompressionRatio(edited, ratio); sendCylinderEdit(edited, error, gradual); } @@ -1619,12 +1628,20 @@ void EngineViewport::updateInspector() { result.key = std::uint64_t { 1 } << 63U; result.length("Bore", size->boreMm, 1.0, 0.1, 20.0, 200.0); result.length("Stroke", size->strokeMm, 1.0, 0.1, 20.0, 200.0); + result.value("Compression ratio", config.cylinders.front().compressionRatio, utf8(" : 1"), 1, 0.5, 0.1, 5.0, + 20.0); result.offersRamp = true; - result.note = "Every cylinder changes, each crank throw at its gas-exchange TDC."; + result.note = "Every cylinder changes, each crank throw at its gas-exchange TDC. A new bore or stroke keeps " + "the compression ratio; a higher ratio gives more torque, and knocks sooner."; return result; }; const auto injectorEdit = [this, &config]() -> std::optional { if (!configEditor_) return std::nullopt; + // Carburettors meter every cylinder: no injector is commanded. + if (!config.intakePaths.empty() + && std::all_of(config.intakePaths.begin(), config.intakePaths.end(), + [](const IntakePathConfig& path) { return path.geometry.carburettor.enabled; })) + return std::nullopt; const auto& injection = config.injection; const auto direct = injection.mode == InjectionMode::direct; PartInspector::Edit result; @@ -1685,6 +1702,41 @@ void EngineViewport::updateInspector() { forced.enabled ? 1 : 0 }; }; + // Joins the next exhaust path into `pathId`'s, a restart: a boxer six's + // two banks made one 6-into-1. Offered only when the join is valid. + const auto joinEdit = [this, &config](std::uint32_t pathId) -> std::optional { + const auto& paths = config.exhaustPaths; + if (!configEditor_ || paths.size() < 2U) return std::nullopt; + const auto kept = std::find_if(paths.begin(), paths.end(), + [pathId](const ExhaustPathConfig& path) { return path.id == pathId; }); + if (kept == paths.end()) return std::nullopt; + const auto joinedId = std::next(kept) != paths.end() ? std::next(kept)->id : paths.front().id; + auto trial = config; + if (!joinExhaustPaths(trial, pathId, joinedId).empty() || validateEngineConfig(trial)) return std::nullopt; + PartInspector::Edit result; + result.key = (std::uint64_t { 9 } << 60U) | pathId; + result.heading = "EXHAUST PATHS"; + result.restarts = true; + result.choice = PartInspector::Edit::Choice { + { "Separate", "Joined" }, + "Joins exhaust path " + juce::String(joinedId) + " into path " + juce::String(pathId) + + ", and restarts: each of its cylinders gets a copy of this path's primary, into this path's " + "collector; its own pipes, silencers and outlet go. The collector keeps its size.", + 0 }; + const auto joinedPipes = kept->cylinderIds.size() + + std::find_if(paths.begin(), paths.end(), [joinedId](const ExhaustPathConfig& path) { + return path.id == joinedId; + })->cylinderIds.size(); + result.note = "Path " + juce::String(joinedId) + " can join this one: one collector for " + + juce::String(static_cast(joinedPipes)) + " pipes."; + partChoice_ = [pathId, joinedId](EngineConfig& edited, int index) { + if (index == 1) (void)joinExhaustPaths(edited, pathId, joinedId); + }; + // The pipes are redrawn: the ports then show the joined path. + partChoiceSelects_ = render::PartRole::exhaustPorts; + return result; + }; + switch (identity.role) { case render::PartRole::block: title = "Cylinder block"; @@ -1916,6 +1968,19 @@ void EngineViewport::updateInspector() { case render::PartRole::exhaustPorts: title = "Exhaust ports"; if (live != nullptr) add("Exhaust gas", model_.units.text(units::Quantity::temperature, live->exhaustTemperatureC)); + if (cylinder != nullptr) + for (const auto& path : config.exhaustPaths) + if (std::find(path.cylinderIds.begin(), path.cylinderIds.end(), cylinder->id) != path.cylinderIds.end()) { + add("Exhaust path", juce::String(path.id) + ", " + juce::String(static_cast(path.cylinderIds.size())) + + (path.cylinderIds.size() == 1U ? " cylinder" : " cylinders")); + edit = joinEdit(path.id); + } + // The stubs of every cylinder are one part: the paths, the first + // offered the next one. + if (cylinder == nullptr && !config.exhaustPaths.empty()) { + add("Exhaust paths", juce::String(static_cast(config.exhaustPaths.size()))); + edit = joinEdit(config.exhaustPaths.front().id); + } break; case render::PartRole::afterfire: title = "Afterfire"; @@ -1926,8 +1991,12 @@ void EngineViewport::updateInspector() { const auto& forced = config.forcedInduction; const auto krpm = [](double rpm) { return juce::String(rpm / 1'000.0, 1) + " krpm"; }; const auto area = [](double mm2) { return juce::String(juce::roundToInt(mm2)) + utf8(" mm²"); }; - title = "Turbocharger"; - subtitle = "On exhaust path " + juce::String(scene_->turbo().pathId); + const auto& drawn = scene_->turbos(); + const auto& clicked = identity.turbo >= 0 && static_cast(identity.turbo) < drawn.size() + ? drawn[static_cast(identity.turbo)] : scene_->turbo(); + title = drawn.size() > 1U ? "Turbocharger " + juce::String(static_cast(clicked.unit) + 1) + : juce::String("Turbocharger"); + subtitle = "On exhaust path " + juce::String(clicked.pathId); const auto units = std::min(state.turbochargerCount, maximumTurbochargers); if (units > 1U) { juce::String speeds; @@ -1986,7 +2055,7 @@ void EngineViewport::updateInspector() { edit->value("Turbochargers", static_cast(forced.turbochargerCount), "", 0, 1.0, 1.0, 1.0, static_cast(maximumTurbochargers)); // Which turbocharger this path's turbine drives, when there is a choice. - const auto pathId = scene_->turbo().pathId; + const auto pathId = clicked.pathId; const auto path = std::find_if(config.exhaustPaths.begin(), config.exhaustPaths.end(), [pathId](const ExhaustPathConfig& candidate) { return candidate.id == pathId; }); const auto pathIndex = static_cast(std::distance(config.exhaustPaths.begin(), path)); @@ -2162,6 +2231,7 @@ void EngineViewport::updateInspector() { case render::DuctKind::intakeInletDuct: title = "Air inlet"; break; } const auto exhaust = duct.kind == render::DuctKind::exhaustComponent || duct.kind == render::DuctKind::exhaustFeeder; + if (duct.kind == render::DuctKind::exhaustFeeder) edit = joinEdit(duct.pathId); if (duct.kind == render::DuctKind::exhaustComponent && configEditor_) { if (const auto size = exhaustComponentSize(config, duct.pathId, duct.elementId)) { edit = PartInspector::Edit {}; @@ -2194,10 +2264,34 @@ void EngineViewport::updateInspector() { } else { edit->length("Bore", size->first, 1.0, 0.1, 10.0, 150.0); } - partEditor_ = [part, pathId, cylinderIndex](EngineConfig& edited, const std::vector& values) { - return part == IntakePart::runner - ? resizeIntakePart(edited, pathId, part, cylinderIndex, values[0], values[1]) - : resizeIntakePart(edited, pathId, part, cylinderIndex, values[0]); + // A carburettor is its throttle bore: its jets are tuned there. + const auto tune = part == IntakePart::throttle ? carburettorTune(config, pathId) : std::nullopt; + if (tune) { + title = "Carburettor"; + edit->heading = "JETTING"; + edit->value("Main jet", tune->mainJetFlowNumber, {}, 1, 10.0, 2.5, 50.0, 1'000.0); + edit->value("Pilot jet", tune->pilotJetFlowNumber, {}, 1, 2.5, 2.5, 10.0, 150.0); + edit->value("Needle clip", tune->needleClipPosition, {}, 0, 1.0, 1.0, 1.0, 5.0); + edit->value("Air screw", tune->airScrewTurns, " turns out", 2, 0.25, 0.125, 0.0, 5.0); + edit->note = "Jets as the number stamped on them (a Mikuni main: cc/min at one metre of head). " + "The main meters from about a quarter throttle up, the pilot at and just off " + "idle, the needle between them (clip 1 richest, 5 leanest); turning the air screw " + "out leans the pilot. Every carburettor changes alike. The running engine takes " + "them."; + } + partEditor_ = [part, pathId, cylinderIndex, carburetted = tune.has_value()]( + EngineConfig& edited, const std::vector& values) { + if (part == IntakePart::runner) + return resizeIntakePart(edited, pathId, part, cylinderIndex, values[0], values[1]); + auto error = resizeIntakePart(edited, pathId, part, cylinderIndex, values[0]); + if (!error.empty() || !carburetted || values.size() < 5U) return error; + // A two-stroke has one per cylinder, jetted alike. + const CarburettorTune tuned { values[1], values[2], values[3], values[4] }; + const auto paths = edited.intakePaths; + for (const auto& path : paths) + if (carburettorTune(edited, path.id)) + if (error = tuneCarburettor(edited, path.id, tuned); !error.empty()) return error; + return error; }; } } diff --git a/src/exhaust/include/enginelab/exhaust/ExhaustComponentResize.hpp b/src/exhaust/include/enginelab/exhaust/ExhaustComponentResize.hpp index 49133aba..3db50996 100644 --- a/src/exhaust/include/enginelab/exhaust/ExhaustComponentResize.hpp +++ b/src/exhaust/include/enginelab/exhaust/ExhaustComponentResize.hpp @@ -41,4 +41,19 @@ struct ExhaustComponentSize final { EngineConfig& config, std::uint32_t pathId, std::uint32_t componentId, double lengthMm, double diameterMm); +/** + * Joins exhaust path `joinedId` into path `keptId`, in place: a boxer six's + * two 3-into-1 banks become one 6-into-1. Every cylinder of the joined path + * gets a copy of the kept path's first primary run (its components up to the + * first junction several pipes meet at) feeding that junction; the joined + * path's own pipes, silencers and outlet go. Banks that fed the joined path + * feed the kept one. Returns an empty string on success, or why the join was + * refused (the configuration is then unchanged): no such paths, the same + * path twice, a kept path whose primary reaches its outlet without meeting + * another (nothing to join into), or a turbocharger left without a path. The + * collector keeps its size. The caller still validates the whole + * configuration. + */ +[[nodiscard]] std::string joinExhaustPaths(EngineConfig& config, std::uint32_t keptId, std::uint32_t joinedId); + } // namespace enginelab diff --git a/src/exhaust/src/ExhaustComponentResize.cpp b/src/exhaust/src/ExhaustComponentResize.cpp index 3b3606ca..25c792f9 100644 --- a/src/exhaust/src/ExhaustComponentResize.cpp +++ b/src/exhaust/src/ExhaustComponentResize.cpp @@ -4,6 +4,8 @@ #include #include +#include +#include #include namespace enginelab { @@ -144,4 +146,101 @@ std::string resizeExhaustComponent(EngineConfig& config, std::uint32_t pathId, s return {}; } +std::string joinExhaustPaths(EngineConfig& config, std::uint32_t keptId, std::uint32_t joinedId) { + if (keptId == joinedId) return "A path cannot be joined to itself."; + const auto* keptPath = findPath(config, keptId); + const auto* joinedPath = findPath(config, joinedId); + if (keptPath == nullptr || joinedPath == nullptr) return "The engine has no such exhaust paths."; + if (config.forcedInduction.enabled && config.forcedInduction.type == ForcedInductionType::turbocharger + && config.forcedInduction.turbochargerCount + 1U > config.exhaustPaths.size()) + return "Each turbocharger needs an exhaust path of its own."; + auto network = keptPath->network ? *keptPath->network : makeEditableExhaustNetwork(*keptPath); + if (network.cylinderConnections.empty()) return "The exhaust path has no primary to copy."; + + std::unordered_map incoming; + std::unordered_map> outgoing; + for (const auto& connection : network.cylinderConnections) ++incoming[connection.componentId]; + for (const auto& connection : network.connections) { + ++incoming[connection.toComponentId]; + outgoing[connection.fromComponentId].push_back(&connection); + } + // The first cylinder's own run: each component fed by the one before + // alone, up to the junction several pipes meet at. + // Copied out: the network grows below. + std::vector run { network.cylinderConnections.front().componentId }; + std::vector runLinks; + ExhaustComponentConnectionConfig intoJunction; + while (true) { + const auto& next = outgoing[run.back()]; + if (next.empty()) return "The primary meets no other pipe before its outlet."; + if (next.size() != 1U) return "The primary splits before it meets the others."; + if (incoming[next.front()->toComponentId] > 1U) { + intoJunction = *next.front(); + break; + } + if (run.size() >= network.components.size()) return "The exhaust graph loops."; + runLinks.push_back(*next.front()); + run.push_back(next.front()->toComponentId); + } + + auto nextId = std::uint32_t { 1 }; + for (const auto& component : network.components) nextId = std::max(nextId, component.id + 1U); + for (const auto cylinderId : joinedPath->cylinderIds) { + std::uint32_t previous = 0; + for (std::size_t index = 0; index < run.size(); ++index) { + const auto* source = findComponent(network, run[index]); + if (source == nullptr) return "The exhaust graph names a missing component."; + auto copy = *source; + copy.id = nextId++; + if (index == 0) { + network.cylinderConnections.push_back({ cylinderId, copy.id }); + } else { + // The same ports as the run it copies. + auto link = runLinks[index - 1]; + link.fromComponentId = previous; + link.toComponentId = copy.id; + network.connections.push_back(link); + } + previous = copy.id; + network.components.push_back(std::move(copy)); + } + auto link = intoJunction; + link.fromComponentId = previous; + network.connections.push_back(link); + } + + auto edited = config; + auto* kept = findPath(edited, keptId); + kept->cylinderIds.insert(kept->cylinderIds.end(), joinedPath->cylinderIds.begin(), joinedPath->cylinderIds.end()); + kept->network = std::move(network); + kept->inheritsGlobalGeometry = false; + std::erase_if(edited.exhaustPaths, [joinedId](const ExhaustPathConfig& path) { return path.id == joinedId; }); + for (auto& bank : edited.banks) + if (bank.exhaustPathId == joinedId) bank.exhaustPathId = keptId; + // A turbocharger chosen by path index moves with the paths after the one + // removed: name the units the paths drove. + for (std::size_t index = 0; index < edited.exhaustPaths.size(); ++index) { + auto& path = edited.exhaustPaths[index]; + if (path.turbocharger >= 0 || config.forcedInduction.turbochargerCount < 2U) continue; + const auto was = std::find_if(config.exhaustPaths.begin(), config.exhaustPaths.end(), + [&path](const ExhaustPathConfig& item) { return item.id == path.id; }); + path.turbocharger = static_cast(static_cast(was - config.exhaustPaths.begin()) + % config.forcedInduction.turbochargerCount); + } + if (config.forcedInduction.enabled && config.forcedInduction.type == ForcedInductionType::turbocharger) + for (std::uint32_t unit = 0; unit < config.forcedInduction.turbochargerCount; ++unit) { + const auto driven = std::any_of(edited.exhaustPaths.begin(), edited.exhaustPaths.end(), + [&](const ExhaustPathConfig& candidate) { + const auto index = static_cast(&candidate - edited.exhaustPaths.data()); + const auto drives = candidate.turbocharger >= 0 ? static_cast(candidate.turbocharger) + : static_cast(index % config.forcedInduction.turbochargerCount); + return drives == unit; + }); + if (!driven) return "Turbocharger " + std::to_string(unit + 1U) + " would have no exhaust path."; + } + if (edited.exhaustPaths.size() == 1U) edited.exhaust = edited.exhaustPaths.front().geometry; + config = std::move(edited); + return {}; +} + } // namespace enginelab diff --git a/src/foundation/include/enginelab/foundation/CylinderResize.hpp b/src/foundation/include/enginelab/foundation/CylinderResize.hpp index bfc82b04..52242d06 100644 --- a/src/foundation/include/enginelab/foundation/CylinderResize.hpp +++ b/src/foundation/include/enginelab/foundation/CylinderResize.hpp @@ -31,6 +31,20 @@ struct CylinderSize final { */ [[nodiscard]] std::string resizeCylinders(EngineConfig& config, double boreMm, double strokeMm); +/** + * Gives every cylinder the compression ratio `ratio`, in place. Returns an + * empty string on success, or why the edit was refused (the configuration is + * then unchanged): a ratio out of [5, 20], or a chamber geometry that cannot + * reach it. + * + * An engine authored by compression ratio takes it as is: the clearance + * volume follows. An engine authored by chamber geometry keeps its head + * chamber, gasket and piston, and its deck moves (a decked or shimmed block): + * refused when the piston crown would have to rise past the gasket. The + * caller still validates the whole configuration. + */ +[[nodiscard]] std::string setCompressionRatio(EngineConfig& config, double ratio); + /** The connecting rod length every cylinder shares, or nothing when the rods * differ or one is a radial's master or articulated rod. */ [[nodiscard]] std::optional connectingRodLength(const EngineConfig& config); diff --git a/src/foundation/include/enginelab/foundation/IntakeResize.hpp b/src/foundation/include/enginelab/foundation/IntakeResize.hpp index 9d2c552b..993a1ced 100644 --- a/src/foundation/include/enginelab/foundation/IntakeResize.hpp +++ b/src/foundation/include/enginelab/foundation/IntakeResize.hpp @@ -46,4 +46,27 @@ struct IntakePartSize final { [[nodiscard]] std::string resizeIntakePart(EngineConfig& config, std::uint32_t pathId, IntakePart part, std::size_t cylinderIndex, double first, double second = 0.0); +/** What the 3-D view's inspector tunes on a carburettor: its jets as stamped + * flow numbers, the needle clip (1 richest to 5) and the pilot air screw's + * turns out (CarburettorConfig). */ +struct CarburettorTune final { + double mainJetFlowNumber { 0.0 }; + double pilotJetFlowNumber { 0.0 }; + double needleClipPosition { 0.0 }; + double airScrewTurns { 0.0 }; +}; + +/** The tune of intake path `pathId`'s carburettor, or nothing when the path + * does not exist or meters with an injector. */ +[[nodiscard]] std::optional carburettorTune(const EngineConfig& config, std::uint32_t pathId); + +/** + * Gives intake path `pathId`'s carburettor `tune` in place, mirrored like + * resizeIntakePart(). Returns an empty string on success, or why the edit was + * refused (the configuration is then unchanged): no such path, no + * carburettor on it, or a value out of range (main jet 50-1,000, pilot jet + * 10-150, clip 1-5, air screw 0-5 turns). + */ +[[nodiscard]] std::string tuneCarburettor(EngineConfig& config, std::uint32_t pathId, const CarburettorTune& tune); + } // namespace enginelab diff --git a/src/foundation/src/CylinderResize.cpp b/src/foundation/src/CylinderResize.cpp index 641839d1..edba9b81 100644 --- a/src/foundation/src/CylinderResize.cpp +++ b/src/foundation/src/CylinderResize.cpp @@ -44,6 +44,28 @@ std::string resizeCylinders(EngineConfig& config, double boreMm, double strokeMm return {}; } +std::string setCompressionRatio(EngineConfig& config, double ratio) { + if (!std::isfinite(ratio) || ratio < 5.0 || ratio > 20.0) + return "The compression ratio must be between 5 and 20."; + auto edited = config; + for (auto& cylinder : edited.cylinders) { + cylinder.compressionRatio = ratio; + if (cylinder.headChamberVolumeCc <= 0.0) continue; + // The clearance this ratio needs, taken from the piston-to-deck gap. + const auto areaMm2 = std::numbers::pi * cylinder.boreMm * cylinder.boreMm * 0.25; + const auto clearanceCc = areaMm2 * cylinder.strokeMm / 1'000.0 / (ratio - 1.0); + const auto deckClearanceMm = (clearanceCc - cylinder.headChamberVolumeCc + cylinder.pistonCrownVolumeCc) + * 1'000.0 / areaMm2 + - cylinder.headGasketThicknessMm; + if (deckClearanceMm + cylinder.headGasketThicknessMm < 0.0) + return "The piston would rise past the head gasket: this chamber cannot reach that ratio."; + cylinder.deckHeightMm = deckClearanceMm + cylinder.strokeMm * 0.5 + cylinder.connectingRodMm + + cylinder.compressionHeightMm; + } + config = std::move(edited); + return {}; +} + std::optional connectingRodLength(const EngineConfig& config) { if (config.cylinders.empty()) return std::nullopt; const auto& first = config.cylinders.front(); diff --git a/src/foundation/src/IntakeResize.cpp b/src/foundation/src/IntakeResize.cpp index c53a2e7b..f668acc9 100644 --- a/src/foundation/src/IntakeResize.cpp +++ b/src/foundation/src/IntakeResize.cpp @@ -2,6 +2,7 @@ #include #include +#include namespace enginelab { namespace { @@ -17,6 +18,16 @@ template return std::isfinite(value) && value >= low && value <= high; } +/** One path mirrors the engine-wide intake (normaliseEngineConfig), from + whichever side it inherits. */ +void mirrorSinglePath(EngineConfig& config) { + if (config.intakePaths.size() != 1) return; + const auto& geometry = config.intakePaths.front().geometry; + config.intake = geometry; + config.plenumVolumeLitres = geometry.plenumVolumeLitres; + config.throttleDiameterMm = geometry.throttleDiameterMm; +} + } // namespace std::optional intakePartSize(const EngineConfig& config, std::uint32_t pathId, @@ -68,13 +79,32 @@ std::string resizeIntakePart(EngineConfig& config, std::uint32_t pathId, IntakeP geometry.throttleDiameterMm = first; break; } - // One path mirrors the engine-wide intake (normaliseEngineConfig), from - // whichever side it inherits. - if (config.intakePaths.size() == 1) { - config.intake = geometry; - config.plenumVolumeLitres = geometry.plenumVolumeLitres; - config.throttleDiameterMm = geometry.throttleDiameterMm; - } + mirrorSinglePath(config); + return {}; +} + +std::optional carburettorTune(const EngineConfig& config, std::uint32_t pathId) { + const auto* path = pathOf(config, pathId); + if (path == nullptr || !path->geometry.carburettor.enabled) return std::nullopt; + const auto& carburettor = path->geometry.carburettor; + return CarburettorTune { carburettor.mainJetFlowNumber, carburettor.pilotJetFlowNumber, + carburettor.needleClipPosition, carburettor.airScrewTurns }; +} + +std::string tuneCarburettor(EngineConfig& config, std::uint32_t pathId, const CarburettorTune& tune) { + auto* path = pathOf(config, pathId); + if (path == nullptr) return "The engine has no intake path " + std::to_string(pathId) + "."; + auto& carburettor = path->geometry.carburettor; + if (!carburettor.enabled) return "Intake path " + std::to_string(pathId) + " has no carburettor."; + if (!within(tune.mainJetFlowNumber, 50.0, 1'000.0)) return "A main jet is a 50 to a 1,000."; + if (!within(tune.pilotJetFlowNumber, 10.0, 150.0)) return "A pilot jet is a 10 to a 150."; + if (!within(tune.needleClipPosition, 1.0, 5.0)) return "The needle clip is in position 1 to 5."; + if (!within(tune.airScrewTurns, 0.0, 5.0)) return "The air screw is 0 to 5 turns out."; + carburettor.mainJetFlowNumber = tune.mainJetFlowNumber; + carburettor.pilotJetFlowNumber = tune.pilotJetFlowNumber; + carburettor.needleClipPosition = tune.needleClipPosition; + carburettor.airScrewTurns = tune.airScrewTurns; + mirrorSinglePath(config); return {}; } diff --git a/src/render/include/enginelab/render/DuctLayout3D.hpp b/src/render/include/enginelab/render/DuctLayout3D.hpp index 8df6fc16..6a05b8e1 100644 --- a/src/render/include/enginelab/render/DuctLayout3D.hpp +++ b/src/render/include/enginelab/render/DuctLayout3D.hpp @@ -61,8 +61,8 @@ struct DuctPiece final { /** * Where a turbocharger sits on the exhaust: bolted to the collector of the - * first path, the trunk resuming at its turbine outlet. The simulator puts - * its turbine on the same collector's outgoing face. + * first path it serves, the trunk resuming at its turbine outlet. The + * simulator puts its turbine on the same collector's outgoing face. * * The shaft runs along the crankshaft (+Z). Gas leaves the collector, bends * outwards and enters the turbine volute tangentially; it leaves through the @@ -70,6 +70,8 @@ struct DuctPiece final { */ struct TurboPlacement final { bool placed {}; + /** The turbocharger unit (ForcedInductionConfig::turbochargerCount). */ + std::uint32_t unit {}; std::uint32_t pathId {}; /** The collector it is bolted to. */ std::uint32_t collectorId {}; @@ -148,16 +150,18 @@ void appendSuperchargerHousing(Mesh& mesh, const SuperchargerPlacement& supercha * A pipe that must span more than its authored length is drawn straight and * therefore longer; tests measure how often that happens. * - * With `turbo` given and a turbocharger configured, the turbo is placed after - * the first path's collector (see TurboPlacement) and the pipes are routed - * clear of it. + * With `turbos` given and turbochargers configured, each unit an exhaust path + * feeds is placed after the collector of the first path it serves, the path + * each unit's turbine face sits on in the simulator (see TurboPlacement), and + * the pipes are routed clear of them. A unit no path feeds gets no gas in the + * simulator and is not drawn. */ [[nodiscard]] std::vector layoutExhaust(const EngineConfig& config, const std::vector& ports, float boreMm, const std::vector& avoid = {}, const std::vector& solids = {}, - TurboPlacement* turbo = nullptr); + std::vector* turbos = nullptr); /** The solids a placed turbo occupies (turbine housing centre, bearing housing, compressor), for the router and the route check. */ diff --git a/src/render/include/enginelab/render/EngineModel3D.hpp b/src/render/include/enginelab/render/EngineModel3D.hpp index d298393b..1182ad20 100644 --- a/src/render/include/enginelab/render/EngineModel3D.hpp +++ b/src/render/include/enginelab/render/EngineModel3D.hpp @@ -132,6 +132,8 @@ struct PartIdentity final { int cylinder { -1 }; /** Index in EngineModel3D::ducts(); -1 when not a duct. */ int duct { -1 }; + /** Index in EngineModel3D::turbos(); -1 when not a turbocharger. */ + int turbo { -1 }; }; /** @@ -162,9 +164,14 @@ class EngineModel3D final { /** Block, heads, crankcase, sump, liners, pulley and flywheel, as simple solids the ducts must stay out of. */ [[nodiscard]] const std::vector& solids() const noexcept { return solids_; } - /** The turbocharger as drawn; `placed` is false without one. Its solids - are at the end of solids(). */ - [[nodiscard]] const TurboPlacement& turbo() const noexcept { return turbo_; } + /** The turbochargers as drawn, one per unit an exhaust path feeds. Their + solids are at the end of solids(). */ + [[nodiscard]] const std::vector& turbos() const noexcept { return turbos_; } + /** The first turbocharger as drawn; `placed` is false without one. */ + [[nodiscard]] const TurboPlacement& turbo() const noexcept { + static const TurboPlacement none {}; + return turbos_.empty() ? none : turbos_.front(); + } /** Cylinder `index`'s injector: its part, nozzle tip and spray direction. */ [[nodiscard]] std::uint16_t injectorPart(std::size_t index) const noexcept { return index < cylinders_.size() ? cylinders_[index].injectorPart : std::uint16_t { 0xFFFFU }; @@ -277,9 +284,10 @@ class EngineModel3D final { std::vector intakePorts_; std::vector exhaustPorts_; std::vector ducts_; - TurboPlacement turbo_; - /** Turbine housing, compressor housing, turbine wheel, compressor wheel. */ - std::array turboParts_ { 0xFFFFU, 0xFFFFU, 0xFFFFU, 0xFFFFU }; + std::vector turbos_; + /** Per turbo: turbine housing, compressor housing, turbine wheel, + compressor wheel. */ + std::vector> turboParts_; SuperchargerPlacement supercharger_; /** Housing (with the charge pipe) and impeller. */ std::array superchargerParts_ { 0xFFFFU, 0xFFFFU }; diff --git a/src/render/src/DuctLayout3D.cpp b/src/render/src/DuctLayout3D.cpp index c172834f..036ba7ee 100644 --- a/src/render/src/DuctLayout3D.cpp +++ b/src/render/src/DuctLayout3D.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -146,6 +147,8 @@ struct Node final { /** A turbo is bolted to its outlet: the trunk resumes at `exitLane`, `exitZ`, past the turbine. */ bool turbo {}; + /** With `turbo`, its placement in the caller's list. */ + std::size_t turboSlot {}; Vec3 exitLane; float exitZ {}; }; @@ -422,11 +425,12 @@ void placeTurbo(const PathLayout& layout, Node& node, const TurboSpec& spec, Tur } void placePath(PathLayout& layout, const std::vector& ports, float clearance, const TurboSpec& spec, - TurboPlacement* turbo) { + std::vector* turbos) { auto& nodes = layout.nodes; for (const auto i : layout.order) { auto& node = nodes[i]; - if (node.firstJunction && node.turbo && turbo != nullptr) placeTurbo(layout, node, spec, *turbo); + if (node.firstJunction && node.turbo && turbos != nullptr) + placeTurbo(layout, node, spec, (*turbos)[node.turboSlot]); if (node.stack || node.firstJunction) continue; if (node.routed) { if (node.ins.empty()) { @@ -493,7 +497,7 @@ void placePath(PathLayout& layout, const std::vector& ports, float c node.lane = lanes > 0 ? laneSum * (1.0F / static_cast(lanes)) : Vec3 {}; node.zIn = z; node.zOut = z + node.drawLength; - if (node.turbo && turbo != nullptr) placeTurbo(layout, node, spec, *turbo); + if (node.turbo && turbos != nullptr) placeTurbo(layout, node, spec, (*turbos)[node.turboSlot]); } } @@ -631,7 +635,7 @@ void shapeComponent(DuctPiece& piece, const Node& node, std::vector points std::vector layoutExhaust(const EngineConfig& config, const std::vector& ports, float boreMm, const std::vector& avoid, const std::vector& solids, - TurboPlacement* turbo) { + std::vector* turbos) { const auto clearance = std::max(60.0F, 0.8F * boreMm); const RouteSettings routing; std::vector layouts(config.exhaustPaths.size()); @@ -710,11 +714,17 @@ std::vector layoutExhaust(const EngineConfig& config, const std::vect } } - // The turbo goes on the first junction where every primary of a path has - // met, in the first path that has one. + // Each turbocharger goes on the first junction where every primary of a + // path has met, in the first path it serves: the simulator gives path p + // its own unit, or unit p modulo the count. const auto spec = turboSpec(config); - if (turbo != nullptr) *turbo = {}; - for (std::size_t p = 0; turbo != nullptr && spec.wanted && p < layouts.size(); ++p) { + if (turbos != nullptr) turbos->clear(); + const auto units = std::clamp(config.forcedInduction.turbochargerCount, 1U, maximumTurbochargers); + std::array unitPlaced {}; + for (std::size_t p = 0; turbos != nullptr && spec.wanted && p < layouts.size(); ++p) { + const auto authored = layouts[p].path->turbocharger; + const auto unit = authored >= 0 ? std::min(static_cast(authored), units - 1U) : p % units; + if (unitPlaced[unit]) continue; auto& nodes = layouts[p].nodes; std::size_t total = 0; for (const auto& node : nodes) total += node.cylinders.size(); @@ -728,10 +738,14 @@ std::vector layoutExhaust(const EngineConfig& config, const std::vect || fed[i] < total) continue; node.turbo = true; + node.turboSlot = turbos->size(); + TurboPlacement placement; + placement.unit = static_cast(unit); + turbos->push_back(std::move(placement)); found = true; break; } - if (found) break; + if (found) unitPlaced[unit] = true; } for (std::size_t a = 0; a < junctions.size(); ++a) { auto& node = layouts[junctions[a].path].nodes[junctions[a].node]; @@ -760,7 +774,7 @@ std::vector layoutExhaust(const EngineConfig& config, const std::vect std::vector routes; for (std::size_t l = 0; l < layouts.size(); ++l) { auto& layout = layouts[l]; - placePath(layout, ports, clearance, spec, turbo); + placePath(layout, ports, clearance, spec, turbos); assignSlots(layout, ports); auto& nodes = layout.nodes; const auto pathId = layout.path->id; @@ -872,10 +886,12 @@ std::vector layoutExhaust(const EngineConfig& config, const std::vect if (piece.radii.size() == piece.centreline.size() && piece.centreline.size() >= 2U) obstacles.push_back({ piece.centreline, piece.radii }); auto blocks = solids; - if (turbo != nullptr && turbo->placed) { - obstacles.push_back({ turbo->scroll, turbo->scrollRadii }); - for (const auto& solid : turboSolids(*turbo)) blocks.push_back(solid); - } + if (turbos != nullptr) + for (const auto& turbo : *turbos) { + if (!turbo.placed) continue; + obstacles.push_back({ turbo.scroll, turbo.scrollRadii }); + for (const auto& solid : turboSolids(turbo)) blocks.push_back(solid); + } relaxRoutes(routes, obstacles, blocks, routing); for (std::size_t r = 0; r < pending.size(); ++r) { auto& item = pending[r]; @@ -989,6 +1005,17 @@ void appendUpstream(std::vector& pieces, std::uint32_t pathId, const appendCylinder(bodies.mesh, frame, 0.62F * throttle, 0.62F * throttle, bodyLength, 28); appendTorus(bodies.mesh, frame * Mat4::translation({ 0.0F, 0.15F * throttle, 0.0F }), 0.66F * throttle, 0.06F * throttle, 28, 8); + if (geometry.carburettor.enabled) { + // A slide carburettor: the slide's tower above the bore, the + // float bowl under it. + auto up = -downAxis - direction * dot(-downAxis, direction); + up = length(up) > 1.0e-3F ? normalise(up) : normalise(cross(direction, zAxis)); + const auto middle = face + direction * (0.5F * bodyLength); + appendCylinder(bodies.mesh, Mat4::frameAlongY(middle + up * (0.45F * throttle), up), 0.42F * throttle, + 0.42F * throttle, 0.75F * throttle, 20); + appendCylinder(bodies.mesh, Mat4::frameAlongY(middle - up * (0.45F * throttle), -up), 0.58F * throttle, + 0.58F * throttle, 0.70F * throttle, 24); + } mouths.push_back(face + direction * bodyLength); mouthCentre += mouths.back(); } diff --git a/src/render/src/EngineModel3D.cpp b/src/render/src/EngineModel3D.cpp index 7d1977a3..3ea442ff 100644 --- a/src/render/src/EngineModel3D.cpp +++ b/src/render/src/EngineModel3D.cpp @@ -526,7 +526,14 @@ void EngineModel3D::buildDucts() { // The injector. A port injector sits in the runner just out of the // head, above it, spraying at the back of the intake valves; a direct // injector sits in the head on the intake side, under the valves, - // spraying across the chamber. + // spraying across the chamber. A cylinder fed by a carburettor has + // none: its fuel comes with the air. + const auto carburetted = std::any_of(config_.intakePaths.begin(), config_.intakePaths.end(), + [&config](const IntakePathConfig& path) { + return path.geometry.carburettor.enabled + && std::find(path.cylinderIds.begin(), path.cylinderIds.end(), config.id) != path.cylinderIds.end(); + }); + if (carburetted) continue; const auto direct = config_.injection.mode == InjectionMode::direct; auto& drawn = cylinders_[i]; drawn.injectorTip = direct ? cylinder.deckCentre + axis * (0.10F * b) - side * (0.42F * b) @@ -588,7 +595,7 @@ void EngineModel3D::buildDucts() { } avoid.push_back(boxBetween(low, high)); } - add(layoutExhaust(config_, exhaustPorts_, bore, intakeTubes, avoid, &turbo_)); + add(layoutExhaust(config_, exhaustPorts_, bore, intakeTubes, avoid, &turbos_)); buildTurbo(); // A plume at every outlet, lit only by an afterfire. @@ -617,20 +624,25 @@ void EngineModel3D::buildDucts() { } void EngineModel3D::buildTurbo() { - if (!turbo_.placed) return; + // A unit the layout found no collector for is not drawn. + std::erase_if(turbos_, [](const TurboPlacement& turbo) { return !turbo.placed; }); + turboParts_.clear(); const auto& forced = config_.forcedInduction; - Mesh turbineHousing, compressorHousing, turbineWheel, compressorWheel; - appendTurboHousings(turbineHousing, compressorHousing, turbo_); // Without a blade count, a typical wheel: it is only drawn. const auto turbineBlades = forced.turbineBladeCount > 0U ? static_cast(forced.turbineBladeCount) : 10; const auto compressorBlades = forced.compressorBladeCount > 0U ? static_cast(forced.compressorBladeCount) : 7; - appendTurboWheel(turbineWheel, turbo_.turbineWheelRadius, 0.8F * turbo_.turbineWidth, turbineBlades, 1.0F); - appendTurboWheel(compressorWheel, turbo_.compressorWheelRadius, 0.7F * turbo_.compressorWidth, compressorBlades, -1.0F); - turboParts_[0] = addPart(std::move(turbineHousing), SceneMaterial::turboHousing, layerExhaust); - turboParts_[1] = addPart(std::move(compressorHousing), SceneMaterial::turboHousing, layerIntake); - turboParts_[2] = addPart(std::move(turbineWheel), SceneMaterial::forged, layerExhaust); - turboParts_[3] = addPart(std::move(compressorWheel), SceneMaterial::steel, layerIntake); - for (const auto& solid : turboSolids(turbo_)) solids_.push_back(solid); + for (const auto& turbo : turbos_) { + Mesh turbineHousing, compressorHousing, turbineWheel, compressorWheel; + appendTurboHousings(turbineHousing, compressorHousing, turbo); + appendTurboWheel(turbineWheel, turbo.turbineWheelRadius, 0.8F * turbo.turbineWidth, turbineBlades, 1.0F); + appendTurboWheel(compressorWheel, turbo.compressorWheelRadius, 0.7F * turbo.compressorWidth, + compressorBlades, -1.0F); + turboParts_.push_back({ addPart(std::move(turbineHousing), SceneMaterial::turboHousing, layerExhaust), + addPart(std::move(compressorHousing), SceneMaterial::turboHousing, layerIntake), + addPart(std::move(turbineWheel), SceneMaterial::forged, layerExhaust), + addPart(std::move(compressorWheel), SceneMaterial::steel, layerIntake) }); + for (const auto& solid : turboSolids(turbo)) solids_.push_back(solid); + } } void EngineModel3D::buildSupercharger() { @@ -844,14 +856,17 @@ void EngineModel3D::pose(const ScenePoseInput& input, std::vector out[cylinder.runnerPart].intensity = static_cast(std::clamp(intakeLift / maximumLift, 0.0, 1.0)); } for (const auto part : outletFlameParts_) out[part].intensity = std::clamp(input.afterfire, 0.0F, 1.0F); - if (turbo_.placed) { - // Both wheels turn with the shaft, about its axis. - const auto shaft = static_cast(std::fmod(input.turboShaftDegrees, 360.0) * (pi / 180.0F)); - const auto axis = turbo_.axis; - const auto x = turbo_.radial * std::cos(shaft) + cross(axis, turbo_.radial) * std::sin(shaft); + for (std::size_t index = 0; index < turbos_.size(); ++index) { + // Both wheels turn with the shaft, about its axis; each unit a little + // out of phase with the next, so a pair does not look geared together. + const auto& turbo = turbos_[index]; + const auto shaft = static_cast(std::fmod(input.turboShaftDegrees + 47.0 * static_cast(turbo.unit), + 360.0) * (pi / 180.0F)); + const auto axis = turbo.axis; + const auto x = turbo.radial * std::cos(shaft) + cross(axis, turbo.radial) * std::sin(shaft); const auto z = cross(x, axis); - out[turboParts_[2]].transform = Mat4::basis(x, axis, z, turbo_.turbineCentre); - out[turboParts_[3]].transform = Mat4::basis(x, axis, z, turbo_.compressorCentre); + out[turboParts_[index][2]].transform = Mat4::basis(x, axis, z, turbo.turbineCentre); + out[turboParts_[index][3]].transform = Mat4::basis(x, axis, z, turbo.compressorCentre); } if (supercharger_.placed) { // Geared to the crank. @@ -918,7 +933,9 @@ PartIdentity EngineModel3D::identify(std::uint16_t part) const noexcept { } if (std::find(outletFlameParts_.begin(), outletFlameParts_.end(), part) != outletFlameParts_.end()) return { PartRole::afterfire, -1, -1 }; - if (std::find(turboParts_.begin(), turboParts_.end(), part) != turboParts_.end()) return { PartRole::turbo, -1, -1 }; + for (std::size_t index = 0; index < turboParts_.size(); ++index) + if (std::find(turboParts_[index].begin(), turboParts_[index].end(), part) != turboParts_[index].end()) + return { PartRole::turbo, -1, -1, static_cast(index) }; if (std::find(superchargerParts_.begin(), superchargerParts_.end(), part) != superchargerParts_.end()) return { PartRole::supercharger, -1, -1 }; if (std::find(gearboxParts_.begin(), gearboxParts_.end(), part) != gearboxParts_.end() diff --git a/src/runtime/src/RealtimeCapacityProbe.cpp b/src/runtime/src/RealtimeCapacityProbe.cpp index 51803760..a570cd95 100644 --- a/src/runtime/src/RealtimeCapacityProbe.cpp +++ b/src/runtime/src/RealtimeCapacityProbe.cpp @@ -69,6 +69,14 @@ RealtimeCapacity measureRealtimeCapacity(const EngineConfig& config, double meas runtime->setDynoHoldEnabled(true); runtime->setDynoHoldRpm(result.holdRpm); runtime->start(); + // Cranked with the throttle shut until it runs, as a carburetted + // engine is: at full throttle and cranking speed its venturi draws + // nearly nothing and its pilot is dead, so it never catches. + while (!cancelled() && Clock::now() < deadline) { + const auto state = runtime->snapshot(); + if (state.rpm > 0.8 * config.idleRpm || state.simulationTimeSeconds > 5.0) break; + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } runtime->startDyno(); RealtimeHoldJudge judge(result.holdRpm); diff --git a/tests/EngineDesignTests.cpp b/tests/EngineDesignTests.cpp index 26c37313..f2226335 100644 --- a/tests/EngineDesignTests.cpp +++ b/tests/EngineDesignTests.cpp @@ -633,6 +633,23 @@ void realtimeIsChecked() { std::cout << " designed single: physics at " << singleCapacity.factor << " x real time" << (singleCapacity.error.empty() ? "" : " (" + singleCapacity.error + ")") << '\n'; require(singleCapacity.error.empty() && singleCapacity.factor > 0.0, "a single holds its measuring speed"); + + // A carburetted two-stroke never catches cranked at full throttle (its + // pilot is dead there and its venturi draws nothing at cranking speed): + // the probe cranks it shut first, as a rider does. + EngineSpec twin; + twin.name = "Two-stroke Twin"; + twin.cycle = EngineCycle::twoStroke; + twin.use = EngineUse::sportMotorcycle; + twin.cylinderCount = 2; + twin.boreMm = 64.0; + twin.strokeMm = 54.0; + const auto stinger = designCatalogueEngine(twin, ENGINELAB_CATALOG_ROOT); + require(stinger.config.has_value(), "a two-stroke twin designs"); + const auto twinCapacity = measureRealtimeCapacity(*stinger.config, 1.0, 60.0); + std::cout << " designed two-stroke twin: physics at " << twinCapacity.factor << " x real time" + << (twinCapacity.error.empty() ? "" : " (" + twinCapacity.error + ")") << '\n'; + require(twinCapacity.error.empty() && twinCapacity.factor > 0.0, "a carburetted two-stroke holds its measuring speed"); } } // namespace diff --git a/tests/EngineModel3DTests.cpp b/tests/EngineModel3DTests.cpp index 154cf405..a287e76e 100644 --- a/tests/EngineModel3DTests.cpp +++ b/tests/EngineModel3DTests.cpp @@ -24,6 +24,7 @@ #include #include #include +#include namespace { using namespace enginelab; @@ -1163,7 +1164,8 @@ int main() { // A turbo fitted to an engine without forced induction is drawn the same // way where one exhaust path takes every cylinder through a collector. // Measured 2026-10-13: the Aircooled's two banks get a turbine each in the - // simulation and the view draws the first; the LS3's X-pipe and the Big + // simulation and the view draws the first (both since 2026-10-23, with two + // units: below); the LS3's X-pipe and the Big // Twin's two pipes have no collector, so the turbine sits at the outlets // and is not drawn. auto turbosFitted = 0, turbosFittedDrawn = 0; @@ -1182,6 +1184,95 @@ int main() { require(turbosFitted >= 10 && turbosFittedDrawn >= turbosFitted - 2, "a fitted turbo is drawn on all but two of the engines without one (" + std::to_string(turbosFittedDrawn) + " of " + std::to_string(turbosFitted) + ")"); + // Two turbochargers on the Aircooled's two banks: each unit sits on its + // own bank's collector, where the simulator puts its turbine, and both + // are drawn and clickable. With one unit, one turbo. + { + const auto selected = selectSingleEngineCatalogEntry(catalogue.entries, "Aircooled"); + require(static_cast(selected), "the catalogue has the Aircooled flat six"); + const auto& flatSix = selected.entry->config; + const EngineModel3D single(withTurbocharger(flatSix)); + const EngineModel3D twin(withTurbochargerCount(withTurbocharger(flatSix), 2U)); + const auto& turbos = twin.turbos(); + std::cout << "EngineModel3D: Aircooled with two turbochargers draws " << turbos.size() << '\n'; + require(single.turbos().size() == 1U, "one turbocharger is drawn once"); + require(turbos.size() == 2U && turbos[0].placed && turbos[1].placed + && turbos[0].unit != turbos[1].unit && turbos[0].pathId != turbos[1].pathId + && length(turbos[0].turbineCentre - turbos[1].turbineCentre) > 50.0F, + "two turbochargers on two banks are both drawn, each on its own bank"); + auto clickable = 0; + for (std::uint16_t part = 0; part < twin.parts().size(); ++part) + if (const auto identity = twin.identify(part); identity.role == PartRole::turbo && identity.turbo == 1) + ++clickable; + require(clickable == 4, "the second turbocharger's housings and wheels identify as it"); + } + // The Aircooled's two 3-into-1 banks joined into one 6-into-1: each + // cylinder of the second bank gets a copy of the first's primary into the + // first's collector, the second path goes, and the engine is drawn, bound + // to its solver and idles. Pipes that meet no other, or a turbocharger + // that would lose its path, are refused and change nothing. + { + const auto selected = selectSingleEngineCatalogEntry(catalogue.entries, "Aircooled"); + require(static_cast(selected), "the catalogue has the Aircooled flat six"); + const auto& flatSix = selected.entry->config; + require(flatSix.exhaustPaths.size() == 2U, "the Aircooled has one exhaust path per bank"); + const auto keptId = flatSix.exhaustPaths[0].id; + const auto joinedId = flatSix.exhaustPaths[1].id; + auto joined = flatSix; + const auto error = joinExhaustPaths(joined, keptId, joinedId); + require(error.empty(), "the Aircooled's banks join: " + error); + const auto invalid = validateEngineConfig(joined); + require(!invalid, "the joined Aircooled is valid: " + invalid.value_or("")); + require(joined.exhaustPaths.size() == 1U && joined.exhaustPaths.front().cylinderIds.size() == 6U + && joined.exhaustPaths.front().network + && joined.exhaustPaths.front().network->cylinderConnections.size() == 6U, + "one path with six primaries"); + std::size_t intoJunction = 0; + const auto& network = *joined.exhaustPaths.front().network; + const auto& original = *flatSix.exhaustPaths.front().network; + std::unordered_map incoming; + for (const auto& connection : network.connections) ++incoming[connection.toComponentId]; + for (const auto& [id, count] : incoming) intoJunction = std::max(intoJunction, count); + require(intoJunction == 6U && network.components.size() > original.components.size(), + "six pipes meet at one collector"); + const EngineModel3D drawn(joined); + auto exhaustPieces = 0; + for (const auto& duct : drawn.ducts()) + if (duct.kind == DuctKind::exhaustComponent || duct.kind == DuctKind::exhaustFeeder) { + require(duct.pathId == keptId, "every drawn exhaust piece is the joined path's"); + ++exhaustPieces; + } + require(exhaustPieces > 0, "the joined exhaust is drawn"); + checkGasFieldBinding(drawn); + Bench bench(joined); + EngineState state; + for (auto frame = 0; frame < 4 * 240; ++frame) { + EngineControls controls; + controls.ignitionEnabled = true; + controls.starterEngaged = frame < 360; + state = bench.simulator->step(1.0 / 240.0, controls).state; + } + std::cout << "EngineModel3D: Aircooled 6-into-1 idles at " << state.rpm << " rpm (idle " + << joined.idleRpm << ")\n"; + require(state.rpm > 0.5 * joined.idleRpm, "the 6-into-1 Aircooled idles"); + + auto same = flatSix; + require(!joinExhaustPaths(same, keptId, keptId).empty(), "a path is not joined to itself"); + auto twinTurbo = withTurbochargerCount(withTurbocharger(flatSix), 2U); + const auto before = twinTurbo.exhaustPaths.size(); + require(!joinExhaustPaths(twinTurbo, keptId, joinedId).empty() && twinTurbo.exhaustPaths.size() == before, + "two turbochargers keep their two paths"); + auto singleTurbo = withTurbocharger(flatSix); + require(joinExhaustPaths(singleTurbo, keptId, joinedId).empty() && !validateEngineConfig(singleTurbo), + "one turbocharger takes the joined path"); + const auto bigTwin = selectSingleEngineCatalogEntry(catalogue.entries, "Big Twin"); + require(static_cast(bigTwin) && bigTwin.entry->config.exhaustPaths.size() == 2U, + "the catalogue has the Big Twin and its two pipes"); + auto pipes = bigTwin.entry->config; + require(!joinExhaustPaths(pipes, pipes.exhaustPaths[0].id, pipes.exhaustPaths[1].id).empty() + && pipes.exhaustPaths.size() == 2U, + "a pipe that meets no other has no collector to join into"); + } const auto withGears = std::count_if(catalogue.entries.begin(), catalogue.entries.end(), [](const auto& entry) { return entry.config.transmission.gearRatios.size() >= 2U; }); require(gearboxesDrawn == static_cast(withGears) && gearboxesDrawn >= 10, diff --git a/tests/LiveChangeTests.cpp b/tests/LiveChangeTests.cpp index 10f14c11..65b226ed 100644 --- a/tests/LiveChangeTests.cpp +++ b/tests/LiveChangeTests.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -738,11 +739,77 @@ void rodChangeSettlesLikeARestart() { } } +/** A compression ratio typed in the inspector (setCompressionRatio()): the + * running engine settles like one built with it, and more torque than at its + * own ratio. An engine authored by chamber geometry moves its deck and stays + * valid; a ratio its chamber cannot reach is refused, changing nothing. */ +void compressionRatioChangeSettlesLikeARestart() { + const auto original = catalogueEngine("K20A"); + const auto before = original.cylinders.front().compressionRatio; + auto raised = original; + const auto error = setCompressionRatio(raised, before + 1.5); + require(error.empty(), "K20A: " + error); + const auto invalid = validateEngineConfig(raised); + require(!invalid, "K20A: " + invalid.value_or("")); + + auto chamber = original; + for (auto& cylinder : chamber.cylinders) { + const auto areaMm2 = std::numbers::pi * cylinder.boreMm * cylinder.boreMm * 0.25; + const auto clearanceCc = areaMm2 * cylinder.strokeMm / 1'000.0 / (cylinder.compressionRatio - 1.0); + cylinder.headGasketThicknessMm = 1.0; + cylinder.compressionHeightMm = 30.0; + // A 0.5 mm piston-to-deck gap; the head chamber holds the rest. + cylinder.deckHeightMm = cylinder.strokeMm * 0.5 + cylinder.connectingRodMm + 30.0 + 0.5; + cylinder.headChamberVolumeCc = clearanceCc - areaMm2 * 1.5 / 1'000.0; + } + require(!validateEngineConfig(chamber), "the chamber-geometry K20A is valid"); + const auto deckBefore = chamber.cylinders.front().deckHeightMm; + auto decked = chamber; + require(setCompressionRatio(decked, before + 0.5).empty() && !validateEngineConfig(decked) + && decked.cylinders.front().deckHeightMm < deckBefore, + "a chamber-geometry engine takes a higher ratio by lowering its deck, and stays valid"); + auto unreachable = chamber; + require(!setCompressionRatio(unreachable, 20.0).empty() + && unreachable.cylinders.front().deckHeightMm == deckBefore + && unreachable.cylinders.front().compressionRatio == chamber.cylinders.front().compressionRatio, + "a ratio the chamber cannot reach is refused and changes nothing"); + + Rig built(raised); + Rig instant(original); + Rig untouched(original); + constexpr auto swapFrame = 5 * 240; + constexpr auto frames = 12 * 240; + constexpr auto settled = frames - 3 * 240; + std::array rigs { &built, &instant, &untouched }; + std::array rpm {}; + std::array brakeSum {}; + for (auto frame = 0; frame < frames; ++frame) { + const auto t = frame * frameSeconds; + if (frame == swapFrame) + require(instant.simulator->beginCylinderResize(raised, 0.0), "K20A: compression ratio change"); + for (std::size_t index = 0; index < rigs.size(); ++index) { + const auto controls = heldAt(t, rpm[index], original); + rpm[index] = rigs[index]->simulator->step(frameSeconds, controls).state.rpm; + if (frame >= settled) brakeSum[index] += controls.dynamometerTorqueNm; + } + } + const auto mean = [&brakeSum](std::size_t index) { return brakeSum[index] / (frames - settled); }; + std::cout << " K20A: compression " << before << " -> " << before + 1.5 << "; settled brake torque, Nm: built " + << mean(0) << ", changed " << mean(1) << ", untouched " << mean(2) << "\n"; + require(instant.simulator->config().cylinders.back().compressionRatio == before + 1.5, + "K20A: the running engine takes the new ratio"); + const auto effect = mean(0) - mean(2); + require(effect > 0.01 * mean(2), "K20A: a higher compression ratio gives more torque"); + require(std::abs(mean(1) - mean(0)) <= std::max(0.25 * effect, 0.005 * mean(0)), + "K20A: the ratio changed live, the engine settles like one built that way"); +} + void liveCylinderResizeRegression() { identicalResizeIsInvisible(); resizeHappensAtGasExchangeTdc(); resizeSettlesLikeARestart(); rodChangeSettlesLikeARestart(); + compressionRatioChangeSettlesLikeARestart(); resizeAtIdleKeepsEveryEngineRunning(); } diff --git a/tests/LiveIntakeTests.cpp b/tests/LiveIntakeTests.cpp index 8688978f..466c143f 100644 --- a/tests/LiveIntakeTests.cpp +++ b/tests/LiveIntakeTests.cpp @@ -6,6 +6,7 @@ // deterministically. #include #include +#include #include #include #include @@ -527,8 +528,89 @@ void intakePartsResize() { "the running engine takes the inspector's intake edits"); } +/** The 3-D view's carburettor jetting: read and written where the engine + * meters from, refused without a carburettor or out of range, and taken by + * the running engine (a bigger main jet richens full throttle). */ +void carburettorIsTunedLive() { + EngineSpec spec; + spec.name = "Two-stroke Twin"; + spec.cycle = EngineCycle::twoStroke; + spec.use = EngineUse::sportMotorcycle; + spec.cylinderCount = 2; + spec.boreMm = 64.0; + spec.strokeMm = 54.0; + const auto designed = designCatalogueEngine(spec, ENGINELAB_CATALOG_ROOT); + require(designed.config.has_value(), "the two-stroke twin is designed"); + auto config = *designed.config; + normaliseEngineConfig(config); + const auto pathId = config.intakePaths.front().id; + const auto& fitted = config.intakePaths.front().geometry.carburettor; + const auto tune = carburettorTune(config, pathId); + require(tune && tune->mainJetFlowNumber == fitted.mainJetFlowNumber + && tune->pilotJetFlowNumber == fitted.pilotJetFlowNumber + && tune->needleClipPosition == fitted.needleClipPosition + && tune->airScrewTurns == fitted.airScrewTurns, + "a designed two-stroke's carburettor reads its jetting"); + const auto injected = catalogueEngine("Yamaha CP2"); + auto untouchedCp2 = injected; + require(!carburettorTune(injected, injected.intakePaths.front().id) + && !tuneCarburettor(untouchedCp2, injected.intakePaths.front().id, *tune).empty(), + "an injected engine has no carburettor to tune"); + + auto richer = config; + auto bigger = *tune; + bigger.mainJetFlowNumber *= 1.5; + // One carburettor per cylinder: the inspector jets them all alike. + require(config.intakePaths.size() == config.cylinders.size(), "a two-stroke has a carburettor per cylinder"); + for (const auto& path : config.intakePaths) + require(tuneCarburettor(richer, path.id, bigger).empty(), "a main jet in range is taken"); + auto single = richer; + single.intakePaths.resize(1); + require(tuneCarburettor(single, pathId, *tune).empty() + && single.intake.carburettor.mainJetFlowNumber == tune->mainJetFlowNumber, + "a single path keeps the engine-wide intake mirrored"); + require(!validateEngineConfig(richer), "the jetted engine is valid"); + const auto scope = engineEditScope(config, richer); + require(scope.intake && !scope.other, "a jetting edit is an intake edit only"); + auto refused = config; + auto clip = *tune; + clip.needleClipPosition = 7.0; + require(!tuneCarburettor(refused, pathId, clip).empty() && !engineEditScope(config, refused).any(), + "a clip out of range is refused and changes nothing"); + + Rig untouched(config); + Rig jetted(config); + constexpr auto swapFrame = 4 * 240; + constexpr auto frames = 8 * 240; + constexpr auto measured = frames - 2 * 240; + std::array rigs { &untouched, &jetted }; + std::array rpm {}; + std::array lambda {}; + for (auto frame = 0; frame < frames; ++frame) { + const auto t = frame * frameSeconds; + if (frame == swapFrame) swapIntake(jetted, richer); + for (std::size_t index = 0; index < rigs.size(); ++index) { + // A designed two-stroke catches after ~3 s of cranking (journal + // 2026-10-23): the starter turns it to 3.5 s, full throttle at 4. + auto controls = heldAt(t - 2.0, rpm[index], config); + controls.starterEngaged = t < 3.5; + const auto state = rigs[index]->simulator->step(frameSeconds, controls).state; + rpm[index] = state.rpm; + if (frame >= measured) lambda[index] += state.lambda / (frames - measured); + } + } + std::cout << " designed two-stroke twin at full throttle: lambda " << lambda[0] << " on a " + << tune->mainJetFlowNumber << " main jet, " << lambda[1] << " on a " << bigger.mainJetFlowNumber + << " fitted live; " << rpm[0] << " and " << rpm[1] << " rpm\n"; + require(jetted.simulator->config().intakePaths.back().geometry.carburettor.mainJetFlowNumber + == bigger.mainJetFlowNumber, + "the running engine takes the new jet"); + require(lambda[1] < lambda[0] - 0.05, "a bigger main jet richens full throttle"); +} + void liveIntakeRegression() { intakePartsResize(); + carburettorIsTunedLive(); plenumKeepsItsState(); editScopeSortsIntakeEdits(); identicalIntakeIsInvisible();