Add American V8 preset, carb fuel transport, and adaptive desktop audio
This commit is contained in:
63
CARB_PROGRESS.md
Normal file
63
CARB_PROGRESS.md
Normal 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`
|
||||
@@ -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")
|
||||
|
||||
add_library(fluidsim_core
|
||||
src/carburetor.cpp
|
||||
src/ecu.cpp
|
||||
src/turbocharger.cpp
|
||||
src/engine.cpp
|
||||
|
||||
106
README.md
106
README.md
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
CLI throttle values are pedal requests; the actual throttle is advanced every
|
||||
simulation sample, independently of rendering. Opening and closing use an
|
||||
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
|
||||
area continuously; shaft obstruction and edge leakage are not modeled.
|
||||
travels per second. The four-stroke uses an ideal butterfly projection; the
|
||||
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
|
||||
intake-valve closure to calculate each cylinder's fuel dose. This explicitly
|
||||
The four-strokes use ideal sensors and direct access to trapped fresh-air mass
|
||||
at intake-valve closure to calculate each cylinder's fuel dose. This explicitly
|
||||
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
|
||||
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
|
||||
and spark at 10000 RPM, recovering below 9750 RPM. The two-stroke has no rev
|
||||
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
|
||||
burning charge completes combustion when a cut begins.
|
||||
and spark at 10000 RPM, recovering below 9750 RPM.
|
||||
|
||||
The two-stroke uses a simulated 38.1 mm slide carburetor instead of the EFI
|
||||
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.
|
||||
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
|
||||
speed or add artificial sustaining torque. Existing combustion/thermal models
|
||||
remain approximations; ECU changes do not establish their physical accuracy.
|
||||
The two-stroke doses fuel after the last scavenging port closes, targets
|
||||
3000 RPM idle, and uses idle ignition trim in addition to throttle control. Its
|
||||
The two-stroke targets 3000 RPM idle and uses idle ignition trim in addition to
|
||||
throttle control. Its
|
||||
reed is a damped lumped cantilever model with finite opening time and seating
|
||||
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.
|
||||
|
||||
VS Code's default build task builds the desktop through CMake. Use
|
||||
|
||||
@@ -31,6 +31,7 @@ private:
|
||||
port.temperature = temperature;
|
||||
port.enthalpy = specific_enthalpy();
|
||||
port.air_fraction = 1.0f;
|
||||
port.fuel_fraction = 0.0f;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ public:
|
||||
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); }
|
||||
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:
|
||||
static constexpr std::size_t block_size = 512;
|
||||
@@ -22,6 +24,9 @@ private:
|
||||
std::array<std::int16_t, block_size * 2> output_{};
|
||||
std::atomic<std::uint64_t> underrun_blocks_{};
|
||||
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_right_{}, previous_output_right_{};
|
||||
|
||||
|
||||
46
include/FluidSim/Carburetor.hpp
Normal file
46
include/FluidSim/Carburetor.hpp
Normal 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
|
||||
@@ -12,9 +12,11 @@ struct Port {
|
||||
float temperature{300.0f};
|
||||
float gamma_value{1.4f}, gas_constant_value{287.0f};
|
||||
float air_fraction{1.0f};
|
||||
float fuel_fraction{};
|
||||
float* owner_energy{};
|
||||
float* owner_air_mass{};
|
||||
float* owner_exhaust_mass{};
|
||||
float* owner_fuel_mass{};
|
||||
};
|
||||
|
||||
// Small equivalent of the C# IComponent lifecycle. Components update their
|
||||
|
||||
@@ -20,6 +20,13 @@ public:
|
||||
float geometry_work() const { return geometry_work_; } // J delivered by gas during the latest geometry step.
|
||||
float mass() const { return mass_; }
|
||||
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}; }
|
||||
void pre_step(float dt);
|
||||
@@ -30,7 +37,7 @@ private:
|
||||
float crank_radius_{}, connecting_rod_length_{}, piston_area_{};
|
||||
float clearance_volume_{}, obliquity_{};
|
||||
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_{};
|
||||
|
||||
void sync_ports();
|
||||
|
||||
@@ -14,7 +14,8 @@ public:
|
||||
two_ = std::make_unique<TwoStrokeScenario>(44100, config);
|
||||
else
|
||||
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() {
|
||||
if (twin_)
|
||||
@@ -60,7 +61,7 @@ public:
|
||||
s.exhaust_pressure = two_->pipes[3].state(0).pressure;
|
||||
s.throttle = source.throttle;
|
||||
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_exhaust_area = source.exhaust_area;
|
||||
s.reed_open = source.reed_open;
|
||||
|
||||
@@ -12,9 +12,12 @@ public:
|
||||
float idle_base_opening{0.08f}, idle_max_opening{0.25f};
|
||||
float idle_kp{0.0001f}, idle_ki{0.00004f};
|
||||
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};
|
||||
bool rev_limit_enabled{true};
|
||||
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 stoichiometric_afr{14.7f}, power_afr{12.8f};
|
||||
bool boost_control_enabled{false};
|
||||
@@ -30,7 +33,7 @@ public:
|
||||
float throttle_target() const { return target_; }
|
||||
float target_afr() const { return afr_; }
|
||||
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 idle_active() const { return idle_; }
|
||||
bool sensor_fault() const { return fault_; }
|
||||
|
||||
@@ -31,6 +31,7 @@ private:
|
||||
class EngineCylinder : public Component {
|
||||
public:
|
||||
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 intake_valve_diameter{0.030f}, exhaust_valve_diameter{0.028f};
|
||||
int intake_valve_count{1}, exhaust_valve_count{1};
|
||||
@@ -60,6 +61,7 @@ public:
|
||||
|
||||
protected:
|
||||
float energy_factor_{1.0f};
|
||||
float active_fuel_mass_{};
|
||||
float rev_limit_timer_{};
|
||||
std::mt19937 random_{0xF17D51u};
|
||||
std::uniform_real_distribution<float> unit_{0.0f, 1.0f};
|
||||
|
||||
@@ -37,6 +37,7 @@ public:
|
||||
p.owner_energy = &energy_;
|
||||
p.owner_air_mass = &air_mass_;
|
||||
p.owner_exhaust_mass = &exhaust_mass_;
|
||||
p.owner_fuel_mass = &fuel_mass_;
|
||||
sync_port(p);
|
||||
return p;
|
||||
}
|
||||
@@ -44,19 +45,30 @@ public:
|
||||
float density() const { return mass() / std::max(volume_, 1e-12f); }
|
||||
float temperature() const { return pressure() / std::max(density() * gas_constant, 1e-9f); }
|
||||
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 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) {
|
||||
if (!std::isfinite(value) || value <= 0 || !std::isfinite(temp))
|
||||
throw std::invalid_argument("invalid volume pressure/temperature");
|
||||
const float target_t = temp > 0.0f ? temp : temperature();
|
||||
const float total = value * volume_ / (gas_constant * target_t);
|
||||
const float fraction = air_fraction();
|
||||
const float fuel = fuel_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);
|
||||
air_roundoff_ = exhaust_roundoff_ = energy_roundoff_ = 0;
|
||||
air_roundoff_ = exhaust_roundoff_ = fuel_roundoff_ = energy_roundoff_ = 0;
|
||||
sync_ports();
|
||||
}
|
||||
void update(float dt) {
|
||||
@@ -64,16 +76,20 @@ public:
|
||||
throw std::invalid_argument("invalid volume timestep");
|
||||
if (dt == 0)
|
||||
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 fuel = fuel_fraction();
|
||||
for (const Port &p : ports_) {
|
||||
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_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;
|
||||
}
|
||||
accumulate(air_mass_, air_roundoff_, d_air * dt);
|
||||
accumulate(exhaust_mass_, exhaust_roundoff_, d_exhaust * dt);
|
||||
accumulate(fuel_mass_, fuel_roundoff_, d_fuel * dt);
|
||||
accumulate(energy_, energy_roundoff_, d_energy * dt);
|
||||
const float new_volume = volume_ + volume_change_rate * dt;
|
||||
if (!(new_volume > 0) || !std::isfinite(new_volume))
|
||||
@@ -93,10 +109,11 @@ public:
|
||||
if (!(mass() > 0) || !(energy_ > 0) || !std::isfinite(mass()) || !std::isfinite(energy_))
|
||||
throw std::runtime_error(
|
||||
"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");
|
||||
air_mass_ = std::max(air_mass_, 0.0f);
|
||||
exhaust_mass_ = std::max(exhaust_mass_, 0.0f);
|
||||
fuel_mass_ = std::max(fuel_mass_, 0.0f);
|
||||
sync_ports();
|
||||
}
|
||||
void update_state(float dt) override { update(dt); }
|
||||
@@ -110,9 +127,9 @@ public:
|
||||
const std::deque<Port> &ports() const { return ports_; }
|
||||
|
||||
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.
|
||||
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) {
|
||||
const double precise = double(value)+roundoff+increment;
|
||||
value = float(precise);
|
||||
@@ -127,6 +144,7 @@ private:
|
||||
p.temperature = temperature();
|
||||
p.enthalpy = gamma / (gamma - 1.0f) * p.pressure / std::max(p.density, 1e-12f);
|
||||
p.air_fraction = air_fraction();
|
||||
p.fuel_fraction = fuel_fraction();
|
||||
}
|
||||
void sync_ports() {
|
||||
for (Port &p : ports_)
|
||||
@@ -138,16 +156,18 @@ struct GasCell {
|
||||
float rho{1.225f}, momentum{};
|
||||
float energy{1.225f * (101325.0f / (pipe_gm1 * 1.225f))};
|
||||
float air{1.0f};
|
||||
float fuel{};
|
||||
};
|
||||
struct GasState {
|
||||
float rho{1.225f}, velocity{}, pressure{101325.0f}, air{1.0f};
|
||||
float fuel{};
|
||||
};
|
||||
|
||||
class Pipe1D {
|
||||
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 {
|
||||
float m{}, p{}, e{}, y{};
|
||||
float m{}, p{}, e{}, y{}, fuel{};
|
||||
};
|
||||
float area{}, dx{};
|
||||
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) {
|
||||
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) {
|
||||
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 en = cells[i].energy - factor * (fr.e - fl.e);
|
||||
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");
|
||||
if (rho < 1e-12f) {
|
||||
rho = 1e-12f;
|
||||
@@ -273,14 +297,18 @@ public:
|
||||
// than 1.18e-38 of the gas mass. Avoid repeatedly transporting
|
||||
// subnormal concentrations; total gas mass and energy are unchanged.
|
||||
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,
|
||||
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_);
|
||||
prepared_ = 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:
|
||||
std::vector<GasCell> next_cells_;
|
||||
@@ -301,7 +329,8 @@ private:
|
||||
return {.5f * (ma + mb - c * (b.rho - a.rho)),
|
||||
.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 * (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; });
|
||||
if (it == budgets_.end()) {
|
||||
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
|
||||
o.budget = static_cast<std::size_t>(it - budgets_.begin());
|
||||
}
|
||||
@@ -385,7 +415,7 @@ public:
|
||||
if (!std::isfinite(dt) || dt <= 0)
|
||||
throw std::invalid_argument("boundary timestep must be positive");
|
||||
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();
|
||||
}
|
||||
for (auto &o : orifices_)
|
||||
@@ -398,7 +428,7 @@ public:
|
||||
|
||||
private:
|
||||
struct Budget {
|
||||
float *air{}, *exhaust{}, *energy{};
|
||||
float *air{}, *exhaust{}, *fuel{}, *energy{};
|
||||
float mass{}, heat{};
|
||||
};
|
||||
struct Orifice {
|
||||
@@ -587,15 +617,18 @@ private:
|
||||
o.length > 0 ? .5f * (v.pressure + s.pressure) : static_cast<float>(duct_pressure);
|
||||
const float direction = o.left ? 1.f : -1.f;
|
||||
const float species = from ? v.air_fraction : s.air;
|
||||
const float fuel = from ? v.fuel_fraction : s.fuel;
|
||||
const float signed_mass = direction * mdot;
|
||||
p.set_boundary_flux(o.left, {signed_mass,
|
||||
mdot * velocity + face_pressure * area + s.pressure * (face_area - area),
|
||||
signed_mass * h, signed_mass * species});
|
||||
ghost(p, o.left, {density, direction * velocity, face_pressure, species});
|
||||
signed_mass * h, signed_mass * species, signed_mass * fuel});
|
||||
ghost(p, o.left, {density, direction * velocity, face_pressure, species, fuel});
|
||||
v.mass_flow = -mdot;
|
||||
v.enthalpy = h;
|
||||
if (!from)
|
||||
v.air_fraction = s.air;
|
||||
if (!from)
|
||||
v.fuel_fraction = s.fuel;
|
||||
}
|
||||
void resolve_open(Open &o) {
|
||||
auto &p = pipes_[o.pipe];
|
||||
@@ -611,7 +644,9 @@ private:
|
||||
const float u =
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
@@ -631,9 +666,11 @@ private:
|
||||
const float area = std::min(a.cell_area(ia), b.cell_area(ib));
|
||||
auto f = Pipe1D::scaled(Pipe1D::riemann(sa, sb), area);
|
||||
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,
|
||||
{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});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ struct OutdoorAudioConfig {
|
||||
Vec3 listener_right{1.0f, 0.0f, 0.0f};
|
||||
Vec3 intake_position{-0.2f, 0.7f, 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 ground_y{0.0f}, wall_x{-1.5f};
|
||||
float ground_reflection{0.35f}, wall_reflection{0.65f};
|
||||
@@ -26,7 +28,7 @@ struct OutdoorAudioConfig {
|
||||
class OutdoorAudio {
|
||||
public:
|
||||
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; }
|
||||
OutdoorScene scene() const { return scene_; }
|
||||
void set_monitor_filter(bool enabled) { monitor_filter_ = enabled; } // Set before playback/capture.
|
||||
@@ -60,7 +62,7 @@ private:
|
||||
OutdoorAudioConfig config_;
|
||||
OutdoorScene scene_{OutdoorScene::Wall};
|
||||
bool monitor_filter_{true};
|
||||
std::array<Emitter, 2> emitters_;
|
||||
std::array<Emitter, 3> emitters_;
|
||||
std::array<TailLine, 4> tail_;
|
||||
std::array<float, 4> weights_{1, 0, 0, 0};
|
||||
float scene_alpha_{}, tail_alpha_{}, tail_mix_{};
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
enum class ScenarioKind { Twin, TwoStroke, SaabB205, TurboTwin };
|
||||
enum class FourStrokePreset { Twin, SaabB205 };
|
||||
enum class ScenarioKind { Twin, TwoStroke, SaabB205, TurboTwin, AmericanV8 };
|
||||
enum class FourStrokePreset { Twin, SaabB205, AmericanV8 };
|
||||
|
||||
struct ScenarioSnapshot {
|
||||
bool turbo_enabled{};
|
||||
@@ -28,7 +28,7 @@ struct ScenarioSnapshot {
|
||||
TurboSnapshot turbo{};
|
||||
int cylinder_count{2};
|
||||
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{};
|
||||
float rpm{}, speed_kmh{}, kinetic_energy{}, average_power{}, average_torque{};
|
||||
float cylinder_pressure1{}, cylinder_pressure2{};
|
||||
@@ -43,8 +43,8 @@ struct ScenarioSnapshot {
|
||||
OutdoorScene audio_scene{OutdoorScene::Wall};
|
||||
int gear{};
|
||||
bool rev_limit1{}, rev_limit2{}, mechanical_sound_enabled{};
|
||||
std::array<std::vector<GasState>, 12> pipe_states;
|
||||
std::array<std::vector<float>, 12> pipe_areas, pipe_lengths;
|
||||
std::array<std::vector<GasState>, 22> pipe_states;
|
||||
std::array<std::vector<float>, 22> pipe_areas, pipe_lengths;
|
||||
void capture_pipes(const std::vector<Pipe1D>& pipes) {
|
||||
for (std::size_t k = 0; k < pipes.size(); ++k) {
|
||||
pipe_areas[k] = pipes[k].cell_areas;
|
||||
@@ -57,14 +57,15 @@ struct ScenarioSnapshot {
|
||||
};
|
||||
|
||||
// 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 {
|
||||
public:
|
||||
explicit FourStrokeScenario(int sample_rate = 44100, FourStrokePreset preset = FourStrokePreset::Twin,
|
||||
bool turbo_enabled = false);
|
||||
const FourStrokePreset preset;
|
||||
int cylinder_count() const { return preset == FourStrokePreset::SaabB205 ? 4 : 2; }
|
||||
std::array<FourStrokeCylinder*, 4> cylinders() { return {&cylinder1, &cylinder2, &cylinder3, &cylinder4}; }
|
||||
int cylinder_count() const { return preset == FourStrokePreset::AmericanV8 ? 8 : preset == FourStrokePreset::SaabB205 ? 4 : 2; }
|
||||
std::array<FourStrokeCylinder*, 8> cylinders() { return {&cylinder1, &cylinder2, &cylinder3, &cylinder4,
|
||||
&cylinder5, &cylinder6, &cylinder7, &cylinder8}; }
|
||||
AudioFrame process();
|
||||
void set_controls(float throttle_value, float load_value, float clutch_value);
|
||||
void toggle_mechanical_sound();
|
||||
@@ -85,9 +86,11 @@ public:
|
||||
std::unique_ptr<Turbocharger> turbo;
|
||||
Crankshaft crankshaft{};
|
||||
FourStrokeCylinder cylinder1{}, cylinder2{}, cylinder3{}, cylinder4{};
|
||||
FourStrokeCylinder cylinder5{}, cylinder6{}, cylinder7{}, cylinder8{};
|
||||
Atmosphere intake_atmosphere{};
|
||||
Volume0D intake_plenum{1.0e-3f, 101325.0f, 300.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;
|
||||
float intake_flow{}, exhaust_flow{};
|
||||
float last_mix_l{}, last_mix_r{};
|
||||
@@ -95,7 +98,7 @@ public:
|
||||
|
||||
private:
|
||||
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_{};
|
||||
BoundarySystem boundaries_;
|
||||
Solver solver_;
|
||||
@@ -108,6 +111,11 @@ private:
|
||||
};
|
||||
|
||||
using FourStrokeTwinScenario = FourStrokeScenario;
|
||||
class AmericanV8Scenario : public FourStrokeScenario {
|
||||
public:
|
||||
explicit AmericanV8Scenario(int sample_rate = 44100)
|
||||
: FourStrokeScenario(sample_rate, FourStrokePreset::AmericanV8) {}
|
||||
};
|
||||
class TurboTwinScenario : public FourStrokeScenario {
|
||||
public:
|
||||
explicit TurboTwinScenario(int sample_rate = 44100) : FourStrokeScenario(sample_rate, FourStrokePreset::Twin, true) {}
|
||||
|
||||
@@ -21,6 +21,8 @@ public:
|
||||
double speed() const;
|
||||
void set_volume(float volume);
|
||||
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(); }
|
||||
bool is_playing() const { return is_playing_; }
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
namespace fluidsim::port {
|
||||
|
||||
class Carburetor;
|
||||
|
||||
// Two-stroke variant of the reciprocating cylinder model. The shared four-
|
||||
// stroke implementation supplies geometry, state balance, and combustion
|
||||
// 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_close() const { return 180.0f + exhaust_duration_ * 0.5f; }
|
||||
void set_crankcase(Crankcase& crankcase) { crankcase_ = &crankcase; }
|
||||
void set_carburetor(Carburetor& carburetor) { carburetor_ = &carburetor; }
|
||||
float transfer_width{.050f}, exhaust_width{.036f};
|
||||
float minimum_burn_seconds{.0008f};
|
||||
std::uint64_t combustion_count() const { return combustion_count_; }
|
||||
@@ -29,6 +32,7 @@ public:
|
||||
private:
|
||||
float transfer_duration_{}, exhaust_duration_{};
|
||||
Crankcase* crankcase_{};
|
||||
Carburetor* carburetor_{};
|
||||
|
||||
float active_burn_duration_{60};
|
||||
float current_rpm_{};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "FluidSim/AudioProcessing.hpp"
|
||||
#include "FluidSim/Crankcase.hpp"
|
||||
#include "FluidSim/Carburetor.hpp"
|
||||
#include "FluidSim/Fluid.hpp"
|
||||
#include "FluidSim/OutdoorAudio.hpp"
|
||||
#include "FluidSim/Atmosphere.hpp"
|
||||
@@ -44,6 +45,7 @@ public:
|
||||
float throttle{};
|
||||
const TwoStrokeConfig configuration;
|
||||
Ecu ecu{};
|
||||
Carburetor carburetor;
|
||||
ReedValve reed{};
|
||||
Atmosphere intake_atmosphere{};
|
||||
float intake_flow{}, exhaust_flow{};
|
||||
@@ -65,6 +67,7 @@ private:
|
||||
OutdoorAudio audio_;
|
||||
|
||||
static float area_for_diameter(float diameter);
|
||||
static float slide_area(float diameter, float opening);
|
||||
static std::vector<Pipe1D> create_pipes(const TwoStrokeConfig& config);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ namespace fluidsim::port {
|
||||
|
||||
AudioOutputStream::AudioOutputStream(SimulationRingBuffer& ring, unsigned int sample_rate)
|
||||
: 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});
|
||||
}
|
||||
|
||||
@@ -14,18 +19,38 @@ bool AudioOutputStream::onGetData(Chunk& data) {
|
||||
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);
|
||||
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.
|
||||
for (std::size_t i = 0; i < block_size; ++i) {
|
||||
float left{}, right{};
|
||||
if (i < read) {
|
||||
const float input_left = std::isfinite(temp_left_[i]) ? temp_left_[i] : 0.0f;
|
||||
const float input_right = std::isfinite(temp_right_[i]) ? temp_right_[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] * playback_gain : 0.0f;
|
||||
left = input_left - previous_input_left_ + dc_coefficient * previous_output_left_;
|
||||
right = input_right - previous_input_right_ + dc_coefficient * previous_output_right_;
|
||||
previous_input_left_ = input_left;
|
||||
previous_input_right_ = input_right;
|
||||
previous_output_left_ = left;
|
||||
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_) {
|
||||
transition_left_ = last_left_ - left;
|
||||
transition_right_ = last_right_ - right;
|
||||
|
||||
102
src/carburetor.cpp
Normal file
102
src/carburetor.cpp
Normal 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
|
||||
@@ -51,18 +51,25 @@ void Crankcase::pre_step(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}) {
|
||||
const float flow = port->mass_flow;
|
||||
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_air_mass += flow * fraction * dt;
|
||||
delta_fuel_mass += flow * fuel * dt;
|
||||
delta_energy += flow * port->enthalpy * dt;
|
||||
}
|
||||
const float old_air_mass = mass_ * air_fraction_;
|
||||
const float old_fuel_mass = mass_ * fuel_fraction_;
|
||||
mass_ += delta_mass;
|
||||
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_))
|
||||
throw std::runtime_error("nonphysical crankcase state");
|
||||
@@ -80,6 +87,7 @@ void Crankcase::sync_ports() {
|
||||
port->temperature = temperature_;
|
||||
port->enthalpy = enthalpy;
|
||||
port->air_fraction = air_fraction_;
|
||||
port->fuel_fraction = fuel_fraction_;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
const std::array<sf::FloatRect, 4> cards{{{{32, 110}, {656, 84}}, {{32, 206}, {656, 84}}, {{32, 302}, {656, 84}}, {{32, 398}, {656, 84}}}};
|
||||
const sf::FloatRect start_button{{32, 506}, {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*, 4> descriptions{
|
||||
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, 522}, {656, 52}};
|
||||
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*, 5> descriptions{
|
||||
"Four-stroke twin, 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",
|
||||
"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;
|
||||
while (window.isOpen()) {
|
||||
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>()) {
|
||||
switch (key->code) {
|
||||
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::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::Num2: selected = 1; break;
|
||||
case sf::Keyboard::Key::Num3: selected = 2; 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);
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
if (const auto* move = event->getIf<sf::Event::MouseMoved>()) {
|
||||
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 (const auto* click = event->getIf<sf::Event::MouseButtonReleased>();
|
||||
click && click->button == sf::Mouse::Button::Left) {
|
||||
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 (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});
|
||||
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);
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
sf::RectangleShape card(cards[i].size);
|
||||
card.setPosition(cards[i].position);
|
||||
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.setFillColor({35, 105, 115});
|
||||
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);
|
||||
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,
|
||||
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;
|
||||
status << (time_warp ? "[Space] normal speed" : "[Space] real time")
|
||||
<< " Sim " << std::fixed << std::setprecision(3) << playback_speed << "x"
|
||||
<< " Audio buffer " << buffer_fill << "/" << buffer_capacity
|
||||
<< " 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))
|
||||
<< " 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]"
|
||||
@@ -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"));
|
||||
if (!state.two_stroke)
|
||||
status << " Mechanical [M]: " << (state.mechanical_sound_enabled ? "ON" : "OFF");
|
||||
status << " Auto gain [G]: " << (auto_gain ? "ON" : "OFF");
|
||||
if (font) {
|
||||
sf::RectangleShape panel({1260.0f, 92.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_pipe(window,s,0,460,30,170);
|
||||
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_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);
|
||||
@@ -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);
|
||||
std::ostringstream pressures;
|
||||
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,"54 x 54.5 mm | 8.2:1 trapped compression | estimated pipe/ports/CDI | fixed-open power valve",
|
||||
{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,
|
||||
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;
|
||||
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 draw_twin_scene(window, font, state);
|
||||
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);
|
||||
window.display();
|
||||
}
|
||||
@@ -486,8 +514,8 @@ int main(int argc, char** argv) {
|
||||
if((option=="--scenario"||option=="--scene"||option=="--exhaust"||option=="--turbo")&&i+1<argc){
|
||||
const std::string value=argv[++i];
|
||||
if(option=="--scenario"){
|
||||
if(value!="twin"&&value!="two-stroke"&&value!="b205"){std::cerr<<"Choose twin, two-stroke or b205\n";return 2;}
|
||||
selected_kind=value=="b205" ? ScenarioKind::SaabB205 :
|
||||
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=="v8" ? ScenarioKind::AmericanV8 : value=="b205" ? ScenarioKind::SaabB205 :
|
||||
(value=="two-stroke" ? ScenarioKind::TwoStroke : ScenarioKind::Twin);
|
||||
scenario_from_args=true;
|
||||
}else if(option=="--turbo"){
|
||||
@@ -505,7 +533,7 @@ int main(int argc, char** argv) {
|
||||
else if(value=="woodland")selected_scene=OutdoorScene::Woodland;
|
||||
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}
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
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"));
|
||||
DesktopScenario scenario(selected_kind, two_stroke_config, turbo_name!="none");
|
||||
scenario.set_audio_scene(selected_scene);
|
||||
@@ -600,6 +628,7 @@ int main(int argc, char** argv) {
|
||||
scenario.set_audio_scene(selected_scene);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -629,7 +658,7 @@ int main(int argc, char** argv) {
|
||||
scenario.set_controls(throttle_current, brake_current, clutch_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();
|
||||
|
||||
@@ -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_,
|
||||
0.0f, idle_max_opening) : 0.0f;
|
||||
target_ = std::max(pedal, idle_command);
|
||||
const float change = (target_ - opening_) * (-std::expm1(-dt / std::max(throttle_time_constant, 1e-4f)));
|
||||
const float travel = std::max(throttle_rate, 0.0f) * dt;
|
||||
const bool closing = target_ < opening_;
|
||||
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);
|
||||
const float enrichment = std::clamp((opening_ - 0.7f) / 0.3f, 0.0f, 1.0f);
|
||||
afr_ = stoichiometric_afr + (power_afr - stoichiometric_afr) * enrichment;
|
||||
|
||||
@@ -50,7 +50,7 @@ float EngineCylinder::clearance_volume() const {
|
||||
}
|
||||
|
||||
float EngineCylinder::total_mass() const {
|
||||
return air_mass + exhaust_mass;
|
||||
return air_mass + exhaust_mass + fuel_mass;
|
||||
}
|
||||
|
||||
float EngineCylinder::temperature() const {
|
||||
@@ -79,11 +79,13 @@ void EngineCylinder::initialize(const Crankshaft& /*crank*/) {
|
||||
volume = clearance_volume();
|
||||
air_mass = 1.225f * volume;
|
||||
exhaust_mass = 0.0f;
|
||||
fuel_mass = 0.0f;
|
||||
energy = 101325.0f * volume / (gamma - 1.0f);
|
||||
pressure = 101325.0f;
|
||||
intake.owner_energy = exhaust.owner_energy = &energy;
|
||||
intake.owner_air_mass = exhaust.owner_air_mass = &air_mass;
|
||||
intake.owner_exhaust_mass = exhaust.owner_exhaust_mass = &exhaust_mass;
|
||||
intake.owner_fuel_mass = exhaust.owner_fuel_mass = &fuel_mass;
|
||||
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 current_degrees = wrap_degrees(theta * 180.0f / pi);
|
||||
if (crossed_window_close(previous_degrees, current_degrees, intake_open, intake_close)) {
|
||||
if (!transported_fuel)
|
||||
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
|
||||
fuel_injected = fuel_mass > 0.0f;
|
||||
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);
|
||||
@@ -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) {
|
||||
combustion_active = true;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
fraction = 1.0f;
|
||||
combustion_active = false;
|
||||
if (!transported_fuel) {
|
||||
exhaust_mass += air_mass;
|
||||
air_mass = 0.0f;
|
||||
}
|
||||
}
|
||||
fuel_injected = false;
|
||||
const float increment = fraction - burn_fraction;
|
||||
if (increment > 0.0f) {
|
||||
energy += fuel_mass * fuel_lhv * energy_factor_ * increment;
|
||||
exhaust_mass += fuel_mass * increment;
|
||||
const float burned_fuel = std::min(active_fuel_mass_ * increment, fuel_mass);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -184,16 +198,20 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
|
||||
}
|
||||
|
||||
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}) {
|
||||
const float mass_flow = port->mass_flow;
|
||||
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_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;
|
||||
}
|
||||
air_mass += delta_air;
|
||||
exhaust_mass += delta_exhaust;
|
||||
fuel_mass += delta_fuel;
|
||||
energy += delta_energy;
|
||||
|
||||
const float current_volume = std::max(volume, 1e-12f);
|
||||
@@ -211,12 +229,14 @@ void EngineCylinder::update_state(float dt) {
|
||||
if (total_mass() < 1e-9f) {
|
||||
air_mass = 1e-9f;
|
||||
exhaust_mass = 0.0f;
|
||||
fuel_mass = 0.0f;
|
||||
energy = 101325.0f * current_volume / (gamma - 1.0f);
|
||||
} else if (energy < 0.0f) {
|
||||
energy = 101325.0f * current_volume / (gamma - 1.0f);
|
||||
}
|
||||
air_mass = std::max(air_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;
|
||||
sync_ports();
|
||||
}
|
||||
@@ -257,12 +277,14 @@ void EngineCylinder::sync_ports() {
|
||||
pressure = (gamma - 1.0f) * energy / std::max(volume, 1e-12f);
|
||||
const float temp = pressure / std::max(density * gas_constant, 1e-12f);
|
||||
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);
|
||||
for (Port* port : {&intake, &exhaust}) {
|
||||
port->pressure = pressure;
|
||||
port->density = density;
|
||||
port->temperature = temp;
|
||||
port->air_fraction = fraction;
|
||||
port->fuel_fraction = fuel_fraction;
|
||||
port->enthalpy = enthalpy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +46,10 @@ int main(int argc, char** argv) {
|
||||
constexpr int sample_rate = 44100;
|
||||
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);
|
||||
if (scenario_name == "twin" || scenario_name == "b205") {
|
||||
FourStrokeScenario scenario(sample_rate, scenario_name == "b205" ? FourStrokePreset::SaabB205 : FourStrokePreset::Twin, turbo_enabled);
|
||||
if (scenario_name == "twin" || scenario_name == "b205" || scenario_name == "v8") {
|
||||
FourStrokeScenario scenario(sample_rate, scenario_name == "v8" ? FourStrokePreset::AmericanV8 : scenario_name == "b205" ? FourStrokePreset::SaabB205 : FourStrokePreset::Twin, turbo_enabled);
|
||||
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";
|
||||
for (std::uint64_t i = 0; i < total_samples; ++i) {
|
||||
scenario.process();
|
||||
@@ -116,7 +116,7 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
} else {
|
||||
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 0;
|
||||
|
||||
@@ -47,6 +47,7 @@ OutdoorAudio::OutdoorAudio(int rate, OutdoorAudioConfig config) : rate_(rate), c
|
||||
config_.listener_right.z / norm};
|
||||
configure_emitter(emitters_[0], config_.intake_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};
|
||||
for (std::size_t i = 0; i < tail_.size(); ++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};
|
||||
}
|
||||
AudioFrame OutdoorAudio::process(float intake, float exhaust) {
|
||||
if (!std::isfinite(intake) || !std::isfinite(exhaust))
|
||||
AudioFrame OutdoorAudio::process(float intake, float exhaust, float second_exhaust) {
|
||||
if (!std::isfinite(intake) || !std::isfinite(exhaust) || !std::isfinite(second_exhaust))
|
||||
throw std::runtime_error("nonfinite acoustic source flow");
|
||||
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,
|
||||
@@ -165,7 +166,8 @@ AudioFrame OutdoorAudio::process(float intake, float exhaust) {
|
||||
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 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);
|
||||
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
|
||||
|
||||
@@ -7,11 +7,17 @@ namespace fluidsim::port {
|
||||
|
||||
FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selected_preset, bool turbo_enabled)
|
||||
: preset(selected_preset),
|
||||
intake_plenum(selected_preset == FourStrokePreset::SaabB205 ? 3e-3f : 1e-3f, 101325, 300),
|
||||
exhaust_collector(selected_preset == FourStrokePreset::SaabB205 ? .6e-3f : .15e-3f, 101325, 700),
|
||||
intake_plenum(selected_preset == FourStrokePreset::AmericanV8 ? 4e-3f : selected_preset == FourStrokePreset::SaabB205 ? 3e-3f : 1e-3f, 101325, 300),
|
||||
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)),
|
||||
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 v8 = preset == FourStrokePreset::AmericanV8;
|
||||
if (v8 && turbo_enabled) throw std::invalid_argument("American V8 preset is naturally aspirated");
|
||||
const int count = cylinder_count();
|
||||
crankshaft.omega = (saab ? 1200 : 2000) * 2 * pi / 60;
|
||||
crankshaft.inertia = saab ? .18f : .04f;
|
||||
@@ -36,13 +42,54 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selecte
|
||||
ecu.overrun_cut_rpm = 2000;
|
||||
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.clutch_input = clutch;
|
||||
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) {
|
||||
auto& c = *engines[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) {
|
||||
c.bore = .090f;
|
||||
c.stroke = .078f;
|
||||
@@ -54,13 +101,17 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selecte
|
||||
c.intake_valve_lift = saab ? .0085f : .008f;
|
||||
c.exhaust_valve_diameter = saab ? .028f : .026f;
|
||||
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_rpm = ecu.rev_limit_rpm;
|
||||
c.rev_limit_hysteresis = 250;
|
||||
c.rev_limit_cut_time = .020f;
|
||||
c.ecu = &ecu;
|
||||
c.initialize(crankshaft);
|
||||
if (saab) {
|
||||
if (saab || v8) {
|
||||
// Initialize each phased cylinder at its actual piston position.
|
||||
c.volume = c.compute_volume(c.phase);
|
||||
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_plenum);
|
||||
solver_.add_component(exhaust_collector);
|
||||
if (v8) solver_.add_component(second_bank_collector);
|
||||
if (turbo_enabled) {
|
||||
turbo = std::make_unique<Turbocharger>(saab ? TurboConfig::gt17() : TurboConfig{});
|
||||
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);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
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]);
|
||||
boundaries_.add_orifice(feed, 1+i, true, runner_area_);
|
||||
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_);
|
||||
const int exhaust_exit = turbo_enabled ? 3+2*count : 1+2*count;
|
||||
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() {
|
||||
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));
|
||||
if (turbo) {
|
||||
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);
|
||||
exhaust_valve_areas_[i] = c.valve_area(degrees, false);
|
||||
}
|
||||
const float angle = ecu.throttle_opening() * pi * .5f;
|
||||
throttle_area_ = area_for_diameter(preset == FourStrokePreset::SaabB205 ? .052f : .026f) * (1-std::cos(angle));
|
||||
const float opening = ecu.throttle_opening();
|
||||
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_);
|
||||
intake_flow = intake_atmosphere.port.mass_flow;
|
||||
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
|
||||
? mechanical_sound_.process(crankshaft, cylinder1, load) * mechanical_mix : 0;
|
||||
last_mix_l = rendered.left + mechanical;
|
||||
@@ -222,7 +283,8 @@ ScenarioSnapshot FourStrokeScenario::snapshot() const {
|
||||
result.mechanical_sound_enabled = mechanical_sound_enabled;
|
||||
result.cylinder_count = cylinder_count();
|
||||
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) {
|
||||
result.cylinder_pressures[i] = engines[i]->pressure;
|
||||
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) {
|
||||
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;
|
||||
std::vector<Pipe1D> result;
|
||||
result.reserve(4+2*count);
|
||||
|
||||
@@ -106,6 +106,7 @@ void Turbocharger::set_transfer(Volume0D& in, Volume0D& out, Port& ip, Port& op,
|
||||
ip.mass_flow = float(-flow);
|
||||
op.mass_flow = float(flow);
|
||||
ip.air_fraction = op.air_fraction = donor.air_fraction();
|
||||
ip.fuel_fraction = op.fuel_fraction = donor.fuel_fraction();
|
||||
ip.enthalpy = op.enthalpy = float(h);
|
||||
// Positive work means energy delivered to the shaft. Only the receiving
|
||||
// fluid enthalpy changes; the donor loses precisely its stagnation enthalpy.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "FluidSim/Carburetor.hpp"
|
||||
#include "FluidSim/Ecu.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) {
|
||||
const float trapped_angle = std::max(transfer_close(), exhaust_close());
|
||||
if (previous_degrees < trapped_angle && current_degrees >= trapped_angle) {
|
||||
if (!carburetor_ && fuel_mass <= 0.0f)
|
||||
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
|
||||
fuel_injected = fuel_mass > 0;
|
||||
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 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) {
|
||||
combustion_active = true;
|
||||
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_;
|
||||
active_spark_angle_ = spark_angle;
|
||||
// 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) {
|
||||
fraction = 1.0f;
|
||||
combustion_active = false;
|
||||
exhaust_mass += air_mass;
|
||||
air_mass = 0.0f;
|
||||
}
|
||||
fuel_injected = false;
|
||||
const float increment = fraction - burn_fraction;
|
||||
if (increment > 0.0f) {
|
||||
energy += fuel_mass * fuel_lhv * energy_factor_ * increment;
|
||||
exhaust_mass += fuel_mass * increment;
|
||||
const float burned_fuel = std::min(active_fuel_mass_ * increment, fuel_mass);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace fluidsim::port {
|
||||
|
||||
TwoStrokeScenario::TwoStrokeScenario(int sample_rate, TwoStrokeConfig config)
|
||||
: configuration(config),
|
||||
carburetor(config.carburetor_diameter),
|
||||
cylinder(config.bore, config.stroke, config.rod, config.geometric_compression(),
|
||||
config.transfer_duration, config.exhaust_duration),
|
||||
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.overrun_cut_rpm = 5000;
|
||||
ecu.overrun_resume_rpm = 3500;
|
||||
ecu.overrun_fuel_cut_enabled = false;
|
||||
cylinder.ecu = &ecu;
|
||||
cylinder.initialize(crankshaft);
|
||||
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) {
|
||||
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_open_ = reed.lift() > 1e-6f;
|
||||
ecu.step(dt_, throttle, crankshaft.omega * 60 / (2 * pi));
|
||||
const float angle = ecu.throttle_opening() * pi * .5f;
|
||||
max_throttle_area_ = area_for_diameter(configuration.carburetor_diameter) * (1 - std::cos(angle));
|
||||
const auto carb_inlet = pipes[0].state(static_cast<int>(pipes[0].cells.size()) - 1);
|
||||
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);
|
||||
crankcase.pre_step(dt_);
|
||||
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::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> result;
|
||||
result.reserve(4);
|
||||
|
||||
@@ -15,8 +15,8 @@ void check(bool b, const std::string &s) {
|
||||
void near(double a, double b, double tol, const char *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) {
|
||||
p.cells[i] = {rho, rho * u, pressure / pipe_gm1 + .5f * rho * u * u, air};
|
||||
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, fuel};
|
||||
}
|
||||
void lossless(Pipe1D &p) {
|
||||
p.damping_multiplier = 0;
|
||||
@@ -499,7 +499,8 @@ int main() {
|
||||
for (int k = 0; k < 2; k++) {
|
||||
lossless(p[k]);
|
||||
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 = [&] {
|
||||
double sum = 0;
|
||||
@@ -508,7 +509,15 @@ int main() {
|
||||
sum += q.cell_mass(i) * q.cells[i].air;
|
||||
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 fuel_before = fuel_mass();
|
||||
BoundarySystem b(p);
|
||||
b.add_closed_end(0, true);
|
||||
b.add_closed_end(1, false);
|
||||
@@ -517,6 +526,7 @@ int main() {
|
||||
for (int i = 0; i < 3000; i++)
|
||||
solver.step(1.f / 44100);
|
||||
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", [] {
|
||||
std::vector<Pipe1D> p;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "FluidSim/Carburetor.hpp"
|
||||
#include "FluidSim/TwoStrokeScenario.hpp"
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
@@ -37,6 +38,17 @@ float reflected_pulse(bool expanding) {
|
||||
}
|
||||
}
|
||||
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 float expected = pi/12 * .4f * (.03f*.03f + .03f*.10f + .10f*.10f);
|
||||
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,
|
||||
"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;
|
||||
crank.omega = 100;
|
||||
crank.load_torque = 10;
|
||||
|
||||
@@ -12,9 +12,17 @@ int main() {
|
||||
bool reed_open_seen = false, reed_closed_seen = false, above_old_limit = false;
|
||||
DynoCurve dyno;
|
||||
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) {
|
||||
scenario.set_throttle(sample >= 2 * 44100 && sample < 6 * 44100 ? .6f : 0.f);
|
||||
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))
|
||||
throw std::runtime_error("Nonfinite two-stroke audio");
|
||||
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");
|
||||
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,
|
||||
scenario.crankshaft.average_torque);
|
||||
reed_open_seen |= state.reed_open;
|
||||
@@ -39,11 +49,16 @@ int main() {
|
||||
}
|
||||
const auto final = scenario.snapshot();
|
||||
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)
|
||||
throw std::runtime_error("Reed did not open and close");
|
||||
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)
|
||||
throw std::runtime_error("Two-stroke audio exceeds device range");
|
||||
for (const auto &pipe : scenario.pipes)
|
||||
@@ -83,5 +98,6 @@ int main() {
|
||||
throw std::runtime_error("Motorcycle launch required pipe positivity repairs");
|
||||
std::cout << "Two-stroke regression passed: peak=" << peak << ", maximum RPM=" << maximum_rpm
|
||||
<< ", 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';
|
||||
}
|
||||
|
||||
@@ -159,8 +159,10 @@ int main(int argc, char **argv) {
|
||||
capture<SaabB205Scenario>(prefix, frames, pedal, selected, config);
|
||||
else if (scenario == "two-stroke")
|
||||
capture<TwoStrokeScenario>(prefix, frames, pedal, selected, config);
|
||||
else if (scenario == "v8")
|
||||
capture<AmericanV8Scenario>(prefix, frames, pedal, selected, config);
|
||||
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) {
|
||||
std::cerr << e.what() << '\n';
|
||||
return 1;
|
||||
|
||||
@@ -4,18 +4,26 @@
|
||||
#include "FluidSim/PulseTestScenario.hpp"
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
using namespace fluidsim::port;
|
||||
template<class S, class Control> void run(const char* name, S& s, int seconds, Control control) {
|
||||
double checksum = 0;
|
||||
std::uint64_t previous_iterations = 0;
|
||||
std::uint64_t previous_substeps = 0;
|
||||
for (int second = 0; second < seconds; ++second) {
|
||||
float peak = 0;
|
||||
const auto begin = std::chrono::steady_clock::now();
|
||||
for (int i = 0; i < 44100; ++i) {
|
||||
control(second);
|
||||
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;
|
||||
}
|
||||
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();
|
||||
std::cerr << name << " iterations_per_frame="
|
||||
<< (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;
|
||||
}
|
||||
}
|
||||
@@ -41,18 +62,19 @@ int main(int argc, char** argv) {
|
||||
const std::string mode = argc > 2 ? argv[2] : "pedal";
|
||||
const std::string only = argc > 3 ? argv[3] : "all";
|
||||
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";
|
||||
for (auto name : {"twin", "turbo", "b205", "b205-gt17"}) {
|
||||
for (auto name : {"twin", "turbo", "b205", "b205-gt17", "v8"}) {
|
||||
if (only != "all" && only != name) continue;
|
||||
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");
|
||||
s->throttle = .7f;
|
||||
run(name, *s, seconds, [&](int 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}) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#include "FluidSim/TwoStrokeScenario.hpp"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
|
||||
using namespace fluidsim::port;
|
||||
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 (float rpm : {6000.f, 8000.f, 10000.f, 12000.f, 14000.f, 17000.f}) {
|
||||
TwoStrokeConfig config;
|
||||
@@ -31,6 +32,9 @@ int main() {
|
||||
std::cout << (exhaust == TwoStrokeExhaust::Racing ? "racing" : "straight") << ',' << rpm
|
||||
<< ',' << power << ',' << torque << ','
|
||||
<< (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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
@@ -70,25 +80,27 @@ positivity repairs, and cost about .53-.58 wall seconds per simulated second.
|
||||
|
||||
| RPM | Straight kW | Racing kW |
|
||||
| ---: | ---: | ---: |
|
||||
| 6000 | 4.02 | 6.27 |
|
||||
| 8000 | .63 | 5.53 |
|
||||
| 10000 | 2.43 | 10.53 |
|
||||
| 12000 | 3.96 | 14.32 |
|
||||
| 14000 | 3.60 | 12.38 |
|
||||
| 17000 | -.80 | -3.94 |
|
||||
| 6000 | 1.43 | 3.82 |
|
||||
| 8000 | .41 | 4.92 |
|
||||
| 10000 | 2.62 | 10.02 |
|
||||
| 12000 | 3.46 | 11.87 |
|
||||
| 14000 | 1.78 | 9.42 |
|
||||
| 17000 | -3.65 | -5.20 |
|
||||
|
||||
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
|
||||
speed differs from peak-power speed: full pedal with the straight pipe still
|
||||
approaches 16950 RPM after eight seconds. This remains an unresolved calibration
|
||||
limit. The standard transient reached 16128 RPM, recovered idle at 3159 RPM,
|
||||
and launched the motorcycle to 61.3 km/h without repairs or nonfinite output.
|
||||
speed differs from peak-power speed and remains an unresolved calibration
|
||||
limit. The standard .6-pedal racing-pipe transient reached 15954 RPM, recovered
|
||||
idle at 3151 RPM, and launched the motorcycle to 57.4 km/h without repairs or
|
||||
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,
|
||||
straight .554. Firing cadence, port timing and geometry are improved, but exact
|
||||
YZ125 sound still requires listening and further combustion/scavenging work.
|
||||
The standard transient rendered peak .395 without clipping. Firing cadence,
|
||||
port timing and geometry are improved, but exact YZ125 sound still requires
|
||||
listening and further combustion/scavenging work.
|
||||
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
|
||||
or synthetic two-stroke firing track has been added.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user