Add American V8 preset, carb fuel transport, and adaptive desktop audio

This commit is contained in:
2026-10-23 12:43:57 +02:00
parent 7a5c77c1c8
commit 85d4fb828a
36 changed files with 805 additions and 144 deletions

63
CARB_PROGRESS.md Normal file
View File

@@ -0,0 +1,63 @@
# Carburetor work in progress
Resumed on 2026-10-23 to fix throttle-release crashes and remove scheduled pops.
All changes are uncommitted. The historical results below predate that revision.
Current revision: bounded slide aperture to avoid negative roundoff near its
seat; removed the hardcoded firing intervals and forced late spark. Exhaust
combustion was subsequently removed at the user's request due to lag and limited
audible benefit. Fuel remains transported through pipes without reacting.
Cylinder combustion consumes air in proportion to burned fuel.
Warm-running startup now seeds fuel in the intake, crankcase and cylinder.
Pilot effective diameter is .40 mm with .08 base/.3 maximum idle slide opening.
The revised transient passes: peak 12954 RPM, idle 2583 RPM, peak audio .0985.
## Implemented
- Added `Carburetor`, with effective pilot, needle and main jet restrictions.
- Jet flow uses simulated air mass flow, air density, venturi dynamic pressure,
static pressure drop, fuel density, discharge coefficients and float-bowl
supply. It is not a cylinder-side target-AFR calculation.
- Added wall-film deposition and evaporation during throttle transients.
- Added fuel as a conserved transported species to pipe cells, boundaries,
0-D volumes, crankcase, cylinder and turbo internal transfers.
- Carb fuel enters the intake plenum, then moves through the reed, crankcase,
transfer pipe and cylinder with the simulated gas flow.
- Cylinder heat release is limited by transported fuel, trapped oxygen and a
basic 7:1--24:1 flammability window. Rich excess remains unburned.
- Disabled electronic overrun fuel cut for the two-stroke. A deterministic
sparse late-fire cadence overlaps exhaust-port opening to make separated
coast-down bangs through the pressure/audio solver; no sound sample is mixed.
- Added fuel conservation and downstream transport tests, carb bounds/cadence
checks, transient firing-count checks, benchmark AFR columns and desktop carb
labeling.
## Last verified state
- Before the final throttle-slide geometry change, the entire 10-test CTest
suite passed.
- That transient reached about 15,954 RPM, recovered to about 3,151 RPM, and
produced 11 late firings in the first second after release with no clipping
or pipe positivity repairs.
- After replacing the old butterfly-area formula with a circular-segment slide
aperture and reducing the idle slide calibration, the project and desktop
rebuilt successfully and `fluidsim_two_stroke_physics` passed.
## Current task / next step
The `fluidsim_two_stroke_regression` run after the slide-area change was stopped
mid-run when work was paused. Run it first, then run full CTest. If idle recovery,
mixture, or overrun firing count moved, tune `idle_base_opening`,
`idle_max_opening`, and the effective jet dimensions in `Carburetor.hpp`.
Re-run `fluidsim_two_stroke_benchmark` afterward and update the YZ125 verification
numbers if the slide geometry changed them.
The most relevant files are:
- `include/FluidSim/Carburetor.hpp`
- `src/carburetor.cpp`
- `src/two_stroke_scenario.cpp`
- `src/two_stroke_cylinder.cpp`
- `include/FluidSim/Fluid.hpp`
- `tests/two_stroke_physics.cpp`
- `tests/two_stroke_regression.cpp`

View File

@@ -5,6 +5,7 @@ option(FLUIDSIM_BUILD_DESKTOP "Build the SFML windowed simulator with keyboard c
set(FLUIDSIM_FONT_FILE "" CACHE FILEPATH "Optional font file copied beside the desktop executable") set(FLUIDSIM_FONT_FILE "" CACHE FILEPATH "Optional font file copied beside the desktop executable")
add_library(fluidsim_core add_library(fluidsim_core
src/carburetor.cpp
src/ecu.cpp src/ecu.cpp
src/turbocharger.cpp src/turbocharger.cpp
src/engine.cpp src/engine.cpp

106
README.md
View File

@@ -144,6 +144,59 @@ physical cell diameter and length. Compare exhausts with `--exhaust racing`
this option. See [YZ125 verification](verification/YZ125_BASELINE.md) for the this option. See [YZ125 verification](verification/YZ125_BASELINE.md) for the
geometry, assumptions, speed sweep and remaining unloaded-RPM limitation. geometry, assumptions, speed sweep and remaining unloaded-RPM limitation.
## 1980s American V8
Choose **1980s American 5.0 V8** in the desktop menu, or run:
```sh
./build-desktop/fluidsim_desktop --scenario v8 --scene field
```
This is an estimated naturally aspirated 302-class cross-plane V8: eight
101.6 x 76.2 mm cylinders, 7:1 compression, mild pushrod-style valve timing,
1-5-4-2-6-3-7-8 firing order, 750 RPM idle target and 7000 RPM limiter
(6750 RPM recovery). Crank/flywheel inertia is 1.1 kg m², versus the original
.28. Eight unequal .60--.78 m headers feed two independent 1.5-litre bank
collectors: cylinders 1--4 and 5--8. Each collector has its own 50 mm outlet
pipe (1.8 / 1.95 m), and each outlet has a separate spatial audio emitter. A
nominal 3000 kg five-speed car uses the existing clutch/gear controls.
The V8 clutch has 1100 Nm capacity to shorten launch slip with the heavy
flywheel and car; engagement remains progressive rather than forcibly locking.
Final drive is approximately 3.624:1 (6.16 / 1.7), making gearing 1.7x taller
than the previous setup: 1.7x road speed at the same RPM and selected gear.
Throttle opening uses a 65 ms response and 2.3 travels/second limit; closing
uses 160 ms and 1.4 travels/second for a slower release.
The V8 uses the shared ideal EFI fuel dose with ECU throttle/idle control,
ignition, overrun fuel cut and rev limiting. Its earlier carb setup has been
removed. Independent exhaust banks retain their uneven cross-plane pulse
spacing rather than blending both banks in a shared collector. Numerical
diffusion in the pipe solver and audio filtering still limit high-frequency rasp.
Dimensions and ignition/cam calibration are design estimates;
the output is not a validated period engine dyno curve.
The intake uses one 70 mm inlet duct, one 65 mm throttle body and a shared
4-litre plenum feeding eight 38 mm diameter, 12 cm runners (previously 30 cm).
The revised mesh has 19 pipes and 190 cells, down from 310. Runner cells are
6 cm long and exhaust cells approximately 5--6.5 cm long. This coarsening trades
some high-frequency resolution for more real-time headroom.
The earlier lift-off slowdown came from the CFL timestep falling below the
22.7 microsecond audio interval: the whole fluid network advanced almost twice
per sample, doubling boundary solves and cell updates. The revised 16-second
rev/coast benchmark stayed at one substep per sample and took .51--.57 wall
seconds per simulated second here, including coast-down (previously up to
1.28). It returned to 759 RPM with finite audio and zero pipe positivity
repairs. The CFL limit remains enforced; other operating points may still
require subdivisions. The desktop retains the 4x playback gain.
Benchmark or capture with:
```sh
./build-desktop/fluidsim_scenario_benchmark 8 transient v8
./build-desktop/fluidsim_scenario_benchmark 20 coast v8
./build-desktop/fluidsim_audio_capture --scenario v8 --seconds 6 --throttle .3 --out /tmp/v8
```
## Twin with a VZ21-size turbo ## Twin with a VZ21-size turbo
Choose **600 cc twin + VZ21-size turbo** in the startup menu, or bypass it: Choose **600 cc twin + VZ21-size turbo** in the startup menu, or bypass it:
@@ -228,26 +281,50 @@ thermodynamics, crankcase and atmosphere state, profiled-pipe geometry,
inertive and closed boundaries, gearing and clutch momentum transfer, finite inertive and closed boundaries, gearing and clutch momentum transfer, finite
runs for every scenario, and finite audio/reverb output. runs for every scenario, and finite audio/reverb output.
## ECU and throttle control ## Fuel and throttle control
Desktop playback has automatic gain enabled by default for every scenario.
Press **G** to toggle it; the dashboard shows its state. It tracks stereo RMS
and peaks after DC removal, lifting quiet idle audio gradually (1.5-second
gain-rise time constant) and reducing gain faster for loud revs (30 ms).
Extra gain is capped at 12x above the existing fixed 4x playback gain, with
linked stereo peak protection. Silence does not request additional boost and
underruns freeze the detector. Disabling smoothly returns to fixed playback
gain. Simulation pressures and WAV capture levels are unaffected.
Both desktop engines use `Ecu` (`include/FluidSim/Ecu.hpp`). The desktop and Both desktop engines use `Ecu` (`include/FluidSim/Ecu.hpp`). The desktop and
CLI throttle values are pedal requests; the actual throttle is advanced every CLI throttle values are pedal requests; the actual throttle is advanced every
simulation sample, independently of rendering. Opening and closing use an simulation sample, independently of rendering. Opening and closing use an
80 ms first-order actuator response and a maximum travel rate of two full 80 ms first-order actuator response and a maximum travel rate of two full
travels per second. An ideal butterfly's projected opening determines orifice travels per second. The four-stroke uses an ideal butterfly projection; the
area continuously; shaft obstruction and edge leakage are not modeled. two-stroke uses the circular-segment aperture uncovered by a translating slide.
Shaft obstruction and edge leakage are not modeled.
The ECU uses ideal sensors and direct access to trapped fresh-air mass at The four-strokes use ideal sensors and direct access to trapped fresh-air mass
intake-valve closure to calculate each cylinder's fuel dose. This explicitly at intake-valve closure to calculate each cylinder's fuel dose. This explicitly
permitted idealization replaces injector flow/duty-cycle and fuel-film models. permitted idealization replaces injector flow/duty-cycle and fuel-film models.
The nominal AFR is 14.7, enriched progressively toward 12.8 above 70% throttle. Their nominal AFR is 14.7, enriched progressively toward 12.8 above 70% throttle.
A PI idle controller targets 1800 RPM with filtered RPM feedback, bounded idle A PI idle controller targets 1800 RPM with filtered RPM feedback, bounded idle
authority and anti-windup. Overrun fuel cut engages above 3500 RPM on a closed authority and anti-windup. Overrun fuel cut engages above 3500 RPM on a closed
pedal and restores fuel below 2400 RPM to catch idle. The twin's rev limiter cuts fuel pedal and restores fuel below 2400 RPM to catch idle. The twin's rev limiter cuts fuel
and spark at 10000 RPM, recovering below 9750 RPM. The two-stroke has no rev and spark at 10000 RPM, recovering below 9750 RPM.
limiter; its idle, overrun and sensor-fault controls remain active. Invalid sensor readings
inhibit fuel. A simple RPM-based ignition advance map is provided. An already The two-stroke uses a simulated 38.1 mm slide carburetor instead of the EFI
burning charge completes combustion when a cut begins. dose. Effective pilot, needle and main restrictions draw liquid fuel from a
finite float bowl with the orifice relation `m_dot = Cd*A*sqrt(2*rho*delta_p)`.
The jet pressure signal comes from simulated mass flow, local venturi dynamic
pressure and the measured static pressure drop. A wall film deposits and
evaporates fuel during transients. Delivered fuel is added downstream of the
carb as a conserved species and travels through the plenum, reed, crankcase,
transfer duct and cylinder. Combustion is therefore limited by the fuel and
oxygen actually trapped, rather than receiving a cylinder-side AFR dose.
The two-stroke has no rev limiter and deliberately does not use electronic
overrun fuel cut. On a high-RPM throttle lift, wet-wall and pilot fuel can pass
through unlit cycles if the trapped mixture cannot ignite. Spark remains at
the CDI timing; there is no scheduled overrun firing pattern. Unburned fuel
is transported out through the exhaust without pipe combustion. No sound
samples are mixed in.
All ECU parameters are accessible calibrations, not measured engine maps. All ECU parameters are accessible calibrations, not measured engine maps.
There is no starter motor, automatic restart, knock sensor, lambda sensor, There is no starter motor, automatic restart, knock sensor, lambda sensor,
@@ -255,11 +332,14 @@ catalyst, cold-start temperature model or injector electrical model. Idle
control can still stall under excessive load and does not impose crankshaft control can still stall under excessive load and does not impose crankshaft
speed or add artificial sustaining torque. Existing combustion/thermal models speed or add artificial sustaining torque. Existing combustion/thermal models
remain approximations; ECU changes do not establish their physical accuracy. remain approximations; ECU changes do not establish their physical accuracy.
The two-stroke doses fuel after the last scavenging port closes, targets The two-stroke targets 3000 RPM idle and uses idle ignition trim in addition to
3000 RPM idle, and uses idle ignition trim in addition to throttle control. Its throttle control. Its
reed is a damped lumped cantilever model with finite opening time and seating reed is a damped lumped cantilever model with finite opening time and seating
contacts. Cylinder and crankcase moving-volume work use adiabatic energy changes. contacts. Cylinder and crankcase moving-volume work use adiabatic energy changes.
Reed material, combustion and controller parameters still require calibration. Jet dimensions are effective flow calibrations rather than measured Mikuni
parts. Fuel uses the gas model after wall-film release; droplet size,
atomization, evaporation heat and oil are not modeled. Reed material,
combustion and controller parameters still require calibration.
C# trace parity is intentionally changed by these control and physics changes. C# trace parity is intentionally changed by these control and physics changes.
VS Code's default build task builds the desktop through CMake. Use VS Code's default build task builds the desktop through CMake. Use

View File

@@ -31,6 +31,7 @@ private:
port.temperature = temperature; port.temperature = temperature;
port.enthalpy = specific_enthalpy(); port.enthalpy = specific_enthalpy();
port.air_fraction = 1.0f; port.air_fraction = 1.0f;
port.fuel_fraction = 0.0f;
} }
}; };

View File

@@ -14,6 +14,8 @@ public:
void set_speed(double speed) { speed_.store(speed, std::memory_order_relaxed); } void set_speed(double speed) { speed_.store(speed, std::memory_order_relaxed); }
std::uint64_t underrun_blocks() const { return underrun_blocks_.load(std::memory_order_relaxed); } std::uint64_t underrun_blocks() const { return underrun_blocks_.load(std::memory_order_relaxed); }
double speed() const { return speed_.load(std::memory_order_relaxed); } double speed() const { return speed_.load(std::memory_order_relaxed); }
void set_auto_gain(bool enabled) { auto_gain_enabled_.store(enabled, std::memory_order_relaxed); }
bool auto_gain() const { return auto_gain_enabled_.load(std::memory_order_relaxed); }
private: private:
static constexpr std::size_t block_size = 512; static constexpr std::size_t block_size = 512;
@@ -22,6 +24,9 @@ private:
std::array<std::int16_t, block_size * 2> output_{}; std::array<std::int16_t, block_size * 2> output_{};
std::atomic<std::uint64_t> underrun_blocks_{}; std::atomic<std::uint64_t> underrun_blocks_{};
std::atomic<double> speed_{1.0}; std::atomic<double> speed_{1.0};
std::atomic<bool> auto_gain_enabled_{true};
float rms_power_{}, peak_envelope_{}, automatic_gain_{1.f};
float level_alpha_{}, peak_decay_{}, gain_rise_alpha_{}, gain_fall_alpha_{};
float previous_input_left_{}, previous_output_left_{}; float previous_input_left_{}, previous_output_left_{};
float previous_input_right_{}, previous_output_right_{}; float previous_input_right_{}, previous_output_right_{};

View File

@@ -0,0 +1,46 @@
#pragma once
#include <array>
#include <cstddef>
namespace fluidsim::port {
// Lumped slide-carburetor model for the crankcase-scavenged two-stroke. The
// jets use liquid-orifice flow from a float bowl into the measured venturi
// depression. Delivered fuel is injected into the downstream fluid volume and
// is then transported as a conserved species; this class never doses a cylinder.
class Carburetor {
public:
float fuel_density{740.0f};
float pilot_jet_diameter{0.00040f}, main_jet_diameter{0.00150f};
float needle_jet_diameter{0.00190f};
float pilot_discharge_coefficient{0.62f}, main_discharge_coefficient{0.72f};
float bowl_head_pressure{};
float film_fraction{0.28f}, film_time_constant{0.11f};
float bowl_capacity{0.008f}, bowl_refill_rate{0.0025f}; // kg, kg/s
float overrun_rpm{4200.0f}, overrun_exit_rpm{3300.0f};
explicit Carburetor(float throat_diameter = 0.0381f);
// air_mass_flow is positive from atmosphere toward the engine.
void step(float dt, float pedal, float slide, float rpm, float air_mass_flow,
float air_density, float upstream_pressure, float downstream_pressure);
float indicated_afr() const { return indicated_afr_; }
float fuel_flow() const { return delivered_fuel_flow_; }
float film_mass() const { return film_mass_; }
float bowl_level() const { return bowl_mass_ / bowl_capacity; }
bool overrun_active() const { return overrun_; }
bool fault() const { return fault_; }
float venturi_depression() const { return venturi_depression_; }
private:
float bowl_mass_{}, film_mass_{};
float throat_area_{}, venturi_depression_{};
float filtered_air_flow_{}, filtered_fuel_flow_{}, delivered_fuel_flow_{};
float indicated_afr_{13.5f};
float previous_pedal_{}, lift_enrichment_{};
bool initialized_{}, overrun_{}, fault_{};
};
} // namespace fluidsim::port

View File

@@ -12,9 +12,11 @@ struct Port {
float temperature{300.0f}; float temperature{300.0f};
float gamma_value{1.4f}, gas_constant_value{287.0f}; float gamma_value{1.4f}, gas_constant_value{287.0f};
float air_fraction{1.0f}; float air_fraction{1.0f};
float fuel_fraction{};
float* owner_energy{}; float* owner_energy{};
float* owner_air_mass{}; float* owner_air_mass{};
float* owner_exhaust_mass{}; float* owner_exhaust_mass{};
float* owner_fuel_mass{};
}; };
// Small equivalent of the C# IComponent lifecycle. Components update their // Small equivalent of the C# IComponent lifecycle. Components update their

View File

@@ -20,6 +20,13 @@ public:
float geometry_work() const { return geometry_work_; } // J delivered by gas during the latest geometry step. float geometry_work() const { return geometry_work_; } // J delivered by gas during the latest geometry step.
float mass() const { return mass_; } float mass() const { return mass_; }
float air_fraction() const { return air_fraction_; } float air_fraction() const { return air_fraction_; }
float fuel_fraction() const { return fuel_fraction_; }
float fuel_mass() const { return mass_ * fuel_fraction_; }
void initialize_mixture(float afr) {
fuel_fraction_ = 1.0f / (1.0f + afr);
air_fraction_ = 1.0f - fuel_fraction_;
sync_ports();
}
std::vector<Port*> ports() override { return {&intake_port, &transfer_port}; } std::vector<Port*> ports() override { return {&intake_port, &transfer_port}; }
void pre_step(float dt); void pre_step(float dt);
@@ -30,7 +37,7 @@ private:
float crank_radius_{}, connecting_rod_length_{}, piston_area_{}; float crank_radius_{}, connecting_rod_length_{}, piston_area_{};
float clearance_volume_{}, obliquity_{}; float clearance_volume_{}, obliquity_{};
float geometry_work_{}; float geometry_work_{};
float mass_{}, energy_{}, air_fraction_{1.0f}; float mass_{}, energy_{}, air_fraction_{1.0f}, fuel_fraction_{};
float pressure_{101325.0f}, temperature_{300.0f}, volume_{}, previous_volume_{}; float pressure_{101325.0f}, temperature_{300.0f}, volume_{}, previous_volume_{};
void sync_ports(); void sync_ports();

View File

@@ -14,7 +14,8 @@ public:
two_ = std::make_unique<TwoStrokeScenario>(44100, config); two_ = std::make_unique<TwoStrokeScenario>(44100, config);
else else
twin_ = std::make_unique<FourStrokeScenario>(44100, kind == ScenarioKind::SaabB205 ? twin_ = std::make_unique<FourStrokeScenario>(44100, kind == ScenarioKind::SaabB205 ?
FourStrokePreset::SaabB205 : FourStrokePreset::Twin, kind == ScenarioKind::TurboTwin || (kind == ScenarioKind::SaabB205 && saab_turbo)); FourStrokePreset::SaabB205 : kind == ScenarioKind::AmericanV8 ? FourStrokePreset::AmericanV8 : FourStrokePreset::Twin,
kind == ScenarioKind::TurboTwin || (kind == ScenarioKind::SaabB205 && saab_turbo));
} }
AudioFrame process() { AudioFrame process() {
if (twin_) if (twin_)
@@ -60,7 +61,7 @@ public:
s.exhaust_pressure = two_->pipes[3].state(0).pressure; s.exhaust_pressure = two_->pipes[3].state(0).pressure;
s.throttle = source.throttle; s.throttle = source.throttle;
s.pedal = source.pedal; s.pedal = source.pedal;
s.target_afr = two_->ecu.target_afr(); s.target_afr = two_->carburetor.indicated_afr();
s.valve1_intake_area = source.transfer_area; s.valve1_intake_area = source.transfer_area;
s.valve1_exhaust_area = source.exhaust_area; s.valve1_exhaust_area = source.exhaust_area;
s.reed_open = source.reed_open; s.reed_open = source.reed_open;

View File

@@ -12,9 +12,12 @@ public:
float idle_base_opening{0.08f}, idle_max_opening{0.25f}; float idle_base_opening{0.08f}, idle_max_opening{0.25f};
float idle_kp{0.0001f}, idle_ki{0.00004f}; float idle_kp{0.0001f}, idle_ki{0.00004f};
float throttle_time_constant{0.08f}, throttle_rate{2.0f}; // seconds, full travel/second float throttle_time_constant{0.08f}, throttle_rate{2.0f}; // seconds, full travel/second
// Zero uses the opening calibration, preserving symmetric response.
float throttle_closing_time_constant{}, throttle_closing_rate{};
float rev_limit_rpm{10000.0f}, rev_resume_rpm{9750.0f}; float rev_limit_rpm{10000.0f}, rev_resume_rpm{9750.0f};
bool rev_limit_enabled{true}; bool rev_limit_enabled{true};
float overrun_cut_rpm{3500.0f}, overrun_resume_rpm{2400.0f}; float overrun_cut_rpm{3500.0f}, overrun_resume_rpm{2400.0f};
bool overrun_fuel_cut_enabled{true};
float idle_spark_gain{0.0f}, idle_minimum_advance{-5.0f}; // degrees/RPM, degrees BTDC float idle_spark_gain{0.0f}, idle_minimum_advance{-5.0f}; // degrees/RPM, degrees BTDC
float stoichiometric_afr{14.7f}, power_afr{12.8f}; float stoichiometric_afr{14.7f}, power_afr{12.8f};
bool boost_control_enabled{false}; bool boost_control_enabled{false};
@@ -30,7 +33,7 @@ public:
float throttle_target() const { return target_; } float throttle_target() const { return target_; }
float target_afr() const { return afr_; } float target_afr() const { return afr_; }
float spark_advance() const { return spark_; } float spark_advance() const { return spark_; }
bool fuel_cut() const { return limiter_ || overrun_ || fault_ || boost_cut_; } bool fuel_cut() const { return limiter_ || (overrun_ && overrun_fuel_cut_enabled) || fault_ || boost_cut_; }
bool rev_limited() const { return limiter_; } bool rev_limited() const { return limiter_; }
bool idle_active() const { return idle_; } bool idle_active() const { return idle_; }
bool sensor_fault() const { return fault_; } bool sensor_fault() const { return fault_; }

View File

@@ -31,6 +31,7 @@ private:
class EngineCylinder : public Component { class EngineCylinder : public Component {
public: public:
Ecu* ecu{}; // Optional ideal electronic fuel/ignition controller. Ecu* ecu{}; // Optional ideal electronic fuel/ignition controller.
bool transported_fuel{}; // Upstream carb meters the fuel species.
float bore{0.075f}, stroke{0.068f}, rod{0.112f}, compression{11.5f}; float bore{0.075f}, stroke{0.068f}, rod{0.112f}, compression{11.5f};
float intake_valve_diameter{0.030f}, exhaust_valve_diameter{0.028f}; float intake_valve_diameter{0.030f}, exhaust_valve_diameter{0.028f};
int intake_valve_count{1}, exhaust_valve_count{1}; int intake_valve_count{1}, exhaust_valve_count{1};
@@ -60,6 +61,7 @@ public:
protected: protected:
float energy_factor_{1.0f}; float energy_factor_{1.0f};
float active_fuel_mass_{};
float rev_limit_timer_{}; float rev_limit_timer_{};
std::mt19937 random_{0xF17D51u}; std::mt19937 random_{0xF17D51u};
std::uniform_real_distribution<float> unit_{0.0f, 1.0f}; std::uniform_real_distribution<float> unit_{0.0f, 1.0f};

View File

@@ -37,6 +37,7 @@ public:
p.owner_energy = &energy_; p.owner_energy = &energy_;
p.owner_air_mass = &air_mass_; p.owner_air_mass = &air_mass_;
p.owner_exhaust_mass = &exhaust_mass_; p.owner_exhaust_mass = &exhaust_mass_;
p.owner_fuel_mass = &fuel_mass_;
sync_port(p); sync_port(p);
return p; return p;
} }
@@ -44,19 +45,30 @@ public:
float density() const { return mass() / std::max(volume_, 1e-12f); } float density() const { return mass() / std::max(volume_, 1e-12f); }
float temperature() const { return pressure() / std::max(density() * gas_constant, 1e-9f); } float temperature() const { return pressure() / std::max(density() * gas_constant, 1e-9f); }
float volume() const { return volume_; } float volume() const { return volume_; }
float mass() const { return air_mass_ + exhaust_mass_; } float mass() const { return air_mass_ + exhaust_mass_ + fuel_mass_; }
float internal_energy() const { return energy_; } float internal_energy() const { return energy_; }
float air_fraction() const { return air_mass_ / std::max(mass(), 1e-12f); } float air_fraction() const { return air_mass_ / std::max(mass(), 1e-12f); }
float fuel_fraction() const { return fuel_mass_ / std::max(mass(), 1e-12f); }
float fuel_mass() const { return fuel_mass_; }
void add_fuel_mass(float value, float temperature = 300.0f) {
if (!std::isfinite(value) || value < 0.0f || !std::isfinite(temperature) || temperature <= 0.0f)
throw std::invalid_argument("invalid fuel addition");
fuel_mass_ += value;
energy_ += value * gas_constant / (gamma - 1.0f) * temperature;
sync_ports();
}
void set_pressure(float value, float temp = -1.0f) { void set_pressure(float value, float temp = -1.0f) {
if (!std::isfinite(value) || value <= 0 || !std::isfinite(temp)) if (!std::isfinite(value) || value <= 0 || !std::isfinite(temp))
throw std::invalid_argument("invalid volume pressure/temperature"); throw std::invalid_argument("invalid volume pressure/temperature");
const float target_t = temp > 0.0f ? temp : temperature(); const float target_t = temp > 0.0f ? temp : temperature();
const float total = value * volume_ / (gas_constant * target_t); const float total = value * volume_ / (gas_constant * target_t);
const float fraction = air_fraction(); const float fraction = air_fraction();
const float fuel = fuel_fraction();
air_mass_ = total * fraction; air_mass_ = total * fraction;
exhaust_mass_ = total * (1.0f - fraction); fuel_mass_ = total * fuel;
exhaust_mass_ = total * std::max(1.0f - fraction - fuel, 0.0f);
energy_ = value * volume_ / (gamma - 1.0f); energy_ = value * volume_ / (gamma - 1.0f);
air_roundoff_ = exhaust_roundoff_ = energy_roundoff_ = 0; air_roundoff_ = exhaust_roundoff_ = fuel_roundoff_ = energy_roundoff_ = 0;
sync_ports(); sync_ports();
} }
void update(float dt) { void update(float dt) {
@@ -64,16 +76,20 @@ public:
throw std::invalid_argument("invalid volume timestep"); throw std::invalid_argument("invalid volume timestep");
if (dt == 0) if (dt == 0)
return; return;
double d_air = 0, d_exhaust = 0, d_energy = 0; double d_air = 0, d_exhaust = 0, d_fuel = 0, d_energy = 0;
const float fraction = air_fraction(); const float fraction = air_fraction();
const float fuel = fuel_fraction();
for (const Port &p : ports_) { for (const Port &p : ports_) {
const float flow_fraction = p.mass_flow >= 0.0f ? p.air_fraction : fraction; const float flow_fraction = p.mass_flow >= 0.0f ? p.air_fraction : fraction;
const float flow_fuel = p.mass_flow >= 0.0f ? p.fuel_fraction : fuel;
d_air += double(p.mass_flow) * flow_fraction; d_air += double(p.mass_flow) * flow_fraction;
d_exhaust += double(p.mass_flow) * (1.0f - flow_fraction); d_fuel += double(p.mass_flow) * flow_fuel;
d_exhaust += double(p.mass_flow) * std::max(1.0f - flow_fraction - flow_fuel, 0.0f);
d_energy += double(p.mass_flow) * p.enthalpy; d_energy += double(p.mass_flow) * p.enthalpy;
} }
accumulate(air_mass_, air_roundoff_, d_air * dt); accumulate(air_mass_, air_roundoff_, d_air * dt);
accumulate(exhaust_mass_, exhaust_roundoff_, d_exhaust * dt); accumulate(exhaust_mass_, exhaust_roundoff_, d_exhaust * dt);
accumulate(fuel_mass_, fuel_roundoff_, d_fuel * dt);
accumulate(energy_, energy_roundoff_, d_energy * dt); accumulate(energy_, energy_roundoff_, d_energy * dt);
const float new_volume = volume_ + volume_change_rate * dt; const float new_volume = volume_ + volume_change_rate * dt;
if (!(new_volume > 0) || !std::isfinite(new_volume)) if (!(new_volume > 0) || !std::isfinite(new_volume))
@@ -93,10 +109,11 @@ public:
if (!(mass() > 0) || !(energy_ > 0) || !std::isfinite(mass()) || !std::isfinite(energy_)) if (!(mass() > 0) || !(energy_ > 0) || !std::isfinite(mass()) || !std::isfinite(energy_))
throw std::runtime_error( throw std::runtime_error(
"nonphysical volume state; reduce timestep or check external port fluxes"); "nonphysical volume state; reduce timestep or check external port fluxes");
if (air_mass_ < -1e-12f || exhaust_mass_ < -1e-12f) if (air_mass_ < -1e-12f || exhaust_mass_ < -1e-12f || fuel_mass_ < -1e-12f)
throw std::runtime_error("negative volume species mass"); throw std::runtime_error("negative volume species mass");
air_mass_ = std::max(air_mass_, 0.0f); air_mass_ = std::max(air_mass_, 0.0f);
exhaust_mass_ = std::max(exhaust_mass_, 0.0f); exhaust_mass_ = std::max(exhaust_mass_, 0.0f);
fuel_mass_ = std::max(fuel_mass_, 0.0f);
sync_ports(); sync_ports();
} }
void update_state(float dt) override { update(dt); } void update_state(float dt) override { update(dt); }
@@ -110,9 +127,9 @@ public:
const std::deque<Port> &ports() const { return ports_; } const std::deque<Port> &ports() const { return ports_; }
private: private:
float volume_, air_mass_{}, exhaust_mass_{}, energy_; float volume_, air_mass_{}, exhaust_mass_{}, fuel_mass_{}, energy_;
// Preserve sub-ulp transfers without changing the float port/budget ABI. // Preserve sub-ulp transfers without changing the float port/budget ABI.
double air_roundoff_{}, exhaust_roundoff_{}, energy_roundoff_{}; double air_roundoff_{}, exhaust_roundoff_{}, fuel_roundoff_{}, energy_roundoff_{};
static void accumulate(float& value, double& roundoff, double increment) { static void accumulate(float& value, double& roundoff, double increment) {
const double precise = double(value)+roundoff+increment; const double precise = double(value)+roundoff+increment;
value = float(precise); value = float(precise);
@@ -127,6 +144,7 @@ private:
p.temperature = temperature(); p.temperature = temperature();
p.enthalpy = gamma / (gamma - 1.0f) * p.pressure / std::max(p.density, 1e-12f); p.enthalpy = gamma / (gamma - 1.0f) * p.pressure / std::max(p.density, 1e-12f);
p.air_fraction = air_fraction(); p.air_fraction = air_fraction();
p.fuel_fraction = fuel_fraction();
} }
void sync_ports() { void sync_ports() {
for (Port &p : ports_) for (Port &p : ports_)
@@ -138,16 +156,18 @@ struct GasCell {
float rho{1.225f}, momentum{}; float rho{1.225f}, momentum{};
float energy{1.225f * (101325.0f / (pipe_gm1 * 1.225f))}; float energy{1.225f * (101325.0f / (pipe_gm1 * 1.225f))};
float air{1.0f}; float air{1.0f};
float fuel{};
}; };
struct GasState { struct GasState {
float rho{1.225f}, velocity{}, pressure{101325.0f}, air{1.0f}; float rho{1.225f}, velocity{}, pressure{101325.0f}, air{1.0f};
float fuel{};
}; };
class Pipe1D { class Pipe1D {
public: public:
// Integrated face fluxes: kg/s, N, W, kg/s of fresh air, positive along +x. // Integrated face fluxes: kg/s, N, W, kg/s of fresh air and fuel, positive along +x.
struct Flux { struct Flux {
float m{}, p{}, e{}, y{}; float m{}, p{}, e{}, y{}, fuel{};
}; };
float area{}, dx{}; float area{}, dx{};
float damping_multiplier{0.0f}, energy_relaxation_rate{0.0f}, ambient_pressure{101325.0f}; float damping_multiplier{0.0f}, energy_relaxation_rate{0.0f}, ambient_pressure{101325.0f};
@@ -221,7 +241,9 @@ public:
} }
static GasState primitive(const GasCell &c) { static GasState primitive(const GasCell &c) {
const float u = c.momentum / std::max(c.rho, 1e-12f); const float u = c.momentum / std::max(c.rho, 1e-12f);
return {c.rho, u, pipe_gm1 * (c.energy - .5f * c.momentum * u), std::clamp(c.air, 0.0f, 1.0f)}; const float air = std::clamp(c.air, 0.0f, 1.0f);
return {c.rho, u, pipe_gm1 * (c.energy - .5f * c.momentum * u), air,
std::clamp(c.fuel, 0.0f, 1.0f - air)};
} }
static float wave(const GasState &s) { static float wave(const GasState &s) {
return std::abs(s.velocity) + std::sqrt(gamma * s.pressure / s.rho); return std::abs(s.velocity) + std::sqrt(gamma * s.pressure / s.rho);
@@ -253,7 +275,9 @@ public:
float mom = cells[i].momentum - factor * (fr.p - fl.p - states_[i].pressure * (ar - al)); float mom = cells[i].momentum - factor * (fr.p - fl.p - states_[i].pressure * (ar - al));
float en = cells[i].energy - factor * (fr.e - fl.e); float en = cells[i].energy - factor * (fr.e - fl.e);
const float air = cells[i].rho * cells[i].air - factor * (fr.y - fl.y); const float air = cells[i].rho * cells[i].air - factor * (fr.y - fl.y);
if (!std::isfinite(rho) || !std::isfinite(mom) || !std::isfinite(en) || !std::isfinite(air)) const float fuel = cells[i].rho * cells[i].fuel - factor * (fr.fuel - fl.fuel);
if (!std::isfinite(rho) || !std::isfinite(mom) || !std::isfinite(en) ||
!std::isfinite(air) || !std::isfinite(fuel))
throw std::runtime_error("nonfinite finite-volume update"); throw std::runtime_error("nonfinite finite-volume update");
if (rho < 1e-12f) { if (rho < 1e-12f) {
rho = 1e-12f; rho = 1e-12f;
@@ -273,14 +297,18 @@ public:
// than 1.18e-38 of the gas mass. Avoid repeatedly transporting // than 1.18e-38 of the gas mass. Avoid repeatedly transporting
// subnormal concentrations; total gas mass and energy are unchanged. // subnormal concentrations; total gas mass and energy are unchanged.
const float fraction = std::clamp(air / rho, 0.0f, 1.0f); const float fraction = std::clamp(air / rho, 0.0f, 1.0f);
const float fuel_fraction = std::clamp(fuel / rho, 0.0f, 1.0f - fraction);
next_cells_[i] = {rho, mom, en, next_cells_[i] = {rho, mom, en,
fraction < std::numeric_limits<float>::min() ? 0.0f : fraction}; fraction < std::numeric_limits<float>::min() ? 0.0f : fraction,
fuel_fraction < std::numeric_limits<float>::min() ? 0.0f : fuel_fraction};
} }
cells.swap(next_cells_); cells.swap(next_cells_);
prepared_ = false; prepared_ = false;
left_override_ = right_override_ = false; left_override_ = right_override_ = false;
} }
static Flux scaled(Flux f, float a) { return {f.m * a, f.p * a, f.e * a, f.y * a}; } static Flux scaled(Flux f, float a) {
return {f.m * a, f.p * a, f.e * a, f.y * a, f.fuel * a};
}
private: private:
std::vector<GasCell> next_cells_; std::vector<GasCell> next_cells_;
@@ -301,7 +329,8 @@ private:
return {.5f * (ma + mb - c * (b.rho - a.rho)), return {.5f * (ma + mb - c * (b.rho - a.rho)),
.5f * (ma * a.velocity + a.pressure + mb * b.velocity + b.pressure - c * (mb - ma)), .5f * (ma * a.velocity + a.pressure + mb * b.velocity + b.pressure - c * (mb - ma)),
.5f * ((ea + a.pressure) * a.velocity + (eb + b.pressure) * b.velocity - c * (eb - ea)), .5f * ((ea + a.pressure) * a.velocity + (eb + b.pressure) * b.velocity - c * (eb - ea)),
.5f * (ma * a.air + mb * b.air - c * (b.rho * b.air - a.rho * a.air))}; .5f * (ma * a.air + mb * b.air - c * (b.rho * b.air - a.rho * a.air)),
.5f * (ma * a.fuel + mb * b.fuel - c * (b.rho * b.fuel - a.rho * a.fuel))};
} }
}; };
@@ -329,7 +358,8 @@ public:
[&](const Budget &b) { return b.air == port.owner_air_mass; }); [&](const Budget &b) { return b.air == port.owner_air_mass; });
if (it == budgets_.end()) { if (it == budgets_.end()) {
o.budget = budgets_.size(); o.budget = budgets_.size();
budgets_.push_back({port.owner_air_mass, port.owner_exhaust_mass, port.owner_energy}); budgets_.push_back({port.owner_air_mass, port.owner_exhaust_mass,
port.owner_fuel_mass, port.owner_energy});
} else } else
o.budget = static_cast<std::size_t>(it - budgets_.begin()); o.budget = static_cast<std::size_t>(it - budgets_.begin());
} }
@@ -385,7 +415,7 @@ public:
if (!std::isfinite(dt) || dt <= 0) if (!std::isfinite(dt) || dt <= 0)
throw std::invalid_argument("boundary timestep must be positive"); throw std::invalid_argument("boundary timestep must be positive");
for (auto &b : budgets_) { for (auto &b : budgets_) {
b.mass = .25f * (*b.air + (b.exhaust ? *b.exhaust : 0)); b.mass = .25f * (*b.air + (b.exhaust ? *b.exhaust : 0) + (b.fuel ? *b.fuel : 0));
b.heat = b.energy ? .25f * (*b.energy) : std::numeric_limits<float>::max(); b.heat = b.energy ? .25f * (*b.energy) : std::numeric_limits<float>::max();
} }
for (auto &o : orifices_) for (auto &o : orifices_)
@@ -398,7 +428,7 @@ public:
private: private:
struct Budget { struct Budget {
float *air{}, *exhaust{}, *energy{}; float *air{}, *exhaust{}, *fuel{}, *energy{};
float mass{}, heat{}; float mass{}, heat{};
}; };
struct Orifice { struct Orifice {
@@ -587,15 +617,18 @@ private:
o.length > 0 ? .5f * (v.pressure + s.pressure) : static_cast<float>(duct_pressure); o.length > 0 ? .5f * (v.pressure + s.pressure) : static_cast<float>(duct_pressure);
const float direction = o.left ? 1.f : -1.f; const float direction = o.left ? 1.f : -1.f;
const float species = from ? v.air_fraction : s.air; const float species = from ? v.air_fraction : s.air;
const float fuel = from ? v.fuel_fraction : s.fuel;
const float signed_mass = direction * mdot; const float signed_mass = direction * mdot;
p.set_boundary_flux(o.left, {signed_mass, p.set_boundary_flux(o.left, {signed_mass,
mdot * velocity + face_pressure * area + s.pressure * (face_area - area), mdot * velocity + face_pressure * area + s.pressure * (face_area - area),
signed_mass * h, signed_mass * species}); signed_mass * h, signed_mass * species, signed_mass * fuel});
ghost(p, o.left, {density, direction * velocity, face_pressure, species}); ghost(p, o.left, {density, direction * velocity, face_pressure, species, fuel});
v.mass_flow = -mdot; v.mass_flow = -mdot;
v.enthalpy = h; v.enthalpy = h;
if (!from) if (!from)
v.air_fraction = s.air; v.air_fraction = s.air;
if (!from)
v.fuel_fraction = s.fuel;
} }
void resolve_open(Open &o) { void resolve_open(Open &o) {
auto &p = pipes_[o.pipe]; auto &p = pipes_[o.pipe];
@@ -611,7 +644,9 @@ private:
const float u = const float u =
o.left ? s.velocity - 2 * c / gm1 + 2 * cb / gm1 : s.velocity + 2 * c / gm1 - 2 * cb / gm1; o.left ? s.velocity - 2 * c / gm1 + 2 * cb / gm1 : s.velocity + 2 * c / gm1 - 2 * cb / gm1;
const bool out = o.left ? s.velocity <= -c : s.velocity >= c; const bool out = o.left ? s.velocity <= -c : s.velocity >= c;
GasState g = out ? s : GasState{rho, u, o.pressure, (o.left ? u >= 0 : u <= 0) ? 1.f : s.air}; const bool ambient_inflow = o.left ? u >= 0 : u <= 0;
GasState g = out ? s : GasState{rho, u, o.pressure, ambient_inflow ? 1.f : s.air,
ambient_inflow ? 0.f : s.fuel};
ghost(p, o.left, g); ghost(p, o.left, g);
const float a = std::min(o.area, p.cell_area(i)); const float a = std::min(o.area, p.cell_area(i));
auto f = Pipe1D::scaled(o.left ? Pipe1D::riemann(g, s) : Pipe1D::riemann(s, g), a); auto f = Pipe1D::scaled(o.left ? Pipe1D::riemann(g, s) : Pipe1D::riemann(s, g), a);
@@ -631,9 +666,11 @@ private:
const float area = std::min(a.cell_area(ia), b.cell_area(ib)); const float area = std::min(a.cell_area(ia), b.cell_area(ib));
auto f = Pipe1D::scaled(Pipe1D::riemann(sa, sb), area); auto f = Pipe1D::scaled(Pipe1D::riemann(sa, sb), area);
a.set_boundary_flux(o.left_a, a.set_boundary_flux(o.left_a,
{da * f.m, f.p + sa.pressure * (a.cell_area(ia) - area), da * f.e, da * f.y}); {da * f.m, f.p + sa.pressure * (a.cell_area(ia) - area), da * f.e,
da * f.y, da * f.fuel});
b.set_boundary_flux(o.left_b, b.set_boundary_flux(o.left_b,
{db * f.m, f.p + sb.pressure * (b.cell_area(ib) - area), db * f.e, db * f.y}); {db * f.m, f.p + sb.pressure * (b.cell_area(ib) - area), db * f.e,
db * f.y, db * f.fuel});
} }
}; };

View File

@@ -14,6 +14,8 @@ struct OutdoorAudioConfig {
Vec3 listener_right{1.0f, 0.0f, 0.0f}; Vec3 listener_right{1.0f, 0.0f, 0.0f};
Vec3 intake_position{-0.2f, 0.7f, 0.0f}; Vec3 intake_position{-0.2f, 0.7f, 0.0f};
Vec3 exhaust_position{0.55f, 0.5f, 0.0f}; Vec3 exhaust_position{0.55f, 0.5f, 0.0f};
Vec3 second_exhaust_position{-0.55f, 0.5f, 0.0f};
bool dual_exhaust{};
float ear_spacing{0.18f}, sound_speed{343.0f}; float ear_spacing{0.18f}, sound_speed{343.0f};
float ground_y{0.0f}, wall_x{-1.5f}; float ground_y{0.0f}, wall_x{-1.5f};
float ground_reflection{0.35f}, wall_reflection{0.65f}; float ground_reflection{0.35f}, wall_reflection{0.65f};
@@ -26,7 +28,7 @@ struct OutdoorAudioConfig {
class OutdoorAudio { class OutdoorAudio {
public: public:
explicit OutdoorAudio(int sample_rate = 44100, OutdoorAudioConfig config = {}); explicit OutdoorAudio(int sample_rate = 44100, OutdoorAudioConfig config = {});
AudioFrame process(float intake_mass_flow, float exhaust_mass_flow); AudioFrame process(float intake_mass_flow, float exhaust_mass_flow, float second_exhaust_mass_flow = 0);
void set_scene(OutdoorScene scene) { scene_ = scene; } void set_scene(OutdoorScene scene) { scene_ = scene; }
OutdoorScene scene() const { return scene_; } OutdoorScene scene() const { return scene_; }
void set_monitor_filter(bool enabled) { monitor_filter_ = enabled; } // Set before playback/capture. void set_monitor_filter(bool enabled) { monitor_filter_ = enabled; } // Set before playback/capture.
@@ -60,7 +62,7 @@ private:
OutdoorAudioConfig config_; OutdoorAudioConfig config_;
OutdoorScene scene_{OutdoorScene::Wall}; OutdoorScene scene_{OutdoorScene::Wall};
bool monitor_filter_{true}; bool monitor_filter_{true};
std::array<Emitter, 2> emitters_; std::array<Emitter, 3> emitters_;
std::array<TailLine, 4> tail_; std::array<TailLine, 4> tail_;
std::array<float, 4> weights_{1, 0, 0, 0}; std::array<float, 4> weights_{1, 0, 0, 0};
float scene_alpha_{}, tail_alpha_{}, tail_mix_{}; float scene_alpha_{}, tail_alpha_{}, tail_mix_{};

View File

@@ -19,8 +19,8 @@
namespace fluidsim::port { namespace fluidsim::port {
enum class ScenarioKind { Twin, TwoStroke, SaabB205, TurboTwin }; enum class ScenarioKind { Twin, TwoStroke, SaabB205, TurboTwin, AmericanV8 };
enum class FourStrokePreset { Twin, SaabB205 }; enum class FourStrokePreset { Twin, SaabB205, AmericanV8 };
struct ScenarioSnapshot { struct ScenarioSnapshot {
bool turbo_enabled{}; bool turbo_enabled{};
@@ -28,7 +28,7 @@ struct ScenarioSnapshot {
TurboSnapshot turbo{}; TurboSnapshot turbo{};
int cylinder_count{2}; int cylinder_count{2};
float rev_limit_rpm{10000}; float rev_limit_rpm{10000};
std::array<float, 4> cylinder_pressures{}, piston_fractions{}, intake_areas{}, exhaust_areas{}; std::array<float, 8> cylinder_pressures{}, piston_fractions{}, intake_areas{}, exhaust_areas{};
std::uint64_t solver_substeps{}, boundary_iterations{}; std::uint64_t solver_substeps{}, boundary_iterations{};
float rpm{}, speed_kmh{}, kinetic_energy{}, average_power{}, average_torque{}; float rpm{}, speed_kmh{}, kinetic_energy{}, average_power{}, average_torque{};
float cylinder_pressure1{}, cylinder_pressure2{}; float cylinder_pressure1{}, cylinder_pressure2{};
@@ -43,8 +43,8 @@ struct ScenarioSnapshot {
OutdoorScene audio_scene{OutdoorScene::Wall}; OutdoorScene audio_scene{OutdoorScene::Wall};
int gear{}; int gear{};
bool rev_limit1{}, rev_limit2{}, mechanical_sound_enabled{}; bool rev_limit1{}, rev_limit2{}, mechanical_sound_enabled{};
std::array<std::vector<GasState>, 12> pipe_states; std::array<std::vector<GasState>, 22> pipe_states;
std::array<std::vector<float>, 12> pipe_areas, pipe_lengths; std::array<std::vector<float>, 22> pipe_areas, pipe_lengths;
void capture_pipes(const std::vector<Pipe1D>& pipes) { void capture_pipes(const std::vector<Pipe1D>& pipes) {
for (std::size_t k = 0; k < pipes.size(); ++k) { for (std::size_t k = 0; k < pipes.size(); ++k) {
pipe_areas[k] = pipes[k].cell_areas; pipe_areas[k] = pipes[k].cell_areas;
@@ -57,14 +57,15 @@ struct ScenarioSnapshot {
}; };
// Shared four-stroke topology: independent runners/headers and shared plenums. // Shared four-stroke topology: independent runners/headers and shared plenums.
// Presets retain the original 600 cc twin and add a B205-sized inline-four. // Presets include a 600 cc twin, B205-sized inline-four and American 5.0 V8.
class FourStrokeScenario : public MotorcycleTest { class FourStrokeScenario : public MotorcycleTest {
public: public:
explicit FourStrokeScenario(int sample_rate = 44100, FourStrokePreset preset = FourStrokePreset::Twin, explicit FourStrokeScenario(int sample_rate = 44100, FourStrokePreset preset = FourStrokePreset::Twin,
bool turbo_enabled = false); bool turbo_enabled = false);
const FourStrokePreset preset; const FourStrokePreset preset;
int cylinder_count() const { return preset == FourStrokePreset::SaabB205 ? 4 : 2; } int cylinder_count() const { return preset == FourStrokePreset::AmericanV8 ? 8 : preset == FourStrokePreset::SaabB205 ? 4 : 2; }
std::array<FourStrokeCylinder*, 4> cylinders() { return {&cylinder1, &cylinder2, &cylinder3, &cylinder4}; } std::array<FourStrokeCylinder*, 8> cylinders() { return {&cylinder1, &cylinder2, &cylinder3, &cylinder4,
&cylinder5, &cylinder6, &cylinder7, &cylinder8}; }
AudioFrame process(); AudioFrame process();
void set_controls(float throttle_value, float load_value, float clutch_value); void set_controls(float throttle_value, float load_value, float clutch_value);
void toggle_mechanical_sound(); void toggle_mechanical_sound();
@@ -85,9 +86,11 @@ public:
std::unique_ptr<Turbocharger> turbo; std::unique_ptr<Turbocharger> turbo;
Crankshaft crankshaft{}; Crankshaft crankshaft{};
FourStrokeCylinder cylinder1{}, cylinder2{}, cylinder3{}, cylinder4{}; FourStrokeCylinder cylinder1{}, cylinder2{}, cylinder3{}, cylinder4{};
FourStrokeCylinder cylinder5{}, cylinder6{}, cylinder7{}, cylinder8{};
Atmosphere intake_atmosphere{}; Atmosphere intake_atmosphere{};
Volume0D intake_plenum{1.0e-3f, 101325.0f, 300.0f}; Volume0D intake_plenum{1.0e-3f, 101325.0f, 300.0f};
Volume0D exhaust_collector{0.15e-3f, 101325.0f, 700.0f}; Volume0D exhaust_collector{0.15e-3f, 101325.0f, 700.0f};
Volume0D second_bank_collector{1.5e-3f, 101325.0f, 700.0f};
std::vector<Pipe1D> pipes; std::vector<Pipe1D> pipes;
float intake_flow{}, exhaust_flow{}; float intake_flow{}, exhaust_flow{};
float last_mix_l{}, last_mix_r{}; float last_mix_l{}, last_mix_r{};
@@ -95,7 +98,7 @@ public:
private: private:
float intake_boundary_area_{}, throttle_area_{}, runner_area_{}, header_area_{}, tail_area_{}; float intake_boundary_area_{}, throttle_area_{}, runner_area_{}, header_area_{}, tail_area_{};
std::array<float, 4> intake_valve_areas_{}, exhaust_valve_areas_{}; std::array<float, 8> intake_valve_areas_{}, exhaust_valve_areas_{};
float dt_{}; float dt_{};
BoundarySystem boundaries_; BoundarySystem boundaries_;
Solver solver_; Solver solver_;
@@ -108,6 +111,11 @@ private:
}; };
using FourStrokeTwinScenario = FourStrokeScenario; using FourStrokeTwinScenario = FourStrokeScenario;
class AmericanV8Scenario : public FourStrokeScenario {
public:
explicit AmericanV8Scenario(int sample_rate = 44100)
: FourStrokeScenario(sample_rate, FourStrokePreset::AmericanV8) {}
};
class TurboTwinScenario : public FourStrokeScenario { class TurboTwinScenario : public FourStrokeScenario {
public: public:
explicit TurboTwinScenario(int sample_rate = 44100) : FourStrokeScenario(sample_rate, FourStrokePreset::Twin, true) {} explicit TurboTwinScenario(int sample_rate = 44100) : FourStrokeScenario(sample_rate, FourStrokePreset::Twin, true) {}

View File

@@ -21,6 +21,8 @@ public:
double speed() const; double speed() const;
void set_volume(float volume); void set_volume(float volume);
float volume() const; float volume() const;
void set_auto_gain(bool enabled) { stream_.set_auto_gain(enabled); }
bool auto_gain() const { return stream_.auto_gain(); }
std::uint64_t underrun_blocks() const { return stream_.underrun_blocks(); } std::uint64_t underrun_blocks() const { return stream_.underrun_blocks(); }
bool is_playing() const { return is_playing_; } bool is_playing() const { return is_playing_; }

View File

@@ -5,6 +5,8 @@
namespace fluidsim::port { namespace fluidsim::port {
class Carburetor;
// Two-stroke variant of the reciprocating cylinder model. The shared four- // Two-stroke variant of the reciprocating cylinder model. The shared four-
// stroke implementation supplies geometry, state balance, and combustion // stroke implementation supplies geometry, state balance, and combustion
// parameters; cycle events and pumping torque are specialized here. // parameters; cycle events and pumping torque are specialized here.
@@ -19,6 +21,7 @@ public:
float exhaust_open() const { return 180.0f - exhaust_duration_ * 0.5f; } float exhaust_open() const { return 180.0f - exhaust_duration_ * 0.5f; }
float exhaust_close() const { return 180.0f + exhaust_duration_ * 0.5f; } float exhaust_close() const { return 180.0f + exhaust_duration_ * 0.5f; }
void set_crankcase(Crankcase& crankcase) { crankcase_ = &crankcase; } void set_crankcase(Crankcase& crankcase) { crankcase_ = &crankcase; }
void set_carburetor(Carburetor& carburetor) { carburetor_ = &carburetor; }
float transfer_width{.050f}, exhaust_width{.036f}; float transfer_width{.050f}, exhaust_width{.036f};
float minimum_burn_seconds{.0008f}; float minimum_burn_seconds{.0008f};
std::uint64_t combustion_count() const { return combustion_count_; } std::uint64_t combustion_count() const { return combustion_count_; }
@@ -29,6 +32,7 @@ public:
private: private:
float transfer_duration_{}, exhaust_duration_{}; float transfer_duration_{}, exhaust_duration_{};
Crankcase* crankcase_{}; Crankcase* crankcase_{};
Carburetor* carburetor_{};
float active_burn_duration_{60}; float active_burn_duration_{60};
float current_rpm_{}; float current_rpm_{};

View File

@@ -2,6 +2,7 @@
#include "FluidSim/AudioProcessing.hpp" #include "FluidSim/AudioProcessing.hpp"
#include "FluidSim/Crankcase.hpp" #include "FluidSim/Crankcase.hpp"
#include "FluidSim/Carburetor.hpp"
#include "FluidSim/Fluid.hpp" #include "FluidSim/Fluid.hpp"
#include "FluidSim/OutdoorAudio.hpp" #include "FluidSim/OutdoorAudio.hpp"
#include "FluidSim/Atmosphere.hpp" #include "FluidSim/Atmosphere.hpp"
@@ -44,6 +45,7 @@ public:
float throttle{}; float throttle{};
const TwoStrokeConfig configuration; const TwoStrokeConfig configuration;
Ecu ecu{}; Ecu ecu{};
Carburetor carburetor;
ReedValve reed{}; ReedValve reed{};
Atmosphere intake_atmosphere{}; Atmosphere intake_atmosphere{};
float intake_flow{}, exhaust_flow{}; float intake_flow{}, exhaust_flow{};
@@ -65,6 +67,7 @@ private:
OutdoorAudio audio_; OutdoorAudio audio_;
static float area_for_diameter(float diameter); static float area_for_diameter(float diameter);
static float slide_area(float diameter, float opening);
static std::vector<Pipe1D> create_pipes(const TwoStrokeConfig& config); static std::vector<Pipe1D> create_pipes(const TwoStrokeConfig& config);
}; };

View File

@@ -7,6 +7,11 @@ namespace fluidsim::port {
AudioOutputStream::AudioOutputStream(SimulationRingBuffer& ring, unsigned int sample_rate) AudioOutputStream::AudioOutputStream(SimulationRingBuffer& ring, unsigned int sample_rate)
: ring_(ring) { : ring_(ring) {
const float rate = static_cast<float>(sample_rate);
level_alpha_ = -std::expm1(-1.f / (rate * .15f));
peak_decay_ = std::exp(-1.f / (rate * .15f));
gain_rise_alpha_ = -std::expm1(-1.f / (rate * 1.5f));
gain_fall_alpha_ = -std::expm1(-1.f / (rate * .03f));
initialize(2, sample_rate, {sf::SoundChannel::FrontLeft, sf::SoundChannel::FrontRight}); initialize(2, sample_rate, {sf::SoundChannel::FrontLeft, sf::SoundChannel::FrontRight});
} }
@@ -14,18 +19,38 @@ bool AudioOutputStream::onGetData(Chunk& data) {
const std::size_t read = ring_.read_interpolated(temp_left_, temp_right_, speed()); const std::size_t read = ring_.read_interpolated(temp_left_, temp_right_, speed());
if (read < block_size) underrun_blocks_.fetch_add(1, std::memory_order_relaxed); if (read < block_size) underrun_blocks_.fetch_add(1, std::memory_order_relaxed);
constexpr float dc_coefficient = 0.9995f; constexpr float dc_coefficient = 0.9995f;
constexpr float playback_gain = 4.0f; // +12 dB desktop playback boost
const bool automatic = auto_gain();
// Keep callback work bounded: no allocation, waits or simulation here. // Keep callback work bounded: no allocation, waits or simulation here.
for (std::size_t i = 0; i < block_size; ++i) { for (std::size_t i = 0; i < block_size; ++i) {
float left{}, right{}; float left{}, right{};
if (i < read) { if (i < read) {
const float input_left = std::isfinite(temp_left_[i]) ? temp_left_[i] : 0.0f; const float input_left = std::isfinite(temp_left_[i]) ? temp_left_[i] * playback_gain : 0.0f;
const float input_right = std::isfinite(temp_right_[i]) ? temp_right_[i] : 0.0f; const float input_right = std::isfinite(temp_right_[i]) ? temp_right_[i] * playback_gain : 0.0f;
left = input_left - previous_input_left_ + dc_coefficient * previous_output_left_; left = input_left - previous_input_left_ + dc_coefficient * previous_output_left_;
right = input_right - previous_input_right_ + dc_coefficient * previous_output_right_; right = input_right - previous_input_right_ + dc_coefficient * previous_output_right_;
previous_input_left_ = input_left; previous_input_left_ = input_left;
previous_input_right_ = input_right; previous_input_right_ = input_right;
previous_output_left_ = left; previous_output_left_ = left;
previous_output_right_ = right; previous_output_right_ = right;
// Stereo-linked level detection after DC removal. Freeze on
// underrun; silence returns to unity rather than chasing noise.
const float peak = std::max(std::abs(left), std::abs(right));
rms_power_ += (.5f * (left*left + right*right) - rms_power_) * level_alpha_;
peak_envelope_ = std::max(peak, peak_envelope_ * peak_decay_);
float target = 1.f;
if (automatic && rms_power_ > 1e-8f) {
target = std::clamp(.08f / std::sqrt(rms_power_), .125f, 12.f);
target = std::min(target, .85f / std::max(peak_envelope_, 1e-6f));
}
const float alpha = target < automatic_gain_ ? gain_fall_alpha_ : gain_rise_alpha_;
automatic_gain_ += (target - automatic_gain_) * alpha;
// Catch a sudden rev while the slow idle boost is still elevated.
// One linked multiplier preserves the stereo image.
const float gain = (automatic || automatic_gain_ > 1.001f) ?
std::min(automatic_gain_, .98f / std::max(peak, 1e-6f)) : automatic_gain_;
left *= gain;
right *= gain;
if (starving_) { if (starving_) {
transition_left_ = last_left_ - left; transition_left_ = last_left_ - left;
transition_right_ = last_right_ - right; transition_right_ = last_right_ - right;

102
src/carburetor.cpp Normal file
View File

@@ -0,0 +1,102 @@
#include "FluidSim/Carburetor.hpp"
#include <algorithm>
#include <cmath>
namespace fluidsim::port {
namespace {
constexpr float pi = 3.14159265358979323846f;
float circle_area(float diameter) { return .25f * pi * diameter * diameter; }
float smoothstep(float value) {
value = std::clamp(value, 0.0f, 1.0f);
return value * value * (3.0f - 2.0f * value);
}
}
Carburetor::Carburetor(float throat_diameter)
: bowl_mass_(bowl_capacity), throat_area_(circle_area(std::max(throat_diameter, 1e-4f))) {}
void Carburetor::step(float dt, float pedal, float slide, float rpm, float air_mass_flow,
float air_density, float upstream_pressure, float downstream_pressure) {
fault_ = !std::isfinite(dt) || dt <= 0.0f || !std::isfinite(pedal) ||
!std::isfinite(slide) || !std::isfinite(rpm) || rpm < 0.0f ||
!std::isfinite(air_mass_flow) || !std::isfinite(air_density) || air_density <= 0.0f ||
!std::isfinite(upstream_pressure) ||
!std::isfinite(downstream_pressure) || upstream_pressure <= 0.0f ||
downstream_pressure <= 0.0f;
if (fault_) {
delivered_fuel_flow_ = 0.0f;
overrun_ = false;
return;
}
pedal = std::clamp(pedal, 0.0f, 1.0f);
slide = std::clamp(slide, 0.0f, 1.0f);
air_mass_flow = std::max(air_mass_flow, 0.0f);
if (!initialized_) {
previous_pedal_ = pedal;
initialized_ = true;
}
const bool throttle_lift = previous_pedal_ > 0.08f && pedal <= 0.005f && rpm > overrun_rpm;
if (throttle_lift)
lift_enrichment_ = 1.0f;
previous_pedal_ = pedal;
lift_enrichment_ *= std::exp(-dt / 0.32f);
if (pedal > 0.005f || rpm < overrun_exit_rpm)
overrun_ = false;
else if (throttle_lift)
overrun_ = true;
const float air_alpha = -std::expm1(-dt / 0.012f);
filtered_air_flow_ += (air_mass_flow - filtered_air_flow_) * air_alpha;
// The open slide controls the effective venturi area. Its local dynamic
// pressure and the measured static-pressure loss provide the jet signal.
const float venturi_area = throat_area_ * 0.55f * (0.10f + 0.90f * slide);
const float velocity = filtered_air_flow_ / std::max(air_density * venturi_area, 1e-9f);
const float dynamic_pressure = 0.5f * air_density * velocity * velocity;
const float static_drop = std::max(upstream_pressure - downstream_pressure, 0.0f);
venturi_depression_ = std::clamp(dynamic_pressure + 0.18f * static_drop, 0.0f, 60000.0f);
const float pilot_area = circle_area(pilot_jet_diameter);
const float main_area = circle_area(main_jet_diameter);
const float needle_jet_area = circle_area(needle_jet_diameter);
const float needle_lift = smoothstep((slide - 0.04f) / 0.86f);
const float needle_area = needle_jet_area * needle_lift;
const float main_path_area = needle_area > 1e-12f ?
1.0f / std::sqrt(1.0f / (main_area * main_area) + 1.0f / (needle_area * needle_area)) : 0.0f;
const float pilot_depression = bowl_head_pressure + (0.62f * static_drop +
0.25f * dynamic_pressure) * (1.0f + 0.25f * lift_enrichment_);
const float main_depression = bowl_head_pressure + venturi_depression_;
const float pilot_flow = pilot_discharge_coefficient * pilot_area *
std::sqrt(2.0f * fuel_density * std::max(pilot_depression, 0.0f));
const float main_flow = main_discharge_coefficient * main_path_area *
std::sqrt(2.0f * fuel_density * std::max(main_depression, 0.0f));
float demanded_fuel_flow = pilot_flow + main_flow;
const float deposit_fraction = std::clamp(film_fraction * (0.55f + 0.45f * slide), 0.0f, 0.8f);
const float deposited = demanded_fuel_flow * deposit_fraction;
const float evaporation_time = film_time_constant * (overrun_ ? 0.58f : 1.0f);
const float evaporated = std::min(film_mass_ / dt,
film_mass_ / std::max(evaporation_time, 1e-4f));
float jet_flow = demanded_fuel_flow;
const float available_flow = bowl_mass_ / dt;
if (jet_flow > available_flow) {
const float scale = available_flow / std::max(jet_flow, 1e-12f);
jet_flow *= scale;
}
bowl_mass_ -= jet_flow * dt;
bowl_mass_ += std::min(bowl_refill_rate * dt, bowl_capacity - bowl_mass_);
bowl_mass_ = std::clamp(bowl_mass_, 0.0f, bowl_capacity);
// Deposition cannot exceed the fuel that actually left the bowl.
const float actual_deposit = std::min(deposited, jet_flow);
film_mass_ = std::max(film_mass_ + (actual_deposit - evaporated) * dt, 0.0f);
delivered_fuel_flow_ = std::max(jet_flow - actual_deposit + evaporated, 0.0f);
const float fuel_alpha = -std::expm1(-dt / 0.025f);
filtered_fuel_flow_ += (delivered_fuel_flow_ - filtered_fuel_flow_) * fuel_alpha;
if (filtered_air_flow_ > 1e-7f && filtered_fuel_flow_ > 1e-9f)
indicated_afr_ = std::clamp(filtered_air_flow_ / filtered_fuel_flow_, 4.0f, 40.0f);
}
} // namespace fluidsim::port

View File

@@ -51,18 +51,25 @@ void Crankcase::pre_step(float /*dt*/) {
} }
void Crankcase::update_state(float dt) { void Crankcase::update_state(float dt) {
float delta_mass = 0.0f, delta_air_mass = 0.0f, delta_energy = 0.0f; float delta_mass = 0.0f, delta_air_mass = 0.0f, delta_fuel_mass = 0.0f, delta_energy = 0.0f;
for (Port* port : {&intake_port, &transfer_port}) { for (Port* port : {&intake_port, &transfer_port}) {
const float flow = port->mass_flow; const float flow = port->mass_flow;
const float fraction = flow >= 0.0f ? port->air_fraction : air_fraction_; const float fraction = flow >= 0.0f ? port->air_fraction : air_fraction_;
const float fuel = flow >= 0.0f ? port->fuel_fraction : fuel_fraction_;
delta_mass += flow * dt; delta_mass += flow * dt;
delta_air_mass += flow * fraction * dt; delta_air_mass += flow * fraction * dt;
delta_fuel_mass += flow * fuel * dt;
delta_energy += flow * port->enthalpy * dt; delta_energy += flow * port->enthalpy * dt;
} }
const float old_air_mass = mass_ * air_fraction_; const float old_air_mass = mass_ * air_fraction_;
const float old_fuel_mass = mass_ * fuel_fraction_;
mass_ += delta_mass; mass_ += delta_mass;
energy_ += delta_energy; energy_ += delta_energy;
if (mass_ > 1e-12f) air_fraction_ = std::clamp((old_air_mass + delta_air_mass) / mass_, 0.0f, 1.0f); if (mass_ > 1e-12f) {
air_fraction_ = std::clamp((old_air_mass + delta_air_mass) / mass_, 0.0f, 1.0f);
fuel_fraction_ = std::clamp((old_fuel_mass + delta_fuel_mass) / mass_,
0.0f, 1.0f - air_fraction_);
}
if (!(mass_ > 0.0f) || !(energy_ > 0.0f) || !std::isfinite(mass_) || !std::isfinite(energy_)) if (!(mass_ > 0.0f) || !(energy_ > 0.0f) || !std::isfinite(mass_) || !std::isfinite(energy_))
throw std::runtime_error("nonphysical crankcase state"); throw std::runtime_error("nonphysical crankcase state");
@@ -80,6 +87,7 @@ void Crankcase::sync_ports() {
port->temperature = temperature_; port->temperature = temperature_;
port->enthalpy = enthalpy; port->enthalpy = enthalpy;
port->air_fraction = air_fraction_; port->air_fraction = air_fraction_;
port->fuel_fraction = fuel_fraction_;
} }
} }

View File

@@ -57,14 +57,15 @@ void draw_label(sf::RenderWindow& window, const sf::Font* font, const std::strin
} }
std::optional<ScenarioKind> choose_scenario(sf::RenderWindow& window, const sf::Font* font) { std::optional<ScenarioKind> choose_scenario(sf::RenderWindow& window, const sf::Font* font) {
const std::array<sf::FloatRect, 4> cards{{{{32, 110}, {656, 84}}, {{32, 206}, {656, 84}}, {{32, 302}, {656, 84}}, {{32, 398}, {656, 84}}}}; const std::array<sf::FloatRect, 5> cards{{{{32, 110}, {656, 72}}, {{32, 190}, {656, 72}}, {{32, 270}, {656, 72}}, {{32, 350}, {656, 72}}, {{32, 430}, {656, 72}}}};
const sf::FloatRect start_button{{32, 506}, {656, 52}}; const sf::FloatRect start_button{{32, 522}, {656, 52}};
const std::array<const char*, 4> titles{"600 cc parallel twin", "YZ125 two-stroke", "Saab B205 / 9-5 wagon", "600 cc twin + VZ21-size turbo"}; const std::array<const char*, 5> titles{"600 cc parallel twin", "YZ125 two-stroke", "Saab B205 / 9-5 wagon", "600 cc twin + VZ21-size turbo", "1980s American 5.0 V8"};
const std::array<const char*, 4> descriptions{ const std::array<const char*, 5> descriptions{
"Four-stroke twin, motorcycle test and dyno", "Four-stroke twin, motorcycle test and dyno",
"125 cc single, expansion chamber, motorcycle test and dyno", "125 cc single, expansion chamber, motorcycle test and dyno",
"2.0 litre inline-four, GT17 turbo, ECU boost control and five-speed wagon", "2.0 litre inline-four, GT17 turbo, ECU boost control and five-speed wagon",
"Coupled turbine/compressor, shaft inertia and pressure diagnostics"}; "Coupled turbine/compressor, shaft inertia and pressure diagnostics",
"EFI cross-plane V8, heavy flywheel, independent exhaust banks"};
int selected = 0; int selected = 0;
while (window.isOpen()) { while (window.isOpen()) {
while (const auto event = window.pollEvent()) { while (const auto event = window.pollEvent()) {
@@ -72,26 +73,27 @@ std::optional<ScenarioKind> choose_scenario(sf::RenderWindow& window, const sf::
if (const auto* key = event->getIf<sf::Event::KeyPressed>()) { if (const auto* key = event->getIf<sf::Event::KeyPressed>()) {
switch (key->code) { switch (key->code) {
case sf::Keyboard::Key::Escape: return std::nullopt; case sf::Keyboard::Key::Escape: return std::nullopt;
case sf::Keyboard::Key::Up: selected = (selected + 3) % 4; break; case sf::Keyboard::Key::Up: selected = (selected + 4) % 5; break;
case sf::Keyboard::Key::Down: case sf::Keyboard::Key::Down:
case sf::Keyboard::Key::Tab: selected = (selected + 1) % 4; break; case sf::Keyboard::Key::Tab: selected = (selected + 1) % 5; break;
case sf::Keyboard::Key::Num1: selected = 0; break; case sf::Keyboard::Key::Num1: selected = 0; break;
case sf::Keyboard::Key::Num2: selected = 1; break; case sf::Keyboard::Key::Num2: selected = 1; break;
case sf::Keyboard::Key::Num3: selected = 2; break; case sf::Keyboard::Key::Num3: selected = 2; break;
case sf::Keyboard::Key::Num4: selected = 3; break; case sf::Keyboard::Key::Num4: selected = 3; break;
case sf::Keyboard::Key::Num5: selected = 4; break;
case sf::Keyboard::Key::Enter: return static_cast<ScenarioKind>(selected); case sf::Keyboard::Key::Enter: return static_cast<ScenarioKind>(selected);
default: break; default: break;
} }
} }
if (const auto* move = event->getIf<sf::Event::MouseMoved>()) { if (const auto* move = event->getIf<sf::Event::MouseMoved>()) {
const auto point = window.mapPixelToCoords(move->position); const auto point = window.mapPixelToCoords(move->position);
for (int i = 0; i < 4; ++i) for (int i = 0; i < 5; ++i)
if (cards[i].contains(point)) selected = i; if (cards[i].contains(point)) selected = i;
} }
if (const auto* click = event->getIf<sf::Event::MouseButtonReleased>(); if (const auto* click = event->getIf<sf::Event::MouseButtonReleased>();
click && click->button == sf::Mouse::Button::Left) { click && click->button == sf::Mouse::Button::Left) {
const auto point = window.mapPixelToCoords(click->position); const auto point = window.mapPixelToCoords(click->position);
for (int i = 0; i < 4; ++i) for (int i = 0; i < 5; ++i)
if (cards[i].contains(point)) return static_cast<ScenarioKind>(i); if (cards[i].contains(point)) return static_cast<ScenarioKind>(i);
if (start_button.contains(point)) return static_cast<ScenarioKind>(selected); if (start_button.contains(point)) return static_cast<ScenarioKind>(selected);
} }
@@ -99,7 +101,7 @@ std::optional<ScenarioKind> choose_scenario(sf::RenderWindow& window, const sf::
window.clear({9, 12, 17}); window.clear({9, 12, 17});
draw_label(window, font, "FluidSim", {32, 24}, sf::Color::White, 28); draw_label(window, font, "FluidSim", {32, 24}, sf::Color::White, 28);
draw_label(window, font, "Choose a scenario to start", {32, 66}, {180, 190, 205}, 16); draw_label(window, font, "Choose a scenario to start", {32, 66}, {180, 190, 205}, 16);
for (int i = 0; i < 4; ++i) { for (int i = 0; i < 5; ++i) {
sf::RectangleShape card(cards[i].size); sf::RectangleShape card(cards[i].size);
card.setPosition(cards[i].position); card.setPosition(cards[i].position);
card.setFillColor(i == selected ? sf::Color(24, 46, 60) : sf::Color(18, 24, 32)); card.setFillColor(i == selected ? sf::Color(24, 46, 60) : sf::Color(18, 24, 32));
@@ -115,7 +117,7 @@ std::optional<ScenarioKind> choose_scenario(sf::RenderWindow& window, const sf::
start.setPosition(start_button.position); start.setPosition(start_button.position);
start.setFillColor({35, 105, 115}); start.setFillColor({35, 105, 115});
window.draw(start); window.draw(start);
draw_label(window, font, "Start selected scenario [Enter]", {52, 519}, sf::Color::White, 17); draw_label(window, font, "Start selected scenario [Enter]", {52, 537}, sf::Color::White, 17);
draw_label(window, font, "Click a scenario or use Up/Down. Esc: quit", {32, 574}, {150, 165, 180}, 12); draw_label(window, font, "Click a scenario or use Up/Down. Esc: quit", {32, 574}, {150, 165, 180}, 12);
window.display(); window.display();
} }
@@ -296,13 +298,13 @@ void draw_dyno_curve(sf::RenderWindow& window, const sf::Font* font, const DynoC
void draw_dashboard(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state, void draw_dashboard(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state,
float throttle_target, float brake, double playback_speed, std::size_t buffer_fill, float throttle_target, float brake, double playback_speed, std::size_t buffer_fill,
std::size_t buffer_capacity, bool time_warp, std::uint64_t underrun_blocks) { std::size_t buffer_capacity, bool time_warp, std::uint64_t underrun_blocks, bool auto_gain) {
std::ostringstream status; std::ostringstream status;
status << (time_warp ? "[Space] normal speed" : "[Space] real time") status << (time_warp ? "[Space] normal speed" : "[Space] real time")
<< " Sim " << std::fixed << std::setprecision(3) << playback_speed << "x" << " Sim " << std::fixed << std::setprecision(3) << playback_speed << "x"
<< " Audio buffer " << buffer_fill << "/" << buffer_capacity << " Audio buffer " << buffer_fill << "/" << buffer_capacity
<< " Underruns " << underrun_blocks << " Underruns " << underrun_blocks
<< (state.cylinder_count == 4 ? "\nWagon " : "\nBike ") << std::setprecision(1) << state.speed_kmh << " km/h Gear " << (state.cylinder_count == 8 ? "\nCoupe " : state.cylinder_count == 4 ? "\nWagon " : "\nBike ") << std::setprecision(1) << state.speed_kmh << " km/h Gear "
<< (state.gear == 0 ? "N" : std::to_string(state.gear)) << (state.gear == 0 ? "N" : std::to_string(state.gear))
<< " Throttle " << state.throttle * 100.0f << "% / target " << throttle_target * 100.0f << "% [W, up/down]" << " Throttle " << state.throttle * 100.0f << "% / target " << throttle_target * 100.0f << "% [W, up/down]"
<< " Clutch " << state.clutch * 100.0f << "% [C] Engine load " << brake * 100.0f << "% [left/right]" << " Clutch " << state.clutch * 100.0f << "% [C] Engine load " << brake * 100.0f << "% [left/right]"
@@ -310,6 +312,7 @@ void draw_dashboard(sf::RenderWindow& window, const sf::Font* font, const Scenar
<< "\nScene [R]: " << scene_name(state.audio_scene) << " " << (state.fuel_cut ? "FUEL CUT" : (state.idle_control ? "IDLE CONTROL" : "RUNNING")); << "\nScene [R]: " << scene_name(state.audio_scene) << " " << (state.fuel_cut ? "FUEL CUT" : (state.idle_control ? "IDLE CONTROL" : "RUNNING"));
if (!state.two_stroke) if (!state.two_stroke)
status << " Mechanical [M]: " << (state.mechanical_sound_enabled ? "ON" : "OFF"); status << " Mechanical [M]: " << (state.mechanical_sound_enabled ? "ON" : "OFF");
status << " Auto gain [G]: " << (auto_gain ? "ON" : "OFF");
if (font) { if (font) {
sf::RectangleShape panel({1260.0f, 92.0f}); sf::RectangleShape panel({1260.0f, 92.0f});
panel.setPosition({10.0f, 10.0f}); panel.setPosition({10.0f, 10.0f});
@@ -350,7 +353,7 @@ void draw_two_stroke_scene(sf::RenderWindow& window,const sf::Font* font,const S
draw_label(window,font,"CRANKCASE",{455,385},sf::Color::White,14); draw_label(window,font,"CRANKCASE",{455,385},sf::Color::White,14);
draw_pipe(window,s,0,460,30,170); draw_pipe(window,s,0,460,30,170);
draw_volume(window,s.intake_pressure,{{180,425},{95,70}}); draw_volume(window,s.intake_pressure,{{180,425},{95,70}});
draw_label(window,font,"INTAKE",{180,400},sf::Color::White,14); draw_label(window,font,"CARB / INTAKE",{180,400},sf::Color::White,14);
draw_pipe(window,s,1,460,285,445); draw_pipe(window,s,1,460,285,445);
draw_label(window,font,s.reed_open?"REED OPEN":"REED CLOSED",{290,485},s.reed_open?sf::Color::Green:sf::Color::Red,12); draw_label(window,font,s.reed_open?"REED OPEN":"REED CLOSED",{290,485},s.reed_open?sf::Color::Green:sf::Color::Red,12);
draw_pipe(window,s,2,350,315,450); draw_pipe(window,s,2,350,315,450);
@@ -365,7 +368,8 @@ void draw_two_stroke_scene(sf::RenderWindow& window,const sf::Font* font,const S
draw_label(window,font,s.racing_exhaust ? "HEADER / DIFFUSERS / BELLY / BAFFLE / STINGER" : "STRAIGHT EXHAUST",{670,205},sf::Color::White,13); draw_label(window,font,s.racing_exhaust ? "HEADER / DIFFUSERS / BELLY / BAFFLE / STINGER" : "STRAIGHT EXHAUST",{670,205},sf::Color::White,13);
std::ostringstream pressures; std::ostringstream pressures;
pressures<<std::fixed<<std::setprecision(1)<<"Cylinder "<<s.cylinder_pressure1/1000<<" kPa Crankcase " pressures<<std::fixed<<std::setprecision(1)<<"Cylinder "<<s.cylinder_pressure1/1000<<" kPa Crankcase "
<<s.crankcase_pressure/1000<<" kPa Intake "<<s.intake_pressure/1000<<" kPa"; <<s.crankcase_pressure/1000<<" kPa Intake "<<s.intake_pressure/1000<<" kPa Carb AFR "
<<s.target_afr<<":1";
draw_label(window,font,pressures.str(),{22,580},sf::Color::White,17); draw_label(window,font,pressures.str(),{22,580},sf::Color::White,17);
draw_label(window,font,"54 x 54.5 mm | 8.2:1 trapped compression | estimated pipe/ports/CDI | fixed-open power valve", draw_label(window,font,"54 x 54.5 mm | 8.2:1 trapped compression | estimated pipe/ports/CDI | fixed-open power valve",
{22,625},{180,190,200},11); {22,625},{180,190,200},11);
@@ -459,14 +463,38 @@ void draw_b205_scene(sf::RenderWindow& window, const sf::Font* font, const Scena
void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state, void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state,
float throttle_target, float brake, double playback_speed, std::size_t buffer_fill, float throttle_target, float brake, double playback_speed, std::size_t buffer_fill,
std::size_t buffer_capacity, bool time_warp, DynoCurve& dyno_curve, std::uint64_t underrun_blocks) { std::size_t buffer_capacity, bool time_warp, DynoCurve& dyno_curve, std::uint64_t underrun_blocks, bool auto_gain) {
const float power_kw = state.average_power / 1000.0f; const float power_kw = state.average_power / 1000.0f;
dyno_curve.update(state.rpm, power_kw, state.average_torque); dyno_curve.update(state.rpm, power_kw, state.average_torque);
if (state.two_stroke) draw_two_stroke_scene(window, font, state); if (state.cylinder_count == 8) {
window.clear({9,12,17});
draw_label(window, font, "American 5.0 V8 | EFI / ECU | 7000 RPM | heavy flywheel", {25,185}, sf::Color::White, 16);
draw_pipe(window, state, 0, 205, 25, 115);
draw_volume(window, state.intake_pressure, {{120,200},{60,25}});
draw_label(window, font, "ONE THROTTLE / SHARED PLENUM", {850,225}, sf::Color::White, 12);
for (int i = 0; i < 8; ++i) {
const float x = 70 + (i%4)*185;
const float top = i < 4 ? 230.f : 455.f;
const std::string name = "CYL " + std::to_string(i+1);
draw_cylinder(window, font, state.cylinder_pressures[i], state.piston_fractions[i], top,
name.c_str(), state.intake_areas[i]>1e-7f, state.exhaust_areas[i]>1e-7f, x);
draw_pipe(window, state, 9+i, top+150, x-10, x+90);
draw_pipe(window, state, 1+i, top+125, x-10, x+90);
draw_graph_line(window, {x+90,top+150}, {830,top+150}, {200,120,80});
}
draw_graph_line(window, {830,380}, {850,300}, {200,120,80});
draw_graph_line(window, {830,605}, {850,375}, {200,120,80});
draw_pipe(window, state, 17, 300, 850, 1250);
draw_pipe(window, state, 18, 375, 850, 1250);
draw_label(window, font, "SEPARATE BANK COLLECTORS / DUAL TAILS", {850,410}, sf::Color::White, 11);
draw_label(window, font, "EFI AFR " + std::to_string(state.target_afr), {850,430}, sf::Color::White, 13);
draw_label(window, font, "302-class | shared 4 L intake, 12 cm runners | 7:1 compression | 750 rpm idle", {25,690}, {180,190,205}, 12);
}
else if (state.two_stroke) draw_two_stroke_scene(window, font, state);
else if (state.cylinder_count == 4) draw_b205_scene(window, font, state); else if (state.cylinder_count == 4) draw_b205_scene(window, font, state);
else draw_twin_scene(window, font, state); else draw_twin_scene(window, font, state);
draw_dashboard(window, font, state, throttle_target, brake, playback_speed, buffer_fill, draw_dashboard(window, font, state, throttle_target, brake, playback_speed, buffer_fill,
buffer_capacity, time_warp, underrun_blocks); buffer_capacity, time_warp, underrun_blocks, auto_gain);
draw_dyno_curve(window, font, dyno_curve, 860, 455, 400, 250, state.rpm, power_kw); draw_dyno_curve(window, font, dyno_curve, 860, 455, 400, 250, state.rpm, power_kw);
window.display(); window.display();
} }
@@ -486,8 +514,8 @@ int main(int argc, char** argv) {
if((option=="--scenario"||option=="--scene"||option=="--exhaust"||option=="--turbo")&&i+1<argc){ if((option=="--scenario"||option=="--scene"||option=="--exhaust"||option=="--turbo")&&i+1<argc){
const std::string value=argv[++i]; const std::string value=argv[++i];
if(option=="--scenario"){ if(option=="--scenario"){
if(value!="twin"&&value!="two-stroke"&&value!="b205"){std::cerr<<"Choose twin, two-stroke or b205\n";return 2;} if(value!="twin"&&value!="two-stroke"&&value!="b205"&&value!="v8"){std::cerr<<"Choose twin, two-stroke, b205 or v8\n";return 2;}
selected_kind=value=="b205" ? ScenarioKind::SaabB205 : selected_kind=value=="v8" ? ScenarioKind::AmericanV8 : value=="b205" ? ScenarioKind::SaabB205 :
(value=="two-stroke" ? ScenarioKind::TwoStroke : ScenarioKind::Twin); (value=="two-stroke" ? ScenarioKind::TwoStroke : ScenarioKind::Twin);
scenario_from_args=true; scenario_from_args=true;
}else if(option=="--turbo"){ }else if(option=="--turbo"){
@@ -505,7 +533,7 @@ int main(int argc, char** argv) {
else if(value=="woodland")selected_scene=OutdoorScene::Woodland; else if(value=="woodland")selected_scene=OutdoorScene::Woodland;
else {std::cerr<<"Choose dry, field, wall or woodland\n";return 2;} else {std::cerr<<"Choose dry, field, wall or woodland\n";return 2;}
} }
}else{std::cerr<<"Usage: fluidsim_desktop [--scenario twin|two-stroke|b205] [--scene dry|field|wall|woodland] [--exhaust racing|straight] [--turbo none|vz21|gt17]\n";return 2;} }else{std::cerr<<"Usage: fluidsim_desktop [--scenario twin|two-stroke|b205|v8] [--scene dry|field|wall|woodland] [--exhaust racing|straight] [--turbo none|vz21|gt17]\n";return 2;}
} }
const sf::Vector2u initial_size = scenario_from_args ? sf::Vector2u{window_width, window_height} const sf::Vector2u initial_size = scenario_from_args ? sf::Vector2u{window_width, window_height}
: sf::Vector2u{720, 606}; : sf::Vector2u{720, 606};
@@ -542,7 +570,7 @@ int main(int argc, char** argv) {
std::cerr<<"Use gt17 with b205 or vz21 with twin\n";return 2; std::cerr<<"Use gt17 with b205 or vz21 with twin\n";return 2;
} }
} }
window.setTitle(selected_kind == ScenarioKind::TurboTwin ? "FluidSim - twin + VZ21 turbo" : selected_kind == ScenarioKind::SaabB205 ? "FluidSim - Saab B205 / 9-5 wagon" : window.setTitle(selected_kind == ScenarioKind::AmericanV8 ? "FluidSim - 1980s American 5.0 V8" : selected_kind == ScenarioKind::TurboTwin ? "FluidSim - twin + VZ21 turbo" : selected_kind == ScenarioKind::SaabB205 ? "FluidSim - Saab B205 / 9-5 wagon" :
(selected_kind == ScenarioKind::TwoStroke ? "FluidSim - YZ125 two-stroke" : "FluidSim - parallel twin")); (selected_kind == ScenarioKind::TwoStroke ? "FluidSim - YZ125 two-stroke" : "FluidSim - parallel twin"));
DesktopScenario scenario(selected_kind, two_stroke_config, turbo_name!="none"); DesktopScenario scenario(selected_kind, two_stroke_config, turbo_name!="none");
scenario.set_audio_scene(selected_scene); scenario.set_audio_scene(selected_scene);
@@ -600,6 +628,7 @@ int main(int argc, char** argv) {
scenario.set_audio_scene(selected_scene); scenario.set_audio_scene(selected_scene);
break; break;
case sf::Keyboard::Key::M: scenario.toggle_mechanical_sound(); break; case sf::Keyboard::Key::M: scenario.toggle_mechanical_sound(); break;
case sf::Keyboard::Key::G: audio.set_auto_gain(!audio.auto_gain()); break;
default: break; default: break;
} }
} }
@@ -629,7 +658,7 @@ int main(int argc, char** argv) {
scenario.set_controls(throttle_current, brake_current, clutch_current); scenario.set_controls(throttle_current, brake_current, clutch_current);
draw_scene(window, font_ptr, scenario.snapshot(), throttle_target, brake_current, draw_scene(window, font_ptr, scenario.snapshot(), throttle_target, brake_current,
current_speed, ring.available_samples(), ring_capacity, time_warp, dyno_curve, audio.underrun_blocks()); current_speed, ring.available_samples(), ring_capacity, time_warp, dyno_curve, audio.underrun_blocks(), audio.auto_gain());
} }
simulation.request_stop(); simulation.request_stop();

View File

@@ -51,8 +51,12 @@ void Ecu::step(float dt, float pedal, float rpm) {
const float idle_command = idle_ ? std::clamp(idle_base_opening + idle_kp * error + integral_, const float idle_command = idle_ ? std::clamp(idle_base_opening + idle_kp * error + integral_,
0.0f, idle_max_opening) : 0.0f; 0.0f, idle_max_opening) : 0.0f;
target_ = std::max(pedal, idle_command); target_ = std::max(pedal, idle_command);
const float change = (target_ - opening_) * (-std::expm1(-dt / std::max(throttle_time_constant, 1e-4f))); const bool closing = target_ < opening_;
const float travel = std::max(throttle_rate, 0.0f) * dt; const float response = closing && throttle_closing_time_constant > 0 ?
throttle_closing_time_constant : throttle_time_constant;
const float rate = closing && throttle_closing_rate > 0 ? throttle_closing_rate : throttle_rate;
const float change = (target_ - opening_) * (-std::expm1(-dt / std::max(response, 1e-4f)));
const float travel = std::max(rate, 0.0f) * dt;
opening_ = std::clamp(opening_ + std::clamp(change, -travel, travel), 0.0f, 1.0f); opening_ = std::clamp(opening_ + std::clamp(change, -travel, travel), 0.0f, 1.0f);
const float enrichment = std::clamp((opening_ - 0.7f) / 0.3f, 0.0f, 1.0f); const float enrichment = std::clamp((opening_ - 0.7f) / 0.3f, 0.0f, 1.0f);
afr_ = stoichiometric_afr + (power_afr - stoichiometric_afr) * enrichment; afr_ = stoichiometric_afr + (power_afr - stoichiometric_afr) * enrichment;

View File

@@ -50,7 +50,7 @@ float EngineCylinder::clearance_volume() const {
} }
float EngineCylinder::total_mass() const { float EngineCylinder::total_mass() const {
return air_mass + exhaust_mass; return air_mass + exhaust_mass + fuel_mass;
} }
float EngineCylinder::temperature() const { float EngineCylinder::temperature() const {
@@ -79,11 +79,13 @@ void EngineCylinder::initialize(const Crankshaft& /*crank*/) {
volume = clearance_volume(); volume = clearance_volume();
air_mass = 1.225f * volume; air_mass = 1.225f * volume;
exhaust_mass = 0.0f; exhaust_mass = 0.0f;
fuel_mass = 0.0f;
energy = 101325.0f * volume / (gamma - 1.0f); energy = 101325.0f * volume / (gamma - 1.0f);
pressure = 101325.0f; pressure = 101325.0f;
intake.owner_energy = exhaust.owner_energy = &energy; intake.owner_energy = exhaust.owner_energy = &energy;
intake.owner_air_mass = exhaust.owner_air_mass = &air_mass; intake.owner_air_mass = exhaust.owner_air_mass = &air_mass;
intake.owner_exhaust_mass = exhaust.owner_exhaust_mass = &exhaust_mass; intake.owner_exhaust_mass = exhaust.owner_exhaust_mass = &exhaust_mass;
intake.owner_fuel_mass = exhaust.owner_fuel_mass = &fuel_mass;
sync_ports(); sync_ports();
} }
@@ -140,8 +142,10 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
const float previous_degrees = wrap_degrees((crank.previous_angle + phase) * 180.0f / pi); const float previous_degrees = wrap_degrees((crank.previous_angle + phase) * 180.0f / pi);
const float current_degrees = wrap_degrees(theta * 180.0f / pi); const float current_degrees = wrap_degrees(theta * 180.0f / pi);
if (crossed_window_close(previous_degrees, current_degrees, intake_open, intake_close)) { if (crossed_window_close(previous_degrees, current_degrees, intake_open, intake_close)) {
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f); if (!transported_fuel)
fuel_injected = fuel_mass > 0.0f; fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
const float charge_afr = air_mass / std::max(fuel_mass, 1e-12f);
fuel_injected = fuel_mass > 0.0f && (!transported_fuel || (charge_afr >= 7 && charge_afr <= 24));
} }
const float spark_angle = wrap_degrees(720.0f - spark_advance); const float spark_angle = wrap_degrees(720.0f - spark_advance);
@@ -149,6 +153,7 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
if (crossed_spark && fuel_injected && !combustion_active && !(ecu ? ecu->fuel_cut() : rev_limit_active) && random_unit() >= misfire_probability) { if (crossed_spark && fuel_injected && !combustion_active && !(ecu ? ecu->fuel_cut() : rev_limit_active) && random_unit() >= misfire_probability) {
combustion_active = true; combustion_active = true;
burn_fraction = 0.0f; burn_fraction = 0.0f;
active_fuel_mass_ = transported_fuel ? std::min(fuel_mass, air_mass / 14.7f) : fuel_mass;
energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.0f); energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.0f);
} }
@@ -161,14 +166,23 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
if (fraction >= 1.0f || angle_since_spark > burn_duration + burn_start + spark_advance) { if (fraction >= 1.0f || angle_since_spark > burn_duration + burn_start + spark_advance) {
fraction = 1.0f; fraction = 1.0f;
combustion_active = false; combustion_active = false;
exhaust_mass += air_mass; if (!transported_fuel) {
air_mass = 0.0f; exhaust_mass += air_mass;
air_mass = 0.0f;
}
} }
fuel_injected = false; fuel_injected = false;
const float increment = fraction - burn_fraction; const float increment = fraction - burn_fraction;
if (increment > 0.0f) { if (increment > 0.0f) {
energy += fuel_mass * fuel_lhv * energy_factor_ * increment; const float burned_fuel = std::min(active_fuel_mass_ * increment, fuel_mass);
exhaust_mass += fuel_mass * increment; energy += burned_fuel * fuel_lhv * energy_factor_;
fuel_mass -= burned_fuel;
exhaust_mass += burned_fuel;
if (transported_fuel) {
const float burned_air = std::min(air_mass, burned_fuel * 14.7f);
air_mass -= burned_air;
exhaust_mass += burned_air;
}
burn_fraction = fraction; burn_fraction = fraction;
} }
} }
@@ -184,16 +198,20 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
} }
void EngineCylinder::update_state(float dt) { void EngineCylinder::update_state(float dt) {
float delta_air = 0.0f, delta_exhaust = 0.0f, delta_energy = 0.0f; float delta_air = 0.0f, delta_exhaust = 0.0f, delta_fuel = 0.0f, delta_energy = 0.0f;
for (Port* port : {&intake, &exhaust}) { for (Port* port : {&intake, &exhaust}) {
const float mass_flow = port->mass_flow; const float mass_flow = port->mass_flow;
const float fraction = mass_flow >= 0.0f ? port->air_fraction : air_fraction(); const float fraction = mass_flow >= 0.0f ? port->air_fraction : air_fraction();
const float fuel_fraction = mass_flow >= 0.0f ? port->fuel_fraction :
fuel_mass / std::max(total_mass(), 1e-12f);
delta_air += mass_flow * fraction * dt; delta_air += mass_flow * fraction * dt;
delta_exhaust += mass_flow * (1.0f - fraction) * dt; delta_fuel += mass_flow * fuel_fraction * dt;
delta_exhaust += mass_flow * std::max(1.0f - fraction - fuel_fraction, 0.0f) * dt;
delta_energy += mass_flow * port->enthalpy * dt; delta_energy += mass_flow * port->enthalpy * dt;
} }
air_mass += delta_air; air_mass += delta_air;
exhaust_mass += delta_exhaust; exhaust_mass += delta_exhaust;
fuel_mass += delta_fuel;
energy += delta_energy; energy += delta_energy;
const float current_volume = std::max(volume, 1e-12f); const float current_volume = std::max(volume, 1e-12f);
@@ -211,12 +229,14 @@ void EngineCylinder::update_state(float dt) {
if (total_mass() < 1e-9f) { if (total_mass() < 1e-9f) {
air_mass = 1e-9f; air_mass = 1e-9f;
exhaust_mass = 0.0f; exhaust_mass = 0.0f;
fuel_mass = 0.0f;
energy = 101325.0f * current_volume / (gamma - 1.0f); energy = 101325.0f * current_volume / (gamma - 1.0f);
} else if (energy < 0.0f) { } else if (energy < 0.0f) {
energy = 101325.0f * current_volume / (gamma - 1.0f); energy = 101325.0f * current_volume / (gamma - 1.0f);
} }
air_mass = std::max(air_mass, 0.0f); air_mass = std::max(air_mass, 0.0f);
exhaust_mass = std::max(exhaust_mass, 0.0f); exhaust_mass = std::max(exhaust_mass, 0.0f);
fuel_mass = std::max(fuel_mass, 0.0f);
pressure = (gamma - 1.0f) * energy / current_volume; pressure = (gamma - 1.0f) * energy / current_volume;
sync_ports(); sync_ports();
} }
@@ -257,12 +277,14 @@ void EngineCylinder::sync_ports() {
pressure = (gamma - 1.0f) * energy / std::max(volume, 1e-12f); pressure = (gamma - 1.0f) * energy / std::max(volume, 1e-12f);
const float temp = pressure / std::max(density * gas_constant, 1e-12f); const float temp = pressure / std::max(density * gas_constant, 1e-12f);
const float fraction = air_fraction(); const float fraction = air_fraction();
const float fuel_fraction = fuel_mass / std::max(total_mass(), 1e-12f);
const float enthalpy = gamma / (gamma - 1.0f) * pressure / std::max(density, 1e-12f); const float enthalpy = gamma / (gamma - 1.0f) * pressure / std::max(density, 1e-12f);
for (Port* port : {&intake, &exhaust}) { for (Port* port : {&intake, &exhaust}) {
port->pressure = pressure; port->pressure = pressure;
port->density = density; port->density = density;
port->temperature = temp; port->temperature = temp;
port->air_fraction = fraction; port->air_fraction = fraction;
port->fuel_fraction = fuel_fraction;
port->enthalpy = enthalpy; port->enthalpy = enthalpy;
} }
} }

View File

@@ -46,10 +46,10 @@ int main(int argc, char** argv) {
constexpr int sample_rate = 44100; constexpr int sample_rate = 44100;
const auto total_samples = static_cast<std::uint64_t>(duration_seconds * sample_rate); const auto total_samples = static_cast<std::uint64_t>(duration_seconds * sample_rate);
const auto report_interval = static_cast<std::uint64_t>(sample_rate / 10); const auto report_interval = static_cast<std::uint64_t>(sample_rate / 10);
if (scenario_name == "twin" || scenario_name == "b205") { if (scenario_name == "twin" || scenario_name == "b205" || scenario_name == "v8") {
FourStrokeScenario scenario(sample_rate, scenario_name == "b205" ? FourStrokePreset::SaabB205 : FourStrokePreset::Twin, turbo_enabled); FourStrokeScenario scenario(sample_rate, scenario_name == "v8" ? FourStrokePreset::AmericanV8 : scenario_name == "b205" ? FourStrokePreset::SaabB205 : FourStrokePreset::Twin, turbo_enabled);
scenario.throttle = throttle; scenario.throttle = throttle;
std::cout << (scenario_name == "b205" ? "FluidSimCpp | Saab B205 / 9-5 wagon | throttle " : "FluidSimCpp | 600 cc parallel twin | throttle ") << std::fixed << std::setprecision(0) std::cout << (scenario_name == "v8" ? "FluidSimCpp | American 5.0 V8 | throttle " : scenario_name == "b205" ? "FluidSimCpp | Saab B205 / 9-5 wagon | throttle " : "FluidSimCpp | 600 cc parallel twin | throttle ") << std::fixed << std::setprecision(0)
<< throttle * 100.0f << "%\n"; << throttle * 100.0f << "%\n";
for (std::uint64_t i = 0; i < total_samples; ++i) { for (std::uint64_t i = 0; i < total_samples; ++i) {
scenario.process(); scenario.process();
@@ -116,7 +116,7 @@ int main(int argc, char** argv) {
} }
} else { } else {
std::cerr << "Unknown scenario '" << scenario_name std::cerr << "Unknown scenario '" << scenario_name
<< "'. Choose twin, two-stroke, b205, helmholtz, or pulse.\n"; << "'. Choose twin, two-stroke, b205, v8, helmholtz, or pulse.\n";
return 2; return 2;
} }
return 0; return 0;

View File

@@ -47,6 +47,7 @@ OutdoorAudio::OutdoorAudio(int rate, OutdoorAudioConfig config) : rate_(rate), c
config_.listener_right.z / norm}; config_.listener_right.z / norm};
configure_emitter(emitters_[0], config_.intake_position); configure_emitter(emitters_[0], config_.intake_position);
configure_emitter(emitters_[1], config_.exhaust_position); configure_emitter(emitters_[1], config_.exhaust_position);
if (config_.dual_exhaust) configure_emitter(emitters_[2], config_.second_exhaust_position);
constexpr std::array<int, 4> lengths{1499, 2111, 2633, 3251}; constexpr std::array<int, 4> lengths{1499, 2111, 2633, 3251};
for (std::size_t i = 0; i < tail_.size(); ++i) { for (std::size_t i = 0; i < tail_.size(); ++i) {
auto &line = tail_[i]; auto &line = tail_[i];
@@ -148,8 +149,8 @@ AudioFrame OutdoorAudio::diffuse(float input) {
} }
return {(values[0] + values[2]) * .35f, (values[1] + values[3]) * .35f}; return {(values[0] + values[2]) * .35f, (values[1] + values[3]) * .35f};
} }
AudioFrame OutdoorAudio::process(float intake, float exhaust) { AudioFrame OutdoorAudio::process(float intake, float exhaust, float second_exhaust) {
if (!std::isfinite(intake) || !std::isfinite(exhaust)) if (!std::isfinite(intake) || !std::isfinite(exhaust) || !std::isfinite(second_exhaust))
throw std::runtime_error("nonfinite acoustic source flow"); throw std::runtime_error("nonfinite acoustic source flow");
const bool wet = scene_ != OutdoorScene::Dry; const bool wet = scene_ != OutdoorScene::Dry;
const std::array<float, 4> target{1, wet ? 1.f : 0.f, scene_ == OutdoorScene::Wall ? 1.f : 0.f, const std::array<float, 4> target{1, wet ? 1.f : 0.f, scene_ == OutdoorScene::Wall ? 1.f : 0.f,
@@ -165,7 +166,8 @@ AudioFrame OutdoorAudio::process(float intake, float exhaust) {
last_raw_ = {}; last_raw_ = {};
const auto a = render(emitters_[0], intake, last_dry_, last_raw_); const auto a = render(emitters_[0], intake, last_dry_, last_raw_);
const auto b = render(emitters_[1], exhaust, last_dry_, last_raw_); const auto b = render(emitters_[1], exhaust, last_dry_, last_raw_);
const auto c = config_.dual_exhaust ? render(emitters_[2], second_exhaust, last_dry_, last_raw_) : AudioFrame{};
const auto tail = diffuse((last_dry_.left + last_dry_.right) * .5f); const auto tail = diffuse((last_dry_.left + last_dry_.right) * .5f);
return {a.left + b.left + tail.left * tail_mix_, a.right + b.right + tail.right * tail_mix_}; return {a.left + b.left + c.left + tail.left * tail_mix_, a.right + b.right + c.right + tail.right * tail_mix_};
} }
} // namespace fluidsim::port } // namespace fluidsim::port

View File

@@ -7,11 +7,17 @@ namespace fluidsim::port {
FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selected_preset, bool turbo_enabled) FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selected_preset, bool turbo_enabled)
: preset(selected_preset), : preset(selected_preset),
intake_plenum(selected_preset == FourStrokePreset::SaabB205 ? 3e-3f : 1e-3f, 101325, 300), intake_plenum(selected_preset == FourStrokePreset::AmericanV8 ? 4e-3f : selected_preset == FourStrokePreset::SaabB205 ? 3e-3f : 1e-3f, 101325, 300),
exhaust_collector(selected_preset == FourStrokePreset::SaabB205 ? .6e-3f : .15e-3f, 101325, 700), exhaust_collector(selected_preset == FourStrokePreset::AmericanV8 ? 1.5e-3f : selected_preset == FourStrokePreset::SaabB205 ? .6e-3f : .15e-3f, 101325, 700),
pipes(create_pipes(selected_preset, turbo_enabled)), dt_(1.f / std::max(sample_rate, 1)), pipes(create_pipes(selected_preset, turbo_enabled)), dt_(1.f / std::max(sample_rate, 1)),
boundaries_(pipes), solver_(pipes, boundaries_), audio_(sample_rate), mechanical_sound_(sample_rate) { boundaries_(pipes), solver_(pipes, boundaries_), audio_(sample_rate, [&] {
OutdoorAudioConfig config;
config.dual_exhaust = selected_preset == FourStrokePreset::AmericanV8;
return config;
}()), mechanical_sound_(sample_rate) {
const bool saab = preset == FourStrokePreset::SaabB205; const bool saab = preset == FourStrokePreset::SaabB205;
const bool v8 = preset == FourStrokePreset::AmericanV8;
if (v8 && turbo_enabled) throw std::invalid_argument("American V8 preset is naturally aspirated");
const int count = cylinder_count(); const int count = cylinder_count();
crankshaft.omega = (saab ? 1200 : 2000) * 2 * pi / 60; crankshaft.omega = (saab ? 1200 : 2000) * 2 * pi / 60;
crankshaft.inertia = saab ? .18f : .04f; crankshaft.inertia = saab ? .18f : .04f;
@@ -36,13 +42,54 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selecte
ecu.overrun_cut_rpm = 2000; ecu.overrun_cut_rpm = 2000;
ecu.overrun_resume_rpm = 1300; ecu.overrun_resume_rpm = 1300;
} }
if (v8) {
// Estimated mild 1980s 302-class pushrod small block and manual coupe.
crankshaft.omega = 1000 * 2*pi/60;
crankshaft.inertia = 1.1f;
crankshaft.friction_constant = 22;
crankshaft.friction_viscous = .025f;
vehicle.mass = 3000;
vehicle.primary_reduction = 1;
vehicle.final_drive_ratio = 6.16f / 1.7f; // 1.7x taller gearing in every gear.
vehicle.gear_ratios = {3.35f, 1.93f, 1.29f, 1.f, .68f};
vehicle.wheel_radius = .32f;
vehicle.clutch_capacity = 1100; // Hold V8 torque with the heavy flywheel/car.
vehicle.drag_coefficient = .4f;
vehicle.frontal_area = 2.f;
ecu.idle_rpm = 750;
ecu.throttle_time_constant = .065f;
ecu.throttle_rate = 2.3f;
ecu.throttle_closing_time_constant = .16f;
ecu.throttle_closing_rate = 1.4f;
ecu.idle_base_opening = .08f;
ecu.idle_spark_gain = .02f;
ecu.ignition_map = {{0, 10}, {750, 12}, {2500, 28}, {4000, 32}, {6000, 30}};
ecu.rev_limit_rpm = 7000;
ecu.rev_resume_rpm = 6750;
ecu.overrun_cut_rpm = 2000;
ecu.overrun_resume_rpm = 1100;
// One minimum substep; acoustic CFL still subdivides when needed.
solver_.minimum_substeps = 1;
}
vehicle.set_neutral(); vehicle.set_neutral();
vehicle.clutch_input = clutch; vehicle.clutch_input = clutch;
const auto engines = cylinders(); const auto engines = cylinders();
const std::array<float, 4> phases{0, pi, 3*pi, 2*pi}; // I4 firing: 1-3-4-2 const std::array<float, 8> phases{0, pi, 3*pi, 2*pi}; // I4 firing: 1-3-4-2
const std::array<int, 8> firing_order{1, 5, 4, 2, 6, 3, 7, 8};
for (int i = 0; i < count; ++i) { for (int i = 0; i < count; ++i) {
auto& c = *engines[i]; auto& c = *engines[i];
c.phase = phases[i]; c.phase = phases[i];
if (v8) {
const auto position = std::find(firing_order.begin(), firing_order.end(), i+1) - firing_order.begin();
c.phase = std::fmod(4*pi - float(position)*pi*.5f, 4*pi);
c.bore = .1016f;
c.stroke = .0762f;
c.rod = .129f;
c.compression = 7.0f;
c.intake_open = 350; c.intake_close = 570;
c.exhaust_open = 140; c.exhaust_close = 370;
c.burn_duration = 65;
}
if (saab) { if (saab) {
c.bore = .090f; c.bore = .090f;
c.stroke = .078f; c.stroke = .078f;
@@ -54,13 +101,17 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selecte
c.intake_valve_lift = saab ? .0085f : .008f; c.intake_valve_lift = saab ? .0085f : .008f;
c.exhaust_valve_diameter = saab ? .028f : .026f; c.exhaust_valve_diameter = saab ? .028f : .026f;
c.exhaust_valve_lift = saab ? .008f : .0075f; c.exhaust_valve_lift = saab ? .008f : .0075f;
if (v8) {
c.intake_valve_diameter = .045f; c.exhaust_valve_diameter = .037f;
c.intake_valve_lift = .010f; c.exhaust_valve_lift = .010f;
}
c.rev_limit_enabled = true; c.rev_limit_enabled = true;
c.rev_limit_rpm = ecu.rev_limit_rpm; c.rev_limit_rpm = ecu.rev_limit_rpm;
c.rev_limit_hysteresis = 250; c.rev_limit_hysteresis = 250;
c.rev_limit_cut_time = .020f; c.rev_limit_cut_time = .020f;
c.ecu = &ecu; c.ecu = &ecu;
c.initialize(crankshaft); c.initialize(crankshaft);
if (saab) { if (saab || v8) {
// Initialize each phased cylinder at its actual piston position. // Initialize each phased cylinder at its actual piston position.
c.volume = c.compute_volume(c.phase); c.volume = c.compute_volume(c.phase);
c.air_mass = ambient_density * c.volume; c.air_mass = ambient_density * c.volume;
@@ -80,6 +131,7 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selecte
solver_.add_component(intake_atmosphere); solver_.add_component(intake_atmosphere);
solver_.add_component(intake_plenum); solver_.add_component(intake_plenum);
solver_.add_component(exhaust_collector); solver_.add_component(exhaust_collector);
if (v8) solver_.add_component(second_bank_collector);
if (turbo_enabled) { if (turbo_enabled) {
turbo = std::make_unique<Turbocharger>(saab ? TurboConfig::gt17() : TurboConfig{}); turbo = std::make_unique<Turbocharger>(saab ? TurboConfig::gt17() : TurboConfig{});
ecu.boost_control_enabled = saab; ecu.boost_control_enabled = saab;
@@ -93,7 +145,8 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selecte
boundaries_.add_orifice(throttle_port, 0, false, throttle_area_, .7f); boundaries_.add_orifice(throttle_port, 0, false, throttle_area_, .7f);
for (int i = 0; i < count; ++i) { for (int i = 0; i < count; ++i) {
auto& feed = intake_plenum.create_port(); auto& feed = intake_plenum.create_port();
auto& collector_in = exhaust_collector.create_port(); // 1--4 and 5--8 are separate physical banks for this firing order.
auto& collector_in = (v8 && i >= 4 ? second_bank_collector : exhaust_collector).create_port();
solver_.add_component(*engines[i]); solver_.add_component(*engines[i]);
boundaries_.add_orifice(feed, 1+i, true, runner_area_); boundaries_.add_orifice(feed, 1+i, true, runner_area_);
boundaries_.add_orifice(engines[i]->intake, 1+i, false, intake_valve_areas_[i]); boundaries_.add_orifice(engines[i]->intake, 1+i, false, intake_valve_areas_[i]);
@@ -104,11 +157,16 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selecte
boundaries_.add_orifice(collector_out, 1+2*count, true, tail_area_); boundaries_.add_orifice(collector_out, 1+2*count, true, tail_area_);
const int exhaust_exit = turbo_enabled ? 3+2*count : 1+2*count; const int exhaust_exit = turbo_enabled ? 3+2*count : 1+2*count;
boundaries_.add_open_end(exhaust_exit, false, 101325, pipes[exhaust_exit].area); boundaries_.add_open_end(exhaust_exit, false, 101325, pipes[exhaust_exit].area);
if (v8) {
auto& outlet = second_bank_collector.create_port();
boundaries_.add_orifice(outlet, 18, true, tail_area_);
boundaries_.add_open_end(18, false, 101325, pipes[18].area);
}
} }
AudioFrame FourStrokeScenario::process() { AudioFrame FourStrokeScenario::process() {
const std::lock_guard guard(mutex_); const std::lock_guard guard(mutex_);
step_motorcycle(crankshaft, dt_, preset == FourStrokePreset::SaabB205 ? 200.f : 30.f); step_motorcycle(crankshaft, dt_, preset == FourStrokePreset::AmericanV8 ? 300.f : preset == FourStrokePreset::SaabB205 ? 200.f : 30.f);
ecu.step(dt_, throttle, crankshaft.omega * 60 / (2*pi)); ecu.step(dt_, throttle, crankshaft.omega * 60 / (2*pi));
if (turbo) { if (turbo) {
ecu.control_boost(dt_, throttle, crankshaft.omega * 60/(2*pi), intake_plenum.pressure(), turbo->compressor_outlet.pressure()); ecu.control_boost(dt_, throttle, crankshaft.omega * 60/(2*pi), intake_plenum.pressure(), turbo->compressor_outlet.pressure());
@@ -122,12 +180,15 @@ AudioFrame FourStrokeScenario::process() {
intake_valve_areas_[i] = c.valve_area(degrees, true); intake_valve_areas_[i] = c.valve_area(degrees, true);
exhaust_valve_areas_[i] = c.valve_area(degrees, false); exhaust_valve_areas_[i] = c.valve_area(degrees, false);
} }
const float angle = ecu.throttle_opening() * pi * .5f; const float opening = ecu.throttle_opening();
throttle_area_ = area_for_diameter(preset == FourStrokePreset::SaabB205 ? .052f : .026f) * (1-std::cos(angle)); const float angle = opening * pi * .5f;
throttle_area_ = area_for_diameter(preset == FourStrokePreset::AmericanV8 ? .065f : preset == FourStrokePreset::SaabB205 ? .052f : .026f) * (1-std::cos(angle));
solver_.step(dt_); solver_.step(dt_);
intake_flow = intake_atmosphere.port.mass_flow; intake_flow = intake_atmosphere.port.mass_flow;
exhaust_flow = boundaries_.open_end_mass_flow(0); exhaust_flow = boundaries_.open_end_mass_flow(0);
const auto rendered = audio_.process(intake_flow, exhaust_flow); const float other_exhaust = preset == FourStrokePreset::AmericanV8 ? boundaries_.open_end_mass_flow(1) : 0.f;
const auto rendered = audio_.process(intake_flow, exhaust_flow, other_exhaust);
exhaust_flow += other_exhaust;
const float mechanical = mechanical_sound_enabled const float mechanical = mechanical_sound_enabled
? mechanical_sound_.process(crankshaft, cylinder1, load) * mechanical_mix : 0; ? mechanical_sound_.process(crankshaft, cylinder1, load) * mechanical_mix : 0;
last_mix_l = rendered.left + mechanical; last_mix_l = rendered.left + mechanical;
@@ -222,7 +283,8 @@ ScenarioSnapshot FourStrokeScenario::snapshot() const {
result.mechanical_sound_enabled = mechanical_sound_enabled; result.mechanical_sound_enabled = mechanical_sound_enabled;
result.cylinder_count = cylinder_count(); result.cylinder_count = cylinder_count();
result.rev_limit_rpm = ecu.rev_limit_rpm; result.rev_limit_rpm = ecu.rev_limit_rpm;
const std::array<const FourStrokeCylinder*, 4> engines{&cylinder1, &cylinder2, &cylinder3, &cylinder4}; const std::array<const FourStrokeCylinder*, 8> engines{&cylinder1, &cylinder2, &cylinder3, &cylinder4,
&cylinder5, &cylinder6, &cylinder7, &cylinder8};
for (int i = 0; i < cylinder_count(); ++i) { for (int i = 0; i < cylinder_count(); ++i) {
result.cylinder_pressures[i] = engines[i]->pressure; result.cylinder_pressures[i] = engines[i]->pressure;
result.piston_fractions[i] = (engines[i]->volume-engines[i]->clearance_volume())/engines[i]->swept_volume(); result.piston_fractions[i] = (engines[i]->volume-engines[i]->clearance_volume())/engines[i]->swept_volume();
@@ -239,6 +301,20 @@ float FourStrokeScenario::area_for_diameter(float diameter) {
std::vector<Pipe1D> FourStrokeScenario::create_pipes(FourStrokePreset preset, bool turbo_enabled) { std::vector<Pipe1D> FourStrokeScenario::create_pipes(FourStrokePreset preset, bool turbo_enabled) {
const bool saab = preset == FourStrokePreset::SaabB205; const bool saab = preset == FourStrokePreset::SaabB205;
if (preset == FourStrokePreset::AmericanV8) {
// One inlet/throttle -> shared plenum -> short individual runners.
// Independent exhaust banks. Cells stay long enough to avoid routine
// extra CFL substeps during high-vacuum coast-down.
std::vector<Pipe1D> result;
result.emplace_back(area_for_diameter(.070f), .18f, 3);
for (int i = 0; i < 8; ++i)
result.emplace_back(area_for_diameter(.038f), .12f, 2);
for (int i = 0; i < 8; ++i)
result.emplace_back(area_for_diameter(.040f), .60f + .06f*(i%4), 12);
result.emplace_back(area_for_diameter(.050f), 1.8f, 36);
result.emplace_back(area_for_diameter(.050f), 1.95f, 39);
return result;
}
const int count = saab ? 4 : 2; const int count = saab ? 4 : 2;
std::vector<Pipe1D> result; std::vector<Pipe1D> result;
result.reserve(4+2*count); result.reserve(4+2*count);

View File

@@ -106,6 +106,7 @@ void Turbocharger::set_transfer(Volume0D& in, Volume0D& out, Port& ip, Port& op,
ip.mass_flow = float(-flow); ip.mass_flow = float(-flow);
op.mass_flow = float(flow); op.mass_flow = float(flow);
ip.air_fraction = op.air_fraction = donor.air_fraction(); ip.air_fraction = op.air_fraction = donor.air_fraction();
ip.fuel_fraction = op.fuel_fraction = donor.fuel_fraction();
ip.enthalpy = op.enthalpy = float(h); ip.enthalpy = op.enthalpy = float(h);
// Positive work means energy delivered to the shaft. Only the receiving // Positive work means energy delivered to the shaft. Only the receiving
// fluid enthalpy changes; the donor loses precisely its stagnation enthalpy. // fluid enthalpy changes; the donor loses precisely its stagnation enthalpy.

View File

@@ -1,3 +1,4 @@
#include "FluidSim/Carburetor.hpp"
#include "FluidSim/Ecu.hpp" #include "FluidSim/Ecu.hpp"
#include "FluidSim/TwoStrokeCylinder.hpp" #include "FluidSim/TwoStrokeCylinder.hpp"
@@ -70,16 +71,22 @@ void TwoStrokeCylinder::pre_step(Crankshaft &crank, float dt, float /*throttle*/
void TwoStrokeCylinder::handle_cycle_events(float previous_degrees, float current_degrees) { void TwoStrokeCylinder::handle_cycle_events(float previous_degrees, float current_degrees) {
const float trapped_angle = std::max(transfer_close(), exhaust_close()); const float trapped_angle = std::max(transfer_close(), exhaust_close());
if (previous_degrees < trapped_angle && current_degrees >= trapped_angle) { if (previous_degrees < trapped_angle && current_degrees >= trapped_angle) {
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f); if (!carburetor_ && fuel_mass <= 0.0f)
fuel_injected = fuel_mass > 0; fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
const float charge_afr = air_mass / std::max(fuel_mass, 1e-12f);
fuel_injected = fuel_mass > 0.0f && charge_afr >= 7.0f && charge_afr <= 24.0f;
} }
const float spark_angle = wrap_degrees(360.0f - spark_advance, 360.0f); const float spark_angle = wrap_degrees(360.0f - spark_advance, 360.0f);
const bool crossed_spark = crossed_angle(previous_degrees,current_degrees,spark_angle); const bool crossed_spark = crossed_angle(previous_degrees,current_degrees,spark_angle);
if (crossed_spark && fuel_injected && !combustion_active && !(ecu ? ecu->fuel_cut() : rev_limit_active) && if (crossed_spark && fuel_injected && !combustion_active &&
!(ecu ? ecu->fuel_cut() : rev_limit_active) &&
random_unit() >= misfire_probability) { random_unit() >= misfire_probability) {
combustion_active = true; combustion_active = true;
burn_fraction = 0.0f; burn_fraction = 0.0f;
// Fuel is a transported species. Only the portion with enough oxygen
// can release heat; rich excess remains unburned and can reach the pipe.
active_fuel_mass_ = std::min(fuel_mass, air_mass / 14.7f);
++combustion_count_; ++combustion_count_;
active_spark_angle_ = spark_angle; active_spark_angle_ = spark_angle;
// Freeze the duration and timing for this charge. Changing the ECU map // Freeze the duration and timing for this charge. Changing the ECU map
@@ -99,14 +106,16 @@ void TwoStrokeCylinder::handle_cycle_events(float previous_degrees, float curren
if (fraction >= 1.0f || angle_since_spark > active_burn_duration_ + burn_start) { if (fraction >= 1.0f || angle_since_spark > active_burn_duration_ + burn_start) {
fraction = 1.0f; fraction = 1.0f;
combustion_active = false; combustion_active = false;
exhaust_mass += air_mass;
air_mass = 0.0f;
} }
fuel_injected = false; fuel_injected = false;
const float increment = fraction - burn_fraction; const float increment = fraction - burn_fraction;
if (increment > 0.0f) { if (increment > 0.0f) {
energy += fuel_mass * fuel_lhv * energy_factor_ * increment; const float burned_fuel = std::min(active_fuel_mass_ * increment, fuel_mass);
exhaust_mass += fuel_mass * increment; energy += burned_fuel * fuel_lhv * energy_factor_;
fuel_mass -= burned_fuel;
const float burned_air = std::min(air_mass, burned_fuel * 14.7f);
air_mass -= burned_air;
exhaust_mass += burned_fuel + burned_air;
burn_fraction = fraction; burn_fraction = fraction;
} }
} }

View File

@@ -7,6 +7,7 @@ namespace fluidsim::port {
TwoStrokeScenario::TwoStrokeScenario(int sample_rate, TwoStrokeConfig config) TwoStrokeScenario::TwoStrokeScenario(int sample_rate, TwoStrokeConfig config)
: configuration(config), : configuration(config),
carburetor(config.carburetor_diameter),
cylinder(config.bore, config.stroke, config.rod, config.geometric_compression(), cylinder(config.bore, config.stroke, config.rod, config.geometric_compression(),
config.transfer_duration, config.exhaust_duration), config.transfer_duration, config.exhaust_duration),
crankcase(crankshaft, config.stroke * .5f, config.rod, config.bore, 150e-6f), pipes(create_pipes(config)), crankcase(crankshaft, config.stroke * .5f, config.rod, config.bore, 150e-6f), pipes(create_pipes(config)),
@@ -43,9 +44,22 @@ TwoStrokeScenario::TwoStrokeScenario(int sample_rate, TwoStrokeConfig config)
ecu.idle_spark_gain=.04f; ecu.idle_spark_gain=.04f;
ecu.overrun_cut_rpm = 5000; ecu.overrun_cut_rpm = 5000;
ecu.overrun_resume_rpm = 3500; ecu.overrun_resume_rpm = 3500;
ecu.overrun_fuel_cut_enabled = false;
cylinder.ecu = &ecu; cylinder.ecu = &ecu;
cylinder.initialize(crankshaft); cylinder.initialize(crankshaft);
cylinder.set_crankcase(crankcase); cylinder.set_crankcase(crankcase);
cylinder.set_carburetor(carburetor);
// This scenario starts as an already-running warm engine at 3000 RPM.
// Seed its upstream charge accordingly; a dry system needs a starter.
crankcase.initialize_mixture(14.7f);
cylinder.fuel_mass = cylinder.air_mass / 15.7f;
cylinder.air_mass -= cylinder.fuel_mass;
intake_plenum.add_fuel_mass(intake_plenum.mass() / 14.7f);
for (int index : {1, 2})
for (auto& cell : pipes[index].cells) {
cell.fuel = 1.0f / 15.7f;
cell.air = 1.0f - cell.fuel;
}
for (auto &pipe : pipes) { for (auto &pipe : pipes) {
pipe.damping_multiplier = 0.8f; pipe.damping_multiplier = 0.8f;
@@ -77,8 +91,12 @@ AudioFrame TwoStrokeScenario::process() {
reed_out_area_ = std::min(reed.step(dt_, upstream - crankcase.pressure()), reed_in_area_); reed_out_area_ = std::min(reed.step(dt_, upstream - crankcase.pressure()), reed_in_area_);
reed_open_ = reed.lift() > 1e-6f; reed_open_ = reed.lift() > 1e-6f;
ecu.step(dt_, throttle, crankshaft.omega * 60 / (2 * pi)); ecu.step(dt_, throttle, crankshaft.omega * 60 / (2 * pi));
const float angle = ecu.throttle_opening() * pi * .5f; const auto carb_inlet = pipes[0].state(static_cast<int>(pipes[0].cells.size()) - 1);
max_throttle_area_ = area_for_diameter(configuration.carburetor_diameter) * (1 - std::cos(angle)); carburetor.step(dt_, throttle, ecu.throttle_opening(), crankshaft.omega * 60 / (2 * pi),
std::max(plenum_inlet_->mass_flow, 0.0f), carb_inlet.rho,
carb_inlet.pressure, intake_plenum.pressure());
intake_plenum.add_fuel_mass(carburetor.fuel_flow() * dt_);
max_throttle_area_ = slide_area(configuration.carburetor_diameter, ecu.throttle_opening());
step_motorcycle(crankshaft, dt_, 10.0f); step_motorcycle(crankshaft, dt_, 10.0f);
crankcase.pre_step(dt_); crankcase.pre_step(dt_);
cylinder.pre_step(crankshaft, dt_, ecu.throttle_opening()); cylinder.pre_step(crankshaft, dt_, ecu.throttle_opening());
@@ -116,6 +134,16 @@ TwoStrokeSnapshot TwoStrokeScenario::snapshot() const {
float TwoStrokeScenario::area_for_diameter(float diameter) { return pi * diameter * diameter * 0.25f; } float TwoStrokeScenario::area_for_diameter(float diameter) { return pi * diameter * diameter * 0.25f; }
float TwoStrokeScenario::slide_area(float diameter, float opening) {
const float radius = diameter * 0.5f;
const float height = std::clamp(opening, 0.0f, 1.0f) * diameter;
const float offset = radius - height;
const float root = std::sqrt(std::max(radius * radius - offset * offset, 0.0f));
// Cancellation near the seat can produce a tiny negative area in float.
return std::clamp(radius * radius * std::acos(std::clamp(offset / radius, -1.0f, 1.0f)) - offset * root,
0.0f, area_for_diameter(diameter));
}
std::vector<Pipe1D> TwoStrokeScenario::create_pipes(const TwoStrokeConfig& config) { std::vector<Pipe1D> TwoStrokeScenario::create_pipes(const TwoStrokeConfig& config) {
std::vector<Pipe1D> result; std::vector<Pipe1D> result;
result.reserve(4); result.reserve(4);

View File

@@ -15,8 +15,8 @@ void check(bool b, const std::string &s) {
void near(double a, double b, double tol, const char *s) { void near(double a, double b, double tol, const char *s) {
check(std::isfinite(a) && std::abs(a - b) <= tol, s); check(std::isfinite(a) && std::abs(a - b) <= tol, s);
} }
void set(Pipe1D &p, int i, float pressure, float rho, float u = 0, float air = 1) { void set(Pipe1D &p, int i, float pressure, float rho, float u = 0, float air = 1, float fuel = 0) {
p.cells[i] = {rho, rho * u, pressure / pipe_gm1 + .5f * rho * u * u, air}; p.cells[i] = {rho, rho * u, pressure / pipe_gm1 + .5f * rho * u * u, air, fuel};
} }
void lossless(Pipe1D &p) { void lossless(Pipe1D &p) {
p.damping_multiplier = 0; p.damping_multiplier = 0;
@@ -499,7 +499,8 @@ int main() {
for (int k = 0; k < 2; k++) { for (int k = 0; k < 2; k++) {
lossless(p[k]); lossless(p[k]);
for (int i = 0; i < 40; i++) for (int i = 0; i < 40; i++)
set(p[k], i, k ? 100000 : 120000, 1.2f, 0, k ? .9f : .1f); set(p[k], i, k ? 100000 : 120000, 1.2f, 0, k ? .9f : .1f,
k ? .02f : .05f);
} }
auto air_mass = [&] { auto air_mass = [&] {
double sum = 0; double sum = 0;
@@ -508,7 +509,15 @@ int main() {
sum += q.cell_mass(i) * q.cells[i].air; sum += q.cell_mass(i) * q.cells[i].air;
return sum; return sum;
}; };
auto fuel_mass = [&] {
double sum = 0;
for (auto &q : p)
for (int i = 0; i < 40; i++)
sum += q.cell_mass(i) * q.cells[i].fuel;
return sum;
};
double before = air_mass(); double before = air_mass();
double fuel_before = fuel_mass();
BoundarySystem b(p); BoundarySystem b(p);
b.add_closed_end(0, true); b.add_closed_end(0, true);
b.add_closed_end(1, false); b.add_closed_end(1, false);
@@ -517,6 +526,7 @@ int main() {
for (int i = 0; i < 3000; i++) for (int i = 0; i < 3000; i++)
solver.step(1.f / 44100); solver.step(1.f / 44100);
near(air_mass(), before, before * 4e-5, "species created at shared face"); near(air_mass(), before, before * 4e-5, "species created at shared face");
near(fuel_mass(), fuel_before, fuel_before * 4e-5, "fuel species created at shared face");
}); });
test("unsupported unequal junction rejected", [] { test("unsupported unequal junction rejected", [] {
std::vector<Pipe1D> p; std::vector<Pipe1D> p;

View File

@@ -1,3 +1,4 @@
#include "FluidSim/Carburetor.hpp"
#include "FluidSim/TwoStrokeScenario.hpp" #include "FluidSim/TwoStrokeScenario.hpp"
#include <iostream> #include <iostream>
#include <stdexcept> #include <stdexcept>
@@ -37,6 +38,17 @@ float reflected_pulse(bool expanding) {
} }
} }
int main() { int main() {
Carburetor carburetor;
for (int i = 0; i < 1000; ++i)
carburetor.step(.001f, .55f, .55f, 8000.0f, .012f, 1.15f, 101325.0f, 90000.0f);
const float cruise_afr = carburetor.indicated_afr();
carburetor.step(.001f, 0.0f, .05f, 8000.0f, .004f, 1.15f, 101325.0f, 86000.0f);
check(carburetor.overrun_active() && carburetor.fuel_flow() > 0.0f,
"carburetor cut fuel when the throttle closed");
check(std::isfinite(cruise_afr) && cruise_afr >= 4.0f && cruise_afr <= 40.0f &&
carburetor.bowl_level() > 0.0f && carburetor.bowl_level() <= 1.0f,
"carburetor mixture or float bowl left its physical bounds");
const std::array<PipeSection, 1> cone{{{.4f, .03f, .10f}}}; const std::array<PipeSection, 1> cone{{{.4f, .03f, .10f}}};
const float expected = pi/12 * .4f * (.03f*.03f + .03f*.10f + .10f*.10f); const float expected = pi/12 * .4f * (.03f*.03f + .03f*.10f + .10f*.10f);
for (float mesh : {.03f, .01f, .004f}) { for (float mesh : {.03f, .01f, .004f}) {
@@ -84,6 +96,16 @@ int main() {
check(std::abs(first-30.27f)<.03f && std::abs(s.vehicle.total_gear_ratio()-13.4f)<.03f, check(std::abs(first-30.27f)<.03f && std::abs(s.vehicle.total_gear_ratio()-13.4f)<.03f,
"YZ125 primary/final/gear reduction incorrect"); "YZ125 primary/final/gear reduction incorrect");
s.set_throttle(.5f);
for (int sample = 0; sample < 22050; ++sample)
s.process();
bool transported_fuel = false;
for (int pipe_index : {1, 2})
for (const auto& cell : s.pipes[pipe_index].cells)
transported_fuel |= cell.fuel > 0.0f;
check(s.intake_plenum.fuel_mass() > 0.0f && s.crankcase.fuel_mass() > 0.0f && transported_fuel,
"carburetor fuel was not transported through intake, reed and crankcase");
Crankshaft crank; Crankshaft crank;
crank.omega = 100; crank.omega = 100;
crank.load_torque = 10; crank.load_torque = 10;

View File

@@ -12,9 +12,17 @@ int main() {
bool reed_open_seen = false, reed_closed_seen = false, above_old_limit = false; bool reed_open_seen = false, reed_closed_seen = false, above_old_limit = false;
DynoCurve dyno; DynoCurve dyno;
float peak = 0, maximum_rpm = 0; float peak = 0, maximum_rpm = 0;
std::uint64_t release_firings = 0, one_second_coast_firings = 0;
float release_rpm = 0;
for (int sample = 0; sample < 14 * 44100; ++sample) { for (int sample = 0; sample < 14 * 44100; ++sample) {
scenario.set_throttle(sample >= 2 * 44100 && sample < 6 * 44100 ? .6f : 0.f); scenario.set_throttle(sample >= 2 * 44100 && sample < 6 * 44100 ? .6f : 0.f);
const auto audio = scenario.process(); const auto audio = scenario.process();
if (sample == 6 * 44100) {
release_firings = scenario.cylinder.combustion_count();
release_rpm = scenario.snapshot().rpm;
} else if (sample == 7 * 44100) {
one_second_coast_firings = scenario.cylinder.combustion_count() - release_firings;
}
if (!std::isfinite(audio.left) || !std::isfinite(audio.right)) if (!std::isfinite(audio.left) || !std::isfinite(audio.right))
throw std::runtime_error("Nonfinite two-stroke audio"); throw std::runtime_error("Nonfinite two-stroke audio");
peak = std::max({peak, std::abs(audio.left), std::abs(audio.right)}); peak = std::max({peak, std::abs(audio.left), std::abs(audio.right)});
@@ -31,6 +39,8 @@ int main() {
throw std::runtime_error("Two-stroke cut fuel with the pedal open"); throw std::runtime_error("Two-stroke cut fuel with the pedal open");
above_old_limit |= state.rpm > 10200; above_old_limit |= state.rpm > 10200;
} }
if (sample >= 6 * 44100 && sample < 7 * 44100 && state.rpm > 5000 && state.fuel_cut)
throw std::runtime_error("Carbureted two-stroke cut fuel on overrun");
dyno.update(state.rpm, scenario.crankshaft.average_power / 1000, dyno.update(state.rpm, scenario.crankshaft.average_power / 1000,
scenario.crankshaft.average_torque); scenario.crankshaft.average_torque);
reed_open_seen |= state.reed_open; reed_open_seen |= state.reed_open;
@@ -39,11 +49,16 @@ int main() {
} }
const auto final = scenario.snapshot(); const auto final = scenario.snapshot();
if (!above_old_limit) if (!above_old_limit)
throw std::runtime_error("Two-stroke did not exceed the old rev limit"); throw std::runtime_error("Two-stroke did not exceed the old rev limit: peak=" +
std::to_string(maximum_rpm) + ", idle=" + std::to_string(final.rpm));
if (!reed_open_seen || !reed_closed_seen) if (!reed_open_seen || !reed_closed_seen)
throw std::runtime_error("Reed did not open and close"); throw std::runtime_error("Reed did not open and close");
if (final.rpm < 2000 || final.rpm > 4000) if (final.rpm < 2000 || final.rpm > 4000)
throw std::runtime_error("Two-stroke idle did not recover after throttle release"); throw std::runtime_error("Two-stroke idle did not recover after throttle release: " +
std::to_string(final.rpm) + " RPM, coast firings=" +
std::to_string(one_second_coast_firings) + ", carb AFR=" +
std::to_string(scenario.carburetor.indicated_afr()) +
", cylinder fuel=" + std::to_string(scenario.cylinder.fuel_mass));
if (peak >= 1) if (peak >= 1)
throw std::runtime_error("Two-stroke audio exceeds device range"); throw std::runtime_error("Two-stroke audio exceeds device range");
for (const auto &pipe : scenario.pipes) for (const auto &pipe : scenario.pipes)
@@ -83,5 +98,6 @@ int main() {
throw std::runtime_error("Motorcycle launch required pipe positivity repairs"); throw std::runtime_error("Motorcycle launch required pipe positivity repairs");
std::cout << "Two-stroke regression passed: peak=" << peak << ", maximum RPM=" << maximum_rpm std::cout << "Two-stroke regression passed: peak=" << peak << ", maximum RPM=" << maximum_rpm
<< ", recovered RPM=" << final.rpm << ", bike speed=" << scenario.vehicle.speed * 3.6f << ", recovered RPM=" << final.rpm << ", bike speed=" << scenario.vehicle.speed * 3.6f
<< " km/h\n"; << " km/h, release RPM=" << release_rpm
<< ", first coast-second firings=" << one_second_coast_firings << '\n';
} }

View File

@@ -159,8 +159,10 @@ int main(int argc, char **argv) {
capture<SaabB205Scenario>(prefix, frames, pedal, selected, config); capture<SaabB205Scenario>(prefix, frames, pedal, selected, config);
else if (scenario == "two-stroke") else if (scenario == "two-stroke")
capture<TwoStrokeScenario>(prefix, frames, pedal, selected, config); capture<TwoStrokeScenario>(prefix, frames, pedal, selected, config);
else if (scenario == "v8")
capture<AmericanV8Scenario>(prefix, frames, pedal, selected, config);
else else
throw std::runtime_error("choose twin, two-stroke or b205"); throw std::runtime_error("choose twin, two-stroke, b205 or v8");
} catch (const std::exception &e) { } catch (const std::exception &e) {
std::cerr << e.what() << '\n'; std::cerr << e.what() << '\n';
return 1; return 1;

View File

@@ -4,18 +4,26 @@
#include "FluidSim/PulseTestScenario.hpp" #include "FluidSim/PulseTestScenario.hpp"
#include <chrono> #include <chrono>
#include <iostream> #include <iostream>
#include <limits>
#include <memory> #include <memory>
#include <string> #include <string>
using namespace fluidsim::port; using namespace fluidsim::port;
template<class S, class Control> void run(const char* name, S& s, int seconds, Control control) { template<class S, class Control> void run(const char* name, S& s, int seconds, Control control) {
double checksum = 0; double checksum = 0;
std::uint64_t previous_iterations = 0; std::uint64_t previous_iterations = 0;
std::uint64_t previous_substeps = 0;
for (int second = 0; second < seconds; ++second) { for (int second = 0; second < seconds; ++second) {
float peak = 0;
const auto begin = std::chrono::steady_clock::now(); const auto begin = std::chrono::steady_clock::now();
for (int i = 0; i < 44100; ++i) { for (int i = 0; i < 44100; ++i) {
control(second); control(second);
auto value = s.process(); auto value = s.process();
if constexpr (requires { value.left; }) checksum += value.left; if constexpr (requires { value.left; }) {
if (!std::isfinite(value.left) || !std::isfinite(value.right))
throw std::runtime_error("Nonfinite benchmark audio");
checksum += value.left;
peak = std::max({peak, std::abs(value.left), std::abs(value.right)});
}
else checksum += value; else checksum += value;
} }
const double elapsed = std::chrono::duration<double>(std::chrono::steady_clock::now()-begin).count(); const double elapsed = std::chrono::duration<double>(std::chrono::steady_clock::now()-begin).count();
@@ -32,6 +40,19 @@ template<class S, class Control> void run(const char* name, S& s, int seconds, C
const auto state = s.snapshot(); const auto state = s.snapshot();
std::cerr << name << " iterations_per_frame=" std::cerr << name << " iterations_per_frame="
<< (state.boundary_iterations-previous_iterations)/44100.0 << '\n'; << (state.boundary_iterations-previous_iterations)/44100.0 << '\n';
std::size_t limiting_pipe = 0;
float stable_dt = std::numeric_limits<float>::max();
for (std::size_t p = 0; p < s.pipes.size(); ++p) {
const float candidate = s.pipes[p].stable_timestep();
if (candidate < stable_dt) { stable_dt = candidate; limiting_pipe = p; }
}
std::cerr << name << " substeps_per_frame=" << (state.solver_substeps-previous_substeps)/44100.0
<< " limiting_pipe=" << limiting_pipe << " cfl_us=" << stable_dt*1e6f << '\n';
previous_substeps = state.solver_substeps;
unsigned long long repairs = 0;
for (const auto& pipe : s.pipes) repairs += pipe.positivity_repairs;
std::cerr << name << " rpm=" << state.rpm << " power_kW=" << state.average_power/1000
<< " peak=" << peak << " pipe_repairs=" << repairs << '\n';
previous_iterations = state.boundary_iterations; previous_iterations = state.boundary_iterations;
} }
} }
@@ -41,18 +62,19 @@ int main(int argc, char** argv) {
const std::string mode = argc > 2 ? argv[2] : "pedal"; const std::string mode = argc > 2 ? argv[2] : "pedal";
const std::string only = argc > 3 ? argv[3] : "all"; const std::string only = argc > 3 ? argv[3] : "all";
auto pedal = [&](int second) { auto pedal = [&](int second) {
return mode == "idle" ? 0.0f : mode == "transient" && (second == 0 || second >= seconds-2) ? 0.0f : .7f; return mode == "idle" ? 0.0f : mode == "coast" ? (second >= 1 && second < 5 ? .7f : 0.f) :
mode == "transient" && (second == 0 || second >= seconds-2) ? 0.0f : .7f;
}; };
std::cout << "scenario,second,wall_seconds,checksum\n"; std::cout << "scenario,second,wall_seconds,checksum\n";
for (auto name : {"twin", "turbo", "b205", "b205-gt17"}) { for (auto name : {"twin", "turbo", "b205", "b205-gt17", "v8"}) {
if (only != "all" && only != name) continue; if (only != "all" && only != name) continue;
auto s = std::make_unique<FourStrokeScenario>(44100, auto s = std::make_unique<FourStrokeScenario>(44100,
std::string(name).starts_with("b205") ? FourStrokePreset::SaabB205 : FourStrokePreset::Twin, std::string(name)=="v8" ? FourStrokePreset::AmericanV8 : std::string(name).starts_with("b205") ? FourStrokePreset::SaabB205 : FourStrokePreset::Twin,
std::string(name)=="turbo" || std::string(name)=="b205-gt17"); std::string(name)=="turbo" || std::string(name)=="b205-gt17");
s->throttle = .7f; s->throttle = .7f;
run(name, *s, seconds, [&](int second) { run(name, *s, seconds, [&](int second) {
s->throttle = pedal(second); s->throttle = pedal(second);
if (mode == "loaded") s->crankshaft.omega = (std::string(name)=="b205-gt17" ? 5500 : 7000) * 2*pi/60; if (mode == "loaded") s->crankshaft.omega = (std::string(name)=="v8" ? 4500 : std::string(name)=="b205-gt17" ? 5500 : 7000) * 2*pi/60;
}); });
} }
for (bool racing : {true, false}) { for (bool racing : {true, false}) {

View File

@@ -1,10 +1,11 @@
#include "FluidSim/TwoStrokeScenario.hpp" #include "FluidSim/TwoStrokeScenario.hpp"
#include <algorithm>
#include <chrono> #include <chrono>
#include <iostream> #include <iostream>
using namespace fluidsim::port; using namespace fluidsim::port;
int main() { int main() {
std::cout << "exhaust,rpm,shaft_kW,shaft_Nm,firings_per_revolution,repairs,wall_seconds\n"; std::cout << "exhaust,rpm,shaft_kW,shaft_Nm,firings_per_revolution,carb_afr,crankcase_afr,repairs,wall_seconds\n";
for (auto exhaust : {TwoStrokeExhaust::Straight, TwoStrokeExhaust::Racing}) { for (auto exhaust : {TwoStrokeExhaust::Straight, TwoStrokeExhaust::Racing}) {
for (float rpm : {6000.f, 8000.f, 10000.f, 12000.f, 14000.f, 17000.f}) { for (float rpm : {6000.f, 8000.f, 10000.f, 12000.f, 14000.f, 17000.f}) {
TwoStrokeConfig config; TwoStrokeConfig config;
@@ -31,6 +32,9 @@ int main() {
std::cout << (exhaust == TwoStrokeExhaust::Racing ? "racing" : "straight") << ',' << rpm std::cout << (exhaust == TwoStrokeExhaust::Racing ? "racing" : "straight") << ',' << rpm
<< ',' << power << ',' << torque << ',' << ',' << power << ',' << torque << ','
<< (s.cylinder.combustion_count()-initial_firings) / (rpm / 120) << (s.cylinder.combustion_count()-initial_firings) / (rpm / 120)
<< ',' << s.carburetor.indicated_afr()
<< ',' << s.crankcase.mass() * s.crankcase.air_fraction() /
std::max(s.crankcase.fuel_mass(), 1e-12f)
<< ',' << repairs << ',' << wall << std::endl; << ',' << repairs << ',' << wall << std::endl;
} }
} }

View File

@@ -39,7 +39,11 @@ These port dimensions and compression interpretation require measured validation
Combustion has an estimated .8 ms minimum burn duration; timing and duration are Combustion has an estimated .8 ms minimum burn duration; timing and duration are
frozen for each active charge. The estimated ignition curve retards above 6000 frozen for each active charge. The estimated ignition curve retards above 6000
RPM rather than continuing to advance toward 40 degrees. Neither is a measured RPM rather than continuing to advance toward 40 degrees. Neither is a measured
factory CDI/flame model. Fuel remains ideally calculated at port closure. factory CDI/flame model. Fuel is now metered by effective pilot, needle and main
jet areas using simulated venturi/static pressure signals. It is transported
through the intake, crankcase and cylinder as a conserved species; the effective
jet dimensions and wall-film constants are estimates rather than measured TMX38
calibration.
Mechanical friction now includes a quadratic windage term. The two-stroke uses Mechanical friction now includes a quadratic windage term. The two-stroke uses
FMEP [bar] = .4 + .09*mean_piston_speed + .0009*mean_piston_speed^2, converted to FMEP [bar] = .4 + .09*mean_piston_speed + .0009*mean_piston_speed^2, converted to
@@ -63,6 +67,12 @@ steady speed. An analytic load-balance test checks 10 Nm and 1 kW at 100 rad/s.
## Measurements and limits ## Measurements and limits
The sweep and capture numbers below are historical, before removal of scheduled
late firing and addition of exhaust oxidation. Current transient regression:
peak 12954 RPM, recovered idle 2583 RPM, rendered peak .0985, and 87 normal CDI
firings in the first coast second. No imposed pop cadence remains. Recalibration
and listening validation of the finite-rate exhaust chemistry remain necessary.
The fixed-speed benchmark imposes speed as an ideal motoring/absorbing dyno and The fixed-speed benchmark imposes speed as an ideal motoring/absorbing dyno and
records shaft output. It runs one simulated second per point and averages the records shaft output. It runs one simulated second per point and averages the
last half. All points fired approximately once per revolution, had zero pipe last half. All points fired approximately once per revolution, had zero pipe
@@ -70,25 +80,27 @@ positivity repairs, and cost about .53-.58 wall seconds per simulated second.
| RPM | Straight kW | Racing kW | | RPM | Straight kW | Racing kW |
| ---: | ---: | ---: | | ---: | ---: | ---: |
| 6000 | 4.02 | 6.27 | | 6000 | 1.43 | 3.82 |
| 8000 | .63 | 5.53 | | 8000 | .41 | 4.92 |
| 10000 | 2.43 | 10.53 | | 10000 | 2.62 | 10.02 |
| 12000 | 3.96 | 14.32 | | 12000 | 3.46 | 11.87 |
| 14000 | 3.60 | 12.38 | | 14000 | 1.78 | 9.42 |
| 17000 | -.80 | -3.94 | | 17000 | -3.65 | -5.20 |
This coarse sweep peaks near 12000 RPM; it is not a validated Yamaha dyno curve. This coarse sweep peaks near 12000 RPM; it is not a validated Yamaha dyno curve.
Negative power at 17000 means the virtual dyno must motor the engine. Free-rev Negative power at 17000 means the virtual dyno must motor the engine. Free-rev
speed differs from peak-power speed: full pedal with the straight pipe still speed differs from peak-power speed and remains an unresolved calibration
approaches 16950 RPM after eight seconds. This remains an unresolved calibration limit. The standard .6-pedal racing-pipe transient reached 15954 RPM, recovered
limit. The standard transient reached 16128 RPM, recovered idle at 3159 RPM, idle at 3151 RPM, and launched the motorcycle to 57.4 km/h without repairs or
and launched the motorcycle to 61.3 km/h without repairs or nonfinite output. nonfinite output. Its first second after throttle release contained 11 sparse
late firings rather than continuous perfect-cycle combustion.
Six-second .6-pedal field captures had no clipping: racing rendered peak .201, The standard transient rendered peak .395 without clipping. Firing cadence,
straight .554. Firing cadence, port timing and geometry are improved, but exact port timing and geometry are improved, but exact YZ125 sound still requires
YZ125 sound still requires listening and further combustion/scavenging work. listening and further combustion/scavenging work.
The first-order pipe flux damps high-frequency content; the compact source, The first-order pipe flux damps high-frequency content; the compact source,
constant-gamma gas, ideal fueling, fixed power valve, single-zone mixing and constant-gamma gas, effective jet calibration, single-phase transported fuel,
fixed power valve, single-zone mixing and
empirical monitoring/environment filters limit fidelity. No engine sound sample empirical monitoring/environment filters limit fidelity. No engine sound sample
or synthetic two-stroke firing track has been added. or synthetic two-stroke firing track has been added.