Add turbocharged twin scenario and cross-platform test harness

This commit is contained in:
2026-10-23 02:27:03 +02:00
parent a859740a95
commit 589d22aa5e
63 changed files with 4903 additions and 771 deletions

6
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"recommendations": [
"ms-vscode.cpptools",
"ms-vscode.cmake-tools"
]
}

38
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,38 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "FluidSim desktop",
"type": "node-terminal",
"request": "launch",
"command": "./build-desktop/fluidsim_desktop",
"windows": {
"command": ".\\build\\windows\\Release\\fluidsim_desktop.exe"
},
"cwd": "${workspaceFolder}",
"preLaunchTask": "FluidSim: build desktop"
},
{
"name": "FluidSim two-stroke",
"type": "node-terminal",
"request": "launch",
"command": "./build-desktop/fluidsim_desktop --scenario two-stroke",
"windows": {
"command": ".\\build\\windows\\Release\\fluidsim_desktop.exe --scenario two-stroke"
},
"cwd": "${workspaceFolder}",
"preLaunchTask": "FluidSim: build desktop"
},
{
"name": "FluidSim Saab B205",
"type": "node-terminal",
"request": "launch",
"command": "./build-desktop/fluidsim_desktop --scenario b205",
"windows": {
"command": ".\\build\\windows\\Release\\fluidsim_desktop.exe --scenario b205"
},
"cwd": "${workspaceFolder}",
"preLaunchTask": "FluidSim: build desktop"
}
]
}

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"C_Cpp.debugShortcut": false,
"C_Cpp.default.configurationProvider": "ms-vscode.cmake-tools",
"cmake.useCMakePresets": "always",
"cmake.buildBeforeRun": true
}

59
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,59 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "FluidSim: configure desktop",
"type": "process",
"command": "cmake",
"args": [
"--preset",
"linux"
],
"windows": {
"command": "cmake",
"args": [
"--preset",
"windows"
]
},
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "FluidSim: build desktop",
"type": "process",
"command": "cmake",
"args": [
"--build",
"--preset",
"linux",
"--target",
"fluidsim_desktop"
],
"windows": {
"command": "cmake",
"args": [
"--build",
"--preset",
"windows",
"--target",
"fluidsim_desktop"
]
},
"options": {
"cwd": "${workspaceFolder}"
},
"dependsOn": "FluidSim: configure desktop",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": [
"$gcc",
"$msCompile"
]
}
]
}

View File

@@ -2,8 +2,11 @@ cmake_minimum_required(VERSION 3.28)
project(FluidSimCpp VERSION 0.1.0 LANGUAGES CXX)
option(FLUIDSIM_BUILD_DESKTOP "Build the SFML windowed simulator with keyboard controls and audio" ON)
set(FLUIDSIM_FONT_FILE "" CACHE FILEPATH "Optional font file copied beside the desktop executable")
add_library(fluidsim_core
src/ecu.cpp
src/turbocharger.cpp
src/engine.cpp
src/crankcase.cpp
src/two_stroke_cylinder.cpp
@@ -11,6 +14,7 @@ add_library(fluidsim_core
src/helmholtz_scenario.cpp
src/pulse_test_scenario.cpp
src/audio_processing.cpp
src/outdoor_audio.cpp
src/outdoor_exhaust_reverb.cpp
src/mechanical_sound_processor.cpp
src/scenario.cpp
@@ -20,6 +24,18 @@ add_library(fluidsim_core
)
target_include_directories(fluidsim_core PUBLIC include)
target_compile_features(fluidsim_core PUBLIC cxx_std_20)
option(FLUIDSIM_ENABLE_SIMD "Allow compiler SIMD in fluid kernels without fast-math" ON)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
if(FLUIDSIM_ENABLE_SIMD)
target_compile_options(fluidsim_core PUBLIC -fno-math-errno)
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
target_compile_options(fluidsim_core PUBLIC -fno-tree-vectorize)
else()
target_compile_options(fluidsim_core PUBLIC -fno-vectorize -fno-slp-vectorize)
endif()
elseif(MSVC AND NOT FLUIDSIM_ENABLE_SIMD)
target_compile_options(fluidsim_core PUBLIC /Qvec-)
endif()
add_executable(fluidsim_cpp src/main.cpp)
target_link_libraries(fluidsim_cpp PRIVATE fluidsim_core)
@@ -54,7 +70,23 @@ if(FLUIDSIM_BUILD_DESKTOP)
else()
target_compile_options(fluidsim_desktop PRIVATE -Wall -Wextra -Wpedantic)
endif()
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/../fonts" DESTINATION "${CMAKE_CURRENT_BINARY_DIR}")
if(FLUIDSIM_FONT_FILE)
if(NOT EXISTS "${FLUIDSIM_FONT_FILE}")
message(FATAL_ERROR "FLUIDSIM_FONT_FILE does not exist: ${FLUIDSIM_FONT_FILE}")
endif()
add_custom_command(TARGET fluidsim_desktop POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:fluidsim_desktop>/fonts"
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${FLUIDSIM_FONT_FILE}"
"$<TARGET_FILE_DIR:fluidsim_desktop>/fonts/FiraCodeNerdFont-Medium.ttf")
elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/../fonts/FiraCodeNerdFont-Medium.ttf")
add_custom_command(TARGET fluidsim_desktop POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:fluidsim_desktop>/fonts"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/../fonts/FiraCodeNerdFont-Medium.ttf"
"$<TARGET_FILE_DIR:fluidsim_desktop>/fonts/FiraCodeNerdFont-Medium.ttf")
else()
message(STATUS "No bundled font; desktop will try installed system fonts. Set FLUIDSIM_FONT_FILE to bundle one.")
endif()
endif()
if(MSVC)
@@ -73,3 +105,47 @@ if(BUILD_TESTING)
target_link_libraries(fluidsim_physics_tests PRIVATE fluidsim_core)
add_test(NAME fluidsim_physics_tests COMMAND fluidsim_physics_tests)
endif()
add_executable(fluidsim_physics_benchmark tools/physics_benchmark.cpp)
target_link_libraries(fluidsim_physics_benchmark PRIVATE fluidsim_core)
if(BUILD_TESTING)
add_executable(fluidsim_fluid_validation tests/fluid_validation.cpp)
target_link_libraries(fluidsim_fluid_validation PRIVATE fluidsim_core)
add_test(NAME fluidsim_fluid_validation COMMAND fluidsim_fluid_validation)
endif()
find_package(Threads REQUIRED)
target_link_libraries(fluidsim_core PUBLIC Threads::Threads)
add_executable(fluidsim_thread_benchmark tools/thread_benchmark.cpp)
target_link_libraries(fluidsim_thread_benchmark PRIVATE fluidsim_core)
if(BUILD_TESTING)
add_executable(fluidsim_intake_regression tests/intake_regression.cpp)
target_link_libraries(fluidsim_intake_regression PRIVATE fluidsim_core)
add_test(NAME fluidsim_intake_regression COMMAND fluidsim_intake_regression)
endif()
add_executable(fluidsim_audio_capture tools/audio_capture.cpp)
target_link_libraries(fluidsim_audio_capture PRIVATE fluidsim_core)
add_executable(fluidsim_two_stroke_benchmark tools/two_stroke_benchmark.cpp)
target_link_libraries(fluidsim_two_stroke_benchmark PRIVATE fluidsim_core)
add_executable(fluidsim_turbo_benchmark tools/turbo_benchmark.cpp)
target_link_libraries(fluidsim_turbo_benchmark PRIVATE fluidsim_core)
if(BUILD_TESTING)
add_executable(fluidsim_outdoor_audio_tests tests/outdoor_audio_tests.cpp)
target_link_libraries(fluidsim_outdoor_audio_tests PRIVATE fluidsim_core)
add_test(NAME fluidsim_outdoor_audio_tests COMMAND fluidsim_outdoor_audio_tests)
add_executable(fluidsim_two_stroke_regression tests/two_stroke_regression.cpp)
target_link_libraries(fluidsim_two_stroke_regression PRIVATE fluidsim_core)
add_test(NAME fluidsim_two_stroke_regression COMMAND fluidsim_two_stroke_regression)
add_executable(fluidsim_two_stroke_physics tests/two_stroke_physics.cpp)
target_link_libraries(fluidsim_two_stroke_physics PRIVATE fluidsim_core)
add_test(NAME fluidsim_two_stroke_physics COMMAND fluidsim_two_stroke_physics)
add_executable(fluidsim_b205_regression tests/b205_regression.cpp)
target_link_libraries(fluidsim_b205_regression PRIVATE fluidsim_core)
add_test(NAME fluidsim_b205_regression COMMAND fluidsim_b205_regression)
add_executable(fluidsim_turbo_physics tests/turbo_physics.cpp)
target_link_libraries(fluidsim_turbo_physics PRIVATE fluidsim_core)
add_test(NAME fluidsim_turbo_physics COMMAND fluidsim_turbo_physics)
add_executable(fluidsim_turbo_twin_regression tests/turbo_twin_regression.cpp)
target_link_libraries(fluidsim_turbo_twin_regression PRIVATE fluidsim_core)
add_test(NAME fluidsim_turbo_twin_regression COMMAND fluidsim_turbo_twin_regression)
endif()

41
CMakePresets.json Normal file
View File

@@ -0,0 +1,41 @@
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 28,
"patch": 0
},
"configurePresets": [
{
"name": "linux",
"displayName": "Linux (Make, desktop)",
"generator": "Unix Makefiles",
"binaryDir": "${sourceDir}/build-desktop",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"FLUIDSIM_BUILD_DESKTOP": "ON"
}
},
{
"name": "windows",
"displayName": "Windows (Visual Studio 2022, desktop)",
"generator": "Visual Studio 17 2022",
"architecture": "x64",
"binaryDir": "${sourceDir}/build/windows",
"cacheVariables": {
"FLUIDSIM_BUILD_DESKTOP": "ON"
}
}
],
"buildPresets": [
{
"name": "linux",
"configurePreset": "linux"
},
{
"name": "windows",
"configurePreset": "windows",
"configuration": "Release"
}
]
}

213
README.md
View File

@@ -1,7 +1,16 @@
# FluidSimCpp
Native C++20 port of FluidSim. The C# application remains the reference while
the component models and behavior are being ported and checked against it.
FluidSimCpp aims to reproduce sound from a physically accurate simulation of
compressible flow in pipes and connected volumes, sampled at 44,100 Hz. Each
audio sample advances physical time by 1/44100 second; adaptive internal
substeps resolve the fluid dynamics. Every subsystem must ultimately follow
physical laws with explicit units, assumptions and validation. Matching the
C# reference is a regression aid, not proof of physical accuracy.
This is a work in progress. Physical correctness is the acceptance goal, not
a claim about all current models. Validation must include conservation of
mass, momentum and energy, analytic acoustic modes, boundary reflection and
radiation, thermodynamics and convergence under time/space refinement.
## Build (Windows / Visual Studio)
@@ -14,6 +23,28 @@ cmake --build build
ctest --test-dir build --output-on-failure
```
## Build (Linux / Windows presets)
The repository includes CMake presets and VS Code tasks for the desktop app.
Install CMake 3.28 or newer, a C++20 compiler, and Make on Linux. On Windows,
install Visual Studio 2022 with the C++ desktop workload and CMake. Configure
and build from the project root with:
```sh
cmake --preset linux
cmake --build --preset linux --target fluidsim_desktop
```
On Windows, use the `windows` preset in place of `linux`. CMake fetches SFML
3.0.2 if it is not installed, so the first desktop configure needs network
access. In VS Code, choose **FluidSim desktop** in Run and Debug; the
same configuration selects the Windows executable on Windows. The `.vscode`
build task configures and builds the desktop target before launching it.
The UI uses the optional Fira Code Nerd Font file from `../fonts` when present.
Without it, the simulation still runs but text labels are hidden. To bundle a
font from another location, configure with `-DFLUIDSIM_FONT_FILE=/path/to/font.ttf`.
The first configure fetches SFML 3.0.2 with CMake FetchContent for the desktop
window and audio stream. `-DFLUIDSIM_BUILD_DESKTOP=OFF` builds only the headless
runner and physics tests.
@@ -47,6 +78,111 @@ cmake --build build-msvc --target fluidsim_parity_trace
.\verification\Compare-ScenarioTrace.ps1
```
## Audio fidelity and playback
Desktop playback starts at real time: one solver output frame per 44.1 kHz
stereo device frame. A simulation worker fills a bounded 8192-frame queue in
512-frame batches with a 4096-frame startup reserve (about 93 ms). The device
callback only reads samples, removes DC, converts to PCM and handles underruns;
it performs no simulation, allocation or waiting. An underrun fades out and
recovery crossfades over 64 samples rather than stepping abruptly to silence.
The callback never advances physical time to conceal missing samples.
The source is a linear compact-monopole approximation,
`p(r,t) = (d mass_flow/dt)/(4*pi*r)`. Intake and exhaust have separate static
positions, fractional-sample propagation delays, inverse-distance spreading,
and left/right ear paths. Playback calibration is 200 Pa per device full scale;
this is not calibrated speaker SPL. A second-order 6 kHz monitoring filter
reduces sharp sample-to-sample transients without saturation or synthetic bangs.
A roughly 3.5 Hz device DC blocker and final PCM clipping protection remain.
Use 1x playback for acoustic evaluation.
The default environment is a nearby wall. Press **R** to cycle dry, field,
wall and woodland. Ground/wall image paths provide early reflections; the
woodland scatterers and decaying feedback-delay tail are approximate effects,
not validated outdoor acoustics. Synthetic mechanical noise remains optional.
The compact source does not implement pipe-end radiation impedance, validated
directivity, or exhaust afterfire chemistry. Filtering cannot recover aliased
source information. See [audio verification](verification/AUDIO_RENDERING.md)
for tests, capture commands, limitations and measured transient reductions.
Launching `fluidsim_desktop` without `--scenario` opens a small scenario menu.
Click the twin, YZ125 or Saab B205 card to start, or select with Up/Down and press Enter.
Escape closes the menu. Simulation and audio start after selection. Passing
`--scenario twin`, `--scenario two-stroke` or `--scenario b205` bypasses the menu; `--scene` and
`--exhaust` options also apply when choosing through the menu.
To test the two desktop engines from the repository root:
```sh
cmake --build build-desktop --target fluidsim_desktop -j 4
./build-desktop/fluidsim_desktop --scenario two-stroke --scene wall
# Or:
./build-desktop/fluidsim_desktop --scenario twin --scene field
```
Hold **W** for throttle, use **Up/Down** to adjust the persistent pedal request,
**Space** to restore real-time playback and **R** to change environment. The two-stroke desktop uses
the existing 125 cc configuration; its dimensions and calibrations must be
adapted before it represents a particular engine. Both scenarios use the same
motorcycle test model, dashboard and rolling peak dyno graph (kW and Nm versus
RPM). Start in neutral, hold **C** to disengage the clutch, press **D** for first
gear, apply throttle and release **C** to pull away. **D/A** shift up/down;
**Z** selects neutral. Left/right adjust an external engine load, not a wheel
brake. The graph records simulated crank power/torque, not measured wheel power.
The two-stroke now uses a YZ125 baseline: published 2022 primary, gearbox and
final reductions, 54 x 54.5 mm geometry, and an estimated racing expansion
chamber. Ports use piston-uncovered window areas. The pipe display follows
physical cell diameter and length. Compare exhausts with `--exhaust racing`
(default) or `--exhaust straight`; the desktop, CLI and WAV capture tool accept
this option. See [YZ125 verification](verification/YZ125_BASELINE.md) for the
geometry, assumptions, speed sweep and remaining unloaded-RPM limitation.
## Twin with a VZ21-size turbo
Choose **600 cc twin + VZ21-size turbo** in the startup menu, or bypass it:
```sh
./build-desktop/fluidsim_desktop --scenario twin --turbo vz21 --scene field
./build-audit/fluidsim_cpp --scenario twin --turbo vz21 --seconds 5 --throttle 0.7
```
`Turbocharger` connects to four existing pipe ends and couples turbine and
compressor work through a rigid rotor. Small housing volumes preserve unsteady
exhaust pressure. Backpressure, charge temperature and spool speed emerge from
flow restrictions and energy exchange. A pressure-can/spring wastegate opens a
bypass; no boost pressure is imposed. HUD pressures are absolute. The default
unboosted twin is unchanged unless `--turbo vz21` is selected.
This is an estimated VZ21-size model, not a measured turbo map. Engine exhaust
pulses are implemented; blade-passing whistle/wakes are still pending. The
seven-second twin transient and all nine CTest suites passed. Sustained high-load
performance is close to, or slower than, real time in the current benchmark;
watch the underrun counter during testing. See
[turbo verification](verification/TURBO_VZ21.md) for assumptions and results.
## Saab B205 / 9-5 wagon baseline
Select **Saab B205 / 9-5 wagon** from the desktop menu, or launch directly:
```sh
./build-desktop/fluidsim_desktop --scenario b205 --scene field
./build-audit/fluidsim_cpp --scenario b205 --seconds 4 --throttle 0.5
```
The shared four-stroke scenario now supports two or four cylinders. The B205
uses 90 x 78 mm geometry, 8.8:1 compression, two intake/two exhaust valves per
cylinder and 1-3-4-2 firing order. Four intake runners and four exhaust headers
connect shared plenums and a tailpipe. The vehicle approximates a 9-5 wagon:
1675 kg with driver, five-speed manual, FM55 B05 overall ratios
13.70 / 7.12 / 4.77 / 3.62 / 2.67, and 4.05 final drive. No sixth gear is offered.
Controls and dyno match the other scenarios. There is no turbo system yet;
this is an atmospheric-breathing B205 geometry baseline. Pipe/cam dimensions,
friction, idle/ignition maps, clutch, drag and loaded tire radius are estimates.
It does not reproduce factory turbo power or turbo/exhaust acoustics.
See [B205 verification](verification/B205_BASELINE.md) for sources and checks.
## Current port status
The C++ core now includes the shared `EngineCylinder` model with four-stroke and
@@ -59,17 +195,16 @@ intake/exhaust radiation, outdoor feedback-delay reverb, optional mechanical
noise, a lock-free simulation audio buffer, a `SoundEngine` lifecycle wrapper,
and an SFML desktop frontend. The shared 50-RPM-bin dyno history and graph are
also ported. A crankcase-scavenged 125 cc two-stroke scenario is available in
the core.
the core and desktop, with an ECU, finite-response throttle and dynamic reed.
This is still an in-progress port, not a claimed complete replacement. All four
current C# scenarios (600 cc twin, 125 cc two-stroke, Helmholtz resonator, and
pressure pulse) have headless C++ counterparts and can be selected by the CLI.
Remaining parity work includes completing the C# drawing/diagnostic controls in
the desktop UI and resolving numerical differences reported by the comparison
harness. The latest MSVC build completed, CTest passed, and the one-second
headless twin run completed with finite output. The desktop executable opened,
but its client area rendered blank in the captured run, so desktop rendering is
not yet verified.
harness. Earlier Windows builds passed CTest, but the current audio and two-stroke
changes still need Windows runtime verification. The Linux desktop builds and
runs; installed-font fallbacks provide labels when no bundled font is present.
The one-second twin trace remains substantially outside its 1% tolerance. The
latest comparison's largest relative difference is 40.6745 for cylinder 1
@@ -81,3 +216,67 @@ geometry and valve timing, dyno-bin interpolation, configurable 0-D volume
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
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.
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
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.
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.
All ECU parameters are accessible calibrations, not measured engine maps.
There is no starter motor, automatic restart, knock sensor, lambda sensor,
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
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.
C# trace parity is intentionally changed by these control and physics changes.
VS Code's default build task builds the desktop through CMake. Use
Ctrl+Shift+B to build, or choose **FluidSim desktop** in Run and Debug and
press F5 to build and run. The single-file C/C++ editor Run shortcut is disabled
because this project requires multiple sources and SFML; IntelliSense gets
include paths from CMake Tools. Open the repository root as the VS Code folder.
## Fluid physics verification and performance
See [the fluid physics audit](verification/FLUID_PHYSICS_AUDIT.md) for corrected
conservation/boundary equations, analytic tests, measured performance and
remaining model limits. The validation executable exercises 29 cases covering
walls, pressure outlets, nozzles, inertance, pipe joints, volumes, species,
acoustic modes, shocks and invalid states. The default coupled solver updates
components on every substep, uses area-weighted shared fluxes and matches
orifice discharge to duct characteristics. These changes intentionally alter
older C# trace comparisons.
Build headless with CMake, then run `ctest --test-dir build-audit
--output-on-failure`. `fluidsim_physics_benchmark` and
`fluidsim_thread_benchmark` reproduce the performance experiments.
`FLUIDSIM_ENABLE_SIMD=OFF` provides a scalar comparison; fast-math is not used.
Production fluid solving remains serial because threading was slower for the
current small pipe graph. SIMD and scalar Linux checks are covered; Windows
execution is still unverified.
The twin intake is supplied by an explicit 300 K atmospheric reservoir rather
than a pressure-only boundary with extrapolated entropy. The repeated-blip
`fluidsim_intake_regression` checks fresh-air supply and pressure/idle recovery.
Manifold vacuum at closed throttle remains part of the simulated pumping cycle.

View File

@@ -24,6 +24,8 @@ public:
private:
void update_port() {
port.gamma_value = gamma_value;
port.gas_constant_value = gas_constant_value;
port.pressure = pressure;
port.density = density();
port.temperature = temperature;

View File

@@ -12,6 +12,7 @@ class AudioOutputStream final : public sf::SoundStream {
public:
explicit AudioOutputStream(SimulationRingBuffer& ring, unsigned int sample_rate = 44100);
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); }
private:
@@ -19,10 +20,16 @@ private:
SimulationRingBuffer& ring_;
std::array<float, block_size> temp_left_{}, temp_right_{};
std::array<std::int16_t, block_size * 2> output_{};
std::atomic<std::uint64_t> underrun_blocks_{};
std::atomic<double> speed_{1.0};
float previous_input_left_{}, previous_output_left_{};
float previous_input_right_{}, previous_output_right_{};
static constexpr std::size_t transition_samples = 64;
std::size_t transition_remaining_{};
float last_left_{}, last_right_{}, transition_left_{}, transition_right_{};
bool starving_{true};
bool onGetData(Chunk& data) override;
void onSeek(sf::Time time_offset) override;
};

View File

@@ -10,21 +10,21 @@ struct Vec3 { float x{}, y{}, z{}; };
// Flow-derivative sound source used by the C# two-stroke and pulse scenarios.
class SoundProcessor {
public:
float gain{1.0f};
float gain{0.005f};
explicit SoundProcessor(int sample_rate = 44100, float listener_distance = 1.0f);
float process(float mass_flow_out);
private:
float dt_{}, scale_factor_{}, lowpass_alpha_{}, derivative_alpha_{};
float flow_lowpass_{}, previous_flow_{}, smooth_derivative_{};
float dt_{}, scale_factor_{}, previous_flow_{};
};
class RadiationSource {
public:
float flow_tau{0.005f}, smooth_tau{0.020f}, gain{15.0f};
// Optional diagnostic filters; disabled for the linear radiation path.
float flow_tau{0.0f}, smooth_tau{0.0f}, gain{0.005f};
Vec3 pipe_end{}, pipe_axis{1.0f, 0.0f, 0.0f};
Vec3 listener{0.0f, 0.0f, 1.5f}, listener_right{1.0f, 0.0f, 0.0f};
float ear_spacing{0.18f}, head_shadow_cutoff{4000.0f}, speed_of_sound{343.0f};
float directivity_balance{0.30f}, air_absorption_exponent{0.002f}, min_air_cutoff{1500.0f};
float ear_spacing{0.18f}, head_shadow_cutoff{0.0f}, speed_of_sound{343.0f};
float directivity_balance{0.0f}, air_absorption_exponent{0.0f}, min_air_cutoff{1500.0f};
explicit RadiationSource(int sample_rate = 44100);
AudioFrame process(float mass_flow);

View File

@@ -10,7 +10,9 @@ struct Port {
float pressure{101325.0f};
float density{1.1768f};
float temperature{300.0f};
float gamma_value{1.4f}, gas_constant_value{287.0f};
float air_fraction{1.0f};
float* owner_energy{};
float* owner_air_mass{};
float* owner_exhaust_mass{};
};

View File

@@ -17,6 +17,7 @@ public:
float temperature() const { return temperature_; }
float density() const { return mass_ / (volume_ > 1e-12f ? volume_ : 1e-12f); }
float volume() const { return volume_; }
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_; }
@@ -28,6 +29,7 @@ private:
Crankshaft& crankshaft_;
float crank_radius_{}, connecting_rod_length_{}, piston_area_{};
float clearance_volume_{}, obliquity_{};
float geometry_work_{};
float mass_{}, energy_{}, air_fraction_{1.0f};
float pressure_{101325.0f}, temperature_{300.0f}, volume_{}, previous_volume_{};

View File

@@ -0,0 +1,87 @@
#pragma once
#include "FluidSim/Scenario.hpp"
#include "FluidSim/TwoStrokeScenario.hpp"
#include <memory>
#include <mutex>
namespace fluidsim::port {
// Shared desktop controls/snapshot contract. Two-stroke access is serialized;
// the twin already protects its own simulation state.
class DesktopScenario {
public:
explicit DesktopScenario(ScenarioKind kind, TwoStrokeConfig config = {}) {
if (kind == ScenarioKind::TwoStroke)
two_ = std::make_unique<TwoStrokeScenario>(44100, config);
else
twin_ = std::make_unique<FourStrokeScenario>(44100, kind == ScenarioKind::SaabB205 ?
FourStrokePreset::SaabB205 : FourStrokePreset::Twin, kind == ScenarioKind::TurboTwin);
}
AudioFrame process() {
if (twin_)
return twin_->process();
const std::lock_guard lock(mutex_);
return two_->process();
}
void set_controls(float pedal, float load, float clutch) {
visit([&](auto& scenario) { scenario.set_controls(pedal, load, clutch); });
}
void shift_up() { visit([](auto& scenario) { scenario.shift_up(); }); }
void shift_down() { visit([](auto& scenario) { scenario.shift_down(); }); }
void set_neutral() { visit([](auto& scenario) { scenario.set_neutral(); }); }
void toggle_mechanical_sound() {
if (twin_)
twin_->toggle_mechanical_sound();
}
void set_audio_scene(OutdoorScene scene) {
visit([&](auto& scenario) { scenario.set_audio_scene(scene); });
}
ScenarioSnapshot snapshot() const {
if (twin_)
return twin_->snapshot();
const std::lock_guard lock(mutex_);
const auto source = two_->snapshot();
ScenarioSnapshot s;
s.two_stroke = true;
s.cylinder_count = 1;
s.racing_exhaust = two_->configuration.exhaust == TwoStrokeExhaust::Racing;
s.rpm = source.rpm;
s.speed_kmh = two_->vehicle.speed * 3.6f;
s.kinetic_energy = two_->vehicle.kinetic_energy();
s.gear = two_->vehicle.current_gear;
s.clutch = two_->clutch;
s.average_power = two_->crankshaft.average_power;
s.average_torque = two_->crankshaft.average_torque;
s.cylinder_pressure1 = source.cylinder_pressure;
s.cylinder_temperature1 = two_->cylinder.temperature();
s.cylinder_piston_fraction1 =
(two_->cylinder.volume - two_->cylinder.clearance_volume()) / two_->cylinder.swept_volume();
s.crankcase_pressure = source.crankcase_pressure;
s.intake_pressure = source.intake_pressure;
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.valve1_intake_area = source.transfer_area;
s.valve1_exhaust_area = source.exhaust_area;
s.reed_open = source.reed_open;
s.fuel_cut = source.fuel_cut;
s.idle_control = two_->ecu.idle_active();
s.rev_limit1 = two_->ecu.rev_limited();
s.audio_scene = source.audio_scene;
s.intake_flow = two_->intake_flow;
s.exhaust_flow = two_->exhaust_flow;
s.capture_pipes(two_->pipes);
return s;
}
private:
template<class Function> void visit(Function&& function) {
const std::lock_guard lock(mutex_);
if (twin_) function(*twin_);
else function(*two_);
}
std::unique_ptr<FourStrokeTwinScenario> twin_;
std::unique_ptr<TwoStrokeScenario> two_;
mutable std::mutex mutex_;
};
} // namespace fluidsim::port

38
include/FluidSim/Ecu.hpp Normal file
View File

@@ -0,0 +1,38 @@
#pragma once
#include <vector>
namespace fluidsim::port {
// Ideal sensors and injectors; calibration parameters are not engine measurements.
class Ecu {
public:
struct IgnitionPoint { float rpm, degrees_btdc; };
std::vector<IgnitionPoint> ignition_map{{0, 10}, {15000, 40}};
float idle_rpm{1800.0f};
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
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};
float idle_spark_gain{0.0f}, idle_minimum_advance{-5.0f}; // degrees/RPM, degrees BTDC
float stoichiometric_afr{14.7f}, power_afr{12.8f};
void step(float dt, float pedal, float rpm);
float fuel_for_air(float trapped_air_mass) const;
float throttle_opening() const { return opening_; }
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_; }
bool rev_limited() const { return limiter_; }
bool idle_active() const { return idle_; }
bool sensor_fault() const { return fault_; }
private:
float filtered_rpm_{};
bool initialized_{};
float opening_{}, target_{}, integral_{}, afr_{14.7f}, spark_{10.0f};
bool limiter_{}, overrun_{}, fault_{}, idle_{};
};
} // namespace fluidsim::port

View File

@@ -8,11 +8,14 @@
namespace fluidsim::port {
class Ecu;
class Crankshaft {
public:
float omega{400.0f * 2.0f * pi / 60.0f};
float angle{}, previous_angle{}, inertia{0.2f};
float friction_constant{}, friction_viscous{};
float friction_quadratic{}; // Nm / (rad/s)^2, dissipative windage term
float load_torque{}, drivetrain_torque{};
float average_power{}, average_torque{};
@@ -27,8 +30,10 @@ private:
class EngineCylinder : public Component {
public:
Ecu* ecu{}; // Optional ideal electronic fuel/ignition controller.
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};
float intake_valve_lift{0.005f}, exhaust_valve_lift{0.005f};
float phase{};
float spark_advance{20.0f}, wiebe_a{5.0f}, wiebe_m{2.0f}, burn_duration{60.0f}, burn_start{5.0f};
@@ -61,6 +66,9 @@ protected:
float random_unit();
static float wrap_degrees(float value, float cycle_degrees = 720.0f);
static bool crossed_angle(float previous, float current, float event) {
return current >= previous ? previous < event && current >= event : event > previous || event <= current;
}
static bool crossed_window_close(float previous, float current, float open, float close);
void update_rev_limiter(float rpm, float dt);
void sync_ports();

View File

@@ -17,18 +17,24 @@ public:
float gas_constant_value = 287.0f, float gamma_value = 1.4f)
: gamma(gamma_value), gas_constant(gas_constant_value), volume_(volume_m3),
air_mass_(pressure * volume_m3 / (gas_constant_value * temperature)),
energy_(pressure * volume_m3 / (gamma_value - 1.0f)) {}
energy_(pressure * volume_m3 / (gamma_value - 1.0f)) {
if (!std::isfinite(volume_m3) || volume_m3 <= 0 || !std::isfinite(pressure) || pressure <= 0 ||
!std::isfinite(temperature) || temperature <= 0 || !std::isfinite(gas_constant_value) ||
gas_constant_value <= 0 || !std::isfinite(gamma_value) || gamma_value <= 1)
throw std::invalid_argument("volume thermodynamic state must be finite and positive, gamma > 1");
}
float gamma{1.4f};
float gas_constant{287.0f};
float ambient_pressure{101325.0f};
float ambient_temperature{300.0f};
float energy_relaxation_rate{10.0f};
float energy_relaxation_rate{0.0f};
float volume_change_rate{};
Port &create_port() {
ports_.emplace_back();
auto &p = ports_.back();
p.owner_energy = &energy_;
p.owner_air_mass = &air_mass_;
p.owner_exhaust_mass = &exhaust_mass_;
sync_port(p);
@@ -39,38 +45,56 @@ public:
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 internal_energy() const { return energy_; }
float air_fraction() const { return air_mass_ / std::max(mass(), 1e-12f); }
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();
air_mass_ = total * fraction;
exhaust_mass_ = total * (1.0f - fraction);
energy_ = value * volume_ / (gamma - 1.0f);
air_roundoff_ = exhaust_roundoff_ = energy_roundoff_ = 0;
sync_ports();
}
void update(float dt) {
if (dt <= 0.0f) return;
float d_air = 0.0f, d_exhaust = 0.0f, d_energy = 0.0f;
if (!std::isfinite(dt) || dt < 0)
throw std::invalid_argument("invalid volume timestep");
if (dt == 0)
return;
double d_air = 0, d_exhaust = 0, d_energy = 0;
const float fraction = air_fraction();
for (const Port &p : ports_) {
const float flow_fraction = p.mass_flow >= 0.0f ? p.air_fraction : fraction;
d_air += p.mass_flow * flow_fraction;
d_exhaust += p.mass_flow * (1.0f - flow_fraction);
d_energy += p.mass_flow * p.enthalpy;
d_air += double(p.mass_flow) * flow_fraction;
d_exhaust += double(p.mass_flow) * (1.0f - flow_fraction);
d_energy += double(p.mass_flow) * p.enthalpy;
}
air_mass_ += d_air * dt;
exhaust_mass_ += d_exhaust * dt;
energy_ += (d_energy - pressure() * volume_change_rate) * dt;
accumulate(air_mass_, air_roundoff_, d_air * dt);
accumulate(exhaust_mass_, exhaust_roundoff_, d_exhaust * 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))
throw std::runtime_error("invalid moving volume");
if (new_volume != volume_) {
const double scale = std::pow(double(volume_) / new_volume, gamma - 1.0f);
const double precise = (double(energy_)+energy_roundoff_)*scale;
energy_ = float(precise);
energy_roundoff_ = precise-energy_;
}
volume_ = new_volume;
if (energy_relaxation_rate > 0.0f && mass() > 1e-12f) {
const float target = mass() * gas_constant * ambient_temperature / (gamma - 1.0f);
energy_ = target + (energy_ - target) * std::exp(-energy_relaxation_rate * dt);
energy_roundoff_ = 0;
}
if (mass() < 1e-9f || energy_ < 0.0f) {
air_mass_ = 1e-9f;
exhaust_mass_ = 0.0f;
energy_ = ambient_pressure * volume_ / (gamma - 1.0f);
}
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)
throw std::runtime_error("negative volume species mass");
air_mass_ = std::max(air_mass_, 0.0f);
exhaust_mass_ = std::max(exhaust_mass_, 0.0f);
sync_ports();
@@ -79,21 +103,35 @@ public:
std::vector<Port *> ports() override {
std::vector<Port *> result;
result.reserve(ports_.size());
for (Port& port : ports_) result.push_back(&port);
for (Port &port : ports_)
result.push_back(&port);
return result;
}
const std::deque<Port> &ports() const { return ports_; }
private:
float volume_, air_mass_{}, exhaust_mass_{}, energy_;
// Preserve sub-ulp transfers without changing the float port/budget ABI.
double air_roundoff_{}, exhaust_roundoff_{}, energy_roundoff_{};
static void accumulate(float& value, double& roundoff, double increment) {
const double precise = double(value)+roundoff+increment;
value = float(precise);
roundoff = precise-value;
}
std::deque<Port> ports_;
void sync_port(Port &p) const {
p.gamma_value = gamma;
p.gas_constant_value = gas_constant;
p.pressure = pressure();
p.density = density();
p.temperature = temperature();
p.enthalpy = gamma / (gamma - 1.0f) * p.pressure / std::max(p.density, 1e-12f);
p.air_fraction = air_fraction();
}
void sync_ports() { for (Port& p : ports_) sync_port(p); }
void sync_ports() {
for (Port &p : ports_)
sync_port(p);
}
};
struct GasCell {
@@ -101,113 +139,160 @@ struct GasCell {
float energy{1.225f * (101325.0f / (pipe_gm1 * 1.225f))};
float air{1.0f};
};
struct GasState { float rho{1.225f}, velocity{}, pressure{101325.0f}, air{1.0f}; };
struct GasState {
float rho{1.225f}, velocity{}, pressure{101325.0f}, air{1.0f};
};
class Pipe1D {
public:
// Integrated face fluxes: kg/s, N, W, kg/s of fresh air, positive along +x.
struct Flux {
float m{}, p{}, e{}, y{};
};
float area{}, dx{};
float damping_multiplier{1.0f}, energy_relaxation_rate{0.5f}, ambient_pressure{101325.0f};
float damping_multiplier{0.0f}, energy_relaxation_rate{0.0f}, ambient_pressure{101325.0f};
std::vector<float> cell_areas, cell_lengths;
std::vector<GasCell> cells;
GasState left_ghost{}, right_ghost{};
Pipe1D(float area_m2, float length_m, int cell_count)
: area(area_m2), dx(length_m / static_cast<float>(cell_count)),
cell_areas(static_cast<std::size_t>(cell_count), area_m2),
cell_lengths(static_cast<std::size_t>(cell_count), dx),
cells(static_cast<std::size_t>(cell_count)), next_cells_(static_cast<std::size_t>(cell_count)) {}
Pipe1D(std::vector<float> areas_m2, std::vector<float> lengths_m)
: area(areas_m2.empty() ? 0.0f : areas_m2.front()),
dx(lengths_m.empty() ? 0.0f : lengths_m.front()),
cell_areas(std::move(areas_m2)), cell_lengths(std::move(lengths_m)),
cells(cell_areas.size()), next_cells_(cell_areas.size()) {
if (cell_areas.empty() || cell_lengths.size() != cell_areas.size())
throw std::invalid_argument("pipe area and length profiles must be non-empty and have equal length");
for (std::size_t i = 0; i < cell_areas.size(); ++i) {
if (cell_areas[i] <= 0.0f || cell_lengths[i] <= 0.0f)
throw std::invalid_argument("pipe cell area and length must be positive");
std::uint64_t positivity_repairs{};
Pipe1D(float a, float length, int count)
: Pipe1D(uniform(a, count), uniform(length / std::max(count, 1), count)) {}
Pipe1D(std::vector<float> areas, std::vector<float> lengths)
: area(areas.empty() ? 0 : areas.front()), dx(lengths.empty() ? 0 : lengths.front()),
cell_areas(std::move(areas)), cell_lengths(std::move(lengths)), cells(cell_areas.size()),
next_cells_(cells.size()), states_(cells.size()), waves_(cells.size()), faces_(cells.size() + 1) {
if (cells.empty() || cell_lengths.size() != cells.size())
throw std::invalid_argument("pipe profiles must be nonempty and have equal length");
for (std::size_t i = 0; i < cells.size(); ++i)
if (!std::isfinite(cell_areas[i]) || !std::isfinite(cell_lengths[i]) || cell_areas[i] <= 0 ||
cell_lengths[i] <= 0)
throw std::invalid_argument("pipe geometry must be finite and positive");
}
GasState state(int i) const { return primitive(cells.at(static_cast<std::size_t>(i))); }
float cell_mass(int i) const { return cells.at(i).rho * cell_areas.at(i) * cell_lengths.at(i); }
float cell_area(int i) const { return cell_areas.at(i); }
float cell_length(int i) const { return cell_lengths.at(i); }
float minimum_cell_length() const { return *std::min_element(cell_lengths.begin(), cell_lengths.end()); }
float stable_timestep(float cfl = 0.8f) const {
float dt = std::numeric_limits<float>::max();
for (std::size_t i = 0; i < cells.size(); ++i) {
auto s = primitive(cells[i]);
if (!(s.rho > 0) || !(s.pressure > 0) || !std::isfinite(s.velocity) || !std::isfinite(s.pressure))
throw std::runtime_error("nonphysical pipe state in CFL calculation");
dt = std::min(dt, cfl * cell_lengths[i] / wave(s));
}
return dt;
}
// Solver preparation is valid until advance; boundary resolution changes
// fluxes/ghosts, not cells. Standalone advance still prepares its own cache.
float prepare_step(float cfl = 0.8f) {
float dt = std::numeric_limits<float>::max();
for (std::size_t i = 0; i < cells.size(); ++i) {
states_[i] = primitive(cells[i]);
const auto &s = states_[i];
if (!(s.rho > 0) || !(s.pressure > 0) || !std::isfinite(s.velocity) || !std::isfinite(s.pressure))
throw std::runtime_error("nonphysical pipe state in preparation");
waves_[i] = wave(s);
dt = std::min(dt, cfl * cell_lengths[i] / waves_[i]);
}
prepared_ = true;
return dt;
}
int required_substeps(float dt, float cfl = 0.8f) const {
if (!std::isfinite(dt) || dt < 0 || !std::isfinite(cfl) || cfl <= 0 || cfl > 1)
throw std::invalid_argument("invalid timestep/CFL");
const double n = std::ceil(double(dt) / stable_timestep(cfl));
if (n > 1000000)
throw std::runtime_error("excessive pipe substeps");
return std::max(1, static_cast<int>(n));
}
void set_boundary_flux(bool left, Flux f) {
if (left) {
left_flux_ = f;
left_override_ = true;
} else {
right_flux_ = f;
right_override_ = true;
}
}
GasState state(int idx) const {
const auto& c = cells.at(static_cast<std::size_t>(idx));
const float rho = c.rho;
const float u = c.momentum / std::max(rho, 1e-12f);
const float p = pipe_gm1 * (c.energy - 0.5f * c.momentum * u);
return {rho, u, p, std::clamp(c.air, 0.0f, 1.0f)};
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)};
}
float cell_mass(int idx) const {
const auto cell = static_cast<std::size_t>(idx);
return cells.at(cell).rho * cell_areas.at(cell) * cell_lengths.at(cell);
static float wave(const GasState &s) {
return std::abs(s.velocity) + std::sqrt(gamma * s.pressure / s.rho);
}
float cell_area(int idx) const { return cell_areas.at(static_cast<std::size_t>(idx)); }
float cell_length(int idx) const { return cell_lengths.at(static_cast<std::size_t>(idx)); }
float minimum_cell_length() const {
return *std::min_element(cell_lengths.begin(), cell_lengths.end());
}
int required_substeps(float dt, float target_cfl = 0.8f) const {
float maximum_wave_speed = 0.0f;
for (int i = 0; i < static_cast<int>(cells.size()); ++i) {
const GasState s = state(i);
maximum_wave_speed = std::max(maximum_wave_speed,
std::abs(s.velocity) + std::sqrt(gamma * s.pressure / std::max(s.rho, 1e-12f)));
}
return std::max(1, static_cast<int>(std::ceil(dt * maximum_wave_speed /
(target_cfl * std::max(minimum_cell_length(), 1e-12f)))));
static Flux riemann(const GasState &a, const GasState &b) {
return riemann(a, b, std::max(wave(a), wave(b)));
}
void advance(float dt) {
const int n = static_cast<int>(cells.size());
if (n == 0 || dt <= 0.0f) return;
const float ambient_energy = ambient_pressure * pipe_gm1_inverse;
for (int i = 0; i < n; ++i) {
const GasState l = i == 0 ? left_ghost : flux_state(i - 1), r = flux_state(i);
const GasState a = flux_state(i), b = i == n - 1 ? right_ghost : flux_state(i + 1);
const Flux fl = flux(l, r), fr = flux(a, b);
const float factor = dt / cell_lengths[static_cast<std::size_t>(i)];
if (dt <= 0)
return;
if (!std::isfinite(dt))
throw std::invalid_argument("nonfinite pipe timestep");
const std::size_t n = cells.size();
if (!prepared_)
prepare_step();
faces_[0] = left_override_ ? left_flux_ : scaled(riemann(left_ghost, states_[0]), cell_areas[0]);
faces_[n] =
right_override_ ? right_flux_ : scaled(riemann(states_[n - 1], right_ghost), cell_areas[n - 1]);
for (std::size_t i = 1; i < n; ++i)
faces_[i] = scaled(riemann(states_[i - 1], states_[i], std::max(waves_[i - 1], waves_[i])),
.5f * (cell_areas[i - 1] + cell_areas[i]));
const float relaxation = energy_relaxation_rate > 0 ? std::exp(-energy_relaxation_rate * dt) : 1.0f;
for (std::size_t i = 0; i < n; ++i) {
const auto fl = faces_[i], fr = faces_[i + 1];
const float factor = dt / (cell_areas[i] * cell_lengths[i]);
const float al = i ? .5f * (cell_areas[i - 1] + cell_areas[i]) : cell_areas[i];
const float ar = i + 1 < n ? .5f * (cell_areas[i] + cell_areas[i + 1]) : cell_areas[i];
float rho = cells[i].rho - factor * (fr.m - fl.m);
float mom = cells[i].momentum - factor * (fr.p - fl.p);
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 rho_air = cells[i].rho * cells[i].air - factor * (fr.y - fl.y);
if (!std::isfinite(rho) || !std::isfinite(mom) || !std::isfinite(en) || !std::isfinite(rho_air)) {
next_cells_[i] = cells[i];
continue;
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))
throw std::runtime_error("nonfinite finite-volume update");
if (rho < 1e-12f) {
rho = 1e-12f;
++positivity_repairs;
}
rho = std::max(rho, 1e-12f);
const float damping = std::exp(-0.1f * damping_multiplier * dt /
std::max(cells[i].rho, 1e-12f));
mom *= damping;
if (energy_relaxation_rate > 0.0f) {
const float relax = std::exp(-energy_relaxation_rate * dt);
en = ambient_energy + (en - ambient_energy) * relax;
if (damping_multiplier > 0)
mom *= std::exp(-.1f * damping_multiplier * dt / cells[i].rho);
if (energy_relaxation_rate > 0)
en = ambient_pressure * pipe_gm1_inverse +
(en - ambient_pressure * pipe_gm1_inverse) * relaxation;
const float minimum = 1e-6f * pipe_gm1_inverse + .5f * mom * mom / rho;
if (en < minimum) {
en = minimum;
++positivity_repairs;
}
en = std::max(en, 100.0f * pipe_gm1_inverse + 0.5f * mom * mom / rho);
next_cells_[i] = {rho, mom, en, std::clamp(rho_air / rho, 0.0f, 1.0f)};
next_cells_[i] = {rho, mom, en, std::clamp(air / rho, 0.0f, 1.0f)};
}
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}; }
private:
struct Flux { float m{}, p{}, e{}, y{}; };
std::vector<GasCell> next_cells_;
GasState flux_state(int idx) const {
const auto& c = cells[static_cast<std::size_t>(idx)];
const float rho = c.rho;
const float inverse_rho = 1.0f / std::max(rho, 1e-12f);
const float u = c.momentum * inverse_rho;
const float p = pipe_gm1 * (c.energy - 0.5f * c.momentum * u);
return {rho, u, p, std::clamp(c.air, 0.0f, 1.0f)};
std::vector<GasState> states_;
std::vector<float> waves_;
std::vector<Flux> faces_;
Flux left_flux_{}, right_flux_{};
bool left_override_{}, right_override_{}, prepared_{};
static std::vector<float> uniform(float v, int n) {
if (n <= 0)
throw std::invalid_argument("pipe count must be positive");
return std::vector<float>(n, v);
}
static Flux flux(const GasState& a, const GasState& b) {
const float ua = a.velocity, ub = b.velocity;
const float inv_rho_a = 1.0f / std::max(a.rho, 1e-12f);
const float inv_rho_b = 1.0f / std::max(b.rho, 1e-12f);
const float ea = a.pressure * pipe_gm1_inverse * inv_rho_a + 0.5f * ua * ua;
const float eb = b.pressure * pipe_gm1_inverse * inv_rho_b + 0.5f * ub * ub;
const float c = std::max(std::abs(ua) + std::sqrt(gamma * a.pressure * inv_rho_a),
std::abs(ub) + std::sqrt(gamma * b.pressure * inv_rho_b));
return {
0.5f * (a.rho * ua + b.rho * ub) - 0.5f * c * (b.rho - a.rho),
0.5f * (a.rho * ua * ua + a.pressure + b.rho * ub * ub + b.pressure) - 0.5f * c * (b.rho * ub - a.rho * ua),
0.5f * ((a.rho * ea + a.pressure) * ua + (b.rho * eb + b.pressure) * ub) - 0.5f * c * (b.rho * eb - a.rho * ea),
0.5f * (a.rho * ua * a.air + b.rho * ub * b.air) - 0.5f * c * (b.rho * b.air - a.rho * a.air)
};
static Flux riemann(const GasState &a, const GasState &b, float c) {
const float ma = a.rho * a.velocity, mb = b.rho * b.velocity;
const float ea = a.pressure * pipe_gm1_inverse + .5f * ma * a.velocity;
const float eb = b.pressure * pipe_gm1_inverse + .5f * mb * b.velocity;
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))};
}
};
@@ -215,172 +300,328 @@ class BoundarySystem {
public:
explicit BoundarySystem(std::vector<Pipe1D> &pipes) : pipes_(pipes) {}
void add_orifice(Port &port, int pipe, bool left, float &area, float cd = 1.0f) {
Orifice desc;
desc.port = &port;
desc.pipe = pipe;
desc.left = left;
desc.area = &area;
desc.cd = cd;
orifices_.push_back(desc);
if (std::abs(port.gamma_value - gamma) > 1e-6f ||
std::abs(port.gas_constant_value - gas_constant) > .001f)
throw std::invalid_argument("pipe coupling requires the same calorically perfect gas");
if (!std::isfinite(cd) || cd < 0 || cd > 1)
throw std::invalid_argument("orifice Cd must be in [0,1]");
for (auto &o : orifices_)
if (o.port == &port)
throw std::invalid_argument("each connection requires a distinct component port");
claim(pipe, left);
Orifice o;
o.port = &port;
o.pipe = pipe;
o.left = left;
o.area = &area;
o.cd = cd;
if (port.owner_air_mass) {
auto it = std::find_if(budgets_.begin(), budgets_.end(),
[&](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});
} else
o.budget = static_cast<std::size_t>(it - budgets_.begin());
}
void add_orifice_with_inertance(Port& port, int pipe, bool left, float& area,
float cd, float effective_length, float loss_coefficient = 0.0f) {
add_orifice(port, pipe, left, area, cd);
auto& desc = orifices_.back();
desc.use_inertance = true;
desc.effective_length = effective_length;
desc.loss_coefficient = loss_coefficient;
orifices_.push_back(o);
}
void add_orifice_with_inertance(Port &p, int pipe, bool left, float &a, float cd, float length,
float loss = 0) {
if (!std::isfinite(length) || length <= 0 || !std::isfinite(loss) || loss < 0)
throw std::invalid_argument("invalid inertance");
add_orifice(p, pipe, left, a, cd);
auto &o = orifices_.back();
o.length = length;
o.loss = loss;
}
void add_closed_end(int pipe, bool left) {
Orifice desc;
desc.pipe = pipe;
desc.left = left;
desc.closed_end = true;
orifices_.push_back(desc);
claim(pipe, left);
Orifice o;
o.pipe = pipe;
o.left = left;
orifices_.push_back(o);
}
void add_open_end(int pipe, bool left, float p, float a) {
if (!std::isfinite(p) || p <= 0 || !std::isfinite(a) || a <= 0)
throw std::invalid_argument("invalid open boundary");
claim(pipe, left);
opens_.push_back({pipe, left, p, a});
}
// An ideal, zero-volume junction between two duct faces. Orientation is explicit.
void add_connection(int a, bool left_a, int b, bool left_b) {
if (a < 0 || b < 0 || static_cast<std::size_t>(a) >= pipes_.size() ||
static_cast<std::size_t>(b) >= pipes_.size())
throw std::invalid_argument("invalid connection pipe index");
const float aa = pipes_[a].cell_area(end(pipes_[a], left_a));
const float ab = pipes_[b].cell_area(end(pipes_[b], left_b));
if (std::abs(aa - ab) > 1e-6f * std::max(aa, ab))
throw std::invalid_argument("unequal-area junction needs a profiled pipe transition");
if (a == b && left_a == left_b)
throw std::invalid_argument("cannot join a face to itself");
check_face(a, left_a);
check_face(b, left_b);
claim(a, left_a);
claim(b, left_b);
connections_.push_back({a, b, left_a, left_b});
}
float open_end_mass_flow(std::size_t i) const { return opens_.at(i).last_mass_flow; }
float open_end_pressure(std::size_t i) const { return opens_.at(i).last_face_pressure; }
std::uint64_t limited_fluxes{}, nonlinear_iterations{};
void validate_topology() const {
if (claims_.size() != 2 * pipes_.size())
throw std::runtime_error("every pipe endpoint needs an explicit boundary or connection");
}
void add_open_end(int pipe, bool left, float pressure, float area) { opens_.push_back({pipe, left, pressure, area}); }
float open_end_mass_flow(std::size_t index) const { return opens_.at(index).last_mass_flow; }
float open_end_pressure(std::size_t index) const { return opens_.at(index).last_face_pressure; }
void resolve(float dt) {
for (auto& o : orifices_) resolve_orifice(o, dt);
for (auto& o : opens_) resolve_open_end(o, dt);
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.heat = b.energy ? .25f * (*b.energy) : std::numeric_limits<float>::max();
}
for (auto &o : orifices_)
resolve_orifice(o, dt);
for (auto &o : opens_)
resolve_open(o);
for (auto &o : connections_)
resolve_connection(o);
}
private:
struct Budget {
float *air{}, *exhaust{}, *energy{};
float mass{}, heat{};
};
struct Orifice {
Port *port{};
int pipe{};
bool left{};
float *area{};
float cd{1.0f};
bool closed_end{}, use_inertance{};
float effective_length{}, loss_coefficient{}, current_mass_flow{};
float cd{1}, length{}, loss{}, mdot{};
std::size_t budget{std::numeric_limits<std::size_t>::max()};
};
struct Open {
int pipe;
bool left;
float pressure, area, last_mass_flow{}, last_face_pressure{};
};
struct Connection {
int a, b;
bool left_a, left_b;
};
struct Claim {
int pipe;
bool left;
};
struct Open { int pipe; bool left; float pressure, area; float last_mass_flow{}, last_face_pressure{}; };
struct OrificeSolution { float mass_flow{}, rho{}, velocity{}, pressure{}; };
std::vector<Pipe1D> &pipes_;
std::vector<Orifice> orifices_;
std::vector<Open> opens_;
static OrificeSolution isentropic(float pup, float rho_up, float t_up, float pdown, float area, float cd) {
OrificeSolution out{0.0f, rho_up, 0.0f, pup};
if (area <= 0.0f || pup <= 0.0f || rho_up <= 0.0f || t_up <= 0.0f) return out;
float ratio = std::clamp(pdown / pup, 1e-6f, 1.0f);
const float critical = std::pow(2.0f / (gamma + 1.0f), gamma / (gamma - 1.0f));
ratio = std::max(ratio, critical);
const float mach = std::sqrt(std::max(0.0f, 2.0f / (gamma - 1.0f) *
(std::pow(ratio, -(gamma - 1.0f) / gamma) - 1.0f)));
const float a_up = std::sqrt(gamma * gas_constant * t_up);
out.velocity = mach * a_up;
out.rho = rho_up * std::pow(ratio, 1.0f / gamma);
out.pressure = pup * ratio;
out.mass_flow = out.rho * out.velocity * area * cd;
return out;
std::vector<Connection> connections_;
std::vector<Budget> budgets_;
std::vector<Claim> claims_;
void check_face(int pipe, bool left) const {
if (pipe < 0 || static_cast<std::size_t>(pipe) >= pipes_.size())
throw std::invalid_argument("invalid boundary pipe index");
for (auto c : claims_)
if (c.pipe == pipe && c.left == left)
throw std::invalid_argument("pipe face already connected");
}
static float available_mass(const Port& port) {
return (port.owner_air_mass ? *port.owner_air_mass : 0.0f) +
(port.owner_exhaust_mass ? *port.owner_exhaust_mass : 0.0f);
void claim(int pipe, bool left) {
check_face(pipe, left);
claims_.push_back({pipe, left});
}
static int end(const Pipe1D &p, bool left) { return left ? 0 : static_cast<int>(p.cells.size()) - 1; }
static void ghost(Pipe1D &p, bool left, GasState s) {
if (left)
p.left_ghost = s;
else
p.right_ghost = s;
}
void resolve_orifice(Orifice &o, float dt) {
Pipe1D& pipe = pipes_.at(static_cast<std::size_t>(o.pipe));
const int idx = o.left ? 0 : static_cast<int>(pipe.cells.size()) - 1;
const GasState interior = pipe.state(idx);
const float area = o.area ? std::max(*o.area, 0.0f) : 0.0f;
if (o.closed_end || !o.port || area <= 1e-12f) {
GasState wall = interior;
wall.velocity = -interior.velocity;
if (o.left) pipe.left_ghost = wall; else pipe.right_ghost = wall;
if (o.port) o.port->mass_flow = 0.0f;
auto &p = pipes_[o.pipe];
int i = end(p, o.left);
auto s = p.state(i);
float face_area = p.cell_area(i);
if (o.area && (!std::isfinite(*o.area) || *o.area < 0))
throw std::invalid_argument("invalid dynamic orifice area");
if (o.port && (!(o.port->pressure > 0) || !(o.port->density > 0) ||
!std::isfinite(o.port->pressure) || !std::isfinite(o.port->density)))
throw std::runtime_error("nonphysical component port");
const float area = o.area ? std::min(std::max(*o.area, 0.f), face_area) : 0.f;
if (!o.port || area <= 0 || o.cd == 0) {
auto g = s;
g.velocity = -s.velocity;
ghost(p, o.left, g);
auto wall_flux =
Pipe1D::scaled(o.left ? Pipe1D::riemann(g, s) : Pipe1D::riemann(s, g), face_area);
// A stationary adiabatic wall transfers momentum, never mass or energy.
p.set_boundary_flux(o.left, {0, wall_flux.p, 0, 0});
if (o.port)
o.port->mass_flow = 0;
o.mdot = 0;
return;
}
const float dp = o.port->pressure - interior.pressure;
OrificeSolution solution{0.0f, interior.rho, 0.0f, interior.pressure};
// Preserve the C# tie-break: at equal pressure it treats the volume as
// upstream, retaining the volume-side density in the ghost state. That
// matters when equal-pressure volume and pipe states have different
// temperatures/densities, as on the initialized intake plenum.
if (o.port->pressure >= interior.pressure) {
solution = isentropic(o.port->pressure, o.port->density, o.port->temperature, interior.pressure, area, o.cd);
} else {
solution = isentropic(interior.pressure, interior.rho,
interior.pressure / std::max(interior.rho * gas_constant, 1e-12f), o.port->pressure, area, o.cd);
solution.mass_flow = -solution.mass_flow;
auto &v = *o.port;
if (std::abs(v.gamma_value - gamma) > 1e-6f || std::abs(v.gas_constant_value - gas_constant) > .001f)
throw std::invalid_argument("pipe coupling requires the same calorically perfect gas");
const float dp = v.pressure - s.pressure;
const double g = gamma;
const double critical = std::pow(2.0 / (g + 1), g / (g - 1));
const double c = std::sqrt(g * s.pressure / s.rho);
const double ui = s.velocity * (o.left ? 1 : -1);
const auto face_density = [&](double pressure) {
return s.rho * std::pow(pressure / s.pressure, 1 / g);
};
const auto nozzle_flow = [&](double pressure) {
const bool from = v.pressure >= pressure;
const double up = from ? v.pressure : pressure;
const double down = from ? pressure : v.pressure;
const double density = from ? v.density : face_density(pressure);
const double ratio = std::max(down / up, critical);
const double r = std::pow(ratio, 1 / g);
const double speed2 = 2 * g / (g - 1) * up / density * (1 - std::pow(ratio, (g - 1) / g));
return (from ? 1 : -1) * density * r * std::sqrt(std::max(0.0, speed2)) * area * o.cd;
};
// Match nozzle discharge to the outgoing duct characteristic. Imposing
// sqrt(dp) against the *cell-centre* pressure is an unstable explicit
// boundary near equilibrium and ignores the duct's acoustic impedance.
const double zero_sound = c - .5 * (g - 1) * ui;
if (zero_sound <= 0)
throw std::runtime_error("orifice characteristic outside subsonic model");
const double zero_pressure = s.pressure * std::pow(zero_sound / c, 2 * g / (g - 1));
double low = std::min(zero_pressure, double(v.pressure));
double high = std::max(zero_pressure, double(v.pressure));
double duct_pressure = .5 * (low + high);
const double flow_sign = v.pressure >= zero_pressure ? 1 : -1;
const double flow_scale = area * o.cd;
const double density_ratio_scale = std::pow(double(s.pressure) / v.pressure, 1 / g);
const double critical_density_ratio = std::pow(critical, 1 / g);
// Squared-flow residual avoids the nozzle sqrt singularity at dp=0.
// Safeguarded analytic Newton; bisection remains the fallback.
if (o.length == 0 && high > low)
for (int iteration = 0; iteration < 40; ++iteration) {
++nonlinear_iterations;
const double density = face_density(duct_pressure);
const double sound = std::sqrt(g * duct_pressure / density);
const double velocity = ui + 2 * (sound - c) / (g - 1);
const double q = density * p.cell_area(i) * velocity;
const double dq = p.cell_area(i) * density / (g * duct_pressure) * (velocity + sound);
const bool from = v.pressure >= duct_pressure;
const double up = from ? v.pressure : duct_pressure;
const double down = from ? duct_pressure : v.pressure;
const double upstream_density = from ? v.density : density;
const double raw_ratio = down / up;
const double ratio = std::max(raw_ratio, critical);
const double root_ratio = raw_ratio <= critical
? critical_density_ratio
: (from ? density / s.rho * density_ratio_scale
: s.rho / (density * density_ratio_scale));
const double r1 = root_ratio * root_ratio, r2 = root_ratio * ratio;
const double coefficient = flow_scale * flow_scale * 2 * g / (g - 1) * upstream_density * up;
const double q2 = coefficient * (r1 - r2);
const double dr = raw_ratio > critical ? (from ? 1 / up : -ratio / up) : 0;
const double derivative = coefficient * ((2 / g * r1 - (g + 1) / g * r2) / ratio) * dr +
(from ? 0 : q2 * (1 + 1 / g) / duct_pressure);
const double residual = flow_sign * (q * q - q2);
if (residual > 0)
high = duct_pressure;
else
low = duct_pressure;
if (high - low < std::max(1e-9, duct_pressure * 1e-12))
break;
const double slope = flow_sign * (2 * q * dq - derivative);
const double candidate = duct_pressure - residual / slope;
if (std::abs(residual) < 4e-7 * std::max(q * q + q2, 1e-24))
break;
duct_pressure = (std::isfinite(candidate) && candidate > low && candidate < high)
? candidate
: .5 * (low + high);
}
float mdot = solution.mass_flow; // positive means port component -> pipe
const float adjacent_mass = pipe.cell_mass(idx);
if (o.use_inertance) {
const float rho_up = o.current_mass_flow >= 0.0f ? o.port->density : interior.rho;
const float inertance = rho_up * o.effective_length / area;
const float derivative = (dp - o.loss_coefficient * o.current_mass_flow) /
std::max(inertance, 1e-12f);
mdot = o.current_mass_flow + derivative * dt;
float mdot = static_cast<float>(nozzle_flow(duct_pressure));
const bool upstream_port = mdot >= 0;
const float pup = upstream_port ? v.pressure : static_cast<float>(duct_pressure);
const float ratio =
static_cast<float>(std::max((upstream_port ? duct_pressure : v.pressure) / pup, critical));
const float throat_rho =
(upstream_port ? v.density : static_cast<float>(face_density(duct_pressure))) *
std::pow(ratio, 1 / gamma);
if (o.length > 0) {
// d(mdot)/dt = A/L dp - K mdot |mdot|/(2 rho A L).
const float donor_rho = o.mdot >= 0 ? v.density : s.rho;
// Semi-implicit resistance remains dissipative for large dt.
mdot = (o.mdot + area / o.length * dp * dt) /
(1 + dt * o.loss * std::abs(o.mdot) / (2 * donor_rho * area * o.length));
}
if (mdot > 0.0f && (o.port->owner_air_mass || o.port->owner_exhaust_mass))
mdot = std::min(mdot, available_mass(*o.port) / std::max(dt, 1e-12f));
if (mdot < 0.0f) mdot = std::max(mdot, -adjacent_mass / std::max(dt, 1e-12f));
if (o.use_inertance) {
o.current_mass_flow = mdot;
const bool leaves_port = mdot >= 0.0f;
const float rho_up = leaves_port ? o.port->density : interior.rho;
const float temperature_up = leaves_port ? o.port->temperature :
interior.pressure / std::max(interior.rho * gas_constant, 1e-12f);
const float max_velocity = 0.9f * std::sqrt(gamma * gas_constant * temperature_up);
const float max_flow = rho_up * max_velocity * area;
mdot = std::clamp(mdot, -max_flow, max_flow);
o.current_mass_flow = mdot;
if (mdot >= 0.0f) {
solution = isentropic(o.port->pressure, o.port->density, o.port->temperature,
interior.pressure, area, o.cd);
} else {
solution = isentropic(interior.pressure, interior.rho, temperature_up,
o.port->pressure, area, o.cd);
solution.mass_flow = -solution.mass_flow;
const bool from = mdot >= 0;
const float h = from ? gamma / (gamma - 1) * v.pressure / v.density
: gamma / (gamma - 1) * s.pressure / s.rho + .5f * s.velocity * s.velocity;
const float requested_mdot = mdot;
if (from && o.budget < budgets_.size()) {
auto &b = budgets_[o.budget];
mdot = std::min(mdot, std::max(0.f, std::min(b.mass, b.heat / std::max(h, 1.f))) / dt);
b.mass -= mdot * dt;
b.heat -= mdot * h * dt;
} else if (!from) {
const float cell_energy = p.cells[i].energy * face_area * p.cell_length(i);
mdot = std::max(mdot, -.25f * std::min(p.cell_mass(i), cell_energy / std::max(h, 1.f)) / dt);
}
solution.mass_flow = mdot;
solution.rho = rho_up;
if (mdot != requested_mdot)
++limited_fluxes;
o.mdot = mdot;
const float donor_rho = from ? v.density : s.rho;
const float density = o.length > 0 ? donor_rho : throat_rho;
const float velocity = mdot / (density * area);
const float face_pressure =
o.length > 0 ? .5f * (v.pressure + s.pressure) : static_cast<float>(duct_pressure);
const float direction = o.left ? 1.f : -1.f;
const float species = from ? v.air_fraction : s.air;
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});
v.mass_flow = -mdot;
v.enthalpy = h;
if (!from)
v.air_fraction = s.air;
}
const bool from_port = mdot >= 0.0f;
const float rho_face = std::max(solution.rho, 1e-9f);
const float u = std::abs(mdot) / std::max(rho_face * area, 1e-12f);
GasState ghost{rho_face, 0.0f, std::max(solution.pressure, 1.0f), from_port ? o.port->air_fraction : interior.air};
ghost.velocity = ((from_port == o.left) ? u : -u);
if (o.left) pipe.left_ghost = ghost; else pipe.right_ghost = ghost;
o.port->mass_flow = -mdot;
if (!from_port) o.port->air_fraction = interior.air;
o.port->enthalpy = from_port
? o.port->enthalpy
: gamma / (gamma - 1.0f) * interior.pressure / std::max(interior.rho, 1e-12f);
(void)dp;
void resolve_open(Open &o) {
auto &p = pipes_[o.pipe];
int i = end(p, o.left);
auto s = p.state(i);
const float c = std::sqrt(gamma * s.pressure / s.rho), gm1 = gamma - 1;
if (o.left ? s.velocity >= c : s.velocity <= -c)
throw std::runtime_error(
"supersonic inlet needs full upstream state; pressure-only boundary is insufficient");
const float entropy = s.pressure / std::pow(s.rho, gamma);
const float rho = std::pow(o.pressure / entropy, 1 / gamma);
const float cb = std::sqrt(gamma * o.pressure / rho);
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};
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);
f.p += s.pressure * (p.cell_area(i) - a);
p.set_boundary_flux(o.left, f);
o.last_mass_flow = f.m * (o.left ? -1 : 1);
o.last_face_pressure = f.p / p.cell_area(i);
}
void resolve_open_end(Open& o, float dt) {
Pipe1D& pipe = pipes_.at(static_cast<std::size_t>(o.pipe));
const int idx = o.left ? 0 : static_cast<int>(pipe.cells.size()) - 1;
const GasState interior = pipe.state(idx);
const float c = std::sqrt(gamma * interior.pressure / std::max(interior.rho, 1e-12f));
const float gm1 = gamma - 1.0f;
const float j_plus = interior.velocity + 2.0f * c / gm1;
const float j_minus = interior.velocity - 2.0f * c / gm1;
const float entropy = interior.pressure / std::pow(std::max(interior.rho, 1e-12f), gamma);
const float rho_iso = std::pow(std::max(o.pressure / entropy, 1e-12f), 1.0f / gamma);
const float c_iso = std::sqrt(gamma * o.pressure / std::max(rho_iso, 1e-12f));
const float u_iso = o.left ? j_minus + 2.0f * c_iso / gm1 : j_plus - 2.0f * c_iso / gm1;
const bool supersonic = o.left ? (interior.velocity <= -c || u_iso <= -c_iso)
: (interior.velocity >= c || u_iso >= c_iso);
GasState ghost = supersonic ? interior : GasState{rho_iso, u_iso, o.pressure,
((o.left ? u_iso >= 0.0f : u_iso <= 0.0f) ? 1.0f : interior.air)};
const float cell_mass = pipe.cell_mass(idx);
float mdot_out = ghost.rho * ghost.velocity * o.area * (o.left ? -1.0f : 1.0f);
if (mdot_out > 0.0f) mdot_out = std::min(mdot_out, cell_mass / std::max(dt, 1e-12f));
else mdot_out = std::max(mdot_out, -10.0f * cell_mass / std::max(dt, 1e-12f));
const float speed = std::abs(mdot_out) / std::max(ghost.rho * o.area, 1e-12f);
ghost.velocity = o.left ? (mdot_out >= 0.0f ? -speed : speed)
: (mdot_out >= 0.0f ? speed : -speed);
if (o.left) pipe.left_ghost = ghost; else pipe.right_ghost = ghost;
o.last_mass_flow = mdot_out;
o.last_face_pressure = ghost.pressure;
void resolve_connection(const Connection &o) {
auto &a = pipes_[o.a];
auto &b = pipes_[o.b];
int ia = end(a, o.left_a), ib = end(b, o.left_b);
auto sa = a.state(ia), sb = b.state(ib);
const float da = o.left_a ? -1.f : 1.f, db = o.left_b ? 1.f : -1.f;
sa.velocity *= da;
sb.velocity *= db;
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});
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});
}
};

View File

@@ -1,8 +1,8 @@
#pragma once
#include "FluidSim\AudioProcessing.hpp"
#include "FluidSim\Fluid.hpp"
#include "FluidSim\Solver.hpp"
#include "FluidSim/AudioProcessing.hpp"
#include "FluidSim/Fluid.hpp"
#include "FluidSim/Solver.hpp"
#include <vector>
namespace fluidsim::port {

View File

@@ -0,0 +1,22 @@
#pragma once
#include "FluidSim/Engine.hpp"
#include "FluidSim/Vehicle.hpp"
#include <algorithm>
namespace fluidsim::port {
// Shared longitudinal load and clutch integration for motorcycle/car presets.
class MotorcycleTest {
public:
Vehicle vehicle{};
float load{}, clutch{1.0f};
protected:
void step_motorcycle(Crankshaft& crank, float dt, float maximum_load_torque) {
crank.load_torque = std::clamp(load, 0.0f, 1.0f) * maximum_load_torque;
vehicle.clutch_input = std::clamp(clutch, 0.0f, 1.0f);
crank.step(dt);
crank.drivetrain_torque = vehicle.update(crank.omega, crank.inertia, dt);
}
};
} // namespace fluidsim::port

View File

@@ -0,0 +1,72 @@
#pragma once
#include "FluidSim/AudioProcessing.hpp"
#include <array>
#include <cstddef>
#include <vector>
namespace fluidsim::port {
enum class OutdoorScene { Dry, Field, Wall, Woodland };
const char *scene_name(OutdoorScene scene);
struct OutdoorAudioConfig {
Vec3 listener{1.6f, 1.0f, 2.0f};
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};
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};
float pressure_gain{0.005f}; // 200 Pa per device full scale, not speaker SPL.
float monitor_cutoff_hz{6000.0f}; // Second-order acquisition/rendering filter.
};
// Static geometric direct/first-order reflection paths plus an explicitly
// approximate diffuse tail. No feedback into the fluid/combustion simulation.
class OutdoorAudio {
public:
explicit OutdoorAudio(int sample_rate = 44100, OutdoorAudioConfig config = {});
AudioFrame process(float intake_mass_flow, float exhaust_mass_flow);
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.
AudioFrame dry_frame() const { return last_dry_; }
AudioFrame unfiltered_frame() const { return last_raw_; }
private:
struct LowPass {
float b0{}, b1{}, b2{}, a1{}, a2{}, z1{}, z2{};
float process(float x);
};
struct Path {
std::size_t delay_left{}, delay_right{};
float fraction_left{}, fraction_right{}, gain_left{}, gain_right{}, alpha{1};
float state_left{}, state_right{};
int group{}; // direct, ground, wall, trees
};
struct Emitter {
std::vector<float> filtered, raw;
std::vector<Path> paths;
std::size_t position{};
float previous_flow{};
LowPass filter;
};
struct TailLine {
std::vector<float> buffer;
std::size_t position{};
float gain{}, state{};
};
int rate_{};
OutdoorAudioConfig config_;
OutdoorScene scene_{OutdoorScene::Wall};
bool monitor_filter_{true};
std::array<Emitter, 2> emitters_;
std::array<TailLine, 4> tail_;
std::array<float, 4> weights_{1, 0, 0, 0};
float scene_alpha_{}, tail_alpha_{}, tail_mix_{};
AudioFrame last_dry_{}, last_raw_{};
void configure_emitter(Emitter &emitter, Vec3 position);
AudioFrame render(Emitter &emitter, float flow, AudioFrame &dry, AudioFrame &raw);
AudioFrame diffuse(float input);
};
} // namespace fluidsim::port

View File

@@ -0,0 +1,34 @@
#pragma once
#include "FluidSim/Fluid.hpp"
#include <span>
namespace fluidsim::port {
struct PipeSection {
float length, inlet_diameter, outlet_diameter;
};
// Linear diameter within each section. Cell area is the exact frustum-volume
// average, so refining the mesh preserves the specified chamber volume.
inline Pipe1D make_profiled_pipe(std::span<const PipeSection> sections, float maximum_cell_length = .01f) {
if (!std::isfinite(maximum_cell_length) || maximum_cell_length <= 0 || sections.empty())
throw std::invalid_argument("invalid pipe profile mesh");
std::vector<float> areas, lengths;
float previous_diameter = sections.front().inlet_diameter;
for (const auto& section : sections) {
if (!std::isfinite(section.length) || !std::isfinite(section.inlet_diameter) ||
!std::isfinite(section.outlet_diameter) || section.length <= 0 ||
section.inlet_diameter <= 0 || section.outlet_diameter <= 0 ||
std::abs(section.inlet_diameter - previous_diameter) > 1e-6f)
throw std::invalid_argument("invalid/discontinuous pipe profile");
const int count = static_cast<int>(std::ceil(section.length / maximum_cell_length));
for (int i = 0; i < count; ++i) {
const float d0 = std::lerp(section.inlet_diameter, section.outlet_diameter, float(i) / count);
const float d1 = std::lerp(section.inlet_diameter, section.outlet_diameter, float(i + 1) / count);
areas.push_back(pi / 12 * (d0*d0 + d0*d1 + d1*d1));
lengths.push_back(section.length / count);
}
previous_diameter = section.outlet_diameter;
}
return Pipe1D(std::move(areas), std::move(lengths));
}
} // namespace fluidsim::port

View File

@@ -0,0 +1,47 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
// Lumped cantilever-petal model; geometry/material values require calibration.
class ReedValve {
public:
float length{0.030f}, width{0.016f}, thickness{0.00035f};
float youngs_modulus{70e9f}, material_density{1600.0f};
float damping_ratio{0.30f}, maximum_lift{0.004f};
int petals{4};
float lift() const { return lift_; }
float step(float dt, float pressure_difference) {
if (!std::isfinite(dt) || dt <= 0 || !std::isfinite(pressure_difference))
throw std::invalid_argument("invalid reed timestep or pressure");
if (!(length > 0 && width > 0 && thickness > 0 && youngs_modulus > 0 && material_density > 0 &&
maximum_lift > 0 && petals > 0 && damping_ratio >= 0))
throw std::invalid_argument("invalid reed geometry/material");
const float mass = .236f * material_density * length * width * thickness * petals;
const float stiffness = petals * youngs_modulus * width * thickness * thickness * thickness /
(4 * length * length * length);
const float damping = 2 * damping_ratio * std::sqrt(mass * stiffness);
const float force = pressure_difference * (3.0f / 8.0f) * length * width * petals;
const int steps = std::max(1, int(std::ceil(dt * std::sqrt(stiffness / mass) / .15f)));
const float h = dt / steps;
for (int i = 0; i < steps; i++) {
velocity_ += (force - stiffness * lift_ - damping * velocity_) / mass * h;
lift_ += velocity_ * h;
if (lift_ < 0) {
lift_ = 0;
velocity_ = 0;
}
if (lift_ > maximum_lift) {
lift_ = maximum_lift;
velocity_ = 0;
}
}
// Front-edge plus triangular side-gap areas for all petals.
return petals * (width + length) * lift_;
}
private:
float lift_{}, velocity_{};
};
} // namespace fluidsim::port

View File

@@ -1,12 +1,17 @@
#pragma once
#include "FluidSim/AudioProcessing.hpp"
#include "FluidSim/Atmosphere.hpp"
#include "FluidSim/Engine.hpp"
#include "FluidSim/Ecu.hpp"
#include "FluidSim/Fluid.hpp"
#include "FluidSim/MechanicalSoundProcessor.hpp"
#include "FluidSim/OutdoorExhaustReverb.hpp"
#include "FluidSim/OutdoorAudio.hpp"
#include "FluidSim/Solver.hpp"
#include "FluidSim/Vehicle.hpp"
#include "FluidSim/MotorcycleTest.hpp"
#include "FluidSim/Turbocharger.hpp"
#include <memory>
#include <array>
#include <cstdint>
#include <mutex>
@@ -14,27 +19,58 @@
namespace fluidsim::port {
enum class ScenarioKind { Twin, TwoStroke, SaabB205, TurboTwin };
enum class FourStrokePreset { Twin, SaabB205 };
struct ScenarioSnapshot {
bool turbo_enabled{};
TurboSnapshot turbo{};
int cylinder_count{2};
float rev_limit_rpm{10000};
std::array<float, 4> 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{};
float cylinder_temperature1{}, cylinder_temperature2{};
float cylinder_piston_fraction1{}, cylinder_piston_fraction2{};
float valve1_intake_area{}, valve1_exhaust_area{}, valve2_intake_area{}, valve2_exhaust_area{};
float intake_pressure{}, exhaust_pressure{};
float throttle{}, clutch{}, intake_flow{}, exhaust_flow{};
float throttle{}, pedal{}, target_afr{}, clutch{}, intake_flow{}, exhaust_flow{};
bool idle_control{}, fuel_cut{}, two_stroke{}, reed_open{};
bool racing_exhaust{};
float crankcase_pressure{};
OutdoorScene audio_scene{OutdoorScene::Wall};
int gear{};
bool rev_limit1{}, rev_limit2{}, mechanical_sound_enabled{};
std::array<std::vector<GasState>, 6> pipe_states;
std::array<std::vector<GasState>, 12> pipe_states;
std::array<std::vector<float>, 12> 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;
pipe_lengths[k] = pipes[k].cell_lengths;
pipe_states[k].reserve(pipes[k].cells.size());
for (std::size_t i = 0; i < pipes[k].cells.size(); ++i)
pipe_states[k].push_back(pipes[k].state(static_cast<int>(i)));
}
}
};
// Active C# SingleCylScenario topology: a 600 cc parallel twin, independent
// intake runners, separate short exhaust headers, shared collector and tailpipe.
class FourStrokeTwinScenario {
// Shared four-stroke topology: independent runners/headers and shared plenums.
// Presets retain the original 600 cc twin and add a B205-sized inline-four.
class FourStrokeScenario : public MotorcycleTest {
public:
explicit FourStrokeTwinScenario(int sample_rate = 44100);
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}; }
AudioFrame process();
void set_controls(float throttle_value, float load_value, float clutch_value);
void toggle_mechanical_sound();
void set_audio_scene(OutdoorScene scene);
void set_monitor_filter(bool enabled);
AudioFrame dry_audio() const;
AudioFrame unfiltered_audio() const;
void shift_up();
void shift_down();
void set_neutral();
@@ -42,14 +78,13 @@ public:
ScenarioSnapshot snapshot() const;
float throttle{};
float load{};
float clutch{1.0f};
bool mechanical_sound_enabled{};
float mechanical_mix{0.02f}, reverb_send_gain{1.0f};
float exhaust_send{0.5f}, intake_send{0.5f}, mechanical_send{};
float mechanical_mix{0.02f};
Ecu ecu{};
std::unique_ptr<Turbocharger> turbo;
Crankshaft crankshaft{};
FourStrokeCylinder cylinder1{}, cylinder2{};
Vehicle vehicle{};
FourStrokeCylinder cylinder1{}, cylinder2{}, cylinder3{}, cylinder4{};
Atmosphere intake_atmosphere{};
Volume0D intake_plenum{1.0e-3f, 101325.0f, 300.0f};
Volume0D exhaust_collector{0.15e-3f, 101325.0f, 700.0f};
std::vector<Pipe1D> pipes;
@@ -58,25 +93,27 @@ public:
std::uint64_t sample_count{};
private:
Port* throttle_port_{};
Port* runner_feed1_{};
Port* runner_feed2_{};
Port* collector_in1_{};
Port* collector_in2_{};
Port* collector_out_{};
float throttle_area_{}, runner_area_{}, valve1_in_{}, valve2_in_{};
float valve1_out_{}, valve2_out_{}, header_area_{}, tail_area_{};
float intake_boundary_area_{}, throttle_area_{}, runner_area_{}, header_area_{}, tail_area_{};
std::array<float, 4> intake_valve_areas_{}, exhaust_valve_areas_{};
float dt_{};
BoundarySystem boundaries_;
Solver solver_;
RadiationSource intake_radiation_;
RadiationSource exhaust_radiation_;
OutdoorExhaustReverb reverb_;
OutdoorAudio audio_;
MechanicalSoundProcessor mechanical_sound_;
mutable std::mutex mutex_;
static float area_for_diameter(float diameter);
static std::vector<Pipe1D> create_pipes();
static std::vector<Pipe1D> create_pipes(FourStrokePreset preset, bool turbo_enabled);
};
using FourStrokeTwinScenario = FourStrokeScenario;
class TurboTwinScenario : public FourStrokeScenario {
public:
explicit TurboTwinScenario(int sample_rate = 44100) : FourStrokeScenario(sample_rate, FourStrokePreset::Twin, true) {}
};
class SaabB205Scenario : public FourStrokeScenario {
public:
explicit SaabB205Scenario(int sample_rate = 44100) : FourStrokeScenario(sample_rate, FourStrokePreset::SaabB205) {}
};
} // namespace fluidsim::port

View File

@@ -10,6 +10,7 @@ namespace fluidsim::port {
class Solver {
public:
Solver(std::vector<Pipe1D>& pipes, BoundarySystem& boundaries);
std::uint64_t substep_count() const { return substep_count_; }
int minimum_substeps{4};
void add_component(Component& component);
void step(float dt);
@@ -18,7 +19,7 @@ private:
std::vector<Pipe1D>& pipes_;
BoundarySystem& boundaries_;
std::vector<Component*> components_;
std::uint64_t step_count_{};
std::uint64_t step_count_{}, substep_count_{};
};
} // namespace fluidsim::port

View File

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

View File

@@ -0,0 +1,78 @@
#pragma once
#include "FluidSim/Fluid.hpp"
#include <array>
#include <cstdint>
namespace fluidsim::port {
// RHB31/VZ21-size mean-line approximation. Geometry, losses and coefficients
// are estimates, not a digitized or validated manufacturer performance map.
struct TurboConfig {
double shaft_inertia{4e-6}; // kg m^2, compressor+turbine+shaft rigid rotor
double compressor_radius{.018}, turbine_radius{.0135};
double compressor_head_coefficient{.60}, compressor_efficiency{.65};
double turbine_peak_efficiency{.65}, turbine_optimum_speed_ratio{.70};
double compressor_effective_area{110e-6}, turbine_effective_area{100e-6};
double bearing_viscous{1e-7}, windage{2e-11}; // Nm/(rad/s), Nm/(rad/s)^2
// Estimated pressure-can/spring wastegate. Set maximum lift to zero for a
// pure ungated turbo bench test. This changes bypass AREA, never boost.
double gate_diaphragm_area{8e-4}, gate_spring_preload{40}, gate_spring_stiffness{6000};
double gate_damping{120}, gate_diameter{.014}, gate_maximum_lift{.004};
double reference_pressure{101325};
float compressor_inlet_volume{20e-6f}, compressor_outlet_volume{30e-6f};
float turbine_inlet_volume{30e-6f}, turbine_outlet_volume{20e-6f};
std::array<float, 4> connection_diameters{.024f, .024f, .025f, .026f};
};
struct TurboSnapshot {
double rpm{}, angle{}, shaft_energy{}, turbine_power{}, compressor_power{}, bearing_power{};
double compressor_flow{}, turbine_flow{}, compressor_pressure_ratio{}, turbine_pressure_ratio{};
double turbine_inlet_pressure{}, turbine_outlet_pressure{}, charge_pressure{}, charge_temperature{};
double bearing_heat{}, energy_residual{};
std::uint64_t limited_transfers{};
double wastegate_flow{}, wastegate_lift{};
};
// Four pressure-storage nodes joined by turbine/nozzle and centrifugal-pump
// characteristics. Existing pipe orifices exchange conserved mass/enthalpy.
// The whole turbo is ONE solver component; do not also register its volumes.
class Turbocharger final : public Component {
public:
explicit Turbocharger(TurboConfig config = {});
const TurboConfig config;
Volume0D compressor_inlet, compressor_outlet, turbine_inlet, turbine_outlet;
Port& compressor_inlet_port;
Port& compressor_outlet_port;
Port& turbine_inlet_port;
Port& turbine_outlet_port;
void connect(BoundarySystem& boundaries, const std::vector<Pipe1D>& pipes,
int compressor_in, bool compressor_in_left, int compressor_out, bool compressor_out_left,
int turbine_in, bool turbine_in_left, int turbine_out, bool turbine_out_left);
std::vector<Port*> ports() override;
void update_state(float dt) override;
TurboSnapshot snapshot() const;
double omega() const { return omega_; }
double shaft_energy() const { return .5*config.shaft_inertia*omega_*omega_; }
double fluid_energy() const;
// Intended for standalone initialization/tests, never an imposed running speed.
void set_initial_rpm(double rpm);
private:
std::array<Port*, 4> internal_;
std::array<float, 4> connection_areas_{};
double omega_{}, angle_{}, bearing_heat_{};
double turbine_power_{}, compressor_power_{}, bearing_power_{}, energy_residual_{};
double compressor_flow_{}, turbine_flow_{};
double wastegate_flow_{}, wastegate_lift_{};
std::uint64_t limited_transfers_{};
bool connected_{};
static double nozzle(double upstream_pressure, double upstream_temperature,
double downstream_pressure, double area);
double transfer_flow(Volume0D& inlet, Volume0D& outlet, Port& inlet_external,
Port& outlet_external, double requested, double dt);
static void set_transfer(Volume0D& inlet, Volume0D& outlet, Port& inlet_internal,
Port& outlet_internal, double flow, double shaft_work, double dt);
};
} // namespace fluidsim::port

View File

@@ -0,0 +1,38 @@
#pragma once
#include "FluidSim/PipeProfile.hpp"
#include <array>
namespace fluidsim::port {
enum class TwoStrokeExhaust { Racing, Straight };
// YZ125-sized baseline. Port/chamber/combustion dimensions are explicit design
// estimates, not a measured Yamaha cylinder or factory exhaust/ignition map.
struct TwoStrokeConfig {
TwoStrokeExhaust exhaust{TwoStrokeExhaust::Racing};
float bore{.054f}, stroke{.0545f}, rod{.110f};
float trapped_compression{8.2f}; // exhaust-closure volume / clearance volume
float transfer_duration{130}, exhaust_duration{190};
float transfer_width{.050f}, exhaust_width{.036f}; // summed effective window widths
float carburetor_diameter{.0381f};
float minimum_burn_seconds{.0008f};
// Empirical mechanical FMEP = A + B*mean_piston_speed + C*speed^2.
// Generic correlation estimates, not a measured YZ125 coast-down fit.
float friction_bar{.4f}, friction_bar_per_mps{.09f}, friction_bar_per_mps2{.0009f};
float exhaust_cell_length{.01f};
std::array<PipeSection, 6> racing_pipe{{
{.160f, .036f, .036f}, // header
{.180f, .036f, .060f}, // shallow first diffuser
{.190f, .060f, .105f}, // second diffuser
{.130f, .105f, .105f}, // belly
{.210f, .105f, .022f}, // convergent / baffle cone
{.240f, .022f, .022f} // stinger
}};
float geometric_compression() const {
const float theta = (180 + exhaust_duration * .5f) * pi / 180;
const float radius = stroke * .5f, sine = std::sin(theta);
const float travel = radius * (1 - std::cos(theta)) + rod -
std::sqrt(rod*rod - radius*radius*sine*sine);
return 1 + (trapped_compression - 1) * stroke / travel;
}
};
} // namespace fluidsim::port

View File

@@ -1,6 +1,7 @@
#pragma once
#include "FluidSim/Crankcase.hpp"
#include <cstdint>
namespace fluidsim::port {
@@ -18,6 +19,9 @@ 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; }
float transfer_width{.050f}, exhaust_width{.036f};
float minimum_burn_seconds{.0008f};
std::uint64_t combustion_count() const { return combustion_count_; }
float valve_area(float cycle_degrees, bool inlet) const override;
void pre_step(Crankshaft& crank, float dt, float throttle) override;
@@ -26,7 +30,10 @@ private:
float transfer_duration_{}, exhaust_duration_{};
Crankcase* crankcase_{};
static float lift(float angle, float opens, float closes, float peak_lift);
float active_burn_duration_{60};
float current_rpm_{};
float active_spark_angle_{};
std::uint64_t combustion_count_{};
void handle_cycle_events(float previous_degrees, float current_degrees);
};

View File

@@ -3,9 +3,14 @@
#include "FluidSim/AudioProcessing.hpp"
#include "FluidSim/Crankcase.hpp"
#include "FluidSim/Fluid.hpp"
#include "FluidSim/OutdoorExhaustReverb.hpp"
#include "FluidSim/OutdoorAudio.hpp"
#include "FluidSim/Atmosphere.hpp"
#include "FluidSim/Ecu.hpp"
#include "FluidSim/ReedValve.hpp"
#include "FluidSim/MotorcycleTest.hpp"
#include "FluidSim/Solver.hpp"
#include "FluidSim/TwoStrokeCylinder.hpp"
#include "FluidSim/TwoStrokeConfig.hpp"
#include <cstdint>
#include <vector>
@@ -14,19 +19,34 @@ namespace fluidsim::port {
struct TwoStrokeSnapshot {
float rpm{}, cylinder_pressure{}, crankcase_pressure{}, intake_pressure{};
float throttle{}, transfer_area{}, exhaust_area{};
bool reed_open{};
bool reed_open{}, fuel_cut{};
float pedal{};
OutdoorScene audio_scene{OutdoorScene::Wall};
};
// Port of the C# 125 cc crankcase-scavenged two-stroke scenario. It retains
// the plenum / reed / crankcase / transfer / cylinder / exhaust topology.
class TwoStrokeScenario {
class TwoStrokeScenario : public MotorcycleTest {
public:
explicit TwoStrokeScenario(int sample_rate = 44100);
explicit TwoStrokeScenario(int sample_rate = 44100, TwoStrokeConfig config = {});
AudioFrame process();
void set_throttle(float value);
void set_controls(float pedal, float load_value, float clutch_value);
void shift_up() { vehicle.shift_up(); }
void shift_down() { vehicle.shift_down(); }
void set_neutral() { vehicle.set_neutral(); }
void set_audio_scene(OutdoorScene scene) { audio_.set_scene(scene); }
void set_monitor_filter(bool enabled) { audio_.set_monitor_filter(enabled); }
AudioFrame dry_audio() const { return audio_.dry_frame(); }
AudioFrame unfiltered_audio() const { return audio_.unfiltered_frame(); }
TwoStrokeSnapshot snapshot() const;
float throttle{};
const TwoStrokeConfig configuration;
Ecu ecu{};
ReedValve reed{};
Atmosphere intake_atmosphere{};
float intake_flow{}, exhaust_flow{};
Crankshaft crankshaft{};
TwoStrokeCylinder cylinder;
Crankcase crankcase;
@@ -34,7 +54,7 @@ public:
std::vector<Pipe1D> pipes;
private:
float dt_{}, max_throttle_area_{};
float dt_{}, max_throttle_area_{}, intake_boundary_area_{};
float reed_in_area_{}, reed_out_area_{}, transfer_pipe_area_{}, transfer_valve_area_{}, exhaust_valve_area_{};
bool reed_open_{};
std::uint64_t step_count_{};
@@ -42,12 +62,10 @@ private:
Port* plenum_outlet_{};
BoundarySystem boundaries_;
Solver solver_;
SoundProcessor intake_sound_;
SoundProcessor exhaust_sound_;
OutdoorExhaustReverb reverb_;
OutdoorAudio audio_;
static float area_for_diameter(float diameter);
static std::vector<Pipe1D> create_pipes();
static std::vector<Pipe1D> create_pipes(const TwoStrokeConfig& config);
};
} // namespace fluidsim::port

View File

@@ -1,14 +1,14 @@
#pragma once
#include <array>
#include <vector>
namespace fluidsim::port {
// Longitudinal motorcycle model and the friction clutch coupling it to the crank.
// Longitudinal vehicle model and the friction clutch coupling it to the crank.
class Vehicle {
public:
int current_gear{};
std::array<float, 6> gear_ratios{4.1f, 2.7f, 2.0f, 1.55f, 1.25f, 0.98f};
std::vector<float> gear_ratios{4.1f, 2.7f, 2.0f, 1.55f, 1.25f, 0.98f};
float final_drive_ratio{2.7f}, primary_reduction{1.8f};
float clutch_input{1.0f}, clutch_capacity{80.0f}, clutch_engagement_rate{8.0f};
float mass{160.0f}, wheel_radius{0.32f}, rotational_inertia{1.2f};

View File

@@ -12,20 +12,47 @@ AudioOutputStream::AudioOutputStream(SimulationRingBuffer& ring, unsigned int sa
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;
for (std::size_t i = 0; i < read; ++i) {
const float left = temp_left_[i] - previous_input_left_ + dc_coefficient * previous_output_left_;
const float right = temp_right_[i] - previous_input_right_ + dc_coefficient * previous_output_right_;
previous_input_left_ = temp_left_[i];
previous_input_right_ = temp_right_[i];
// 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;
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;
const float finite_left = std::isfinite(left) ? std::clamp(left, -1.0f, 1.0f) : 0.0f;
const float finite_right = std::isfinite(right) ? std::clamp(right, -1.0f, 1.0f) : 0.0f;
output_[i * 2] = static_cast<std::int16_t>(finite_left * 32767.0f);
output_[i * 2 + 1] = static_cast<std::int16_t>(finite_right * 32767.0f);
if (starving_) {
transition_left_ = last_left_ - left;
transition_right_ = last_right_ - right;
transition_remaining_ = transition_samples;
starving_ = false;
}
const float weight = static_cast<float>(transition_remaining_) / transition_samples;
left += transition_left_ * weight;
right += transition_right_ * weight;
} else {
if (!starving_) {
transition_left_ = last_left_;
transition_right_ = last_right_;
transition_remaining_ = transition_samples;
starving_ = true;
}
const float weight = static_cast<float>(transition_remaining_) / transition_samples;
left = transition_left_ * weight;
right = transition_right_ * weight;
}
if (transition_remaining_ > 0) --transition_remaining_;
// Device protection only; simulated pressure stays linear upstream.
last_left_ = std::clamp(left, -1.0f, 1.0f);
last_right_ = std::clamp(right, -1.0f, 1.0f);
output_[i * 2] = static_cast<std::int16_t>(last_left_ * 32767.0f);
output_[i * 2 + 1] = static_cast<std::int16_t>(last_right_ * 32767.0f);
}
std::fill(output_.begin() + static_cast<std::ptrdiff_t>(read * 2), output_.end(), 0);
data.samples = output_.data();
data.sampleCount = output_.size();
return true;

View File

@@ -10,30 +10,27 @@ SoundProcessor::SoundProcessor(int sample_rate, float listener_distance) {
const int safe_rate = std::max(sample_rate, 1);
dt_ = 1.0f / static_cast<float>(safe_rate);
scale_factor_ = 1.0f / (4.0f * pi * std::max(listener_distance, 1e-3f));
derivative_alpha_ = std::exp(-dt_ / 0.020f);
lowpass_alpha_ = std::exp(-dt_ / 0.005f);
}
float SoundProcessor::process(float mass_flow_out) {
flow_lowpass_ = lowpass_alpha_ * flow_lowpass_ + (1.0f - lowpass_alpha_) * mass_flow_out;
const float raw_derivative = (flow_lowpass_ - previous_flow_) / dt_;
previous_flow_ = flow_lowpass_;
smooth_derivative_ = derivative_alpha_ * smooth_derivative_ +
(1.0f - derivative_alpha_) * raw_derivative;
return std::tanh(smooth_derivative_ * scale_factor_ * gain);
// Compact monopole approximation: p(r,t) = (d mass_flow/dt) / (4 pi r).
// gain maps pressure in pascals to device full scale (200 Pa by default).
const float derivative = (mass_flow_out - previous_flow_) / dt_;
previous_flow_ = mass_flow_out;
return derivative * scale_factor_ * gain;
}
RadiationSource::RadiationSource(int sample_rate)
: sample_rate_(std::max(sample_rate, 1)), dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))) {
const std::size_t max_delay = static_cast<std::size_t>(std::max(1.0f,
0.5f / std::max(speed_of_sound, 1.0f) * static_cast<float>(sample_rate_)));
100.0f / std::max(speed_of_sound, 1.0f) * static_cast<float>(sample_rate_)));
left_delay_.resize(max_delay);
right_delay_.resize(max_delay);
}
AudioFrame RadiationSource::process(float mass_flow) {
const float flow_alpha = 1.0f - std::exp(-dt_ / std::max(flow_tau, 1e-5f));
const float derivative_decay = std::exp(-dt_ / std::max(smooth_tau, 1e-5f));
const float flow_alpha = flow_tau > 0.0f ? 1.0f - std::exp(-dt_ / flow_tau) : 1.0f;
const float derivative_decay = smooth_tau > 0.0f ? std::exp(-dt_ / smooth_tau) : 0.0f;
flow_lp_ += flow_alpha * (mass_flow - flow_lp_);
const float raw_derivative = (flow_lp_ - previous_flow_lp_) / dt_;
previous_flow_lp_ = flow_lp_;
@@ -44,11 +41,12 @@ AudioFrame RadiationSource::process(float mass_flow) {
const Vec3 direction{to_listener.x / distance, to_listener.y / distance, to_listener.z / distance};
const float cosine = dot(pipe_axis, direction);
const float directivity = (1.0f - directivity_balance) + directivity_balance * cosine;
last_mono_pressure_ = std::tanh(smooth_derivative_ * gain * directivity / (4.0f * 3.14159265358979323846f * distance));
last_mono_pressure_ = smooth_derivative_ * gain * directivity / (4.0f * 3.14159265358979323846f * distance);
const float air_cutoff = std::max(min_air_cutoff,
20000.0f * std::exp(-distance * air_absorption_exponent));
const float filtered = one_pole(last_mono_pressure_, air_cutoff, air_filter_);
const float filtered = air_absorption_exponent > 0.0f
? one_pole(last_mono_pressure_, air_cutoff, air_filter_) : last_mono_pressure_;
const Vec3 ear_offset{listener_right.x * ear_spacing * 0.5f,
listener_right.y * ear_spacing * 0.5f,
@@ -62,15 +60,15 @@ AudioFrame RadiationSource::process(float mass_flow) {
const auto delay_sample = [this, filtered](std::vector<float>& buffer, std::size_t offset, float ear_distance) {
const std::size_t read = (write_position_ + buffer.size() - offset) % buffer.size();
const float value = buffer[read];
buffer[write_position_] = filtered * (length(subtract(listener, pipe_end)) / ear_distance);
const float value = buffer[read];
return value;
};
float left = delay_sample(left_delay_, left_offset_, left_distance);
float right = delay_sample(right_delay_, right_offset_, right_distance);
write_position_ = (write_position_ + 1) % left_delay_.size();
if (left_distance > right_distance) left = one_pole(left, head_shadow_cutoff, shadow_left_);
else if (right_distance > left_distance) right = one_pole(right, head_shadow_cutoff, shadow_right_);
if (head_shadow_cutoff > 0.0f && left_distance > right_distance) left = one_pole(left, head_shadow_cutoff, shadow_left_);
else if (head_shadow_cutoff > 0.0f && right_distance > left_distance) right = one_pole(right, head_shadow_cutoff, shadow_right_);
return {left, right};
}

View File

@@ -2,6 +2,7 @@
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
namespace {
@@ -26,6 +27,7 @@ Crankcase::Crankcase(Crankshaft& crankshaft, float crank_radius, float connectin
temperature_ = initial_temperature;
mass_ = initial_pressure * volume_ / (gas_constant * initial_temperature);
energy_ = mass_ * cv * initial_temperature;
intake_port.owner_energy = transfer_port.owner_energy = &energy_;
intake_port.owner_air_mass = transfer_port.owner_air_mass = &mass_;
sync_ports();
}
@@ -38,6 +40,9 @@ void Crankcase::pre_step(float /*dt*/) {
const float displacement = crank_radius_ * (1.0f - cosine) + connecting_rod_length_ * (1.0f - term);
const float maximum_volume = clearance_volume_ + piston_area_ * 2.0f * crank_radius_;
volume_ = std::max(maximum_volume - piston_area_ * displacement, 1e-12f);
const float old_energy=energy_;
energy_ *= std::pow(previous_volume_ / volume_, gamma - 1.0f);
geometry_work_=old_energy-energy_;
if (mass_ > 1e-12f) {
temperature_ = energy_ / (mass_ * cv);
pressure_ = mass_ * gas_constant * temperature_ / volume_;
@@ -59,31 +64,10 @@ void Crankcase::update_state(float dt) {
energy_ += delta_energy;
if (mass_ > 1e-12f) air_fraction_ = std::clamp((old_air_mass + delta_air_mass) / mass_, 0.0f, 1.0f);
const float volume_change = volume_ - previous_volume_;
energy_ -= pressure_ * volume_change;
if (mass_ < 1e-9f) {
mass_ = 1e-9f;
energy_ = mass_ * cv * 300.0f;
air_fraction_ = 1.0f;
}
if (energy_ < 0.0f) energy_ = mass_ * cv * 300.0f;
if (!(mass_ > 0.0f) || !(energy_ > 0.0f) || !std::isfinite(mass_) || !std::isfinite(energy_))
throw std::runtime_error("nonphysical crankcase state");
temperature_ = energy_ / (mass_ * cv);
pressure_ = mass_ * gas_constant * temperature_ / std::max(volume_, 1e-12f);
if (pressure_ < 1.0f) {
temperature_ = volume_ / (mass_ * gas_constant);
energy_ = mass_ * cv * temperature_;
pressure_ = 1.0f;
}
constexpr float max_pressure = 5.0e5f;
if (pressure_ > max_pressure) {
const float target_mass = max_pressure * volume_ / (gas_constant * temperature_);
if (mass_ > target_mass) {
mass_ = target_mass;
energy_ = mass_ * cv * temperature_;
pressure_ = max_pressure;
}
}
pressure_ = mass_ * gas_constant * temperature_ / volume_;
sync_ports();
}

View File

@@ -1,4 +1,4 @@
#include "FluidSim/Scenario.hpp"
#include "FluidSim/DesktopScenario.hpp"
#include "FluidSim/SoundEngine.hpp"
#include "FluidSim/DynoCurve.hpp"
@@ -6,6 +6,8 @@
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <cmath>
#include <filesystem>
#include <iomanip>
@@ -19,8 +21,8 @@ using namespace fluidsim::port;
constexpr unsigned int window_width = 1280;
constexpr unsigned int window_height = 720;
constexpr std::size_t ring_capacity = 8192;
constexpr std::size_t target_fill = 1024;
constexpr std::size_t preroll_samples = 1024;
constexpr std::size_t target_fill = 4096;
constexpr std::size_t preroll_samples = 4096;
constexpr float throttle_lerp_rate = 10.0f;
sf::Color pressure_color(float pressure) {
@@ -54,6 +56,72 @@ void draw_label(sf::RenderWindow& window, const sf::Font* font, const std::strin
window.draw(label);
}
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{
"Four-stroke twin, motorcycle test and dyno",
"125 cc single, expansion chamber, motorcycle test and dyno",
"2.0 litre inline-four, no turbo yet, five-speed wagon and dyno",
"Coupled turbine/compressor, shaft inertia and pressure diagnostics"};
int selected = 0;
while (window.isOpen()) {
while (const auto event = window.pollEvent()) {
if (event->is<sf::Event::Closed>()) return std::nullopt;
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::Down:
case sf::Keyboard::Key::Tab: selected = (selected + 1) % 4; 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::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)
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)
if (cards[i].contains(point)) return static_cast<ScenarioKind>(i);
if (start_button.contains(point)) return static_cast<ScenarioKind>(selected);
}
}
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) {
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));
card.setOutlineColor(i == selected ? sf::Color(90, 200, 210) : sf::Color(55, 65, 78));
card.setOutlineThickness(2);
window.draw(card);
draw_label(window, font, titles[i], cards[i].position + sf::Vector2f{20, 16},
sf::Color::White, 20);
draw_label(window, font, descriptions[i], cards[i].position + sf::Vector2f{20, 54},
{180, 190, 205}, 13);
}
sf::RectangleShape start(start_button.size);
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, "Click a scenario or use Up/Down. Esc: quit", {32, 574}, {150, 165, 180}, 12);
window.display();
}
return std::nullopt;
}
void draw_volume(sf::RenderWindow& window, float pressure, sf::FloatRect bounds) {
sf::RectangleShape body({bounds.size.x, bounds.size.y});
body.setPosition(bounds.position);
@@ -67,20 +135,27 @@ void draw_pipe(sf::RenderWindow& window, const ScenarioSnapshot& snapshot, std::
float center_y, float x0, float x1) {
const auto& cells = snapshot.pipe_states[pipe_index];
if (cells.empty()) return;
const float cell_width = (x1 - x0) / static_cast<float>(cells.size());
const auto& areas = snapshot.pipe_areas[pipe_index];
const auto& lengths = snapshot.pipe_lengths[pipe_index];
float total_length = 0;
for (float length : lengths) total_length += length;
float x = x0;
for (std::size_t i = 0; i < cells.size(); ++i) {
const auto& cell = cells[i];
const float thickness = std::clamp(6.0f + cell.pressure / 101325.0f * 0.7f, 6.0f, 20.0f);
const float cell_width = total_length > 0 ? (x1 - x0) * lengths[i] / total_length
: (x1 - x0) / cells.size();
const float thickness = areas.empty() ? 8 : 600 * std::sqrt(4 * areas[i] / pi);
sf::RectangleShape segment({cell_width + 1.0f, thickness});
segment.setPosition({x0 + static_cast<float>(i) * cell_width, center_y - thickness * 0.5f});
segment.setPosition({x, center_y - thickness * .5f});
segment.setFillColor(temperature_color(cell.pressure / std::max(cell.rho * 287.0f, 1e-9f)));
window.draw(segment);
x += cell_width;
}
}
void draw_cylinder(sf::RenderWindow& window, const sf::Font* font, float pressure,
float piston_fraction, float top, const char* name, bool intake_open, bool exhaust_open) {
constexpr float x = 470.0f, width = 74.0f, height = 142.0f;
float piston_fraction, float top, const char* name, bool intake_open, bool exhaust_open, float x = 470.0f) {
constexpr float width = 74.0f, height = 142.0f;
const float fraction = std::clamp(piston_fraction, 0.0f, 1.0f);
const float gas_height = height * fraction;
sf::RectangleShape wall({width, height});
@@ -219,18 +294,95 @@ void draw_dyno_curve(sf::RenderWindow& window, const sf::Font* font, const DynoC
}
}
void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state,
void draw_dashboard(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state,
float throttle_target, float brake, double playback_speed, std::size_t buffer_fill,
std::size_t buffer_capacity, bool time_warp, DynoCurve& dyno_curve) {
std::size_t buffer_capacity, bool time_warp, std::uint64_t underrun_blocks) {
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.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]"
<< " Shift [A/D], Neutral [Z]"
<< "\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");
if (font) {
sf::RectangleShape panel({1260.0f, 92.0f});
panel.setPosition({10.0f, 10.0f});
panel.setFillColor({12, 18, 26, 236});
panel.setOutlineColor({80, 110, 130});
panel.setOutlineThickness(1.0f);
window.draw(panel);
draw_label(window, font, status.str(), {22.0f, 20.0f}, sf::Color::White, 14);
draw_label(window, font,
"RPM " + std::to_string(static_cast<int>(state.rpm)) +
" Power " + std::to_string(state.average_power / 1000.0f) + " kW" +
" Vehicle kinetic energy " + std::to_string(static_cast<int>(state.kinetic_energy)) + " J",
{24.0f, 122.0f}, {220, 230, 240}, 17);
draw_label(window, font, state.two_stroke ? "REV LIMIT DISABLED" :
((state.rev_limit1 || state.rev_limit2) ? std::string("REV CUT") : "REV LIMIT " + std::to_string(int(state.rev_limit_rpm)) + " rpm"),
{24.0f, 151.0f}, {120, 220, 120}, 14);
}
if (state.turbo_enabled) {
const auto& t = state.turbo;
std::ostringstream text;
text << std::fixed << std::setprecision(0) << "VZ21-size: " << t.rpm << " rpm "
<< std::setprecision(2) << "CPR " << t.compressor_pressure_ratio
<< " Charge " << t.charge_pressure/1000 << " kPa / " << std::setprecision(0) << t.charge_temperature << " K"
<< "\nTurbine inlet " << t.turbine_inlet_pressure/1000 << " kPa Outlet " << t.turbine_outlet_pressure/1000 << " kPa"
<< " Power " << t.turbine_power << " / " << t.compressor_power << " W";
draw_label(window, font, text.str(), {625, 125}, {130, 215, 220}, 12);
}
}
void draw_two_stroke_scene(sf::RenderWindow& window,const sf::Font* font,const ScenarioSnapshot& s) {
window.clear({9,12,17});
draw_label(window,font,"YZ125 baseline | piston-controlled ports",
{650,185},sf::Color::White,14);
draw_cylinder(window,font,s.cylinder_pressure1,s.cylinder_piston_fraction1,220,"CYLINDER",
s.valve1_intake_area>0,s.valve1_exhaust_area>0);
draw_volume(window,s.crankcase_pressure,{{455,410},{110,95}});
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_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);
draw_graph_line(window,{510,505},{510,530},{180,190,200});
draw_graph_line(window,{510,530},{315,530},{180,190,200});
draw_graph_line(window,{315,530},{315,350},{180,190,200});
draw_graph_line(window,{450,350},{450,245},{180,190,200});
draw_graph_line(window,{450,245},{467,245},{180,190,200});
draw_label(window,font,"TRANSFER",{320,325},sf::Color::White,12);
draw_graph_line(window,{551,235},{590,235},{180,190,200});
draw_pipe(window,s,3,235,590,1230);
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";
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);
}
void draw_twin_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state) {
constexpr float intake_y = 310.0f;
constexpr float upper_y = 152.0f;
constexpr float lower_y = 400.0f;
window.clear({9, 12, 17});
const float power_kw = state.average_power / 1000.0f;
dyno_curve.update(state.rpm, power_kw, state.average_torque);
// Intake: ambient pipe → throttle → plenum → two cylinder runners.
draw_pipe(window, state, 0, intake_y, 36.0f, 165.0f);
draw_pipe(window, state, 0, intake_y, state.turbo_enabled ? 135.f : 36.f, 165.0f);
if (state.turbo_enabled) {
draw_pipe(window, state, 6, intake_y, 36, 90);
draw_volume(window, float(state.turbo.charge_pressure), {{95, intake_y-18}, {35, 36}});
draw_label(window, font, "COMP", {92, intake_y+30}, {130,215,220}, 10);
}
sf::RectangleShape throttle({8.0f, 28.0f});
throttle.setPosition({169.0f, intake_y - 14.0f});
throttle.setFillColor(sf::Color::Yellow);
@@ -247,7 +399,12 @@ void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSn
draw_pipe(window, state, 3, upper_y + 71.0f, 556.0f, 820.0f);
draw_pipe(window, state, 4, lower_y + 71.0f, 556.0f, 820.0f);
draw_volume(window, state.exhaust_pressure, {{838.0f, 305.0f}, {70.0f, 82.0f}});
draw_pipe(window, state, 5, 346.0f, 915.0f, 1236.0f);
draw_pipe(window, state, 5, 346.0f, 915.0f, state.turbo_enabled ? 1040.f : 1236.f);
if (state.turbo_enabled) {
draw_volume(window, float(state.turbo.turbine_inlet_pressure), {{1045, 323}, {45, 46}});
draw_label(window, font, "TURBINE", {1040, 380}, {130,215,220}, 10);
draw_pipe(window, state, 7, 346, 1100, 1236);
}
draw_label(window, font, "AMBIENT", {37.0f, intake_y + 22.0f}, {180, 190, 200}, 12);
draw_label(window, font, "THROTTLE", {151.0f, intake_y + 24.0f}, {240, 210, 90}, 11);
@@ -255,42 +412,49 @@ void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSn
draw_label(window, font, "SHARED EXHAUST", {818.0f, 285.0f}, sf::Color::White, 11);
draw_label(window, font, "TAILPIPE", {1084.0f, 362.0f}, {180, 190, 200}, 11);
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
<< "\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] Brake " << brake * 100.0f << "% [left/right]"
<< " Shift [A/D], Neutral [Z] Mechanical [M]: "
<< (state.mechanical_sound_enabled ? "ON" : "OFF");
if (font) {
sf::RectangleShape panel({1260.0f, 92.0f});
panel.setPosition({10.0f, 10.0f});
panel.setFillColor({12, 18, 26, 236});
panel.setOutlineColor({80, 110, 130});
panel.setOutlineThickness(1.0f);
window.draw(panel);
draw_label(window, font, status.str(), {22.0f, 20.0f}, sf::Color::White, 14);
draw_label(window, font,
"RPM " + std::to_string(static_cast<int>(state.rpm)) +
" Power " + std::to_string(state.average_power / 1000.0f) + " kW" +
" Bike kinetic energy " + std::to_string(static_cast<int>(state.kinetic_energy)) + " J",
{24.0f, 122.0f}, {220, 230, 240}, 17);
const sf::Color rev = state.rev_limit1 || state.rev_limit2 ? sf::Color::Red : sf::Color(120, 220, 120);
draw_label(window, font, (state.rev_limit1 || state.rev_limit2) ? "REV CUT" : "REV LIMIT 10000 rpm",
{24.0f, 151.0f}, rev, 14);
draw_label(window, font,
"Cylinder pressure " + std::to_string(state.cylinder_pressure1 / 1e5f) + " / " +
std::to_string(state.cylinder_pressure2 / 1e5f) + " bar Intake " +
std::to_string(state.intake_pressure / 1000.0f) + " kPa Exhaust " +
std::to_string(state.exhaust_pressure / 1000.0f) + " kPa",
{24.0f, 176.0f}, {205, 215, 225}, 13);
}
draw_dyno_curve(window, font, dyno_curve, 860.0f, 455.0f, 400.0f, 250.0f,
state.rpm, power_kw);
void draw_b205_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& s) {
window.clear({9, 12, 17});
draw_label(window, font, "SAAB B205 | 1985 cc inline-four | firing 1-3-4-2 | no turbo yet",
{260, 181}, sf::Color::White, 16);
draw_pipe(window, s, 0, 215, 25, 110);
draw_volume(window, s.intake_pressure, {{120, 195}, {65, 65}});
draw_label(window, font, "PLENUM", {120, 270}, sf::Color::White, 12);
draw_graph_line(window, {190, 211}, {820, 211}, {80, 145, 200});
draw_graph_line(window, {220, 420}, {850, 420}, {200, 120, 80});
for (int i = 0; i < 4; ++i) {
const float x = 230 + i*150;
const std::string title = "CYL " + std::to_string(i+1);
draw_cylinder(window, font, s.cylinder_pressures[i], s.piston_fractions[i], 255, title.c_str(),
s.intake_areas[i] > 1e-7f, s.exhaust_areas[i] > 1e-7f, x);
draw_pipe(window, s, 1+i, 235, x-15, x+75);
draw_pipe(window, s, 5+i, 410, x-15, x+75);
draw_graph_line(window, {x-15, 211}, {x-15, 235}, {80,145,200});
draw_graph_line(window, {x+75, 410}, {x+75, 420}, {200,120,80});
draw_label(window, font, std::to_string(int(s.cylinder_pressures[i]/1000))+" kPa",
{x, 445}, {180,190,205}, 12);
}
draw_volume(window, s.exhaust_pressure, {{850, 393}, {65, 48}});
draw_pipe(window, s, 9, 420, 920, 1240);
draw_label(window, font, "COLLECTOR / OPEN EXHAUST", {930, 390}, sf::Color::White, 12);
draw_label(window, font, "9-5 wagon baseline: 1675 kg with driver, five-speed manual, 4.05 final drive",
{25, 550}, sf::Color::White, 14);
draw_label(window, font, "Engine dimensions/firing from Saab WIS. Manifolds, cams, losses and ECU are estimates.",
{25, 580}, {180,190,205}, 12);
}
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) {
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);
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);
draw_dyno_curve(window, font, dyno_curve, 860, 455, 400, 250, state.rpm, power_kw);
window.display();
}
@@ -298,22 +462,72 @@ void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSn
int main(int argc, char** argv) {
using namespace fluidsim::port;
sf::RenderWindow window(sf::VideoMode({window_width, window_height}), "FluidSim C++ - parallel twin");
ScenarioKind selected_kind=ScenarioKind::Twin;
bool scenario_from_args=false;
bool turbo_from_args=false;
TwoStrokeConfig two_stroke_config;
OutdoorScene selected_scene=OutdoorScene::Wall;
for(int i=1;i<argc;++i){
const std::string option=argv[i];
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 :
(value=="two-stroke" ? ScenarioKind::TwoStroke : ScenarioKind::Twin);
scenario_from_args=true;
}else if(option=="--turbo"){
if(value!="none"&&value!="vz21"){std::cerr<<"Choose none or vz21 turbo\n";return 2;}
turbo_from_args=value=="vz21";
}else if(option=="--exhaust"){
if(value=="racing") two_stroke_config.exhaust=TwoStrokeExhaust::Racing;
else if(value=="straight") two_stroke_config.exhaust=TwoStrokeExhaust::Straight;
else { std::cerr<<"Choose racing or straight exhaust\n";return 2; }
}else{
if(value=="dry")selected_scene=OutdoorScene::Dry;
else if(value=="field")selected_scene=OutdoorScene::Field;
else if(value=="wall")selected_scene=OutdoorScene::Wall;
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]\n";return 2;}
}
const sf::Vector2u initial_size = scenario_from_args ? sf::Vector2u{window_width, window_height}
: sf::Vector2u{720, 606};
sf::RenderWindow window(sf::VideoMode(initial_size), "FluidSim");
window.setVerticalSyncEnabled(false);
window.setFramerateLimit(60);
window.setKeyRepeatEnabled(false);
sf::Font font;
std::filesystem::path font_path{"fonts/FiraCodeNerdFont-Medium.ttf"};
if (!std::filesystem::exists(font_path) && argc > 0) {
const auto executable_font = std::filesystem::path(argv[0]).parent_path() /
"fonts/FiraCodeNerdFont-Medium.ttf";
if (std::filesystem::exists(executable_font)) font_path = executable_font;
}
const bool has_font = font.openFromFile(font_path.string());
std::vector<std::filesystem::path> font_paths{"fonts/FiraCodeNerdFont-Medium.ttf"};
if(argc>0)font_paths.push_back(std::filesystem::path(argv[0]).parent_path()/"fonts/FiraCodeNerdFont-Medium.ttf");
font_paths.emplace_back("/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf");
font_paths.emplace_back("/usr/share/fonts/TTF/DejaVuSansMono.ttf");
font_paths.emplace_back("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf");
if(const char* windows_directory=std::getenv("WINDIR"))font_paths.push_back(std::filesystem::path(windows_directory)/"Fonts/consola.ttf");
bool has_font=false;
for(const auto& path:font_paths)if(std::filesystem::exists(path)&&font.openFromFile(path)){has_font=true;break;}
if(!has_font)std::cerr<<"No UI font available; configure FLUIDSIM_FONT_FILE.\n";
const sf::Font* font_ptr=has_font?&font:nullptr;
FourStrokeTwinScenario scenario(44100);
if (!scenario_from_args) {
const auto choice = choose_scenario(window, font_ptr);
if (!choice) return 0;
selected_kind = *choice;
window.setSize({window_width, window_height});
window.setView(sf::View(sf::FloatRect({0, 0}, {float(window_width), float(window_height)})));
}
if(turbo_from_args) {
if(selected_kind!=ScenarioKind::Twin && selected_kind!=ScenarioKind::TurboTwin) {
std::cerr<<"The VZ21 turbo test uses the twin scenario\n";return 2;
}
selected_kind=ScenarioKind::TurboTwin;
}
window.setTitle(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);
scenario.set_audio_scene(selected_scene);
SimulationRingBuffer ring(ring_capacity);
for (std::size_t i = 0; i < preroll_samples; ++i) {
const AudioFrame audio = scenario.process();
@@ -325,17 +539,19 @@ int main(int argc, char** argv) {
audio.set_volume(100.0f);
audio.start();
std::jthread simulation([&](std::stop_token stop) {
while (ring.wait_for_space(target_fill, stop)) {
constexpr std::size_t batch_size = 512;
while (ring.wait_for_space(target_fill - batch_size, stop)) {
for (std::size_t i = 0; i < batch_size; ++i) {
const AudioFrame frame = scenario.process();
if (!ring.write(frame.left, frame.right, stop)) break;
if (!ring.write(frame.left, frame.right, stop)) return;
}
}
});
double desired_speed = 0.001;
double desired_speed = 1.0;
double current_speed = desired_speed;
double last_normal_speed = 0.1;
double last_normal_speed = 1.0;
bool time_warp = false;
bool real_time = false;
float throttle_target = 1.0f, throttle_current = 0.0f;
float brake_target = 0.0f, brake_current = 0.0f;
float clutch_current = 0.0f;
@@ -350,7 +566,7 @@ int main(int argc, char** argv) {
switch (key->code) {
case sf::Keyboard::Key::Space:
time_warp = !time_warp;
if (!time_warp) { desired_speed = last_normal_speed; real_time = false; }
if (!time_warp) { desired_speed = last_normal_speed; }
break;
case sf::Keyboard::Key::W: throttle_held = true; break;
case sf::Keyboard::Key::C: clutch_held = true; break;
@@ -361,6 +577,10 @@ int main(int argc, char** argv) {
case sf::Keyboard::Key::D: scenario.shift_up(); break;
case sf::Keyboard::Key::A: scenario.shift_down(); break;
case sf::Keyboard::Key::Z: scenario.set_neutral(); break;
case sf::Keyboard::Key::R:
selected_scene=static_cast<OutdoorScene>((static_cast<int>(selected_scene)+1)%4);
scenario.set_audio_scene(selected_scene);
break;
case sf::Keyboard::Key::M: scenario.toggle_mechanical_sound(); break;
default: break;
}
@@ -373,7 +593,6 @@ int main(int argc, char** argv) {
desired_speed *= wheel->delta > 0.0f ? 1.1 : (wheel->delta < 0.0f ? 1.0 / 1.1 : 1.0);
desired_speed = std::clamp(desired_speed, 0.0001, 1.0);
last_normal_speed = desired_speed;
real_time = std::abs(desired_speed - 1.0) < 1e-6;
}
}
@@ -385,15 +604,14 @@ int main(int argc, char** argv) {
audio.set_speed(current_speed);
const float wanted_throttle = throttle_held ? throttle_target : 0.0f;
if (wanted_throttle == 0.0f) throttle_current = 0.0f;
else throttle_current += (wanted_throttle - throttle_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
throttle_current = wanted_throttle; // ECU actuator evolves at the simulation sample rate.
brake_current += (brake_target - brake_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
const float wanted_clutch = clutch_held ? 1.0f : 0.0f;
clutch_current += (wanted_clutch - clutch_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
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);
current_speed, ring.available_samples(), ring_capacity, time_warp, dyno_curve, audio.underrun_blocks());
}
simulation.request_stop();

56
src/ecu.cpp Normal file
View File

@@ -0,0 +1,56 @@
#include "FluidSim/Ecu.hpp"
#include <algorithm>
#include <cmath>
namespace fluidsim::port {
void Ecu::step(float dt, float pedal, float rpm) {
fault_ = !std::isfinite(dt) || dt <= 0.0f || !std::isfinite(pedal) || !std::isfinite(rpm) || rpm < 0.0f;
if (fault_) { target_ = 0.0f; idle_ = false; return; }
pedal = std::clamp(pedal, 0.0f, 1.0f);
if (!rev_limit_enabled) limiter_ = false;
else if (rpm >= rev_limit_rpm) limiter_ = true;
else if (rpm <= rev_resume_rpm) limiter_ = false;
if (pedal > 0.005f || rpm < overrun_resume_rpm) overrun_ = false;
else if (rpm > overrun_cut_rpm) overrun_ = true;
if (!initialized_) { filtered_rpm_ = rpm; initialized_ = true; }
filtered_rpm_ += (rpm - filtered_rpm_) * (-std::expm1(-dt / 0.05f));
idle_ = pedal <= 0.005f && !overrun_;
const float error = idle_rpm - filtered_rpm_;
if (idle_) {
const float proposed = integral_ + idle_ki * error * dt;
const float command = idle_base_opening + idle_kp * error + proposed;
// Conditional integration prevents windup at either actuator limit.
if ((command >= 0.0f && command <= idle_max_opening) ||
(command > idle_max_opening && error < 0.0f) || (command < 0.0f && error > 0.0f))
integral_ = proposed;
} else {
integral_ *= std::exp(-dt / 0.5f);
}
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;
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;
if (ignition_map.empty()) {
spark_ = 10;
} else {
spark_ = ignition_map.front().degrees_btdc;
for (std::size_t i = 1; i < ignition_map.size(); ++i) {
const auto a = ignition_map[i-1], b = ignition_map[i];
const float t = std::clamp((rpm-a.rpm) / std::max(b.rpm-a.rpm, 1e-6f), 0.f, 1.f);
spark_ = std::lerp(a.degrees_btdc, b.degrees_btdc, t);
if (rpm <= b.rpm) break;
}
}
if(idle_ && idle_spark_gain>0) spark_=std::clamp(spark_+idle_spark_gain*error,idle_minimum_advance,35.0f);
}
float Ecu::fuel_for_air(float air) const {
if (fuel_cut() || !std::isfinite(air) || air <= 0.0f) return 0.0f;
return air / std::max(afr_, 1.0f);
}
} // namespace fluidsim::port

View File

@@ -1,4 +1,5 @@
#include "FluidSim/Engine.hpp"
#include "FluidSim/Ecu.hpp"
#include <algorithm>
#include <cmath>
@@ -14,12 +15,15 @@ void Crankshaft::step(float dt) {
if (!std::isfinite(external_torque_)) external_torque_ = 0.0f;
previous_angle = angle;
const float sign = omega > 0.0f ? 1.0f : omega < 0.0f ? -1.0f : 0.0f;
const float friction = friction_constant * sign + friction_viscous * omega;
const float friction = friction_constant * sign + friction_viscous * omega +
friction_quadratic * omega * std::abs(omega);
const float net_torque = external_torque_ - friction;
const float total_torque = net_torque - std::max(load_torque, 0.0f) + drivetrain_torque;
omega = std::max(omega + total_torque / std::max(inertia, 1e-6f) * dt, 0.0f);
angle = std::fmod(angle + omega * dt, 4.0f * pi);
const float instant_power = total_torque * omega;
// Brake shaft output, before external test load/drivetrain reaction. Using
// acceleration torque reports zero dyno power at a loaded steady speed.
const float instant_power = net_torque * omega;
if (power_count_ == power_buffer_.size()) power_sum_ -= power_buffer_[power_index_];
else ++power_count_;
power_buffer_[power_index_] = instant_power;
@@ -29,8 +33,8 @@ void Crankshaft::step(float dt) {
if (torque_count_ == torque_buffer_.size()) torque_sum_ -= torque_buffer_[torque_index_];
else ++torque_count_;
torque_buffer_[torque_index_] = total_torque;
torque_sum_ += total_torque;
torque_buffer_[torque_index_] = net_torque;
torque_sum_ += net_torque;
torque_index_ = (torque_index_ + 1) % torque_buffer_.size();
average_torque = torque_sum_ / static_cast<float>(torque_count_);
external_torque_ = 0.0f;
@@ -77,6 +81,7 @@ void EngineCylinder::initialize(const Crankshaft& /*crank*/) {
exhaust_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;
sync_ports();
@@ -106,13 +111,18 @@ float FourStrokeCylinder::valve_area(float cycle_degrees, bool inlet) const {
const float t = (mapped - (opens + ramp + hold)) / ramp;
lift = peak_lift * (1.0f - t) * (1.0f - t) * (1.0f + 2.0f * t);
}
return pi * diameter * lift;
return pi * diameter * lift * (inlet ? intake_valve_count : exhaust_valve_count);
}
void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*/) {
const float rpm = crank.omega * 60.0f / (2.0f * pi);
if (ecu) {
spark_advance = ecu->spark_advance();
rev_limit_active = ecu->rev_limited();
} else {
spark_advance = std::clamp(10.0f + rpm * 0.002f, 5.0f, 40.0f);
update_rev_limiter(rpm, dt);
}
const float theta = crank.angle + phase;
const float previous_volume = volume;
@@ -130,14 +140,13 @@ 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)) {
fuel_mass = air_mass / std::max(afr, 1e-6f);
fuel_injected = true;
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
fuel_injected = fuel_mass > 0.0f;
}
const float spark_angle = wrap_degrees(720.0f - spark_advance);
const bool crossed_spark = (previous_degrees < spark_angle && current_degrees >= spark_angle) ||
(previous_degrees > spark_angle && current_degrees < spark_angle);
if (crossed_spark && fuel_injected && !combustion_active && !rev_limit_active && random_unit() >= misfire_probability) {
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) && random_unit() >= misfire_probability) {
combustion_active = true;
burn_fraction = 0.0f;
energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.0f);
@@ -164,7 +173,7 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
}
}
if (rev_limit_active) {
if (!ecu && rev_limit_active) {
fuel_mass = 0.0f;
fuel_injected = false;
combustion_active = false;

View File

@@ -10,8 +10,7 @@ HelmholtzScenario::HelmholtzScenario(int sample_rate)
boundaries_(pipes_), solver_(pipes_, boundaries_), sound_(sample_rate, 1.0f) {
pipes_.emplace_back(neck_area_, 0.050f, 20);
cavity_port_ = &cavity_.create_port();
boundaries_.add_orifice_with_inertance(*cavity_port_, 0, true, cavity_orifice_area_,
0.9f, 0.050f);
boundaries_.add_orifice(*cavity_port_, 0, true, cavity_orifice_area_, 0.9f);
boundaries_.add_open_end(0, false, 101325.0f, neck_area_);
solver_.minimum_substeps = 8;
solver_.add_component(cavity_);

View File

@@ -15,20 +15,34 @@ int main(int argc, char** argv) {
float duration_seconds = 1.0f;
float throttle = 0.35f;
std::string scenario_name = "twin";
TwoStrokeConfig two_stroke_config;
bool turbo_enabled = false;
for (int i = 1; i + 1 < argc; ++i) {
const std::string option = argv[i];
if (option == "--seconds") duration_seconds = std::max(0.0f, std::strtof(argv[++i], nullptr));
else if (option == "--throttle") throttle = std::clamp(std::strtof(argv[++i], nullptr), 0.0f, 1.0f);
else if (option == "--scenario") scenario_name = argv[++i];
else if (option == "--turbo") {
const std::string value = argv[++i];
if (value != "none" && value != "vz21") { std::cerr << "Choose none or vz21 turbo\n"; return 2; }
turbo_enabled = value == "vz21";
}
else if (option == "--exhaust") {
const std::string value = argv[++i];
if (value == "racing") two_stroke_config.exhaust = TwoStrokeExhaust::Racing;
else if (value == "straight") two_stroke_config.exhaust = TwoStrokeExhaust::Straight;
else { std::cerr << "Choose racing or straight exhaust\n"; return 2; }
}
}
if (turbo_enabled && scenario_name != "twin") { std::cerr << "The turbo test preset uses --scenario twin\n"; return 2; }
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") {
FourStrokeTwinScenario scenario;
if (scenario_name == "twin" || scenario_name == "b205") {
FourStrokeScenario scenario(sample_rate, scenario_name == "b205" ? FourStrokePreset::SaabB205 : FourStrokePreset::Twin, turbo_enabled);
scenario.throttle = throttle;
std::cout << "FluidSimCpp | 600 cc parallel twin | throttle " << std::fixed << std::setprecision(0)
std::cout << (scenario_name == "b205" ? "FluidSimCpp | Saab B205 / 9-5 wagon (no turbo) | 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();
@@ -41,11 +55,18 @@ int main(int argc, char** argv) {
<< " cyl=" << scenario.cylinder1.pressure / 1000.0f << "/"
<< scenario.cylinder2.pressure / 1000.0f << " kPa"
<< " intake=" << scenario.intake_plenum.pressure() / 1000.0f << " kPa"
<< " exhaust=" << scenario.exhaust_collector.pressure() / 1000.0f << " kPa\n";
<< " exhaust=" << scenario.exhaust_collector.pressure() / 1000.0f << " kPa";
if (scenario.turbo) {
const auto turbo = scenario.turbo->snapshot();
std::cout << " turbo_rpm=" << turbo.rpm << " CPR=" << turbo.compressor_pressure_ratio
<< " turbine_in=" << turbo.turbine_inlet_pressure/1000 << " kPa"
<< " charge_T=" << turbo.charge_temperature << " K";
}
std::cout << '\n';
}
}
} else if (scenario_name == "two-stroke") {
TwoStrokeScenario scenario;
TwoStrokeScenario scenario(sample_rate, two_stroke_config);
scenario.set_throttle(throttle);
std::cout << "FluidSimCpp | 125 cc crankcase-scavenged two-stroke | throttle "
<< std::fixed << std::setprecision(0) << throttle * 100.0f << "%\n";
@@ -88,7 +109,7 @@ int main(int argc, char** argv) {
}
} else {
std::cerr << "Unknown scenario '" << scenario_name
<< "'. Choose twin, two-stroke, helmholtz, or pulse.\n";
<< "'. Choose twin, two-stroke, b205, helmholtz, or pulse.\n";
return 2;
}
return 0;

171
src/outdoor_audio.cpp Normal file
View File

@@ -0,0 +1,171 @@
#include "FluidSim/Constants.hpp"
#include "FluidSim/OutdoorAudio.hpp"
#include <algorithm>
#include <bit>
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
namespace {
Vec3 sub(Vec3 a, Vec3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
float length(Vec3 a) { return std::sqrt(a.x * a.x + a.y * a.y + a.z * a.z); }
float distance(Vec3 a, Vec3 b) { return length(sub(a, b)); }
AudioFrame add(AudioFrame a, AudioFrame b) { return {a.left + b.left, a.right + b.right}; }
} // namespace
const char *scene_name(OutdoorScene scene) {
switch (scene) {
case OutdoorScene::Dry:
return "Dry";
case OutdoorScene::Field:
return "Open field";
case OutdoorScene::Wall:
return "Nearby wall";
case OutdoorScene::Woodland:
return "Woodland";
}
return "Unknown";
}
float OutdoorAudio::LowPass::process(float x) {
const float y = b0 * x + z1;
z1 = b1 * x - a1 * y + z2;
z2 = b2 * x - a2 * y;
if (std::abs(z1) < 1e-25f)
z1 = 0;
if (std::abs(z2) < 1e-25f)
z2 = 0;
return y;
}
OutdoorAudio::OutdoorAudio(int rate, OutdoorAudioConfig config) : rate_(rate), config_(config) {
if (rate < 8000 || !std::isfinite(config.sound_speed) || config.sound_speed <= 0 ||
!std::isfinite(config.monitor_cutoff_hz) || config.monitor_cutoff_hz <= 0 ||
!std::isfinite(config.pressure_gain) || config.pressure_gain < 0)
throw std::invalid_argument("invalid outdoor audio configuration");
const float norm = length(config_.listener_right);
if (!std::isfinite(norm) || norm < 1e-6f)
throw std::invalid_argument("invalid listener right vector");
config_.listener_right = {config_.listener_right.x / norm, config_.listener_right.y / norm,
config_.listener_right.z / norm};
configure_emitter(emitters_[0], config_.intake_position);
configure_emitter(emitters_[1], config_.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];
line.buffer.resize(std::max(1, int(std::lround(lengths[i] * rate_ / 44100.0))));
line.gain = std::pow(10.0f, -3.0f * (float(line.buffer.size()) / rate_) / 0.35f);
}
scene_alpha_ = 1 - std::exp(-1.0f / (rate_ * .02f));
tail_alpha_ = 1 - std::exp(-2 * pi * 2500 / rate_);
}
void OutdoorAudio::configure_emitter(Emitter &emitter, Vec3 source) {
emitter.filtered.resize(std::bit_ceil(std::size_t(rate_)));
emitter.raw.resize(emitter.filtered.size()); // One second, validated path range.
const auto ear = [&](float sign) {
return Vec3{config_.listener.x + sign * config_.listener_right.x * config_.ear_spacing * .5f,
config_.listener.y + sign * config_.listener_right.y * config_.ear_spacing * .5f,
config_.listener.z + sign * config_.listener_right.z * config_.ear_spacing * .5f};
};
const auto path = [&](Vec3 image, float coefficient, int group, float cutoff, float extra = 0,
float source_distance = 1) {
Path p;
p.group = group;
const float dl = std::max(distance(image, ear(-1)), .1f), dr = std::max(distance(image, ear(1)), .1f);
const float sl = (dl + extra) * rate_ / config_.sound_speed,
sr = (dr + extra) * rate_ / config_.sound_speed;
if (!std::isfinite(sl) || !std::isfinite(sr) || sl >= rate_ - 2 || sr >= rate_ - 2)
throw std::invalid_argument("outdoor propagation path exceeds one second");
p.delay_left = std::size_t(sl);
p.delay_right = std::size_t(sr);
p.fraction_left = sl - p.delay_left;
p.fraction_right = sr - p.delay_right;
p.gain_left = coefficient / (dl * source_distance);
p.gain_right = coefficient / (dr * source_distance);
p.alpha = cutoff > 0 ? 1 - std::exp(-2 * pi * cutoff / rate_) : 1;
emitter.paths.push_back(p);
};
path(source, 1, 0, 0);
path({source.x, 2 * config_.ground_y - source.y, source.z}, config_.ground_reflection, 1, 1800);
path({2 * config_.wall_x - source.x, source.y, source.z}, config_.wall_reflection, 2, 4500);
// Single-bounce point scatterers: geometric paths, empirical scattering strengths.
for (Vec3 tree :
std::array<Vec3, 6>{{{-4, 1, 3}, {3, 1, 5}, {5, 1, -2}, {-6, 2, -3}, {-2, 1, 7}, {6, 2, 6}}}) {
const float first = std::max(distance(source, tree), .1f);
path(tree, .35f, 3, 2400, first, first);
}
const float cutoff = std::min(config_.monitor_cutoff_hz, rate_ * .45f);
const float w = 2 * pi * cutoff / rate_, cs = std::cos(w), alpha = std::sin(w) / std::sqrt(2.0f),
a0 = 1 + alpha;
emitter.filter = {(1 - cs) * .5f / a0, (1 - cs) / a0, (1 - cs) * .5f / a0, -2 * cs / a0,
(1 - alpha) / a0};
}
AudioFrame OutdoorAudio::render(Emitter &e, float flow, AudioFrame &dry, AudioFrame &raw) {
const float pressure = (flow - e.previous_flow) * rate_ * config_.pressure_gain / (4 * pi);
e.previous_flow = flow;
const float filtered = e.filter.process(pressure);
e.raw[e.position] = pressure;
e.filtered[e.position] = monitor_filter_ ? filtered : pressure;
const auto read = [&](const std::vector<float> &data, std::size_t delay, float fraction) {
const auto i = (e.position + data.size() - delay) & (data.size() - 1);
const auto j = (i + data.size() - 1) & (data.size() - 1);
return data[i] + (data[j] - data[i]) * fraction;
};
AudioFrame result{};
for (auto &p : e.paths) {
const float l = read(e.filtered, p.delay_left, p.fraction_left) * p.gain_left;
const float r = read(e.filtered, p.delay_right, p.fraction_right) * p.gain_right;
p.state_left += p.alpha * (l - p.state_left);
p.state_right += p.alpha * (r - p.state_right);
if (std::abs(p.state_left) < 1e-25f)
p.state_left = 0;
if (std::abs(p.state_right) < 1e-25f)
p.state_right = 0;
result.left += p.state_left * weights_[p.group];
result.right += p.state_right * weights_[p.group];
if (p.group == 0) {
dry = add(dry, {l, r});
raw = add(raw, {read(e.raw, p.delay_left, p.fraction_left) * p.gain_left,
read(e.raw, p.delay_right, p.fraction_right) * p.gain_right});
}
}
e.position = (e.position + 1) & (e.filtered.size() - 1);
return result;
}
AudioFrame OutdoorAudio::diffuse(float input) {
std::array<float, 4> values{};
float sum = 0;
for (std::size_t i = 0; i < 4; ++i) {
auto &line = tail_[i];
values[i] = line.buffer[line.position];
sum += values[i];
}
for (std::size_t i = 0; i < 4; ++i) {
auto &line = tail_[i];
const float scattered = values[i] - .5f * sum;
line.state += tail_alpha_ * (scattered - line.state);
if (std::abs(line.state) < 1e-25f)
line.state = 0;
line.buffer[line.position] = line.gain * line.state + .5f * input;
line.position = (line.position + 1) % line.buffer.size();
}
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))
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,
scene_ == OutdoorScene::Woodland ? 1.f : 0.f};
for (std::size_t i = 0; i < 4; ++i)
weights_[i] += (target[i] - weights_[i]) * scene_alpha_;
const float target_tail =
scene_ == OutdoorScene::Wall
? .14f
: (scene_ == OutdoorScene::Woodland ? .25f : (scene_ == OutdoorScene::Field ? .025f : 0));
tail_mix_ += (target_tail - tail_mix_) * scene_alpha_;
last_dry_ = {};
last_raw_ = {};
const auto a = render(emitters_[0], intake, last_dry_, last_raw_);
const auto b = render(emitters_[1], exhaust, last_dry_, last_raw_);
const auto 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_};
}
} // namespace fluidsim::port

View File

@@ -5,168 +5,184 @@
namespace fluidsim::port {
FourStrokeTwinScenario::FourStrokeTwinScenario(int sample_rate)
: pipes(create_pipes()),
dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))),
boundaries_(pipes), solver_(pipes, boundaries_),
intake_radiation_(sample_rate), exhaust_radiation_(sample_rate),
reverb_(sample_rate), mechanical_sound_(sample_rate) {
crankshaft.omega = 2000.0f * 2.0f * pi / 60.0f;
crankshaft.inertia = 0.04f;
crankshaft.friction_constant = 5.0f;
crankshaft.friction_viscous = 0.003f;
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),
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) {
const bool saab = preset == FourStrokePreset::SaabB205;
const int count = cylinder_count();
crankshaft.omega = (saab ? 1200 : 2000) * 2 * pi / 60;
crankshaft.inertia = saab ? .18f : .04f;
crankshaft.friction_constant = saab ? 12.f : 5.f;
crankshaft.friction_viscous = saab ? .012f : .003f;
if (saab) {
// Rough 9-5 wagon with driver; FM55 B05 overall reductions from WIS.
vehicle.mass = 1600 + 75;
vehicle.primary_reduction = 1;
vehicle.final_drive_ratio = 4.05f;
vehicle.gear_ratios = {13.70f/4.05f, 7.12f/4.05f, 4.77f/4.05f, 3.62f/4.05f, 2.67f/4.05f};
vehicle.wheel_radius = .317f;
vehicle.rotational_inertia = 3.6f;
vehicle.drag_coefficient = .32f;
vehicle.frontal_area = 2.2f;
vehicle.clutch_capacity = 300;
ecu.idle_rpm = 850;
ecu.idle_spark_gain = .025f;
ecu.idle_minimum_advance = -10;
ecu.rev_limit_rpm = 6500;
ecu.rev_resume_rpm = 6250;
ecu.overrun_cut_rpm = 2000;
ecu.overrun_resume_rpm = 1300;
}
vehicle.set_neutral();
vehicle.clutch_input = clutch;
cylinder1.intake_valve_diameter = 0.030f;
cylinder1.intake_valve_lift = 0.0080f;
cylinder1.exhaust_valve_diameter = 0.026f;
cylinder1.exhaust_valve_lift = 0.0075f;
cylinder2.phase = pi;
cylinder2.intake_valve_diameter = 0.030f;
cylinder2.intake_valve_lift = 0.0080f;
cylinder2.exhaust_valve_diameter = 0.026f;
cylinder2.exhaust_valve_lift = 0.0075f;
cylinder1.rev_limit_enabled = cylinder2.rev_limit_enabled = true;
cylinder1.rev_limit_rpm = cylinder2.rev_limit_rpm = 10000.0f;
cylinder1.rev_limit_cut_time = cylinder2.rev_limit_cut_time = 0.020f;
cylinder1.rev_limit_hysteresis = cylinder2.rev_limit_hysteresis = 250.0f;
cylinder1.initialize(crankshaft);
cylinder2.initialize(crankshaft);
const float intake_area = area_for_diameter(0.0275f);
const float exhaust_area = area_for_diameter(0.028f);
runner_area_ = intake_area;
header_area_ = tail_area_ = exhaust_area;
for (auto& pipe : pipes) {
pipe.damping_multiplier = 1.0f;
pipe.energy_relaxation_rate = 0.5f;
pipe.ambient_pressure = 101325.0f;
const auto engines = cylinders();
const std::array<float, 4> phases{0, pi, 3*pi, 2*pi}; // I4 firing: 1-3-4-2
for (int i = 0; i < count; ++i) {
auto& c = *engines[i];
c.phase = phases[i];
if (saab) {
c.bore = .090f;
c.stroke = .078f;
c.rod = .153f; // estimated, not a verified B205 rod measurement
c.compression = 8.8f;
c.intake_valve_count = c.exhaust_valve_count = 2;
}
throttle_port_ = &intake_plenum.create_port();
runner_feed1_ = &intake_plenum.create_port();
runner_feed2_ = &intake_plenum.create_port();
collector_in1_ = &exhaust_collector.create_port();
collector_in2_ = &exhaust_collector.create_port();
collector_out_ = &exhaust_collector.create_port();
intake_radiation_.pipe_end = {-0.20f, 0.0f, 0.0f};
intake_radiation_.pipe_axis = {-1.0f, 0.0f, 0.0f};
intake_radiation_.gain = 30.0f;
intake_radiation_.directivity_balance = 0.30f;
intake_radiation_.flow_tau = 0.005f;
intake_radiation_.smooth_tau = 0.020f;
exhaust_radiation_.pipe_end = {0.55f, 0.0f, 0.0f};
exhaust_radiation_.pipe_axis = {1.0f, 0.0f, 0.0f};
exhaust_radiation_.gain = 25.0f;
exhaust_radiation_.directivity_balance = 0.30f;
exhaust_radiation_.flow_tau = 0.005f;
exhaust_radiation_.smooth_tau = 0.020f;
reverb_.dry_mix = 0.0f;
reverb_.early_mix = 0.30f;
reverb_.tail_mix = 0.22f;
reverb_.transient_feedback_boost = 0.40f;
reverb_.transient_hf_boost = 3.5f;
reverb_.saturation_drive = 1.5f;
solver_.add_component(cylinder1);
solver_.add_component(cylinder2);
c.intake_valve_diameter = saab ? .032f : .030f;
c.intake_valve_lift = saab ? .0085f : .008f;
c.exhaust_valve_diameter = saab ? .028f : .026f;
c.exhaust_valve_lift = saab ? .008f : .0075f;
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) {
// Initialize each phased cylinder at its actual piston position.
c.volume = c.compute_volume(c.phase);
c.air_mass = ambient_density * c.volume;
c.energy = 101325 * c.volume / (gamma - 1);
c.update_state(0);
}
}
intake_boundary_area_ = pipes.front().area;
runner_area_ = pipes[1].area;
header_area_ = pipes[1+count].area;
tail_area_ = pipes[1+2*count].area;
for (auto& pipe : pipes) {
pipe.damping_multiplier = saab ? 0.f : 1.f;
pipe.energy_relaxation_rate = 0;
}
auto& throttle_port = intake_plenum.create_port();
solver_.add_component(intake_atmosphere);
solver_.add_component(intake_plenum);
solver_.add_component(exhaust_collector);
boundaries_.add_open_end(0, true, 101325.0f, intake_area);
boundaries_.add_open_end(5, false, 101325.0f, exhaust_area);
boundaries_.add_orifice(*throttle_port_, 0, false, throttle_area_, 0.7f);
boundaries_.add_orifice(*runner_feed1_, 1, true, runner_area_);
boundaries_.add_orifice(*runner_feed2_, 2, true, runner_area_);
boundaries_.add_orifice(cylinder1.intake, 1, false, valve1_in_);
boundaries_.add_orifice(cylinder2.intake, 2, false, valve2_in_);
boundaries_.add_orifice(cylinder1.exhaust, 3, true, valve1_out_);
boundaries_.add_orifice(cylinder2.exhaust, 4, true, valve2_out_);
boundaries_.add_orifice(*collector_in1_, 3, false, header_area_);
boundaries_.add_orifice(*collector_in2_, 4, false, header_area_);
boundaries_.add_orifice(*collector_out_, 5, true, tail_area_);
if (turbo_enabled) {
turbo = std::make_unique<Turbocharger>();
const int fresh = 2+2*count, exhaust_exit = fresh+1;
boundaries_.add_orifice(intake_atmosphere.port, fresh, true, intake_boundary_area_);
turbo->connect(boundaries_, pipes, fresh, false, 0, true, 1+2*count, false, exhaust_exit, true);
solver_.add_component(*turbo);
} else {
boundaries_.add_orifice(intake_atmosphere.port, 0, true, intake_boundary_area_);
}
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();
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]);
boundaries_.add_orifice(engines[i]->exhaust, 1+count+i, true, exhaust_valve_areas_[i]);
boundaries_.add_orifice(collector_in, 1+count+i, false, header_area_);
}
auto& collector_out = exhaust_collector.create_port();
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);
}
AudioFrame FourStrokeTwinScenario::process() {
const std::lock_guard<std::mutex> guard(mutex_);
crankshaft.load_torque = std::clamp(load, 0.0f, 1.0f) * 30.0f;
vehicle.clutch_input = std::clamp(clutch, 0.0f, 1.0f);
crankshaft.step(dt_);
crankshaft.drivetrain_torque = vehicle.update(crankshaft.omega, crankshaft.inertia, dt_);
cylinder1.pre_step(crankshaft, dt_, throttle);
cylinder2.pre_step(crankshaft, dt_, throttle);
const float throttle_fraction = std::clamp(throttle, 0.0f, 1.0f);
throttle_area_ = throttle_fraction <= 0.001f ? 0.0f : area_for_diameter(0.026f) * throttle_fraction;
const auto cycle_degrees = [this](float phase) {
float value = std::fmod((crankshaft.angle + phase) * 180.0f / pi, 720.0f);
return value < 0.0f ? value + 720.0f : value;
};
valve1_in_ = cylinder1.valve_area(cycle_degrees(cylinder1.phase), true);
valve2_in_ = cylinder2.valve_area(cycle_degrees(cylinder2.phase), true);
valve1_out_ = cylinder1.valve_area(cycle_degrees(cylinder1.phase), false);
valve2_out_ = cylinder2.valve_area(cycle_degrees(cylinder2.phase), false);
AudioFrame FourStrokeScenario::process() {
const std::lock_guard guard(mutex_);
step_motorcycle(crankshaft, dt_, preset == FourStrokePreset::SaabB205 ? 200.f : 30.f);
ecu.step(dt_, throttle, crankshaft.omega * 60 / (2*pi));
const auto engines = cylinders();
for (int i = 0; i < cylinder_count(); ++i) {
auto& c = *engines[i];
c.pre_step(crankshaft, dt_, throttle);
const float degrees = std::fmod((crankshaft.angle+c.phase)*180/pi, 720.f);
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));
solver_.step(dt_);
intake_flow = boundaries_.open_end_mass_flow(0);
exhaust_flow = boundaries_.open_end_mass_flow(1);
const AudioFrame intake_audio = intake_radiation_.process(intake_flow);
const AudioFrame exhaust_audio = exhaust_radiation_.process(exhaust_flow);
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 mechanical = mechanical_sound_enabled
? mechanical_sound_.process(crankshaft, cylinder1, load) * mechanical_mix : 0.0f;
const float reverb_input = intake_radiation_.last_mono_pressure() * intake_send +
exhaust_radiation_.last_mono_pressure() * exhaust_send +
mechanical * mechanical_send;
const AudioFrame wet = reverb_.process_stereo(reverb_input);
const float flow_left = std::tanh(intake_audio.left + exhaust_audio.left + wet.left * reverb_send_gain);
const float flow_right = std::tanh(intake_audio.right + exhaust_audio.right + wet.right * reverb_send_gain);
last_mix_l = std::isfinite(flow_left + mechanical)
? std::clamp(flow_left + mechanical, -1.0f, 1.0f) : 0.0f;
last_mix_r = std::isfinite(flow_right + mechanical)
? std::clamp(flow_right + mechanical, -1.0f, 1.0f) : 0.0f;
? mechanical_sound_.process(crankshaft, cylinder1, load) * mechanical_mix : 0;
last_mix_l = rendered.left + mechanical;
last_mix_r = rendered.right + mechanical;
++sample_count;
return {last_mix_l, last_mix_r};
}
void FourStrokeTwinScenario::set_controls(float throttle_value, float load_value, float clutch_value) {
void FourStrokeScenario::set_audio_scene(OutdoorScene scene) {
const std::lock_guard<std::mutex> guard(mutex_); audio_.set_scene(scene);
}
void FourStrokeScenario::set_monitor_filter(bool enabled) {
const std::lock_guard<std::mutex> guard(mutex_); audio_.set_monitor_filter(enabled);
}
AudioFrame FourStrokeScenario::dry_audio() const {
const std::lock_guard<std::mutex> guard(mutex_); return audio_.dry_frame();
}
AudioFrame FourStrokeScenario::unfiltered_audio() const {
const std::lock_guard<std::mutex> guard(mutex_); return audio_.unfiltered_frame();
}
void FourStrokeScenario::set_controls(float throttle_value, float load_value, float clutch_value) {
const std::lock_guard<std::mutex> guard(mutex_);
throttle = std::clamp(throttle_value, 0.0f, 1.0f);
load = std::clamp(load_value, 0.0f, 1.0f);
clutch = std::clamp(clutch_value, 0.0f, 1.0f);
}
void FourStrokeTwinScenario::toggle_mechanical_sound() {
void FourStrokeScenario::toggle_mechanical_sound() {
const std::lock_guard<std::mutex> guard(mutex_);
mechanical_sound_enabled = !mechanical_sound_enabled;
}
void FourStrokeTwinScenario::shift_up() {
void FourStrokeScenario::shift_up() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.shift_up();
}
void FourStrokeTwinScenario::shift_down() {
void FourStrokeScenario::shift_down() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.shift_down();
}
void FourStrokeTwinScenario::set_neutral() {
void FourStrokeScenario::set_neutral() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.set_neutral();
}
void FourStrokeTwinScenario::set_first_gear() {
void FourStrokeScenario::set_first_gear() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.set_first_gear();
}
ScenarioSnapshot FourStrokeTwinScenario::snapshot() const {
ScenarioSnapshot FourStrokeScenario::snapshot() const {
const std::lock_guard<std::mutex> guard(mutex_);
ScenarioSnapshot result;
result.turbo_enabled = bool(turbo);
if (turbo) result.turbo = turbo->snapshot();
result.solver_substeps = solver_.substep_count();
result.boundary_iterations = boundaries_.nonlinear_iterations;
result.rpm = crankshaft.omega * 60.0f / (2.0f * pi);
result.speed_kmh = vehicle.speed * 3.6f;
result.kinetic_energy = vehicle.kinetic_energy();
@@ -178,13 +194,18 @@ ScenarioSnapshot FourStrokeTwinScenario::snapshot() const {
result.cylinder_temperature2 = cylinder2.temperature();
result.cylinder_piston_fraction1 = (cylinder1.volume - cylinder1.clearance_volume()) / cylinder1.swept_volume();
result.cylinder_piston_fraction2 = (cylinder2.volume - cylinder2.clearance_volume()) / cylinder2.swept_volume();
result.valve1_intake_area = valve1_in_;
result.valve1_exhaust_area = valve1_out_;
result.valve2_intake_area = valve2_in_;
result.valve2_exhaust_area = valve2_out_;
result.valve1_intake_area = intake_valve_areas_[0];
result.valve1_exhaust_area = exhaust_valve_areas_[0];
result.valve2_intake_area = intake_valve_areas_[1];
result.valve2_exhaust_area = exhaust_valve_areas_[1];
result.intake_pressure = intake_plenum.pressure();
result.exhaust_pressure = exhaust_collector.pressure();
result.throttle = throttle;
result.throttle = ecu.throttle_opening();
result.pedal = throttle;
result.target_afr = ecu.target_afr();
result.idle_control = ecu.idle_active();
result.fuel_cut = ecu.fuel_cut();
result.audio_scene = audio_.scene();
result.clutch = clutch;
result.intake_flow = intake_flow;
result.exhaust_flow = exhaust_flow;
@@ -192,30 +213,38 @@ ScenarioSnapshot FourStrokeTwinScenario::snapshot() const {
result.rev_limit1 = cylinder1.rev_limit_active;
result.rev_limit2 = cylinder2.rev_limit_active;
result.mechanical_sound_enabled = mechanical_sound_enabled;
for (std::size_t pipe_index = 0; pipe_index < pipes.size(); ++pipe_index) {
auto& states = result.pipe_states[pipe_index];
states.reserve(pipes[pipe_index].cells.size());
for (std::size_t cell = 0; cell < pipes[pipe_index].cells.size(); ++cell)
states.push_back(pipes[pipe_index].state(static_cast<int>(cell)));
result.cylinder_count = cylinder_count();
result.rev_limit_rpm = ecu.rev_limit_rpm;
const std::array<const FourStrokeCylinder*, 4> engines{&cylinder1, &cylinder2, &cylinder3, &cylinder4};
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();
result.intake_areas[i] = intake_valve_areas_[i];
result.exhaust_areas[i] = exhaust_valve_areas_[i];
}
result.capture_pipes(pipes);
return result;
}
float FourStrokeTwinScenario::area_for_diameter(float diameter) {
float FourStrokeScenario::area_for_diameter(float diameter) {
return pi * diameter * diameter * 0.25f;
}
std::vector<Pipe1D> FourStrokeTwinScenario::create_pipes() {
const float intake_area = area_for_diameter(0.0275f);
const float exhaust_area = area_for_diameter(0.028f);
std::vector<Pipe1D> FourStrokeScenario::create_pipes(FourStrokePreset preset, bool turbo_enabled) {
const bool saab = preset == FourStrokePreset::SaabB205;
const int count = saab ? 4 : 2;
std::vector<Pipe1D> result;
result.reserve(6);
result.emplace_back(intake_area, 0.06f, 10);
result.emplace_back(intake_area, 0.12f, 10);
result.emplace_back(intake_area, 0.12f, 10);
result.emplace_back(exhaust_area, 0.20f, 6);
result.emplace_back(exhaust_area, 0.20f, 6);
result.emplace_back(exhaust_area, 0.40f, 10);
result.reserve(4+2*count);
result.emplace_back(area_for_diameter(saab ? .060f : .0275f), saab ? .15f : .06f, saab ? 8 : 10);
for (int i = 0; i < count; ++i)
result.emplace_back(area_for_diameter(saab ? .032f : .0275f), saab ? .25f : .12f, 10);
for (int i = 0; i < count; ++i)
result.emplace_back(area_for_diameter(saab ? .034f : .028f), saab ? .28f : .20f, saab ? 10 : 6);
result.emplace_back(area_for_diameter(saab ? .060f : .028f), turbo_enabled ? .12f : (saab ? 1.4f : .40f), saab ? 40 : 10);
if (turbo_enabled) {
result.emplace_back(area_for_diameter(saab ? .060f : .0275f), .10f, 8);
result.emplace_back(area_for_diameter(saab ? .060f : .032f), .60f, 24);
}
return result;
}

View File

@@ -2,6 +2,7 @@
#include <algorithm>
#include <chrono>
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
@@ -13,9 +14,9 @@ SimulationRingBuffer::SimulationRingBuffer(std::size_t capacity)
}
std::size_t SimulationRingBuffer::available_samples() const {
const auto written = write_head_.load(std::memory_order_acquire);
const auto read = read_head_.load(std::memory_order_acquire);
return static_cast<std::size_t>(written - read);
const auto written = write_head_.load(std::memory_order_acquire);
return std::min(left_.size(), static_cast<std::size_t>(written - read));
}
std::size_t SimulationRingBuffer::free_space() const {
@@ -52,6 +53,7 @@ void SimulationRingBuffer::reset_consumer() {
std::size_t SimulationRingBuffer::read_interpolated(std::span<float> dst_left,
std::span<float> dst_right,
double speed) {
speed = std::isfinite(speed) ? std::clamp(speed, 0.0001, 1.0) : 1.0;
const std::size_t count = std::min(dst_left.size(), dst_right.size());
if (!consumer_initialized_) {
read_position_ = static_cast<double>(read_head_.load(std::memory_order_acquire));
@@ -70,7 +72,7 @@ std::size_t SimulationRingBuffer::read_interpolated(std::span<float> dst_left,
const float fraction = static_cast<float>(read_position_ - static_cast<double>(floor_position));
dst_left[produced] = left0 + (left1 - left0) * fraction;
dst_right[produced] = right0 + (right1 - right0) * fraction;
read_position_ += std::clamp(speed, 0.0001, 1.0);
read_position_ += speed;
}
const auto new_read_head = static_cast<std::uint64_t>(read_position_);
const auto previous_read_head = read_head_.load(std::memory_order_relaxed);

View File

@@ -7,20 +7,31 @@ namespace fluidsim::port {
Solver::Solver(std::vector<Pipe1D> &pipes, BoundarySystem &boundaries)
: pipes_(pipes), boundaries_(boundaries) {}
void Solver::add_component(Component& component) {
components_.push_back(&component);
}
void Solver::add_component(Component &component) { components_.push_back(&component); }
void Solver::step(float dt) {
if (dt <= 0.0f) return;
int substeps = std::max(minimum_substeps, 1);
for (const auto& pipe : pipes_) substeps = std::max(substeps, pipe.required_substeps(dt, 0.8f));
const float sub_dt = dt / static_cast<float>(substeps);
for (int i = 0; i < substeps; ++i) {
if (!std::isfinite(dt) || dt < 0)
throw std::invalid_argument("invalid solver timestep");
if (dt == 0)
return;
boundaries_.validate_topology();
const float maximum_dt = dt / static_cast<float>(std::max(minimum_substeps, 1));
double remaining = dt;
int count = 0;
while (remaining > double(dt) * 1e-7) {
float sub_dt = std::min(maximum_dt, static_cast<float>(remaining));
for (auto &pipe : pipes_)
sub_dt = std::min(sub_dt, pipe.prepare_step());
if (!(sub_dt > 0) || ++count > 1000000)
throw std::runtime_error("solver timestep failed to progress");
++substep_count_;
boundaries_.resolve(sub_dt);
for (auto& pipe : pipes_) pipe.advance(sub_dt);
for (auto &pipe : pipes_)
pipe.advance(sub_dt);
for (Component *component : components_)
component->update_state(sub_dt);
remaining -= sub_dt;
}
for (Component* component : components_) component->update_state(dt);
++step_count_;
}

179
src/turbocharger.cpp Normal file
View File

@@ -0,0 +1,179 @@
#include "FluidSim/Turbocharger.hpp"
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
namespace {
constexpr double cp = double(gamma)*gas_constant/(gamma-1);
constexpr double exponent = double(gamma-1)/gamma;
TurboConfig checked(TurboConfig c) {
for (double x : {c.shaft_inertia, c.compressor_radius, c.turbine_radius,
c.compressor_head_coefficient, c.turbine_optimum_speed_ratio,
c.compressor_effective_area, c.turbine_effective_area, c.gate_diaphragm_area,
c.gate_spring_stiffness, c.gate_damping, c.gate_diameter, c.reference_pressure})
if (!std::isfinite(x) || x <= 0) throw std::invalid_argument("invalid turbo geometry/inertia");
for (double x : {c.compressor_efficiency, c.turbine_peak_efficiency})
if (!std::isfinite(x) || x <= 0 || x > 1) throw std::invalid_argument("invalid turbo efficiency");
for (double x : {c.bearing_viscous, c.windage, c.gate_spring_preload, c.gate_maximum_lift})
if (!std::isfinite(x) || x < 0) throw std::invalid_argument("invalid turbo loss coefficient");
for (float x : c.connection_diameters)
if (!std::isfinite(x) || x <= 0) throw std::invalid_argument("invalid turbo connection diameter");
return c;
}
}
Turbocharger::Turbocharger(TurboConfig configuration)
: config(checked(configuration)),
compressor_inlet(config.compressor_inlet_volume), compressor_outlet(config.compressor_outlet_volume),
turbine_inlet(config.turbine_inlet_volume), turbine_outlet(config.turbine_outlet_volume),
compressor_inlet_port(compressor_inlet.create_port()),
compressor_outlet_port(compressor_outlet.create_port()),
turbine_inlet_port(turbine_inlet.create_port()), turbine_outlet_port(turbine_outlet.create_port()),
internal_{&compressor_inlet.create_port(), &compressor_outlet.create_port(),
&turbine_inlet.create_port(), &turbine_outlet.create_port()} {}
void Turbocharger::connect(BoundarySystem& b, const std::vector<Pipe1D>& pipes,
int ci, bool cil, int co, bool col, int ti, bool til, int to, bool tol) {
if (connected_) throw std::logic_error("turbo already connected");
const std::array<int, 4> indices{ci, co, ti, to};
const std::array<bool, 4> left{cil, col, til, tol};
auto external = ports();
for (std::size_t i = 0; i < 4; ++i) {
const auto& p = pipes.at(indices[i]);
const float diameter = config.connection_diameters[i];
connection_areas_[i] = std::min(p.cell_area(left[i] ? 0 : int(p.cells.size())-1), pi*diameter*diameter*.25f);
b.add_orifice(*external[i], indices[i], left[i], connection_areas_[i]);
}
connected_ = true;
}
std::vector<Port*> Turbocharger::ports() {
return {&compressor_inlet_port, &compressor_outlet_port, &turbine_inlet_port, &turbine_outlet_port};
}
double Turbocharger::fluid_energy() const {
return double(compressor_inlet.internal_energy()) + compressor_outlet.internal_energy() +
turbine_inlet.internal_energy() + turbine_outlet.internal_energy();
}
void Turbocharger::set_initial_rpm(double rpm) {
if (!std::isfinite(rpm) || rpm < 0) throw std::invalid_argument("invalid initial turbo speed");
omega_ = rpm*2*pi/60;
}
double Turbocharger::nozzle(double p, double t, double back, double area) {
if (back >= p) return 0;
const double critical = std::pow(2/(gamma+1), gamma/(gamma-1));
const double ratio = std::max(back/p, critical);
const double factor = 2*gamma/(gas_constant*(gamma-1)) *
(std::pow(ratio, 2/gamma)-std::pow(ratio, (gamma+1)/gamma));
return area*p/std::sqrt(t)*std::sqrt(std::max(0., factor));
}
double Turbocharger::transfer_flow(Volume0D& in, Volume0D& out, Port& in_ext,
Port& out_ext, double requested, double dt) {
const bool forward = requested >= 0;
const auto& donor = forward ? in : out;
const auto& external = forward ? in_ext : out_ext;
const double h = cp*donor.temperature();
// Reserve the mass/energy already promised to the external pipe boundary.
const double mass = donor.mass()+std::min(0.f, external.mass_flow)*dt;
const double energy = donor.internal_energy()+std::min(0.f, external.mass_flow)*external.enthalpy*dt;
const double bound = .25*std::max(0., std::min(mass, energy/h))/dt;
const double actual = std::clamp(requested, -bound, bound);
if (actual != requested) ++limited_transfers_;
return actual;
}
void Turbocharger::set_transfer(Volume0D& in, Volume0D& out, Port& ip, Port& op,
double flow, double work, double dt) {
const auto& donor = flow >= 0 ? in : out;
const double h = cp*donor.temperature();
ip.mass_flow = float(-flow);
op.mass_flow = float(flow);
ip.air_fraction = op.air_fraction = donor.air_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.
if (std::abs(flow) > 0) {
auto& receiver = flow >= 0 ? op : ip;
receiver.enthalpy = float(h-work/(std::abs(flow)*dt));
}
}
void Turbocharger::update_state(float timestep) {
if (!std::isfinite(timestep) || timestep < 0) throw std::invalid_argument("invalid turbo timestep");
if (timestep == 0) return;
const double dt = timestep, initial_fluid = fluid_energy(), initial_shaft = shaft_energy();
const std::array<const Port*, 4> external{&compressor_inlet_port, &compressor_outlet_port,
&turbine_inlet_port, &turbine_outlet_port};
double external_work = 0;
for (const auto* p : external) external_work += double(p->mass_flow)*p->enthalpy*dt;
const double pin = compressor_inlet.pressure(), tin = compressor_inlet.temperature();
const double head = config.compressor_head_coefficient*std::pow(omega_*config.compressor_radius, 2);
const double pump_pressure = pin*std::pow(1+config.compressor_efficiency*head/(cp*tin), 1/exponent);
double mc = compressor_outlet.pressure() <= pump_pressure
? nozzle(pump_pressure, tin+head/cp, compressor_outlet.pressure(), config.compressor_effective_area)
: -nozzle(compressor_outlet.pressure(), compressor_outlet.temperature(), pump_pressure,
config.compressor_effective_area);
mc = transfer_flow(compressor_inlet, compressor_outlet, compressor_inlet_port, compressor_outlet_port, mc, dt);
const double tp = turbine_inlet.pressure(), tout = turbine_outlet.pressure();
const double can_force = std::max(0., double(compressor_outlet.pressure())-config.reference_pressure)*
config.gate_diaphragm_area;
const double equilibrium_lift = (can_force-config.gate_spring_preload)/config.gate_spring_stiffness;
// Overdamped spring-can linkage: b*dx/dt + k*x = F-Fpreload, with seats.
wastegate_lift_ = std::clamp(equilibrium_lift+(wastegate_lift_-equilibrium_lift)*
std::exp(-dt*config.gate_spring_stiffness/config.gate_damping), 0., config.gate_maximum_lift);
const double gate_area = pi*config.gate_diameter*wastegate_lift_;
double mt = tp >= tout ? nozzle(tp, turbine_inlet.temperature(), tout, config.turbine_effective_area)
: -nozzle(tout, turbine_outlet.temperature(), tp, config.turbine_effective_area);
double mw = tp >= tout ? nozzle(tp, turbine_inlet.temperature(), tout, gate_area)
: -nozzle(tout, turbine_outlet.temperature(), tp, gate_area);
const double requested = mt+mw;
const double actual = transfer_flow(turbine_inlet, turbine_outlet, turbine_inlet_port, turbine_outlet_port, requested, dt);
if (requested != 0) { mt *= actual/requested; mw *= actual/requested; }
const double ideal_drop = tp > tout ? cp*turbine_inlet.temperature()*(1-std::pow(tout/tp, exponent)) : 0;
const double jet = std::sqrt(2*ideal_drop), radius = config.turbine_radius;
const double ratio = config.turbine_optimum_speed_ratio, eta = config.turbine_peak_efficiency;
const double drive = std::max(0., mt)*eta*jet*radius/ratio;
const double turbine_drag = std::max(0., mt)*eta*.5*radius*radius/(ratio*ratio);
const double compressor_drag = std::max(0., mc)*config.compressor_head_coefficient*
config.compressor_radius*config.compressor_radius;
// Implicit midpoint solves I*domega/dt = drive - b*omega - c*omega^2.
// Evaluating work at midpoint conserves rotor kinetic energy, even at rest.
double wt = 0, wc = 0, loss = 0, remaining = dt;
int steps = 0;
while (remaining > dt*1e-12) {
const double drag = turbine_drag+compressor_drag+config.bearing_viscous+config.windage*omega_;
const double h = std::min(remaining, config.shaft_inertia/std::max(drag, 1e-300));
if (!(h > 0) || ++steps > 1000000) throw std::runtime_error("turbo shaft timestep failed");
const double b = 2*config.shaft_inertia/h+turbine_drag+compressor_drag+config.bearing_viscous;
const double rhs = 2*config.shaft_inertia/h*omega_+drive;
const double mid = 2*rhs/(b+std::sqrt(b*b+4*config.windage*rhs));
const double next = 2*mid-omega_;
if (next < 0) throw std::runtime_error("turbo shaft integration reversed unexpectedly");
wt += (drive-turbine_drag*mid)*mid*h;
wc += compressor_drag*mid*mid*h;
loss += (config.bearing_viscous+config.windage*mid)*mid*mid*h;
omega_ = next;
angle_ = std::fmod(angle_+mid*h, 2*double(pi));
remaining -= h;
}
bearing_heat_ += loss;
turbine_power_ = wt/dt;
compressor_power_ = wc/dt;
bearing_power_ = loss/dt;
turbine_flow_ = mt;
wastegate_flow_ = mw;
compressor_flow_ = mc;
set_transfer(compressor_inlet, compressor_outlet, *internal_[0], *internal_[1], mc, -wc, dt);
set_transfer(turbine_inlet, turbine_outlet, *internal_[2], *internal_[3], mt+mw, wt, dt);
compressor_inlet.update_state(timestep);
compressor_outlet.update_state(timestep);
turbine_inlet.update_state(timestep);
turbine_outlet.update_state(timestep);
energy_residual_ += fluid_energy()-initial_fluid + shaft_energy()-initial_shaft+loss-external_work;
}
TurboSnapshot Turbocharger::snapshot() const {
return {omega_*60/(2*pi), angle_, shaft_energy(), turbine_power_, compressor_power_, bearing_power_,
compressor_flow_, turbine_flow_, compressor_outlet.pressure()/compressor_inlet.pressure(),
turbine_inlet.pressure()/turbine_outlet.pressure(), turbine_inlet.pressure(),
turbine_outlet.pressure(), compressor_outlet.pressure(), compressor_outlet.temperature(),
bearing_heat_, energy_residual_, limited_transfers_, wastegate_flow_, wastegate_lift_};
}
} // namespace fluidsim::port

View File

@@ -1,3 +1,4 @@
#include "FluidSim/Ecu.hpp"
#include "FluidSim/TwoStrokeCylinder.hpp"
#include <algorithm>
@@ -6,12 +7,12 @@
namespace fluidsim::port {
TwoStrokeCylinder::TwoStrokeCylinder(float bore_value, float stroke_value,
float connecting_rod_length, float compression_ratio,
float transfer_duration_degrees, float exhaust_duration_degrees)
TwoStrokeCylinder::TwoStrokeCylinder(float bore_value, float stroke_value, float connecting_rod_length,
float compression_ratio, float transfer_duration_degrees,
float exhaust_duration_degrees)
: transfer_duration_(transfer_duration_degrees), exhaust_duration_(exhaust_duration_degrees) {
if (transfer_duration_ <= 0.0f || transfer_duration_ >= 360.0f ||
exhaust_duration_ <= 0.0f || exhaust_duration_ >= 360.0f)
if (transfer_duration_ <= 0.0f || transfer_duration_ >= 360.0f || exhaust_duration_ <= 0.0f ||
exhaust_duration_ >= 360.0f)
throw std::invalid_argument("two-stroke port durations must be between 0 and 360 degrees");
bore = bore_value;
stroke = stroke_value;
@@ -21,59 +22,43 @@ TwoStrokeCylinder::TwoStrokeCylinder(float bore_value, float stroke_value,
throw std::invalid_argument("two-stroke exhaust must open before the transfer port");
}
float TwoStrokeCylinder::lift(float angle, float opens, float closes, float peak_lift) {
float mapped = std::fmod(angle, 360.0f);
if (mapped < 0.0f) mapped += 360.0f;
float effective_close = closes;
if (effective_close < opens) effective_close += 360.0f;
if (mapped < opens) mapped += 360.0f;
const float duration = effective_close - opens;
if (mapped < opens || mapped > effective_close || duration <= 0.0f) return 0.0f;
const float ramp = duration * 0.25f;
const float hold = duration - 2.0f * ramp;
if (mapped < opens + ramp) {
const float t = (mapped - opens) / ramp;
return peak_lift * t * t * (3.0f - 2.0f * t);
}
if (mapped < opens + ramp + hold) return peak_lift;
const float t = (mapped - (opens + ramp + hold)) / ramp;
return peak_lift * (1.0f - t) * (1.0f - t) * (1.0f + 2.0f * t);
}
float TwoStrokeCylinder::valve_area(float cycle_degrees, bool inlet) const {
const float opens = inlet ? transfer_open() : exhaust_open();
const float closes = inlet ? transfer_close() : exhaust_close();
const float diameter = inlet ? intake_valve_diameter : exhaust_valve_diameter;
const float peak_lift = inlet ? intake_valve_lift : exhaust_valve_lift;
return pi * diameter * lift(cycle_degrees, opens, closes, peak_lift);
const float width = inlet ? transfer_width : exhaust_width;
const float piston_area = pi * bore * bore * .25f;
const float port_top = compute_volume(opens * pi / 180);
const float piston = compute_volume(cycle_degrees * pi / 180);
return width * std::max(0.0f, (piston - port_top) / piston_area);
}
void TwoStrokeCylinder::pre_step(Crankshaft &crank, float dt, float /*throttle*/) {
const float rpm = crank.omega * 60.0f / (2.0f * pi);
current_rpm_ = rpm;
if (ecu) {
spark_advance = ecu->spark_advance();
rev_limit_active = ecu->rev_limited();
} else {
spark_advance = std::clamp(10.0f + rpm * 0.002f, 5.0f, 40.0f);
update_rev_limiter(rpm, dt);
}
const float theta = crank.angle + phase;
const float previous_volume = volume;
volume = compute_volume(theta);
pressure = (gamma - 1.0f) * energy / std::max(volume, 1e-12f);
const float dvolume = volume - previous_volume;
const float radius = stroke * 0.5f;
const float obliquity = radius / rod;
const float sine = std::sin(theta), cosine = std::cos(theta);
const float term = std::sqrt(std::max(1.0f - obliquity * obliquity * sine * sine, 1e-12f));
const float dx_dtheta = radius * sine * (1.0f + obliquity * cosine / term);
const float piston_area = pi * 0.25f * bore * bore;
const float back_pressure = crankcase_ ? crankcase_->pressure() : 101325.0f;
crank.add_torque((pressure - back_pressure) * piston_area * dx_dtheta);
energy -= pressure * dvolume;
// Exact closed-gas geometry work; use the same work for crank torque.
const float old_energy=energy;
energy*=std::pow(previous_volume/volume,gamma-1.0f);
const float gas_work=old_energy-energy;
const float back_work=crankcase_?crankcase_->geometry_work():-101325.0f*(volume-previous_volume);
const float angle_step=crank.omega*dt;
if(angle_step>1e-9f)crank.add_torque((gas_work+back_work)/angle_step);
pressure=(gamma-1)*energy/volume;
const float previous_degrees = wrap_degrees((crank.previous_angle + phase) * 180.0f / pi, 360.0f);
const float current_degrees = wrap_degrees(theta * 180.0f / pi, 360.0f);
handle_cycle_events(previous_degrees, current_degrees);
if (rev_limit_active) {
if (!ecu && rev_limit_active) {
fuel_mass = 0.0f;
fuel_injected = false;
combustion_active = false;
@@ -83,29 +68,35 @@ void TwoStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*/
}
void TwoStrokeCylinder::handle_cycle_events(float previous_degrees, float current_degrees) {
if (previous_degrees >= transfer_open() && previous_degrees < transfer_close() &&
current_degrees >= transfer_close()) {
fuel_mass = air_mass / std::max(afr, 1e-6f);
fuel_injected = true;
const float trapped_angle = std::max(transfer_close(), exhaust_close());
if (previous_degrees < trapped_angle && current_degrees >= trapped_angle) {
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
fuel_injected = fuel_mass > 0;
}
const float spark_angle = wrap_degrees(360.0f - spark_advance, 360.0f);
const bool crossed_spark = (previous_degrees < spark_angle && current_degrees >= spark_angle) ||
(previous_degrees > spark_angle && current_degrees < spark_angle);
if (crossed_spark && fuel_injected && !combustion_active && !rev_limit_active &&
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) &&
random_unit() >= misfire_probability) {
combustion_active = true;
burn_fraction = 0.0f;
++combustion_count_;
active_spark_angle_ = spark_angle;
// Freeze the duration and timing for this charge. Changing the ECU map
// while a charge burns must not move its combustion phase backwards.
active_burn_duration_ = std::max(burn_duration, current_rpm_ * 6 * minimum_burn_seconds);
energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.0f);
}
if (!combustion_active) return;
float angle_since_spark = current_degrees - spark_angle;
if (angle_since_spark < 0.0f) angle_since_spark += 360.0f;
const float phi = (angle_since_spark - burn_start) / std::max(burn_duration, 1e-6f);
if (!combustion_active)
return;
float angle_since_spark = current_degrees - active_spark_angle_;
if (angle_since_spark < 0.0f)
angle_since_spark += 360.0f;
const float phi = (angle_since_spark - burn_start) / std::max(active_burn_duration_, 1e-6f);
float fraction = phi <= 0.0f ? 0.0f : 1.0f - std::exp(-wiebe_a * std::pow(phi, wiebe_m + 1.0f));
fraction = std::max(burn_fraction, std::clamp(fraction, 0.0f, 1.0f));
if (fraction >= 1.0f || angle_since_spark > burn_duration + burn_start + spark_advance) {
if (fraction >= 1.0f || angle_since_spark > active_burn_duration_ + burn_start) {
fraction = 1.0f;
combustion_active = false;
exhaust_mass += air_mass;

View File

@@ -5,37 +5,60 @@
namespace fluidsim::port {
TwoStrokeScenario::TwoStrokeScenario(int sample_rate)
: cylinder(0.054f, 0.0545f, 0.110f, 7.2f, 140.0f, 195.0f),
crankcase(crankshaft, 0.0545f * 0.5f, 0.110f, 0.054f, 150e-6f),
pipes(create_pipes()), dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))),
max_throttle_area_(area_for_diameter(0.042f)),
reed_in_area_(area_for_diameter(0.040f)),
reed_out_area_(0.0f), transfer_pipe_area_(area_for_diameter(0.040f)),
transfer_valve_area_(0.0f), exhaust_valve_area_(0.0f), boundaries_(pipes), solver_(pipes, boundaries_),
intake_sound_(sample_rate), exhaust_sound_(sample_rate), reverb_(sample_rate) {
TwoStrokeScenario::TwoStrokeScenario(int sample_rate, TwoStrokeConfig config)
: configuration(config),
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)),
dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))), max_throttle_area_(area_for_diameter(config.carburetor_diameter)),
reed_in_area_(area_for_diameter(0.040f)), reed_out_area_(0.0f),
transfer_pipe_area_(area_for_diameter(0.032f)), transfer_valve_area_(0.0f), exhaust_valve_area_(0.0f),
boundaries_(pipes), solver_(pipes, boundaries_), audio_(sample_rate) {
crankshaft.omega = 3000.0f * 2.0f * pi / 60.0f;
crankshaft.inertia = 0.01f;
crankshaft.friction_constant = 1.0f;
crankshaft.friction_viscous = 0.002f;
const float pressure_to_torque = cylinder.swept_volume() * 1e5f / (2 * pi);
const float piston_speed_per_omega = config.stroke / pi;
crankshaft.friction_constant = pressure_to_torque * config.friction_bar;
crankshaft.friction_viscous = pressure_to_torque * config.friction_bar_per_mps * piston_speed_per_omega;
crankshaft.friction_quadratic = pressure_to_torque * config.friction_bar_per_mps2 *
piston_speed_per_omega * piston_speed_per_omega;
cylinder.intake_valve_diameter = 0.030f;
cylinder.intake_valve_lift = 0.010f;
cylinder.exhaust_valve_diameter = 0.040f;
cylinder.exhaust_valve_lift = 0.010f;
cylinder.transfer_width = config.transfer_width;
cylinder.exhaust_width = config.exhaust_width;
cylinder.minimum_burn_seconds = config.minimum_burn_seconds;
// Yamaha's 2022 YZ125 published ratios; mass includes a nominal 75 kg rider.
vehicle.primary_reduction = 64.f / 19;
vehicle.final_drive_ratio = 49.f / 13;
vehicle.gear_ratios = {31.f/13, 29.f/15, 27.f/17, 23.f/17, 20.f/17, 19.f/18};
vehicle.mass = 95 + 75;
vehicle.wheel_radius = .3313f; // nominal 100/90-19 geometry; loaded radius unmeasured
// Estimated two-stroke ignition calibration, not a factory CDI map.
ecu.ignition_map = {{0, 10}, {3000, 18}, {6000, 24}, {9000, 18},
{11000, 12}, {13000, 6}, {15000, 2}, {18000, 0}};
ecu.idle_rpm = 3000;
ecu.rev_limit_enabled = false;
cylinder.rev_limit_enabled = false;
ecu.idle_base_opening = .08f;
ecu.idle_max_opening = .3f;
ecu.idle_spark_gain=.04f;
ecu.overrun_cut_rpm = 5000;
ecu.overrun_resume_rpm = 3500;
cylinder.ecu = &ecu;
cylinder.initialize(crankshaft);
cylinder.set_crankcase(crankcase);
for (auto &pipe : pipes) {
pipe.damping_multiplier = 0.8f;
pipe.energy_relaxation_rate = 0.4f;
pipe.energy_relaxation_rate = 0.0f;
pipe.ambient_pressure = 101325.0f;
}
plenum_inlet_ = &intake_plenum.create_port();
plenum_outlet_ = &intake_plenum.create_port();
boundaries_.add_open_end(0, true, 101325.0f, pipes[0].area);
boundaries_.add_open_end(3, false, 101325.0f, pipes[3].cell_area(static_cast<int>(pipes[3].cells.size()) - 1));
intake_boundary_area_ = pipes[0].area;
boundaries_.add_orifice(intake_atmosphere.port, 0, true, intake_boundary_area_);
boundaries_.add_open_end(3, false, 101325.0f,
pipes[3].cell_area(static_cast<int>(pipes[3].cells.size()) - 1));
boundaries_.add_orifice(*plenum_inlet_, 0, false, max_throttle_area_, 0.72f);
boundaries_.add_orifice(*plenum_outlet_, 1, true, reed_in_area_);
boundaries_.add_orifice(crankcase.intake_port, 1, false, reed_out_area_, 0.9f);
@@ -43,103 +66,71 @@ TwoStrokeScenario::TwoStrokeScenario(int sample_rate)
boundaries_.add_orifice(cylinder.intake, 2, false, transfer_valve_area_);
boundaries_.add_orifice(cylinder.exhaust, 3, true, exhaust_valve_area_);
solver_.minimum_substeps = 4;
solver_.add_component(intake_atmosphere);
solver_.add_component(intake_plenum);
solver_.add_component(crankcase);
solver_.add_component(cylinder);
intake_sound_.gain = 4.5f;
exhaust_sound_.gain = 4.5f;
}
AudioFrame TwoStrokeScenario::process() {
const float reed_margin = 200.0f;
if (crankcase.pressure() < intake_plenum.pressure() - reed_margin) reed_open_ = true;
else if (crankcase.pressure() > intake_plenum.pressure() + reed_margin) reed_open_ = false;
const float throttle_fraction = std::clamp(throttle, 0.0f, 1.0f);
max_throttle_area_ = throttle_fraction < 0.001f ? 0.0f : area_for_diameter(0.042f) * throttle_fraction;
reed_out_area_ = reed_open_ ? reed_in_area_ : 0.0f;
if (step_count_ < 20000) crankshaft.add_torque(5.0f);
crankshaft.step(dt_);
const float upstream = pipes[1].state(static_cast<int>(pipes[1].cells.size()) - 1).pressure;
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));
step_motorcycle(crankshaft, dt_, 10.0f);
crankcase.pre_step(dt_);
cylinder.pre_step(crankshaft, dt_, throttle_fraction);
cylinder.pre_step(crankshaft, dt_, ecu.throttle_opening());
transfer_valve_area_ = cylinder.valve_area(crankshaft.angle * 180.0f / pi, true);
exhaust_valve_area_ = cylinder.valve_area(crankshaft.angle * 180.0f / pi, false);
solver_.step(dt_);
++step_count_;
const float intake = intake_sound_.process(boundaries_.open_end_mass_flow(0));
const float exhaust = exhaust_sound_.process(boundaries_.open_end_mass_flow(1));
const float reverb_input = 0.5f * (intake + exhaust);
return reverb_.process_stereo(reverb_input);
intake_flow = intake_atmosphere.port.mass_flow;
exhaust_flow = boundaries_.open_end_mass_flow(0);
return audio_.process(intake_flow, exhaust_flow);
}
void TwoStrokeScenario::set_throttle(float value) {
throttle = std::clamp(value, 0.0f, 1.0f);
void TwoStrokeScenario::set_throttle(float value) { throttle = std::clamp(value, 0.0f, 1.0f); }
void TwoStrokeScenario::set_controls(float pedal, float load_value, float clutch_value) {
set_throttle(pedal);
load = std::clamp(load_value, 0.0f, 1.0f);
clutch = std::clamp(clutch_value, 0.0f, 1.0f);
}
TwoStrokeSnapshot TwoStrokeScenario::snapshot() const {
return {
crankshaft.omega * 60.0f / (2.0f * pi), cylinder.pressure,
crankcase.pressure(), intake_plenum.pressure(), throttle,
return {crankshaft.omega * 60.0f / (2.0f * pi),
cylinder.pressure,
crankcase.pressure(),
intake_plenum.pressure(),
ecu.throttle_opening(),
cylinder.valve_area(crankshaft.angle * 180.0f / pi, true),
cylinder.valve_area(crankshaft.angle * 180.0f / pi, false), reed_open_
};
cylinder.valve_area(crankshaft.angle * 180.0f / pi, false),
reed_open_,
ecu.fuel_cut(),
throttle,
audio_.scene()};
}
float TwoStrokeScenario::area_for_diameter(float diameter) {
return pi * diameter * diameter * 0.25f;
}
float TwoStrokeScenario::area_for_diameter(float diameter) { return pi * diameter * diameter * 0.25f; }
std::vector<Pipe1D> TwoStrokeScenario::create_pipes() {
const float intake_area = area_for_diameter(0.042f);
const float reed_area = area_for_diameter(0.040f);
const float transfer_area = area_for_diameter(0.040f);
std::vector<Pipe1D> TwoStrokeScenario::create_pipes(const TwoStrokeConfig& config) {
std::vector<Pipe1D> result;
result.reserve(4);
result.emplace_back(intake_area, 0.100f, 8);
result.emplace_back(reed_area, 0.030f, 4);
result.emplace_back(transfer_area, 0.200f, 8);
constexpr int exhaust_cells = 60;
constexpr float header_length = 0.130f, diffuser_length = 0.250f;
constexpr float belly_length = 0.220f, convergent_length = 0.160f;
constexpr float stinger_length = 0.080f;
constexpr float header_diameter = 0.040f, belly_diameter = 0.070f;
constexpr float stinger_diameter = 0.028f;
constexpr float total_length = header_length + diffuser_length + belly_length +
convergent_length + stinger_length;
const int header_cells = static_cast<int>(exhaust_cells * header_length / total_length);
const int diffuser_cells = static_cast<int>(exhaust_cells * diffuser_length / total_length);
const int belly_cells = static_cast<int>(exhaust_cells * belly_length / total_length);
const int convergent_cells = static_cast<int>(exhaust_cells * convergent_length / total_length);
const int stinger_cells = exhaust_cells - header_cells - diffuser_cells - belly_cells - convergent_cells;
std::vector<float> exhaust_areas;
std::vector<float> exhaust_lengths;
exhaust_areas.reserve(exhaust_cells);
exhaust_lengths.reserve(exhaust_cells);
const auto append_uniform = [&](int count, float diameter, float length) {
for (int i = 0; i < count; ++i) {
exhaust_areas.push_back(area_for_diameter(diameter));
exhaust_lengths.push_back(length / static_cast<float>(count));
result.emplace_back(area_for_diameter(config.carburetor_diameter), .100f, 10);
result.emplace_back(area_for_diameter(.040f), .030f, 4);
result.emplace_back(area_for_diameter(.032f), .060f, 6);
if (config.exhaust == TwoStrokeExhaust::Racing) {
result.push_back(make_profiled_pipe(config.racing_pipe, config.exhaust_cell_length));
} else {
float length = 0;
for (const auto& section : config.racing_pipe) length += section.length;
const PipeSection straight{length, config.racing_pipe.front().inlet_diameter,
config.racing_pipe.front().inlet_diameter};
result.push_back(make_profiled_pipe(std::span(&straight, 1), config.exhaust_cell_length));
}
};
append_uniform(header_cells, header_diameter, header_length);
for (int i = 0; i < diffuser_cells; ++i) {
const float t = diffuser_cells > 1 ? static_cast<float>(i) / (diffuser_cells - 1) : 0.0f;
const float diameter = header_diameter + (belly_diameter - header_diameter) * t;
exhaust_areas.push_back(area_for_diameter(diameter));
exhaust_lengths.push_back(diffuser_length / static_cast<float>(diffuser_cells));
}
append_uniform(belly_cells, belly_diameter, belly_length);
for (int i = 0; i < convergent_cells; ++i) {
const float t = convergent_cells > 1 ? static_cast<float>(i) / (convergent_cells - 1) : 0.0f;
const float diameter = belly_diameter + (stinger_diameter - belly_diameter) * t;
exhaust_areas.push_back(area_for_diameter(diameter));
exhaust_lengths.push_back(convergent_length / static_cast<float>(convergent_cells));
}
append_uniform(stinger_cells, stinger_diameter, stinger_length);
result.emplace_back(std::move(exhaust_areas), std::move(exhaust_lengths));
return result;
}

75
tests/b205_regression.cpp Normal file
View File

@@ -0,0 +1,75 @@
#include "FluidSim/Scenario.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <iostream>
#include <stdexcept>
#include <vector>
using namespace fluidsim::port;
namespace {
void check(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
}
int main() {
SaabB205Scenario s;
const auto cylinders = s.cylinders();
check(s.cylinder_count() == 4 && s.pipes.size() == 10, "B205 topology is not four-cylinder");
check(std::abs(4*s.cylinder1.swept_volume()*1e6f-1984.9f) < 1, "B205 displacement incorrect");
check(s.vehicle.gear_ratios.size() == 5 && s.vehicle.mass == 1675, "wagon configuration incorrect");
for (int i = 0; i < 4; ++i) {
check(cylinders[i]->compression == 8.8f && cylinders[i]->intake_valve_count == 2 &&
cylinders[i]->exhaust_valve_count == 2, "B205 compression/valve count incorrect");
}
s.set_controls(.5f, 0, 1);
std::array<bool, 4> burning{};
std::vector<int> firing_order;
float peak = 0;
const auto started = std::chrono::steady_clock::now();
const auto step = [&] {
const auto audio = s.process();
check(std::isfinite(audio.left) && std::isfinite(audio.right), "nonfinite B205 audio");
peak = std::max({peak, std::abs(audio.left), std::abs(audio.right)});
for (const auto* c : cylinders)
check(std::isfinite(c->pressure) && c->pressure > 0 && c->total_mass() > 0,
"nonphysical B205 cylinder");
};
// Prescribed-speed absorbing dyno isolates phasing from acceleration.
for (int sample = 0; sample < 44100; ++sample) {
s.crankshaft.omega = 2000 * 2*pi/60;
step();
for (int i = 0; i < 4; ++i) {
if (sample > 22050 && cylinders[i]->combustion_active && !burning[i])
firing_order.push_back(i);
burning[i] = cylinders[i]->combustion_active;
}
}
check(firing_order.size() >= 28 && firing_order.size() <= 36, "B205 did not fire twice per revolution");
constexpr std::array<int, 4> next{2, 0, 3, 1};
for (std::size_t i = 1; i < firing_order.size(); ++i)
check(firing_order[i] == next[firing_order[i-1]], "B205 firing order is not 1-3-4-2");
s.set_first_gear();
for (int sample = 0; sample < 4*44100; ++sample) {
const float clutch = std::max(0.f, 1-sample/(.8f*44100));
s.set_controls(.6f, 0, clutch);
step();
}
check(s.vehicle.speed > 1 && s.vehicle.current_gear == 1, "B205 failed to launch wagon");
const float speed = s.vehicle.speed*3.6f;
for (int i = 0; i < 8; ++i) s.shift_up();
check(s.vehicle.current_gear == 5, "wagon has a phantom sixth gear");
check(std::abs(s.vehicle.total_gear_ratio()-2.67f) < 1e-5f, "wagon top overall ratio incorrect");
s.set_neutral();
s.set_controls(0, 0, 1);
for (int sample = 0; sample < 3*44100; ++sample) step();
const auto final = s.snapshot();
check(final.rpm > 300 && final.rpm < 2500, "B205 did not recover toward idle");
check(final.cylinder_count == 4 && !final.pipe_states[9].empty(), "B205 desktop snapshot incomplete");
for (const auto& pipe : s.pipes)
check(pipe.positivity_repairs == 0, "B205 required pipe positivity repairs");
const double wall = std::chrono::duration<double>(std::chrono::steady_clock::now()-started).count();
std::cout << "B205 passed: firing events=" << firing_order.size() << ", wagon speed=" << speed
<< " km/h, recovered RPM=" << final.rpm << ", audio peak=" << peak
<< ", wall seconds=" << wall << " for 8 simulated seconds\n";
}

625
tests/fluid_validation.cpp Normal file
View File

@@ -0,0 +1,625 @@
#include "FluidSim/Atmosphere.hpp"
#include "FluidSim/Crankcase.hpp"
#include "FluidSim/Fluid.hpp"
#include "FluidSim/Solver.hpp"
#include <cmath>
#include <functional>
#include <iostream>
#include <string>
using namespace fluidsim::port;
namespace {
void check(bool b, const std::string &s) {
if (!b)
throw std::runtime_error(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 lossless(Pipe1D &p) {
p.damping_multiplier = 0;
p.energy_relaxation_rate = 0;
}
double mass(const Pipe1D &p) {
double v = 0;
for (int i = 0; i < int(p.cells.size()); i++)
v += p.cell_mass(i);
return v;
}
double energy(const Pipe1D &p) {
double v = 0;
for (int i = 0; i < int(p.cells.size()); i++)
v += double(p.cells[i].energy) * p.cell_area(i) * p.cell_length(i);
return v;
}
double vol_energy(const Volume0D &v) { return v.pressure() * v.volume() / (v.gamma - 1); }
} // namespace
int main() {
int failed = 0, total = 0;
auto test = [&](const char *n, std::function<void()> fn) {
++total;
try {
fn();
std::cout << "PASS " << n << '\n';
} catch (const std::exception &e) {
++failed;
std::cout << "FAIL " << n << ": " << e.what() << '\n';
}
};
test("invalid uniform geometry", [] {
bool caught = false;
try {
Pipe1D p(0, .1f, 4);
} catch (const std::invalid_argument &) {
caught = true;
}
check(caught, "zero area accepted");
});
test("closed pipe conservation", [] {
std::vector<Pipe1D> p;
p.emplace_back(.001f, 1, 50);
lossless(p[0]);
for (int i = 0; i < 50; i++)
set(p[0], i, i < 25 ? 120000 : 100000, 1.2f);
BoundarySystem b(p);
b.add_closed_end(0, true);
b.add_closed_end(0, false);
Solver s(p, b);
auto m = mass(p[0]), e = energy(p[0]);
for (int i = 0; i < 2000; i++)
s.step(1.f / 44100);
near(mass(p[0]), m, m * 2e-5, "closed mass drift");
near(energy(p[0]), e, e * 2e-5, "closed energy drift");
});
test("profiled pipe conservation", [] {
std::vector<float> a(40), l(40, .025f);
for (int i = 0; i < 40; i++)
a[i] = .001f * (1 + .5f * std::sin(i * .1f));
std::vector<Pipe1D> p;
p.emplace_back(a, l);
lossless(p[0]);
for (int i = 0; i < 40; i++)
set(p[0], i, i < 20 ? 120000 : 100000, 1.2f);
BoundarySystem b(p);
b.add_closed_end(0, true);
b.add_closed_end(0, false);
Solver s(p, b);
auto m = mass(p[0]), e = energy(p[0]);
for (int i = 0; i < 1000; i++)
s.step(1.f / 44100);
near(mass(p[0]), m, m * 3e-5, "profile mass drift");
near(energy(p[0]), e, e * 3e-5, "profile energy drift");
});
test("volume pipe sealed conservation", [] {
Volume0D v(.001f, 180000, 400);
v.energy_relaxation_rate = 0;
auto &port = v.create_port();
std::vector<Pipe1D> p;
p.emplace_back(.0003f, .5f, 25);
lossless(p[0]);
BoundarySystem b(p);
float a = .0001f;
b.add_orifice(port, 0, true, a);
b.add_closed_end(0, false);
Solver s(p, b);
s.add_component(v);
auto m = mass(p[0]) + v.mass(), e = energy(p[0]) + vol_energy(v);
for (int i = 0; i < 2000; i++)
s.step(1.f / 44100);
near(mass(p[0]) + v.mass(), m, m * 3e-5, "coupling creates/deletes mass");
near(energy(p[0]) + vol_energy(v), e, e * 3e-5, "coupling creates/deletes energy");
});
test("equal pressure hot contact", [] {
Volume0D v(.001f, 101325, 600);
v.energy_relaxation_rate = 0;
auto &port = v.create_port();
std::vector<Pipe1D> p;
p.emplace_back(.001f, .2f, 20);
lossless(p[0]);
BoundarySystem b(p);
float a = .001f;
b.add_orifice(port, 0, true, a);
b.add_closed_end(0, false);
Solver s(p, b);
s.add_component(v);
double m = mass(p[0]) + v.mass();
for (int i = 0; i < 500; i++)
s.step(1.f / 44100);
near(mass(p[0]) + v.mass(), m, m * 2e-5, "contact produces mass");
near(v.pressure(), 101325, 5, "stationary contact pressure changes");
});
test("NASA choked nozzle mass flow", [] {
Atmosphere v;
v.pressure = 200000;
v.update_state(0);
std::vector<Pipe1D> p;
p.emplace_back(.001f, .2f, 10);
for (int i = 0; i < 10; i++)
set(p[0], i, 10000, 10000 / (gas_constant * 300));
BoundarySystem b(p);
float a = .0001f;
b.add_orifice(v.port, 0, true, a, .8f);
b.resolve(1e-8f);
double expected = .8 * a * 200000 / std::sqrt(300.) *
std::sqrt(fluidsim::port::gamma / gas_constant) *
std::pow(2 / (fluidsim::port::gamma + 1),
(fluidsim::port::gamma + 1) / (2 * (fluidsim::port::gamma - 1)));
near(-v.port.mass_flow, expected, expected * 2e-5,
"choked mdot differs from isentropic sonic-throat formula");
});
test("inertance units", [] {
Atmosphere v;
v.pressure = 102325;
v.update_state(0);
std::vector<Pipe1D> p;
p.emplace_back(.001f, .1f, 10);
BoundarySystem b(p);
float a = .0001f;
b.add_orifice_with_inertance(v.port, 0, true, a, 1, .05f);
b.resolve(1e-6f);
double expected = a / .05 * 1000 * 1e-6;
near(-v.port.mass_flow, expected, expected * 1e-4,
"mass-flow inertance must satisfy d(mdot)/dt=A*dp/L");
});
test("moving volume adiabatic law", [] {
Volume0D v(.001f);
v.energy_relaxation_rate = 0;
v.volume_change_rate = .0001f;
for (int i = 0; i < 1000; i++)
v.update(1e-3f);
near(v.volume(), .0011, 1e-8, "volume work did not change geometric volume");
near(v.pressure(), 101325 * std::pow(.001 / .0011, 1.4), 3, "p V^gamma not conserved");
});
test("profile at rest is well balanced", [] {
std::vector<float> a(80), l(80, .01f);
for (int i = 0; i < 80; i++)
a[i] = .001f * (1 + .7f * std::sin(.1f * i));
std::vector<Pipe1D> p;
p.emplace_back(a, l);
lossless(p[0]);
BoundarySystem b(p);
b.add_closed_end(0, true);
b.add_closed_end(0, false);
Solver s(p, b);
for (int j = 0; j < 1000; j++)
s.step(1.f / 44100);
for (int i = 0; i < 80; i++) {
near(p[0].state(i).velocity, 0, 1e-3, "stationary nozzle accelerates");
near(p[0].state(i).pressure, 101325, 2, "stationary nozzle pressure drifts");
}
});
test("pipe connection all four orientations", [] {
for (bool la : {false, true})
for (bool lb : {false, true}) {
std::vector<Pipe1D> p;
p.emplace_back(.001f, .3f, 20);
p.emplace_back(.001f, .3f, 20);
for (auto &q : p)
lossless(q);
for (int i = 0; i < 20; i++)
set(p[0], i, 130000, 1.4f, 0, .2f);
BoundarySystem b(p);
b.add_connection(0, la, 1, lb);
b.add_closed_end(0, !la);
b.add_closed_end(1, !lb);
Solver s(p, b);
double m = mass(p[0]) + mass(p[1]), e = energy(p[0]) + energy(p[1]);
for (int i = 0; i < 2000; i++)
s.step(1.f / 44100);
near(mass(p[0]) + mass(p[1]), m, m * 4e-5, "junction mass drift");
near(energy(p[0]) + energy(p[1]), e, e * 4e-5, "junction energy drift");
for (auto &q : p)
check(q.positivity_repairs == 0, "junction positivity repair");
}
});
test("split pipe matches unsplit pipe", [] {
std::vector<Pipe1D> a, b;
a.emplace_back(.001f, 1, 80);
b.emplace_back(.001f, .5f, 40);
b.emplace_back(.001f, .5f, 40);
lossless(a[0]);
lossless(b[0]);
lossless(b[1]);
for (int i = 0; i < 80; i++) {
float pr = 101325 + 100 * std::exp(-std::pow((i - 30) / 5.f, 2));
set(a[0], i, pr, 1.2f);
set(b[i / 40], i % 40, pr, 1.2f);
}
BoundarySystem ba(a), bb(b);
ba.add_closed_end(0, true);
ba.add_closed_end(0, false);
bb.add_closed_end(0, true);
bb.add_closed_end(1, false);
bb.add_connection(0, false, 1, true);
Solver sa(a, ba), sb(b, bb);
for (int j = 0; j < 300; j++) {
sa.step(1.f / 44100);
sb.step(1.f / 44100);
}
for (int i = 0; i < 80; i++)
near(a[0].state(i).pressure, b[i / 40].state(i % 40).pressure, .1,
"split junction changes solution");
});
test("three volumes shared donor conservation", [] {
Volume0D a(.000001f, 1e6f, 500), b(.001f), c(.001f);
a.energy_relaxation_rate = b.energy_relaxation_rate = c.energy_relaxation_rate = 0;
auto &ap = a.create_port();
auto &ap2 = a.create_port();
auto &bp = b.create_port();
auto &cp = c.create_port();
std::vector<Pipe1D> p;
p.emplace_back(.001f, .1f, 10);
p.emplace_back(.001f, .1f, 10);
for (auto &q : p)
lossless(q);
BoundarySystem bs(p);
float ar = .001f;
bs.add_orifice(ap, 0, true, ar);
bs.add_orifice(ap2, 1, true, ar);
bs.add_orifice(bp, 0, false, ar);
bs.add_orifice(cp, 1, false, ar);
Solver s(p, bs);
s.add_component(a);
s.add_component(b);
s.add_component(c);
double m = mass(p[0]) + mass(p[1]) + a.mass() + b.mass() + c.mass();
double e = energy(p[0]) + energy(p[1]) + vol_energy(a) + vol_energy(b) + vol_energy(c);
for (int j = 0; j < 3000; j++)
s.step(1.f / 44100);
near(mass(p[0]) + mass(p[1]) + a.mass() + b.mass() + c.mass(), m, m * 5e-5,
"shared donor mass drift");
near(energy(p[0]) + energy(p[1]) + vol_energy(a) + vol_energy(b) + vol_energy(c), e, e * 5e-5,
"shared donor energy drift");
check(a.pressure() > 0, "donor vacuum");
});
test("open boundary reports actual conserved flux both ends", [] {
for (bool left : {false, true}) {
std::vector<Pipe1D> p;
p.emplace_back(.001f, .1f, 10);
lossless(p[0]);
for (int i = 0; i < 10; i++)
set(p[0], i, 105000, 1.2f);
BoundarySystem b(p);
b.add_open_end(0, left, 101325, .001f);
b.add_closed_end(0, !left);
double m = mass(p[0]);
b.resolve(1e-6f);
double dm = -b.open_end_mass_flow(0) * 1e-6;
p[0].advance(1e-6f);
near(mass(p[0]) - m, dm, 1e-11,
"reported radiation flow differs from finite-volume mass exchange");
}
});
test("duplicate and invalid boundary rejection", [] {
std::vector<Pipe1D> p;
p.emplace_back(.001f, 1, 10);
BoundarySystem b(p);
b.add_closed_end(0, true);
bool caught = false;
try {
b.add_open_end(0, true, 101325, .001f);
} catch (const std::invalid_argument &) {
caught = true;
}
check(caught, "duplicate face accepted");
caught = false;
try {
b.add_closed_end(9, true);
} catch (const std::invalid_argument &) {
caught = true;
}
check(caught, "invalid pipe accepted");
});
test("supersonic outlet extrapolates", [] {
std::vector<Pipe1D> p;
p.emplace_back(.001f, 1, 10);
for (int i = 0; i < 10; i++)
set(p[0], i, 101325, 1.2f, 700);
BoundarySystem b(p);
b.add_open_end(0, false, 50000, .001f);
b.resolve(1e-6f);
near(b.open_end_mass_flow(0), .001 * 1.2 * 700, 1e-5,
"supersonic outlet responds to downstream pressure");
});
test("underdefined supersonic inlet rejected", [] {
std::vector<Pipe1D> p;
p.emplace_back(.001f, 1, 10);
set(p[0], 0, 101325, 1.2f, 700);
BoundarySystem b(p);
b.add_open_end(0, true, 101325, .001f);
bool caught = false;
try {
b.resolve(1e-6f);
} catch (const std::runtime_error &) {
caught = true;
}
check(caught, "supersonic inlet silently accepted pressure-only data");
});
test("closed and open acoustic reflection signs", [] {
for (bool closed : {false, true}) {
std::vector<Pipe1D> p;
p.emplace_back(.001f, 1, 400);
lossless(p[0]);
const double c = std::sqrt(1.4 * 101325 / 1.2);
for (int i = 0; i < 400; i++) {
double x = (i + .5) / 400.;
float dp = 100 * std::exp(-std::pow((x - .3) / .04, 2));
set(p[0], i, 101325 + dp, 1.2f + dp / (c * c), dp / (1.2 * c));
}
BoundarySystem b(p);
b.add_closed_end(0, true);
if (closed)
b.add_closed_end(0, false);
else
b.add_open_end(0, false, 101325, .001f);
Solver s(p, b);
s.minimum_substeps = 1;
const int steps = int((1.4 / c) * 44100);
for (int j = 0; j < steps; j++)
s.step(1.f / 44100);
float dp = p[0].state(119).pressure - 101325;
check(closed ? dp > 30 : dp < -30, "wrong reflected pressure sign/amplitude");
check(p[0].positivity_repairs == 0, "acoustic positivity repair");
}
});
test("Sod shock tube convergence", [] {
double previous = 1e9;
for (int n : {100, 200, 400}) {
Pipe1D p(1, 1, n);
lossless(p);
for (int i = 0; i < n; i++)
set(p, i, i < n / 2 ? 1 : .1f, i < n / 2 ? 1 : .125f);
double t = 0;
while (t < .2) {
p.left_ghost = p.state(0);
p.right_ghost = p.state(n - 1);
float dt = std::min(.2 - t, double(p.stable_timestep(.7f)));
p.advance(dt);
t += dt;
}
double error = 0;
for (int i = 0; i < n; i++) {
double xi = ((i + .5) / n - .5) / .2;
double rho;
if (xi < -std::sqrt(1.4))
rho = 1;
else if (xi < -.0702728)
rho = std::pow((2 / (1.4 + 1)) + (1.4 - 1) / (1.4 + 1) * (-xi / std::sqrt(1.4)),
2 / (1.4 - 1));
else if (xi < .9274526)
rho = .4263194;
else if (xi < 1.7521557)
rho = .2655737;
else
rho = .125;
error += std::abs(p.state(i).rho - rho) / n;
}
std::cout << " Sod n=" << n << " density L1=" << error << '\n';
check(error < previous * .9, "shock density error does not decrease on refinement");
previous = error;
check(p.positivity_repairs == 0, "Sod positivity repair");
}
check(previous < .025, "Sod density error excessive");
});
test("strong pulse and near-vacuum rarefaction", [] {
for (bool vacuum : {false, true}) {
Pipe1D p(1, 1, 200);
lossless(p);
for (int i = 0; i < 200; i++)
set(p, i, vacuum ? .4f : (i < 100 ? 1000 : .01f), 1, vacuum ? (i < 100 ? -2 : 2) : 0);
double t = 0;
while (t < .005) {
p.left_ghost = p.state(0);
p.right_ghost = p.state(199);
float dt = std::min(.005 - t, double(p.stable_timestep(.5f)));
p.advance(dt);
t += dt;
}
for (int i = 0; i < 200; i++)
check(p.state(i).rho > 0 && p.state(i).pressure > 0, "negative shock/vacuum state");
check(p.positivity_repairs == 0, "stress case required conservation-breaking positivity repair");
}
});
test("small-signal Helmholtz resonance", [] {
std::vector<Pipe1D> p;
float area = .000314159f;
p.emplace_back(area, .05f, 40);
Volume0D v(.001f, 101335, 300);
auto &port = v.create_port();
BoundarySystem b(p);
b.add_orifice(port, 0, true, area, 1);
b.add_open_end(0, false, 101325, area);
Solver solver(p, b);
solver.minimum_substeps = 1;
solver.add_component(v);
double prev = v.pressure() - 101325, first = 0, last = 0;
int count = 0;
for (int i = 0; i < 4410; i++) {
solver.step(1.f / 44100);
double now = v.pressure() - 101325;
check(std::abs(now) < 15, "small-signal cavity grows instead of oscillating/decaying");
if (prev > 0 && now <= 0 && i > 441) {
double t = (i + prev / (prev - now)) / 44100;
if (!count)
first = t;
last = t;
count++;
}
prev = now;
}
double frequency = (count - 1) / (last - first);
double expected =
std::sqrt(1.4 * 287 * 300) / (2 * 3.141592653589793) * std::sqrt(area / (.001 * .05));
std::cout << " Helmholtz frequency=" << frequency << " analytic lumped=" << expected << '\n';
check(count > 5, "not enough cavity oscillations");
near(frequency, expected, expected * .05, "Helmholtz mode frequency error exceeds 5%");
check(p[0].positivity_repairs == 0, "cavity requires positivity repairs");
});
test("invalid volume and timestep rejection", [] {
bool caught = false;
try {
Volume0D v(-1);
} catch (const std::invalid_argument &) {
caught = true;
}
check(caught, "negative volume accepted");
Pipe1D p(.001f, .1f, 10);
caught = false;
try {
p.required_substeps(std::nanf(""));
} catch (const std::invalid_argument &) {
caught = true;
}
check(caught, "NaN timestep accepted");
});
test("fresh-air species conservation across connection", [] {
std::vector<Pipe1D> p;
p.emplace_back(.001f, .5f, 40);
p.emplace_back(.001f, .5f, 40);
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);
}
auto air_mass = [&] {
double sum = 0;
for (auto &q : p)
for (int i = 0; i < 40; i++)
sum += q.cell_mass(i) * q.cells[i].air;
return sum;
};
double before = air_mass();
BoundarySystem b(p);
b.add_closed_end(0, true);
b.add_closed_end(1, false);
b.add_connection(0, false, 1, true);
Solver solver(p, b);
for (int i = 0; i < 3000; i++)
solver.step(1.f / 44100);
near(air_mass(), before, before * 4e-5, "species created at shared face");
});
test("unsupported unequal junction rejected", [] {
std::vector<Pipe1D> p;
p.emplace_back(.001f, .5f, 20);
p.emplace_back(.002f, .5f, 20);
BoundarySystem b(p);
bool caught = false;
try {
b.add_connection(0, false, 1, true);
} catch (const std::invalid_argument &) {
caught = true;
}
check(caught, "unvalidated sudden area junction silently accepted");
});
test("unconnected endpoint rejected by solver", [] {
std::vector<Pipe1D> p;
p.emplace_back(.001f, .5f, 20);
BoundarySystem b(p);
b.add_closed_end(0, true);
Solver solver(p, b);
bool caught = false;
try {
solver.step(1.f / 44100);
} catch (const std::runtime_error &) {
caught = true;
}
check(caught, "unconnected face silently used default ghost");
});
test("closed duct acoustic eigenfrequency", [] {
for (int n : {50, 100, 200}) {
std::vector<Pipe1D> p;
p.emplace_back(.001f, 1, n);
double c = std::sqrt(1.4 * 101325 / 1.2);
for (int i = 0; i < n; i++) {
double dp = 100 * std::cos(3.141592653589793 * (i + .5) / n);
set(p[0], i, 101325 + dp, 1.2f + dp / (c * c));
}
BoundarySystem b(p);
b.add_closed_end(0, true);
b.add_closed_end(0, false);
Solver solver(p, b);
solver.minimum_substeps = 1;
double prev = 1, first = 0, last = 0;
int count = 0;
for (int j = 0; j < 1764; j++) {
solver.step(1.f / 44100);
double projection = 0;
for (int i = 0; i < n; i++)
projection +=
(p[0].state(i).pressure - 101325) * std::cos(3.141592653589793 * (i + .5) / n);
if (prev > 0 && projection <= 0) {
double t = (j + prev / (prev - projection)) / 44100;
if (!count)
first = t;
last = t;
count++;
}
prev = projection;
}
double f = (count - 1) / (last - first);
std::cout << " closed mode n=" << n << " f=" << f << " analytic=" << c / 2 << '\n';
check(count > 4, "insufficient acoustic cycles");
near(f, c / 2, c / 2 * .01, "closed duct frequency exceeds 1% error");
}
});
test("inertance impulse and dissipative resistance", [] {
Atmosphere v;
v.pressure = 102325;
v.update_state(0);
std::vector<Pipe1D> p;
p.emplace_back(.001f, .1f, 10);
BoundarySystem b(p);
float area = .0001f;
b.add_orifice_with_inertance(v.port, 0, true, area, 1, .05f, 1);
for (int j = 0; j < 10000; j++)
b.resolve(.0001f);
double equilibrium = std::sqrt(2 * v.density() * area * area * 1000);
near(-v.port.mass_flow, equilibrium, equilibrium * .001,
"inertance resistance steady flow has wrong units or sign");
});
test("incompatible gas coupling rejected", [] {
Volume0D v(.001f, 101325, 300, 300, 1.3f);
auto &port = v.create_port();
std::vector<Pipe1D> p;
p.emplace_back(.001f, .1f, 10);
BoundarySystem b(p);
float area = .0001f;
bool caught = false;
try {
b.add_orifice(port, 0, true, area);
} catch (const std::invalid_argument &) {
caught = true;
}
check(caught, "different gamma gases silently coupled");
});
test("thermal relaxation matches analytic energy decay", [] {
Volume0D v(.001f, 101325, 600);
v.energy_relaxation_rate = 2;
double initial = vol_energy(v), target = v.mass() * 287 * 300 / (1.4 - 1);
for (int i = 0; i < 1000; i++)
v.update(.001f);
near(vol_energy(v), target + (initial - target) * std::exp(-2), initial * 3e-5,
"thermal relaxation differs from exact exponential");
});
test("sealed crankcase compression conserves mass above five bar", [] {
Crankshaft crank;
crank.angle = 0;
Crankcase v(crank, .02725f, .11f, .054f, 1e-5f);
double m = v.mass(), invariant = v.pressure() * std::pow(v.volume(), 1.4);
for (int i = 1; i <= 1000; i++) {
crank.angle = pi * i / 1000;
v.pre_step(1e-5f);
v.update_state(1e-5f);
}
near(v.mass(), m, m * 1e-6, "crankcase pressure cap deletes mass");
near(v.pressure() * std::pow(v.volume(), 1.4), invariant, invariant * 1e-4,
"crankcase compression violates adiabatic invariant");
check(v.pressure() > 5e5, "compression did not exercise old hard pressure cap");
});
std::cout << total - failed << "/" << total << " passed\n";
return failed ? 1 : 0;
}

View File

@@ -0,0 +1,51 @@
#include "FluidSim/Scenario.hpp"
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool condition, const char *message) {
if (!condition)
throw std::runtime_error(message);
}
void run(FourStrokeTwinScenario &s, float seconds, float pedal) {
s.throttle = pedal;
for (int i = 0; i < int(std::lround(seconds * 44100)); ++i) {
const auto audio = s.process();
check(std::isfinite(audio.left) && std::isfinite(audio.right), "nonfinite transient audio");
}
}
} // namespace
int main() {
FourStrokeTwinScenario s;
run(s, 3, 0);
run(s, .5f, .8f);
run(s, 4.5f, 0);
const float idle_map = s.intake_plenum.pressure();
check(idle_map > 20000 && idle_map < 80000, "idle MAP did not recover after first blip");
run(s, 1, 1);
const float open_map = s.intake_plenum.pressure();
const float open_temperature = s.intake_plenum.temperature();
const auto inlet = s.pipes.front().state(0);
const float inlet_temperature = inlet.pressure / (inlet.rho * gas_constant);
check(open_map > 75000 && open_map > idle_map + 20000,
"opening throttle did not relieve manifold vacuum");
check(open_temperature > 250 && open_temperature < 400,
"fresh charge heats progressively after repeated blips");
check(inlet_temperature > 280 && inlet_temperature < 320,
"inlet no longer supplies near-ambient fresh air");
check(std::abs(s.intake_atmosphere.port.temperature - 300) < .01f, "reservoir temperature drift");
check(std::abs(s.intake_atmosphere.port.air_fraction - 1) < 1e-6, "reservoir admits exhaust composition");
run(s, 7, 0);
const auto recovered = s.snapshot();
check(recovered.rpm > 1000 && recovered.rpm < 2600, "engine did not recover idle after second blip");
check(recovered.intake_pressure > 20000 && recovered.intake_pressure < 80000,
"manifold remains evacuated after idle recovery");
for (const auto &p : s.pipes)
check(p.positivity_repairs == 0, "intake transient required positivity repairs");
std::cout << "Intake regression passed: idle_MAP=" << idle_map / 1000
<< " kPa, open_MAP=" << open_map / 1000 << " kPa, open_T=" << open_temperature
<< " K, inlet_T=" << inlet_temperature
<< " K, recovered_MAP=" << recovered.intake_pressure / 1000
<< " kPa, recovered_RPM=" << recovered.rpm << '\n';
}

View File

@@ -0,0 +1,114 @@
#include "FluidSim/OutdoorAudio.hpp"
#include "FluidSim/ReedValve.hpp"
#include <algorithm>
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool c, const char *m) {
if (!c)
throw std::runtime_error(m);
}
OutdoorAudioConfig config() {
OutdoorAudioConfig c;
c.exhaust_position = {0, 1, 0};
c.listener = {0, 1, 3};
c.ear_spacing = 0;
c.wall_x = -2;
return c;
}
} // namespace
int main() {
// Geometric reflections must not arrive before the direct path or before
// the ground image's path length, even with the diffuse network enabled.
OutdoorAudio dry(44100, config()), wall(44100, config());
dry.set_scene(OutdoorScene::Dry);
dry.set_monitor_filter(false);
wall.set_monitor_filter(false);
for (int i = 0; i < 4410; i++) {
dry.process(0, 0);
wall.process(0, 0);
}
int first = -1, first_reflection = -1;
double tail_energy = 0, late_energy = 0;
float peak = 0;
for (int i = 0; i < 88200; i++) {
const auto d = dry.process(0, .001f), w = wall.process(0, .001f);
if (first < 0 && std::abs(d.left) > 1e-9)
first = i;
if (first_reflection < 0 && std::abs(w.left - d.left) > 1e-9)
first_reflection = i;
peak = std::max(peak, std::abs(w.left));
if (i > 2000 && i < 10000)
tail_energy += w.left * w.left + w.right * w.right;
if (i > 60000)
late_energy += w.left * w.left + w.right * w.right;
check(std::isfinite(w.left) && std::isfinite(w.right), "unstable outdoor tail");
}
check(first == 385, "direct fractional propagation delay is wrong");
check(first_reflection >= 463 && first_reflection <= 465, "ground image reflection time is wrong");
check(tail_energy > 0 && late_energy < tail_energy * 1e-6, "outdoor tail does not decay");
check(peak < 1, "impulse reflection exceeds device range");
OutdoorAudio filtered(44100, config()), unfiltered(44100, config()), doubled(44100, config());
for (auto *p : {&filtered, &unfiltered, &doubled})
p->set_scene(OutdoorScene::Dry);
unfiltered.set_monitor_filter(false);
float prevf = 0, prevu = 0, maxf = 0, maxu = 0;
for (int i = 0; i < 1000; i++) {
const float q = i == 0 ? .001f : 0;
auto f = filtered.process(0, q), u = unfiltered.process(0, q), twice = doubled.process(0, 2 * q);
maxf = std::max(maxf, std::abs(f.left - prevf));
maxu = std::max(maxu, std::abs(u.left - prevu));
prevf = f.left;
prevu = u.left;
check(std::abs(twice.left - 2 * f.left) < 1e-7, "monitoring path is nonlinear");
}
check(maxf < .6f * maxu, "monitor bandwidth fails to reduce adjacent-sample impulse steps");
auto near_config = config(), far_config = config();
near_config.listener.z = 1;
far_config.listener.z = 2;
OutdoorAudio near(44100, near_config), far(44100, far_config);
near.set_scene(OutdoorScene::Dry);
far.set_scene(OutdoorScene::Dry);
near.set_monitor_filter(false);
far.set_monitor_filter(false);
double sum_near = 0, sum_far = 0;
for (int i = 0; i < 1000; i++) {
sum_near += near.process(0, .001f).left;
sum_far += far.process(0, .001f).left;
}
check(std::abs(sum_near - 2 * sum_far) < 1e-7, "direct spreading does not follow inverse distance");
auto stereo_config = config();
stereo_config.ear_spacing = .18f;
stereo_config.exhaust_position.x = 1;
OutdoorAudio stereo(44100, stereo_config);
stereo.set_scene(OutdoorScene::Dry);
stereo.set_monitor_filter(false);
int left = -1, right = -1;
for (int i = 0; i < 1000; i++) {
auto a = stereo.process(0, .001f);
if (left < 0 && std::abs(a.left) > 1e-9)
left = i;
if (right < 0 && std::abs(a.right) > 1e-9)
right = i;
}
check(right < left, "source on right does not reach right ear first");
ReedValve reed;
for (int i = 0; i < 10000; i++)
reed.step(1.f / 44100, 100);
const double inertia = reed.width * std::pow(reed.thickness, 3) / 12;
const double expected = 100 * reed.width * std::pow(reed.length, 4) / (8 * reed.youngs_modulus * inertia);
check(std::abs(reed.lift() - expected) < expected * .001,
"reed static compliance differs from cantilever equation");
for (int i = 0; i < 1000; i++)
reed.step(1.f / 44100, -1000);
check(reed.lift() == 0, "reed does not seat under reverse pressure");
std::cout << "Outdoor audio tests passed: direct=" << first << " samples, ground=" << first_reflection
<< " samples, filtered step ratio=" << maxf / maxu << ", stereo lead=" << left - right
<< " samples\n";
}

View File

@@ -1,4 +1,6 @@
#include "FluidSim/Scenario.hpp"
#include "FluidSim/SimulationRingBuffer.hpp"
#include <array>
#include "FluidSim/Atmosphere.hpp"
#include "FluidSim/Crankcase.hpp"
#include "FluidSim/DynoCurve.hpp"
@@ -26,6 +28,60 @@ void require(bool condition, const char* message) {
int main() {
using namespace fluidsim::port;
Ecu controller;
constexpr float ecu_dt = 1.0f / 44100.0f;
controller.step(ecu_dt, 1.0f, 2000.0f);
require(controller.throttle_opening() > 0.0f && controller.throttle_opening() <= 2.0f * ecu_dt,
"ECU throttle must obey its per-second travel limit");
for (int i = 0; i < 44100; ++i) controller.step(ecu_dt, 1.0f, 2000.0f);
require(controller.throttle_opening() > 0.99f && controller.target_afr() < 13.0f,
"ECU must reach full opening and enrich the high-load mixture");
const float before_close = controller.throttle_opening();
controller.step(ecu_dt, 0.0f, 2000.0f);
require(controller.throttle_opening() >= before_close - 2.01f * ecu_dt,
"ECU must also rate-limit throttle closing");
controller.step(ecu_dt, 1.0f, 10001.0f);
require(controller.fuel_cut() && controller.fuel_for_air(0.001f) == 0.0f,
"ECU rev limit must inhibit injection");
controller.step(ecu_dt, 1.0f, 9900.0f);
require(controller.rev_limited(), "ECU rev limiter must latch through hysteresis");
controller.rev_limit_enabled = false;
controller.step(ecu_dt, 1.0f, 15000.0f);
require(!controller.rev_limited() && !controller.fuel_cut() && controller.fuel_for_air(.001f) > 0,
"disabling a latched limiter must restore fuel above the old limit");
controller.rev_limit_enabled = true;
controller.step(ecu_dt, 1.0f, 9700.0f);
require(!controller.fuel_cut() && controller.fuel_for_air(0.001f) > 0.0f,
"ECU must resume injection below the rev recovery threshold");
controller.step(ecu_dt, 0.0f, 4000.0f);
require(controller.fuel_cut(), "closed-pedal overrun must cut injection");
controller.step(ecu_dt, 0.0f, 1300.0f);
require(!controller.fuel_cut() && controller.idle_active(), "idle catch must restore injection");
controller.step(ecu_dt, 0.0f, std::nanf(""));
require(controller.sensor_fault() && controller.fuel_cut(), "invalid sensor data must inhibit fuel");
// Unit-rate playback must preserve every sample across callback boundaries.
SimulationRingBuffer audio_ring(8);
for (int i = 0; i < 8; ++i) audio_ring.write(float(i), -float(i));
std::array<float, 4> left{}, right{};
require(audio_ring.read_interpolated(left, right, 1.0) == 4,
"unit-rate ring read must fill a block");
for (int i = 0; i < 4; ++i)
require(left[i] == float(i) && right[i] == -float(i), "ring must preserve stereo samples");
require(audio_ring.read_interpolated(left, right, 1.0) == 3,
"ring underrun must stop before unavailable interpolation lookahead");
for (int i = 8; i < 12; ++i) audio_ring.write(float(i), -float(i));
require(audio_ring.read_interpolated(left, right, 1.0) == 4 && left[0] == 7.0f && left[3] == 10.0f,
"ring must resume continuously after underrun and wraparound");
SoundProcessor linear_source(44100, 1.0f);
linear_source.process(0.0f);
const float response = linear_source.process(0.01f);
SoundProcessor doubled_source(44100, 1.0f);
doubled_source.process(0.0f);
require(std::abs(doubled_source.process(0.02f) - 2.0f * response) < 1e-5f,
"radiation source must remain linear instead of saturating");
require(std::abs(units::celsius(25.0) - 298.15) < 1e-9 &&
std::abs(units::area_from_diameter(0.02) - static_cast<double>(pi) * 1e-4) < 1e-12,
"unit helpers must preserve the C# conversions");
@@ -42,6 +98,15 @@ int main() {
"dyno bins must retain their peak power and torque");
dyno_curve.reset();
require(dyno_curve.bins().empty(), "dyno reset must clear accumulated history");
RadiationSource propagation;
int first_arrival = -1;
for (int i = 0; i < 240; ++i) {
const auto frame = propagation.process(i == 0 ? 0.001f : 0.0f);
if (first_arrival < 0 && (frame.left != 0.0f || frame.right != 0.0f)) first_arrival = i;
}
require(first_arrival >= 190 && first_arrival <= 195,
"1.5 m propagation must delay sound about 193 samples at 44.1 kHz");
ThreadLoadTracker thread_load;
thread_load.record(1.0, 2.0);
require(std::abs(thread_load.load_percent() - 2.5) < 1e-9 &&
@@ -159,8 +224,8 @@ int main() {
const float before_volume_work = configurable_volume.pressure();
configurable_volume.update_state(1e-3f);
require(configurable_volume.pressure() < before_volume_work &&
std::abs(configurable_volume.pressure() - before_volume_work * 0.997f) < 1.0f,
"moving-volume work must remove p*dV energy using the configured volume rate");
std::abs(configurable_volume.pressure() - before_volume_work * std::pow(1.0f / 1.01f, 1.3f)) < 1.0f,
"moving-volume work must conserve the closed adiabatic p V^gamma invariant");
Pipe1D profiled_pipe{{0.001f, 0.002f, 0.003f}, {0.1f, 0.2f, 0.3f}};
require(std::abs(profiled_pipe.cell_mass(1) - 1.225f * 0.002f * 0.2f) < 1e-8f &&
@@ -191,6 +256,14 @@ int main() {
require(scenario.cylinder1.pressure > 0.0f && scenario.cylinder2.pressure > 0.0f,
"both cylinders must retain positive pressure");
FourStrokeTwinScenario idling;
for (int i = 0; i < 6 * 44100; ++i) idling.process();
const auto idle_state = idling.snapshot();
require(idle_state.idle_control && idle_state.rpm > 1000.0f && idle_state.rpm < 3000.0f,
"ECU idle control must sustain the unloaded twin without artificial torque");
require(idle_state.throttle > 0.0f && idle_state.pedal == 0.0f,
"idle control must open the butterfly independently of the pedal");
scenario.toggle_mechanical_sound();
for (int i = 0; i < 1000; ++i) {
const AudioFrame frame = scenario.process();

115
tests/turbo_physics.cpp Normal file
View File

@@ -0,0 +1,115 @@
#include "FluidSim/Turbocharger.hpp"
#include "FluidSim/Atmosphere.hpp"
#include "FluidSim/Solver.hpp"
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool condition, const char* message) { if (!condition) throw std::runtime_error(message); }
double mass(const Turbocharger& t) {
return double(t.compressor_inlet.mass())+t.compressor_outlet.mass()+t.turbine_inlet.mass()+t.turbine_outlet.mass();
}
double energy(const Turbocharger& t) { return t.fluid_energy()+t.shaft_energy()+t.snapshot().bearing_heat; }
void closed_balance(double initial_rpm) {
Turbocharger t;
t.set_initial_rpm(initial_rpm);
t.turbine_inlet.set_pressure(250000, 900);
const double before = energy(t), initial_mass = mass(t);
for (int i = 0; i < 10000; ++i) t.update_state(1e-6f);
check(std::abs(mass(t)-initial_mass) < initial_mass*3e-5, "turbo created/lost gas mass");
check(std::abs(energy(t)-before) < before*2e-4, "fluid + shaft + bearing heat energy drift");
check(t.omega() > 0, "turbine did not start its shaft from pressure energy");
const auto s = t.snapshot();
check(std::abs(s.energy_residual) < before*2e-4, "turbo energy residual excessive");
std::cout << "Closed balance initial_rpm=" << initial_rpm << " mass_error=" << mass(t)-initial_mass
<< " energy_error=" << energy(t)-before << " J\n";
}
double turbine_flow(float downstream) {
Turbocharger t;
t.turbine_inlet.set_pressure(300000, 900);
t.turbine_outlet.set_pressure(downstream, 600);
t.update_state(1e-7f);
return t.snapshot().turbine_flow;
}
}
int main() {
Turbocharger rest;
const double e = energy(rest);
for (int i = 0; i < 1000; ++i) rest.update_state(1.f/44100);
check(rest.omega() == 0 && energy(rest) == e && rest.snapshot().compressor_pressure_ratio == 1,
"stationary equilibrium generated boost/shaft energy");
closed_balance(0);
closed_balance(120000);
check(std::abs(turbine_flow(100000)-turbine_flow(50000)) < 1e-10, "choked turbine flow depends on back pressure");
check(turbine_flow(250000) < turbine_flow(100000), "higher backpressure did not reduce subcritical flow");
Turbocharger reverse;
reverse.compressor_outlet.set_pressure(150000, 350);
reverse.turbine_outlet.set_pressure(150000, 600);
const auto reverse_energy = energy(reverse);
reverse.update_state(1e-6f);
check(reverse.snapshot().compressor_flow < 0 && reverse.snapshot().turbine_flow < 0,
"turbo acted as an artificial one-way valve");
check(std::abs(energy(reverse)-reverse_energy) < reverse_energy*1e-6, "reverse flow creates energy");
Turbocharger gate;
gate.compressor_outlet.set_pressure(220000, 350);
gate.turbine_inlet.set_pressure(250000, 900);
gate.update_state(1e-6f);
check(gate.snapshot().wastegate_lift > 0 && gate.snapshot().wastegate_flow > 0,
"pressure can did not open the wastegate bypass");
check(std::abs(gate.snapshot().energy_residual) < 1e-4, "wastegate bypass created energy");
Turbocharger coarse;
coarse.set_initial_rpm(200000);
const double coarse_energy = energy(coarse);
coarse.update_state(.5f);
check(coarse.omega() >= 0 && std::abs(energy(coarse)-coarse_energy) < coarse_energy*1e-5,
"large turbo timestep creates energy or reverses shaft");
bool caught = false;
try { TurboConfig bad; bad.shaft_inertia = 0; Turbocharger invalid(bad); }
catch (const std::invalid_argument&) { caught = true; }
check(caught, "zero rotor inertia accepted");
// Hot pulsing reservoir -> real pipes -> turbine -> exhaust reservoir.
// Compressor feeds a pressure reservoir representing a loaded charge duct.
std::vector<Pipe1D> pipes;
for (int i = 0; i < 4; ++i) pipes.emplace_back(pi*.024f*.024f*.25f, .08f, 8);
Atmosphere fresh, charge, hot, exhaust;
charge.pressure = 130000;
hot.temperature = 900;
hot.pressure = 220000;
charge.update_state(0); hot.update_state(0);
BoundarySystem boundaries(pipes);
Turbocharger turbo;
turbo.connect(boundaries, pipes, 0, false, 1, true, 2, false, 3, true);
std::array<float, 4> areas;
const std::array<Atmosphere*, 4> reservoirs{&fresh, &charge, &hot, &exhaust};
for (int i = 0; i < 4; ++i) {
areas[i] = pipes[i].area;
boundaries.add_orifice(reservoirs[i]->port, i, i%2 == 0, areas[i]);
}
Solver solver(pipes, boundaries);
solver.add_component(turbo);
float minimum_power = 1e9f, maximum_power = -1e9f;
double initial_speed = 0;
for (int i = 0; i < 44100; ++i) {
hot.pressure = 220000+60000*std::sin(2*pi*80*i/44100);
hot.update_state(0);
solver.step(1.f/44100);
const auto s = turbo.snapshot();
if (i == 100) initial_speed = s.rpm;
if (i > 40000) {
minimum_power = std::min(minimum_power, float(s.turbine_power));
maximum_power = std::max(maximum_power, float(s.turbine_power));
}
}
const auto result = turbo.snapshot();
check(result.rpm > initial_speed+10000, "hot gas did not spool the coupled turbo");
check(result.compressor_flow > 0 && result.compressor_power > 0 && result.charge_temperature > 300,
"loaded compressor did not deliver heated charge using shaft work");
check(maximum_power-minimum_power > 100, "exhaust pulses were averaged away before turbine torque");
for (const auto& pipe : pipes) check(pipe.positivity_repairs == 0, "turbo bench required pipe repairs");
check(result.limited_transfers == 0, "normal turbo bench depleted a storage node");
std::cout << "Turbo pipe bench passed: rpm=" << result.rpm << " compressor flow=" << result.compressor_flow
<< " kg/s, charge T=" << result.charge_temperature << " K, turbine power range="
<< minimum_power << ".." << maximum_power << " W, residual=" << result.energy_residual << " J\n";
}

View File

@@ -0,0 +1,43 @@
#include "FluidSim/Scenario.hpp"
#include <algorithm>
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool condition, const char* message) { if (!condition) throw std::runtime_error(message); }
}
int main() {
TurboTwinScenario s;
double maximum_speed = 0, maximum_charge = 0, maximum_turbine_pressure = 0;
double maximum_gate = 0, peak_audio = 0, coast_speed = 0;
for (int sample = 0; sample < 7*44100; ++sample) {
// Idle, accelerate/limiter operation, abrupt pedal release, then recover.
s.set_controls(sample >= 44100 && sample < 4*44100 ? .7f : 0, 0, 1);
const auto audio = s.process();
check(std::isfinite(audio.left) && std::isfinite(audio.right), "nonfinite turbo twin audio");
peak_audio = std::max({peak_audio, double(std::abs(audio.left)), double(std::abs(audio.right))});
const auto t = s.turbo->snapshot();
check(t.rpm >= 0 && std::isfinite(t.rpm) && t.charge_temperature > 0 &&
t.turbine_inlet_pressure > 0 && t.turbine_outlet_pressure > 0,
"turbo twin developed nonphysical turbo state");
maximum_speed = std::max(maximum_speed, t.rpm);
maximum_charge = std::max(maximum_charge, t.charge_pressure);
maximum_turbine_pressure = std::max(maximum_turbine_pressure, t.turbine_inlet_pressure);
maximum_gate = std::max(maximum_gate, t.wastegate_lift);
if (sample == 5*44100) coast_speed = t.rpm;
}
const auto final = s.turbo->snapshot();
check(maximum_speed > 50000 && maximum_charge > 120000, "turbo did not spool and generate boost");
check(maximum_turbine_pressure > maximum_charge, "twin has no turbine backpressure");
check(maximum_gate > 0, "wastegate never opened under boost");
check(final.rpm < coast_speed, "turbo did not coast down after throttle release");
check(final.limited_transfers == 0, "turbo twin depleted its internal volumes");
check(std::abs(final.energy_residual) < .1, "turbo twin accumulated excessive energy residual");
check(peak_audio < 1, "turbo twin audio exceeds device range");
for (const auto& p : s.pipes) check(p.positivity_repairs == 0, "turbo twin required pipe repairs");
std::cout << "Turbo twin passed: max_rpm=" << maximum_speed << ", max_charge=" << maximum_charge/1000
<< " kPa, max_turbine_pressure=" << maximum_turbine_pressure/1000 << " kPa, gate=" << maximum_gate*1000
<< " mm, final_rpm=" << final.rpm << ", peak_audio=" << peak_audio
<< ", energy_residual=" << final.energy_residual << " J\n";
}

View File

@@ -0,0 +1,103 @@
#include "FluidSim/TwoStrokeScenario.hpp"
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
float reflected_pulse(bool expanding) {
const float inlet = expanding ? .03f : .08f, outlet = expanding ? .08f : .03f;
const std::array<PipeSection, 3> sections{{{.1f, inlet, inlet}, {.1f, inlet, outlet}, {.4f, outlet, outlet}}};
auto pipe = make_profiled_pipe(sections, .002f);
const float rho = 1.2f, pressure = 101325, c = std::sqrt(fluidsim::port::gamma*pressure/rho);
float x = 0;
for (std::size_t i = 0; i < pipe.cells.size(); ++i) {
const float center = x + .5f * pipe.cell_lengths[i];
const float dp = 500 * std::exp(-std::pow((center-.04f)/.006f, 2));
const float density = rho + dp/(c*c), velocity = dp/(rho*c);
pipe.cells[i] = {density, density*velocity,
(pressure+dp)/(fluidsim::port::gamma-1) + .5f*density*velocity*velocity, 1};
x += pipe.cell_lengths[i];
}
pipe.left_ghost = pipe.right_ghost = {rho, 0, pressure, 1};
float extremum = 0;
for (float time = 0; time < .0012f;) {
const float dt = pipe.stable_timestep(.5f);
pipe.advance(dt);
time += dt;
if (time > .00025f) {
const float dp = pipe.state(29).pressure-pressure;
extremum = expanding ? std::min(extremum, dp) : std::max(extremum, dp);
}
}
check(pipe.positivity_repairs == 0, "taper pulse needed positivity repairs");
return extremum;
}
}
int main() {
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}) {
const auto pipe = make_profiled_pipe(cone, mesh);
double volume = 0;
for (std::size_t i = 0; i < pipe.cells.size(); ++i)
volume += pipe.cell_areas[i] * pipe.cell_lengths[i];
check(std::abs(volume-expected) < expected*2e-6, "frustum mesh changes physical volume");
}
const float negative = reflected_pulse(true), positive = reflected_pulse(false);
check(negative < -5, "diffuser did not return a rarefaction");
check(positive > 5, "convergent did not return a compression");
TwoStrokeScenario s;
auto& cylinder = s.cylinder;
const float closed = cylinder.compute_volume(cylinder.exhaust_close()*pi/180);
check(std::abs(closed/cylinder.clearance_volume()-8.2f) < 1e-4f,
"trapped compression was confused with geometric compression");
for (bool intake : {false, true}) {
const float opens = intake ? cylinder.transfer_open() : cylinder.exhaust_open();
const float width = intake ? cylinder.transfer_width : cylinder.exhaust_width;
const float area = pi*cylinder.bore*cylinder.bore*.25f;
const float expected_area = width * (cylinder.compute_volume(pi)-cylinder.compute_volume(opens*pi/180))/area;
check(std::abs(cylinder.valve_area(180, intake)-expected_area) < 1e-8f,
"port is not a piston-uncovered window");
float previous = 0;
for (float angle = opens; angle <= 180; angle += .5f) {
const float current = cylinder.valve_area(angle, intake);
check(current >= previous, "port does not open monotonically toward BDC");
check(std::abs(current-cylinder.valve_area(360-angle, intake)) < 1e-8f,
"piston port area is not symmetric");
previous = current;
}
check(cylinder.valve_area(0, intake) == 0 && cylinder.valve_area(360, intake) == 0,
"port is open at TDC");
}
s.ecu.step(1.f/44100, 1, 6000);
const float low_spark = s.ecu.spark_advance();
s.ecu.step(1.f/44100, 1, 17000);
check(s.ecu.spark_advance() < low_spark && !s.ecu.fuel_cut(),
"two-stroke high-speed ignition does not retard without cutting fuel");
s.vehicle.current_gear = 1;
const float first = s.vehicle.total_gear_ratio();
s.vehicle.current_gear = 6;
check(std::abs(first-30.27f)<.03f && std::abs(s.vehicle.total_gear_ratio()-13.4f)<.03f,
"YZ125 primary/final/gear reduction incorrect");
Crankshaft crank;
crank.omega = 100;
crank.load_torque = 10;
crank.friction_constant = 2;
crank.add_torque(12);
crank.step(1.f/44100);
check(std::abs(crank.omega-100)<1e-6 && std::abs(crank.average_power-1000)<.01f &&
std::abs(crank.average_torque-10)<1e-6,
"loaded steady-speed dyno lost shaft power");
crank.load_torque = 0;
crank.friction_constant = 0;
crank.friction_quadratic = .001f;
crank.step(1.f/44100);
check(crank.omega < 100 && crank.average_power < 1000, "windage is not dissipative");
std::cout << "Two-stroke physics passed: diffuser reflection=" << negative
<< " Pa, baffle reflection=" << positive << " Pa, first reduction=" << first << '\n';
}

View File

@@ -0,0 +1,87 @@
#include "FluidSim/TwoStrokeScenario.hpp"
#include "FluidSim/DynoCurve.hpp"
#include <algorithm>
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
int main() {
TwoStrokeScenario scenario;
bool reed_open_seen = false, reed_closed_seen = false, above_old_limit = false;
DynoCurve dyno;
float peak = 0, maximum_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 (!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)});
if (sample % 44 != 0)
continue;
const auto state = scenario.snapshot();
if (!std::isfinite(state.rpm) || !(state.cylinder_pressure > 0) ||
!(state.crankcase_pressure > 0) || !(state.intake_pressure > 0))
throw std::runtime_error("Invalid two-stroke physical state");
if (scenario.ecu.rev_limited() || scenario.cylinder.rev_limit_active)
throw std::runtime_error("Two-stroke rev limiter is active");
if (sample >= 2 * 44100 && sample < 6 * 44100) {
if (state.fuel_cut)
throw std::runtime_error("Two-stroke cut fuel with the pedal open");
above_old_limit |= state.rpm > 10200;
}
dyno.update(state.rpm, scenario.crankshaft.average_power / 1000,
scenario.crankshaft.average_torque);
reed_open_seen |= state.reed_open;
reed_closed_seen |= !state.reed_open;
maximum_rpm = std::max(maximum_rpm, state.rpm);
}
const auto final = scenario.snapshot();
if (!above_old_limit)
throw std::runtime_error("Two-stroke did not exceed the old rev limit");
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");
if (peak >= 1)
throw std::runtime_error("Two-stroke audio exceeds device range");
for (const auto &pipe : scenario.pipes)
if (pipe.positivity_repairs != 0)
throw std::runtime_error("Two-stroke required pipe positivity repairs");
// Launch the same motorcycle used by the twin, then exercise gear controls.
scenario.shift_up();
scenario.set_controls(.7f, 0, 0);
for (int sample = 0; sample < 6 * 44100; ++sample) {
const auto audio = scenario.process();
if (!std::isfinite(audio.left) || !std::isfinite(audio.right))
throw std::runtime_error("Nonfinite motorcycle launch audio");
if (scenario.ecu.rev_limited())
throw std::runtime_error("Limiter re-enabled under motorcycle load");
}
if (scenario.vehicle.current_gear != 1 || scenario.vehicle.speed < 1 ||
!std::isfinite(scenario.vehicle.kinetic_energy()) || scenario.vehicle.engagement < .9f)
throw std::runtime_error("Two-stroke motorcycle did not launch with clutch engaged");
scenario.shift_up();
if (scenario.vehicle.current_gear != 2)
throw std::runtime_error("Two-stroke did not shift up");
scenario.shift_down();
if (scenario.vehicle.current_gear != 1)
throw std::runtime_error("Two-stroke did not shift down");
scenario.set_neutral();
if (scenario.vehicle.current_gear != 0)
throw std::runtime_error("Two-stroke neutral selection failed");
bool positive_power = false, positive_torque = false;
for (const auto& point : dyno.bins()) {
positive_power |= point.power_kw > 0;
positive_torque |= point.torque_nm > 0;
}
if (!positive_power || !positive_torque)
throw std::runtime_error("Two-stroke dyno did not record power and torque");
for (const auto& pipe : scenario.pipes)
if (pipe.positivity_repairs != 0)
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";
}

161
tools/audio_capture.cpp Normal file
View File

@@ -0,0 +1,161 @@
#include "FluidSim/Scenario.hpp"
#include "FluidSim/TwoStrokeScenario.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <string>
#include <type_traits>
using namespace fluidsim::port;
namespace {
class Wav {
std::ofstream out_;
void integer(std::uint32_t n, int bytes) {
for (int i = 0; i < bytes; i++)
out_.put(char(n >> (8 * i)));
}
public:
Wav(const std::string &path, int frames) : out_(path, std::ios::binary) {
if (!out_)
throw std::runtime_error("cannot open WAV: " + path);
out_.write("RIFF", 4);
integer(36 + frames * 4, 4);
out_.write("WAVEfmt ", 8);
integer(16, 4);
integer(1, 2);
integer(2, 2);
integer(44100, 4);
integer(44100 * 4, 4);
integer(4, 2);
integer(16, 2);
out_.write("data", 4);
integer(frames * 4, 4);
}
void sample(AudioFrame f) {
for (float x : {f.left, f.right}) {
const auto v = static_cast<std::int16_t>(std::lround(std::clamp(x, -1.f, 1.f) * 32767));
integer(static_cast<std::uint16_t>(v), 2);
}
}
};
struct Metrics {
double sum{}, peak{}, delta{};
std::uint64_t samples{}, clips{};
AudioFrame previous{};
void add(AudioFrame f) {
for (float x : {f.left, f.right}) {
if (!std::isfinite(x))
throw std::runtime_error("nonfinite capture");
sum += x * x;
peak = std::max(peak, double(std::abs(x)));
clips += std::abs(x) > 1;
++samples;
}
delta = std::max(
{delta, double(std::abs(f.left - previous.left)), double(std::abs(f.right - previous.right))});
previous = f;
}
void print(const char *name) const {
std::cout << name << " peak=" << peak << " rms=" << std::sqrt(sum / samples)
<< " max_sample_delta=" << delta << " clipping_samples=" << clips << '\n';
}
};
template <class Scenario>
void capture(const std::string &prefix, int frames, float pedal, OutdoorScene scene, TwoStrokeConfig config) {
std::unique_ptr<Scenario> s;
if constexpr (std::is_same_v<Scenario, TwoStrokeScenario>) s = std::make_unique<Scenario>(44100, config);
else s = std::make_unique<Scenario>();
s->throttle = pedal;
s->set_audio_scene(scene);
Wav rendered(prefix + "-rendered.wav", frames), dry(prefix + "-dry.wav", frames),
raw(prefix + "-unfiltered.wav", frames);
Metrics m, d, r;
int cuts = 0;
for (int i = 0; i < frames; i++) {
auto f = s->process(), df = s->dry_audio(), rf = s->unfiltered_audio();
m.add(f);
d.add(df);
r.add(rf);
rendered.sample(f);
dry.sample(df);
raw.sample(rf);
if (i % 4410 == 0)
cuts += s->snapshot().fuel_cut;
}
m.print("rendered");
d.print("dry filtered");
r.print("unfiltered spatial");
std::cout << "fuel-cut checkpoints=" << cuts << " final_rpm=" << s->snapshot().rpm
<< " scene=" << scene_name(scene) << '\n';
}
} // namespace
int main(int argc, char **argv) {
try {
std::string scenario = "twin", prefix = "build/audio/twin", scene = "wall";
float seconds = 6, pedal = 1;
TwoStrokeConfig config;
bool turbo_enabled = false;
for (int i = 1; i < argc; i++) {
std::string key = argv[i];
if (i + 1 >= argc)
throw std::runtime_error("missing option value");
std::string value = argv[++i];
if (key == "--scenario")
scenario = value;
else if (key == "--out")
prefix = value;
else if (key == "--seconds")
seconds = std::stof(value);
else if (key == "--throttle")
pedal = std::stof(value);
else if (key == "--turbo") {
if (value != "none" && value != "vz21") throw std::runtime_error("choose none or vz21 turbo");
turbo_enabled = value == "vz21";
}
else if (key == "--exhaust") {
if (value == "racing") config.exhaust = TwoStrokeExhaust::Racing;
else if (value == "straight") config.exhaust = TwoStrokeExhaust::Straight;
else throw std::runtime_error("choose racing or straight exhaust");
}
else if (key == "--scene")
scene = value;
else
throw std::runtime_error("unknown capture option");
}
if (!std::isfinite(seconds) || seconds <= 0 || seconds > 600 || !std::isfinite(pedal) || pedal < 0 ||
pedal > 1)
throw std::runtime_error("invalid capture duration or throttle");
OutdoorScene selected;
if (scene == "dry")
selected = OutdoorScene::Dry;
else if (scene == "field")
selected = OutdoorScene::Field;
else if (scene == "wall")
selected = OutdoorScene::Wall;
else if (scene == "woodland")
selected = OutdoorScene::Woodland;
else
throw std::runtime_error("invalid scene");
if (!std::filesystem::path(prefix).parent_path().empty())
std::filesystem::create_directories(std::filesystem::path(prefix).parent_path());
const int frames = int(std::lround(seconds * 44100));
if (turbo_enabled && scenario != "twin") throw std::runtime_error("turbo test uses the twin scenario");
if (scenario == "twin" && turbo_enabled)
capture<TurboTwinScenario>(prefix, frames, pedal, selected, config);
else if (scenario == "twin")
capture<FourStrokeTwinScenario>(prefix, frames, pedal, selected, config);
else if (scenario == "b205")
capture<SaabB205Scenario>(prefix, frames, pedal, selected, config);
else if (scenario == "two-stroke")
capture<TwoStrokeScenario>(prefix, frames, pedal, selected, config);
else
throw std::runtime_error("choose twin, two-stroke or b205");
} catch (const std::exception &e) {
std::cerr << e.what() << '\n';
return 1;
}
}

View File

@@ -0,0 +1,62 @@
#include "FluidSim/PulseTestScenario.hpp"
#include "FluidSim/Scenario.hpp"
#include <chrono>
#include <iostream>
#include <string>
using namespace fluidsim::port;
int main(int argc, char **argv) {
// Track the hot-engine cost over time; a one-second benchmark misses it.
if (argc > 1 && std::string(argv[1]) == "--soak") {
const int seconds = argc > 2 ? std::stoi(argv[2]) : 12;
for (float pedal : {0.0f, 1.0f}) {
FourStrokeTwinScenario scenario;
scenario.throttle = pedal;
for (int second = 0; second < seconds; ++second) {
const auto before = scenario.snapshot();
const auto start = std::chrono::steady_clock::now();
for (int sample = 0; sample < 44100; ++sample)
scenario.process();
const double wall =
std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
const auto after = scenario.snapshot();
std::cout << "pedal=" << pedal << " second=" << second + 1 << " wall_seconds=" << wall
<< " rpm=" << after.rpm
<< " substeps/frame=" << (after.solver_substeps - before.solver_substeps) / 44100.0
<< " boundary_iterations/frame="
<< (after.boundary_iterations - before.boundary_iterations) / 44100.0 << std::endl;
}
}
return 0;
}
int samples = argc > 1 ? std::stoi(argv[1]) : 44100;
for (int n : {64, 256, 1024}) {
Pipe1D p(.001f, 2.f, n);
p.damping_multiplier = 0;
p.energy_relaxation_rate = 0;
double sum = 0;
auto t = std::chrono::steady_clock::now();
for (int i = 0; i < samples; i++) {
auto l = p.state(0), r = p.state(n - 1);
l.velocity = -l.velocity;
r.velocity = -r.velocity;
p.left_ghost = l;
p.right_ghost = r;
p.advance(0.0000005f);
sum += p.state(n / 2).pressure;
}
double sec = std::chrono::duration<double>(std::chrono::steady_clock::now() - t).count();
std::cout << "pipe cells=" << n << " steps=" << samples << " seconds=" << sec
<< " Mcells/s=" << n * double(samples) / sec / 1e6 << " checksum=" << sum << '\n';
}
FourStrokeTwinScenario s;
s.throttle = .35f;
double sum = 0;
auto t = std::chrono::steady_clock::now();
for (int i = 0; i < samples; i++) {
auto a = s.process();
sum += a.left;
}
double sec = std::chrono::duration<double>(std::chrono::steady_clock::now() - t).count();
std::cout << "twin seconds=" << sec << " realtime_factor=" << samples / 44100.0 / sec
<< " checksum=" << sum << '\n';
}

View File

@@ -0,0 +1,54 @@
#include "FluidSim/Fluid.hpp"
#include <barrier>
#include <chrono>
#include <iostream>
#include <thread>
using namespace fluidsim::port;
int main() {
for (auto size : {std::pair{6, 10}, std::pair{64, 256}}) {
const int steps = 1000;
for (int workers : {1, 2, 4}) {
std::vector<Pipe1D> p;
for (int i = 0; i < size.first; i++)
p.emplace_back(.001f, 2, size.second);
auto advance = [&](int worker) {
for (int k = worker; k < size.first; k += workers) {
auto &a = p[k];
auto l = a.state(0), r = a.state(size.second - 1);
l.velocity = -l.velocity;
r.velocity = -r.velocity;
a.left_ghost = l;
a.right_ghost = r;
a.advance(.0000005f);
}
};
std::barrier sync(workers + 1);
std::vector<std::jthread> pool;
if (workers > 1)
for (int k = 0; k < workers; k++)
pool.emplace_back([&, k] {
for (int i = 0; i < steps; i++) {
sync.arrive_and_wait();
advance(k);
sync.arrive_and_wait();
}
});
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < steps; i++) {
if (workers == 1)
advance(0);
else {
sync.arrive_and_wait();
sync.arrive_and_wait();
}
}
double sec = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
pool.clear();
double sum = 0;
for (auto &q : p)
sum += q.state(0).pressure;
std::cout << "pipes=" << size.first << " cells/pipe=" << size.second << " workers=" << workers
<< " steps=" << steps << " seconds=" << sec << " checksum=" << sum << '\n';
}
}
}

48
tools/turbo_benchmark.cpp Normal file
View File

@@ -0,0 +1,48 @@
#include "FluidSim/Scenario.hpp"
#include <chrono>
#include <cmath>
#include <iostream>
#include <ctime>
using namespace fluidsim::port;
int main() {
std::cout << "engine_rpm,turbo,MAP_kPa,exhaust_kPa,shaft_kW,turbo_rpm,charge_kPa,charge_K,turbine_power_ripple_W,gate_mm,limited,energy_residual_J,substeps_per_frame,iterations_per_frame,wall_s,cpu_s\n";
for (float rpm : {4000.f, 7000.f}) {
for (bool enabled : {false, true}) {
FourStrokeScenario s(44100, FourStrokePreset::Twin, enabled);
s.throttle = .7f;
double map = 0, exhaust = 0, power = 0, turbo_rpm = 0, charge_p = 0, charge_t = 0, gate = 0;
double min_power = 1e30, max_power = -1e30;
const auto start = std::chrono::steady_clock::now();
const auto cpu_start = std::clock();
for (int i = 0; i < 2*44100; ++i) {
// Ideal speed-holding dyno. The turbo remains completely free.
s.crankshaft.omega = rpm*2*pi/60;
s.process();
if (i >= 3*44100/2) {
constexpr double samples = 22050;
map += s.intake_plenum.pressure()/samples/1000;
exhaust += s.exhaust_collector.pressure()/samples/1000;
power += s.crankshaft.average_power/samples/1000;
if (s.turbo) {
const auto t = s.turbo->snapshot();
turbo_rpm += t.rpm/samples;
charge_p += t.charge_pressure/samples/1000;
charge_t += t.charge_temperature/samples;
gate += t.wastegate_lift/samples*1000;
min_power = std::min(min_power, t.turbine_power);
max_power = std::max(max_power, t.turbine_power);
}
}
}
const auto t = s.turbo ? s.turbo->snapshot() : TurboSnapshot{};
const auto state = s.snapshot();
const double cpu = double(std::clock()-cpu_start)/CLOCKS_PER_SEC;
const auto wall = std::chrono::duration<double>(std::chrono::steady_clock::now()-start).count();
std::cout << rpm << ',' << enabled << ',' << map << ',' << exhaust << ',' << power << ','
<< turbo_rpm << ',' << charge_p << ',' << charge_t << ','
<< (enabled ? max_power-min_power : 0) << ',' << gate << ',' << t.limited_transfers
<< ',' << t.energy_residual << ',' << state.solver_substeps/88200.0 << ','
<< state.boundary_iterations/88200.0 << ',' << wall << ',' << cpu << std::endl;
}
}
}

View File

@@ -0,0 +1,37 @@
#include "FluidSim/TwoStrokeScenario.hpp"
#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";
for (auto exhaust : {TwoStrokeExhaust::Straight, TwoStrokeExhaust::Racing}) {
for (float rpm : {6000.f, 8000.f, 10000.f, 12000.f, 14000.f, 17000.f}) {
TwoStrokeConfig config;
config.exhaust = exhaust;
TwoStrokeScenario s(44100, config);
s.set_throttle(1);
const auto start = std::chrono::steady_clock::now();
double torque = 0, power = 0;
std::uint64_t initial_firings = 0;
// An ideal absorbing/motoring dyno imposes speed, exchanging shaft
// work externally. It never changes gas energy or adds combustion.
for (int i = 0; i < 44100; ++i) {
s.crankshaft.omega = rpm * 2 * pi / 60;
s.process();
if (i == 22049) initial_firings = s.cylinder.combustion_count();
if (i >= 22050) {
torque += s.crankshaft.average_torque / 22050;
power += s.crankshaft.average_power / (22050 * 1000.f);
}
}
unsigned long long repairs = 0;
for (const auto& pipe : s.pipes) repairs += pipe.positivity_repairs;
const double wall = std::chrono::duration<double>(std::chrono::steady_clock::now()-start).count();
std::cout << (exhaust == TwoStrokeExhaust::Racing ? "racing" : "straight") << ',' << rpm
<< ',' << power << ',' << torque << ','
<< (s.cylinder.combustion_count()-initial_firings) / (rpm / 120)
<< ',' << repairs << ',' << wall << std::endl;
}
}
}

View File

@@ -0,0 +1,97 @@
# Static outdoor audio and two-stroke verification
Verified on Linux in Release mode, 2026-10-23. The target remains fluid-derived
sound at 44.1 kHz. No synthetic bang, saturation, or combustion event is added
by the audio renderer. Acoustic processing does not feed energy into the solver.
## Renderer
`OutdoorAudio` replaces the active twin and two-stroke legacy reverb paths.
Each intake/exhaust mass-flow derivative drives a compact monopole. Separate
left/right paths use inverse distance and fractional-sample delays at 343 m/s.
The listener and sources are static. Default listener position is (1.6, 1, 2) m;
intake is (-0.2, .7, 0), exhaust (.55, .5, 0), ground y=0, wall x=-1.5.
Ground and wall reflection coefficients are .35 and .65. These are configurable
assumptions rather than measurements of a particular location.
A second-order 6 kHz monitoring low-pass reduces sharp acquisition transients;
it is linear and does not create afterfire. Early ground/wall reflections use
image paths. Woodland uses six approximate scatterers. A stable four-line
feedback-delay network supplies an empirical diffuse tail; it does not solve
outdoor wave propagation or vegetation scattering. Scene changes smooth over
20 ms. The old `OutdoorExhaustReverb` remains for legacy tests but is inactive
in both engine scenarios.
Pipe-end radiation impedance, calibrated directivity, alias-free acquisition,
and exhaust afterfire chemistry remain unresolved. PCM output is not calibrated
speaker SPL. The monitoring filter cannot recover information lost to aliasing.
## Measured captures
Four seconds at full pedal, wall environment, 44.1 kHz stereo. Each row's raw
and rendered signals come from the same simulation run. Measurements are
before PCM clipping; the raw signal includes direct spatial propagation.
| Engine | Raw maximum adjacent-sample change | Rendered change | Rendered peak | Clipping samples |
| --- | ---: | ---: | ---: | ---: |
| Twin | .569144 | .0881295 | .119947 | 0 |
| Two-stroke | .412956 | .138741 | .390789 | 0 |
These measurements establish transient reduction, not a subjective judgment
that limiter events sound like realistic bangs. Listening on the target audio
device is still required. Reproduce from the repository root:
```sh
cmake --build build-audit --target fluidsim_audio_capture -j 4
./build-audit/fluidsim_audio_capture --scenario twin --out build/audio/twin-wall --seconds 4 --throttle 1 --scene wall
./build-audit/fluidsim_audio_capture --scenario two-stroke --out build/audio/two-stroke-wall --seconds 4 --throttle 1 --scene wall
```
Each produces `-rendered.wav`, `-dry.wav` (filtered direct paths) and
`-unfiltered.wav` for comparison. Capture files are build artifacts.
## Two-stroke and regression checks
The existing 125 cc geometry now uses the shared ECU and finite-response
throttle, fuel dosing after the last scavenging port closes, 3000 RPM idle
control with ignition trim, and a dynamic damped cantilever reed with contacts.
Cylinder/crankcase geometry work uses adiabatic energy differences. An active
charge finishes combustion when a fuel cut begins. Ignition event crossing
handles wraparound without spuriously firing retarded ignition at zero degrees.
Reed parameters, combustion, scavenging and controller calibrations have not
been validated against a measured engine.
Both desktop engines share `MotorcycleTest` for clutch/vehicle integration,
common control dispatch and a common dashboard/dyno renderer. The two-stroke
limiter is disabled explicitly in the ECU and cylinder; the twin limiter is
unchanged. Idle and overrun controls remain enabled. The motorcycle and gearing
reuse the existing twin test configuration, not a measured 125 cc motorcycle.
The rolling peak dyno reports crank power and torque versus RPM.
All five CTest suites passed: basic physics/control, fluid boundary validation,
intake regression, outdoor audio, and two-stroke regression. Audio tests cover
fractional direct arrival, image reflection arrival, stereo ordering, distance
gain, linearity, filtering, causal decaying tails, and reed static compliance
and seating. The two-stroke sequence idles for two seconds, holds .6 pedal for
four, then releases for eight. It checks fuel stays enabled at open pedal above
the old limit, reed opening/seating, positive states, finite audio and idle
recovery, with no pipe positivity repairs. It then launches the motorcycle for
six seconds and checks shifting, neutral and positive dyno power/torque.
Latest Release results: free-rev maximum 22471.3 RPM, recovered idle 3236.08 RPM,
free-rev audio peak .512784, motorcycle speed 51.3773 km/h after launch. The high
unloaded RPM exposes the uncalibrated high-speed friction/combustion behavior;
removing the limiter does not validate this as realistic engine performance.
Unit checks also verify disabling an already latched limiter restores fueling.
The earlier four-second capture table above predates limiter removal and is
retained as an audio-renderer measurement, not a current engine baseline.
Windows execution of the latest changes is pending.
## Reference basis
- [Image source geometry](https://www.acs.psu.edu/noahparker/acs/img-src/img-src.html)
- [Feedback delay networks](https://msp.ucsd.edu/techniques/latest/book-html/node111.html)
- [Beam theory](https://mechanics.ju.se/SolidMechanics/BeamTheory.html), including uniform-load cantilever deflection used for reed compliance.
The later YZ125 baseline changes these engine calibrations and measurements; see
[YZ125 baseline verification](YZ125_BASELINE.md) for current results.

View File

@@ -0,0 +1,56 @@
# Saab B205 / 9-5 wagon baseline
Linux Release, 2026-10-23. This quick preset reuses the four-stroke scenario,
ECU, compressible pipes/volumes, vehicle coupling, spatial audio and dyno.
The original twin remains available through its backward-compatible type alias.
There are four actual cylinders and ten pipes, not copies of an audio waveform.
## References and assumptions
[Saab WIS B205 general data](https://saabwisonline.com/9-3-9400/2001/2-engine/basic-engine-4-cylinder-petrol/technical-data/general-data)
supplies 90 mm bore, 78 mm stroke, approximately 1985 cc, 8.8:1 compression,
four valves per cylinder and 1-3-4-2 firing order. Cylinder phases in physical
1/2/3/4 order are 0/180/540/360 degrees; pistons 1/4 move together and 2/3 move
together. Phased cylinders initialize at their actual piston positions.
[Saab WIS 9-5 gearbox data](https://saabwisonline.com/9-5-9600/2005/4-transmission/manual-gearbox/technical-data/gear-ratio)
supplies FM55 B05 overall reductions 13.70, 7.12, 4.77, 3.62 and 2.67 with
4.05 final reduction. The vehicle configuration factors out final reduction
and has five selectable forward gears, no motorcycle primary reduction.
Other settings are estimates: 1600 kg wagon + 75 kg driver, .317 m loaded tire
radius, Cd .32, frontal area 2.2 square metres, wheel-equivalent rotational
inertia 3.6 kg m^2, 300 Nm clutch capacity, crank inertia .18 kg m^2, and
12 Nm constant + .012 Nm/(rad/s) viscous friction. Road rolling resistance
retains the shared .015 coefficient. No attempt is made to identify a particular
year/trim with these estimates.
The intake uses a 3 litre plenum, estimated 52 mm throttle, four 32 mm x 250 mm
runners, 34 mm x 280 mm headers, .6 litre collector, and a 60 mm x 1.4 m open
tailpipe. Estimated valves are 32/28 mm intake/exhaust, 8.5/8 mm lift, two each.
Cams/combustion retain the shared four-stroke model. The ideal ECU targets
850 RPM idle, with estimated 6500/6250 RPM cut/resume and 2000/1300 overrun
thresholds. These are scenario calibrations, not a factory Trionic map.
There is no compressor, turbine, intercooler, wastegate, catalyst or muffler in
this first version. Intake boundary pressure is ambient. Consequently factory
turbo torque/power and factory exhaust sound are not reproduced. Thermal,
combustion and single-zone mixing limitations from the fluid audit still apply.
## Verification
The six existing suites passed after the shared four-stroke/vehicle changes.
The added B205 regression passed independently: it checks engine displacement,
valve count, cylinder positivity, ten-pipe topology, firing order/cadence, wagon
launch, five-gear selection, final ratio, idle recovery, finite audio and zero
pipe positivity repairs. A prescribed 2000 RPM dyno counted 33 firings in half a
second, consistent with two firing events per revolution. The eight-second
sequence reached 56.57 km/h in first under load, recovered to 1311 RPM after
three seconds of unloaded release, and peaked at .18645 audio full scale.
It cost 7.26 wall seconds while another headless run was also active. Idle
convergence and engine calibration still need refinement; these checks establish
basic operation, not measured Saab performance. Windows runtime remains untested.
Run `fluidsim_desktop --scenario b205` or select the third startup card.
Headless CLI and WAV capture also accept `--scenario b205`. The graph reports
crankshaft power, not power measured at the wheels.

View File

@@ -0,0 +1,261 @@
# Fluid physics audit — 23 October 2026
This pass repairs conservation and boundary coupling, establishes repeatable
physical tests, and measures kernel and threading performance. It is not a
certification that every engine or acoustic subsystem is physically validated.
## Findings and changes
1. **Pipe–volume mass/energy mismatch.** Previously, the volume used an orifice's
requested discharge while the pipe used a different Rusanov ghost-cell flux.
Each boundary now supplies one integrated mass, energy and fresh-air flux;
the volume receives the exact opposite exchange. Stationary walls transfer
momentum but no mass or energy. Open-end audio reports the mass flux actually
applied by the finite-volume update.
2. **Substep integration.** Components previously received only the final
boundary flow after several pipe substeps. Every component now updates after
every pipe substep. CFL is recalculated as the solution evolves.
3. **Profiled pipes.** Geometry was stored but absent from the conservation
update. Shared face fluxes now include face area and divide by cell volume;
momentum includes the quasi-1D pressure source `p dA/dx`. Constant-pressure
rest remains stationary while closed profiled pipes conserve mass/energy.
4. **Orifice choking.** The old formula used the upstream sound speed with
throat Mach number, overestimating discharge. The nozzle calculation now
uses the isentropic throat temperature and satisfies the sonic-flow test.
5. **Unstable small-signal boundary.** Evaluating `sqrt(dp)` using only the
adjacent cell-centre pressure destabilized a small Helmholtz excitation.
The nozzle discharge is now matched to the outgoing duct characteristic
using a safeguarded analytic Newton solve of a squared-flow residual.
Bisection remains the fallback. Subsonic, isentropic characteristic and
quasi-steady nozzle assumptions still limit this model.
6. **Inertance units.** For mass flow, `d(mdot)/dt = A dp/L`, not
`A dp/(rho L)`. Dimensionless quadratic resistance is integrated with a
dissipative semi-implicit term. Removed the additional lumped inertance in
the Helmholtz scenario: its resolved neck already carries inertance.
7. **Moving volumes.** Volume0D previously applied work without changing its
geometric volume. Closed adiabatic work now follows `E V^(gamma-1)=constant`
and updates volume. Crankcase geometry work is applied once per geometry
change rather than again for each solver substep. Removed its five-bar cap,
which silently deleted gas during high compression.
8. **Invalid configurations.** Reject invalid geometry, timesteps, duplicate or
missing endpoints and incompatible gas constants. A pressure-only boundary
cannot specify a supersonic inlet; that case now fails explicitly. Direct
pipe joints support equal endpoint areas and all orientation combinations;
unequal-area direct joints are rejected. Use a profiled transition instead.
9. **Performance.** Cache primitive states/wave speeds, evaluate each shared
interior face once, and hoist uniform relaxation outside the cell loop.
Compiler SIMD is enabled without fast-math. No per-step allocations were
introduced into the pipe/boundary kernels.
## Validation
`fluidsim_fluid_validation` contains 29 named cases, in addition to the existing
physics/audio/ECU regression executable. Coverage includes:
- Closed pipes, variable-area equilibrium/conservation, stationary hot contacts.
- Sealed volume–pipe exchange, several volumes and a shared tiny donor.
- Direct joints in four orientations and split/unsplit equivalence.
- Fresh-air species conservation, actual open-end mass accounting on both ends.
- Sonic choking against the analytic ideal-gas formula; inertance units and
steady quadratic resistance.
- Adiabatic moving volumes, thermal exponential relaxation and sealed crankcase
compression beyond five bar without mass deletion.
- Closed/open acoustic reflection signs, closed-duct mode frequencies at
50/100/200 cells, and small-signal cavity resonance.
- Sod shock-tube density error at 100/200/400 cells, strong pressure pulses,
expanding-flow near-vacuum states, positivity-repair checks.
- Invalid geometry/configuration, incompatible gas coupling, unsupported
supersonic inlet and missing/duplicate endpoint rejection.
Representative measured results:
| Check | Result |
| --- | --- |
| Sod density L1, 100 / 200 / 400 cells | 0.02332 / 0.01595 / 0.01052 |
| Closed-duct fundamental | approximately 171.9 Hz; analytic 171.91 Hz |
| Cavity fundamental | approximately 136.2 Hz; lumped estimate 138.51 Hz |
| Closed mass/energy relative tolerances | 2e-5 to 5e-5, depending on case |
| Cavity frequency tolerance | 5%, with growth and positivity checks |
| Closed-duct frequency tolerance | 1% across three resolutions |
The cavity estimate assumes a compact, lossless neck without radiation end
correction; the discretized model includes neck compressibility and numerical
losses. These comparisons are verification of stated assumptions, not a
comparison with measured engine hardware.
## Benchmarks and SIMD
Machine: Intel Core i5-12600K, GCC 16.2.1, Linux x86-64, Release. These are local
wall-clock microbenchmarks; CPU frequency and background load affect timings.
The pipe benchmark advances an equilibrium lossless duct with reflected ends;
the twin benchmark runs one simulated second including its nonlinear boundaries.
Before this audit, isolated kernels measured about 49–52 million cell updates/s
and the twin about 3.84 times real time. Cached shared-face evaluation raised
pipe throughput to roughly 110–140 million updates/s. The final twin is around
1.7 times real time: it does substantially more boundary work than the original,
physically inconsistent solver. Early faster twin numbers from before the
characteristic/nozzle correction must not be treated as final results.
A SIMD comparison measured roughly 111–139 million updates/s with compiler
vectorization versus 84–102 million with vectorization disabled. GCC's
vectorization report confirms 16-byte SIMD in the primitive-state and face-flux
loops. `FLUIDSIM_ENABLE_SIMD=ON` permits vectorization and disables math errno;
it does **not** enable fast-math, approximate reciprocal/square-root intrinsics,
or machine-specific instruction sets. OFF disables compiler vectorization for
repeatable comparison. Scalar and SIMD builds run the same physics tests.
Windows/MSVC settings are provided but were not executed in this Linux audit.
## Multithreading investigation
`fluidsim_thread_benchmark` measures persistent workers with two barriers per
iteration. Boundaries/components must remain in ordered phases; pipes can be
advanced independently only after all boundary fluxes are resolved.
| Independent pipe workload, 1000 steps | Serial | 2 workers | 4 workers |
| --- | ---: | ---: | ---: |
| 6 pipes × 10 cells | 0.00093 s | 0.00272 s | 0.00625 s |
| 64 pipes × 256 cells | 0.1275 s | 0.0700 s | 0.0410 s |
This is an optimistic stage-only benchmark with equilibrium data, excluding
worker startup and a real graph's serial boundary work. It supports further
work on persistent pools for large networks; it does not justify threading the
current small twin. No fluid worker pool is enabled in production. The existing
simulation/audio/UI separation remains.
## Reproduce
```sh
cmake -S . -B build-audit -DFLUIDSIM_BUILD_DESKTOP=OFF -DCMAKE_BUILD_TYPE=Release
cmake --build build-audit -j 4
ctest --test-dir build-audit --output-on-failure
./build-audit/fluidsim_physics_benchmark 44100
./build-audit/fluidsim_thread_benchmark
```
For a scalar comparison configure a separate directory with
`-DFLUIDSIM_ENABLE_SIMD=OFF`. On GCC/Clang, use another directory with
`-DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer"` to
reproduce the sanitizer run. Final Linux desktop regressions, all 29 scalar fluid cases, and both test
executables under AddressSanitizer/UndefinedBehaviorSanitizer passed. The final
sanitizer run completed in about 13 seconds.
Compiler vectorization diagnostics can be produced
with GCC `-fopt-info-vec-optimized`.
## Remaining limits and next work
- First-order Rusanov flux is dissipative. Spatial/time resolution convergence
and attenuation across the full audible band are not yet established. A
higher-order limited scheme is a separate change, not silently enabled here.
- The default four-substep floor was retained: removing it failed a tiny-volume
stress case. CFL alone is not a positivity guarantee for arbitrary stiff
pipe/volume networks. Conservative donor budgets can limit transfer; inspect
`BoundarySystem::limited_fluxes` and refine time resolution when they activate.
Budgets for shared donors are allocated in registration order. Pipe positivity
repairs are counted by `Pipe1D::positivity_repairs`; a nonzero count invalidates
a claim of exact conservation for that run.
- Pipe geometry/cell storage remains public for initialization. Changing its
topology or resizing cells after connections have been built is unsupported.
- The pressure-only open boundary extrapolates interior entropy. It is an ideal
pressure-release model, not a complete external atmosphere, anechoic boundary,
or frequency-dependent radiation impedance. Strong inlet flows need richer
thermodynamic boundary data.
- Junctions and orifices are quasi-1D approximations. Jet separation, vena
contracta, discharge coefficients and strong multidimensional area changes
need validation; equal-area joint tests do not validate arbitrary fittings.
- The optional lumped inertance law is dimensionally checked, but its stored
neck kinetic energy is not a separately tracked thermodynamic state. Use
resolved pipe cells when evaluating physical neck acoustics and energy.
- Core pipe damping and volume thermal relaxation default to zero. Explicit
scenario damping/relaxation still represents empirical source terms and must
be calibrated before claiming real dissipation or heat transfer.
- Engine cylinders retain pressure/temperature safety clamps, approximate
combustion/species bookkeeping and unvalidated mechanical work integration.
ECU behavior and the broader engine model are not certified by this audit.
- Sound radiation remains the documented compact-source approximation. These
fluid tests do not establish microphone SPL, high-frequency radiation,
thermoviscous losses or full mechanical/chemical energy conservation.
## Physical references
- [Clawpack: approximate Euler Riemann solvers](https://www.clawpack.org/riemann_book/html/Euler_approximate.html)
— conservative Euler fluxes and total-energy equation of state.
- [Clawpack: Euler equations and exact Riemann solutions](https://www.clawpack.org/riemann_book/html/Euler.html)
— shock/contact/rarefaction verification and the Sod problem.
- [NASA Glenn: mass-flow choking](https://www.grc.nasa.gov/www/k-12/BGP/mflchk.html)
— isentropic throat relations and maximum mass flow at Mach one.
- [EPFL: quasi-1D Laval nozzle model](https://www.epfl.ch/labs/mathicse/wp-content/uploads/2018/10/25.2016_MP-FN-PL.pdf)
— variable-area Euler fluxes and geometric pressure source.
## Sustained-load follow-up
A one-second run missed a runtime deadline problem: at full throttle the heated
gas raised average CFL work from 4 to approximately 7 substeps per audio frame.
By simulated second 5, each second needed about 1.04 seconds of CPU time, so a
finite startup buffer could not prevent eventual underruns. Idle stayed near
0.6 seconds per simulated second.
The solver now reuses its CFL primitive-state/wave-speed preparation in the
finite-volume update. Boundary Newton residual tolerance is matched to the
single-precision conserved state (`4e-7` in the normalized squared-flow
residual), rather than solving to `1e-12` relative residual each time. The same
CFL limits and four-substep floor remain. On the same machine, a nine-second
full-throttle run measured approximately 0.74–0.81 seconds per simulated second
in the hot portion; idle measured approximately 0.49–0.52 seconds. All 29 fluid
cases and existing regressions still pass. This is measured headroom, not a
hard real-time guarantee on other workloads or machines.
Reproduce sustained-load timings with:
```sh
./build-audit/fluidsim_physics_benchmark --soak 12
```
The benchmark reports each simulated second, average substeps/frame and
boundary iterations/frame. Desktop audio also counts underrun blocks; the
counter is shown with the audio-buffer status when a UI font is available.
## Intake reservoir and repeated-blip regression
The twin's upstream pressure-only boundary inherited pipe entropy during
inflow. Repeated blips could therefore admit progressively hotter gas rather
than fresh ambient air: a captured sequence reached approximately 1140 K in
the plenum at wide-open throttle and lost fresh charge density. The intake now
connects to an explicit infinite `Atmosphere` reservoir (101325 Pa, 300 K,
fresh-air fraction one), updated on every fluid substep. Intake radiation uses
that reservoir's actual signed mass exchange. The remaining pressure-release
open-end index now refers only to the exhaust.
The twin also disables pipe relaxation toward ambient **pressure** energy.
That source can heat low-density evacuated cells without a physical wall heat
transfer law. This does not impose any pressure floor or artificial manifold
refill. Hot residual-gas reversion, closed-throttle pumping and pressure waves
remain possible; their engine-model accuracy still needs further validation.
`fluidsim_intake_regression` runs 16 simulated seconds: idle, an 80% blip,
recovery, a full-throttle opening, and return to idle. It checks pressure
recovery, fresh-air temperature/composition, finite audio, zero pipe positivity
repairs and final idle recovery. Representative results after the fix:
- Before the second opening: 35.76 kPa manifold absolute pressure.
- After opening: 85.45 kPa near 9950 RPM; manifold 298.96 K, inlet 296.92 K.
- After return to idle: 34.51 kPa at 1820 RPM.
Manifold vacuum at small/closed throttle is expected and is not removed by the
fix. An actual obstruction/regression is distinguished by failure to recover
pressure and fresh-charge density when reopening the throttle. The inlet
reservoir controls the upstream gas state; it does not thermostat the manifold.
See [AEM's engine-management manual](https://documents.aemelectronics.com/techlibrary_30-6905_universal_programmable_engine_management_system.pdf)
for the qualitative throttle-angle/manifold-pressure relationship. These are
scenario regression bounds, not universal pressure limits for real engines.
All three CTest executables pass after this change (existing regressions,
29-case fluid suite, and the new intake transient). A six-second sustained
full-throttle follow-up stayed at four substeps/frame and approximately
0.51–0.53 seconds of wall time per simulated second, with RPM around the ECU
rev limit. This also identifies the earlier hot-gas CFL growth as a consequence
of the incorrect inlet thermodynamic state, not solely an unavoidable engine
workload increase. The earlier timing sections are historical measurements of
those intermediate implementations.

109
verification/TURBO_VZ21.md Normal file
View File

@@ -0,0 +1,109 @@
# VZ21-size turbo prototype
Linux Release, 2026-10-23. Core is ready for an early interactive test. Further
map fitting, acoustic work and performance tuning are deferred at the user's
request so testing can start. No blade-passing whistle has been implemented yet.
## Model
Four finite 0-D gas volumes connect compressor inlet/outlet and turbine
inlet/outlet to existing `BoundarySystem` pipe orifices. Register the turbo once
with `Solver`; do not separately register its internal volumes. Internal flows
are bidirectional and choked where appropriate. Donor budgets preserve positive
mass/energy. Compensated 0-D volume accumulation prevents sub-ulp mass drift.
The compressor's estimated Euler head is psi*(omega*R)^2. Efficiency maps this
to a virtual stagnation pressure; a compressible restriction determines actual
flow against the downstream pressure. The turbine flow follows an effective
nozzle. Its torque uses a mean-line speed-ratio efficiency parabola, with finite
starting torque. Reverse flow is passive; deep surge/reverse-wheel aerodynamics
are not validated. These correlations are not a measured compressor/turbine map.
A rigid shaft obeys I*domega/dt = turbine torque - compressor torque - bearing
and windage torque. Implicit midpoint work balances the exact change in rotor
kinetic energy, including starting from rest. Adaptive shaft steps prevent
large timesteps from reversing the rotor numerically. Turbine work is removed
from gas enthalpy; compressor work is added to charge enthalpy. Bearing loss is
tracked as heat outside the gas circuits. No shaft-speed or boost clamp is used.
Estimated default parameters: inertia 4e-6 kg m^2, compressor effective radius
18 mm, turbine mean effective radius 13.5 mm, efficiencies .65, compressor head
coefficient .6, effective compressor/turbine flow areas 110/100 mm^2. Housing
volumes are 20/30/30/20 cc. Connection bores are 24/24/25/26 mm. Viscous bearing
coefficient is 1e-7 Nm/(rad/s), windage coefficient 2e-11 Nm/(rad/s)^2.
The estimated wastegate has an 800 mm^2 pressure diaphragm, 40 N preload,
6000 N/m spring, 120 Ns/m overdamped linkage, 14 mm valve and 4 mm maximum lift.
Compressor delivery gauge pressure drives its opening. Bypass gas transfers
enthalpy without delivering turbine shaft work. The actuator is an idealized
external pressure-control linkage; its mechanical/thermal energy is outside the
audited gas/rotor system. There is no intercooler, blow-off valve, oil-temperature
model, knock model or thermal/wheel failure model.
## Reference basis
[Ecotrons VZ21 specification](https://ecotrons.com/files/ECOTRONS%20Turbo%20VZ21%20technical%20specl.pdf)
provides rough applications and casing dimensions; its performance illustrations
explicitly refer to a similar IHI family rather than measured VZ21 maps. Those
figures cannot validate the coefficients used here. A
[supplier's VZ21 listing](https://www.maxpeedingrods.nl/en/products/rhb31-vz21-turbocharger-junta-100ps-for-motor-pequeno-rhino-motocicleta-at-vz21-npvl)
lists 24.2/37.1 mm compressor and 35/26.7 mm turbine inducer/exducer diameters.
The model's effective radii remain estimates.
[NASA compressor/turbine work relations](https://www.grc.nasa.gov/WWW/K-12/BGP/ctmatch.html)
provide the ideal-gas thermodynamic basis. Conservation checks establish
numerical consistency, not predictive accuracy for a real VZ21.
## Verification
All nine CTest suites passed after the changes. New checks cover stationary
equilibrium, startup, closed mass/energy balance, a pre-spun rotor, large shaft
timestep passivity, reverse flow, choking, subcritical backpressure response,
wastegate bypass, hot pulsing pipe coupling and twin throttle/limiter/release.
Closed 10 ms runs: measured mass drift zero; energy error -0.16 microjoule from
rest and -0.71 microjoule with an initially 120000 RPM shaft. Energy totals
include all gas, rotor and bearing heat. A hot 80 Hz pulsing pipe bench reached
189122 RPM, delivered .03067 kg/s charge at 376 K, and retained turbine power
pulses of 1189..4022 W. No internal transfer limiting or pipe repairs occurred.
The seven-second twin transient reached 236218 RPM, peak charge pressure
181.46 kPa absolute, peak instantaneous turbine-inlet pressure 674.10 kPa and
2.78 mm gate lift. It coasted down to 151755 RPM after release. Audio peak was
.02933 full scale, with no clipping; accumulated turbo energy residual was
-8.8 microjoules. High shaft speeds are model output, not validated operating
limits or evidence that a real VZ21 would survive them.
Same-engine-speed dyno comparison, .7 pedal, averaging the last half-second of
each two-second run:
| RPM | Turbo | MAP kPa | Exhaust collector kPa | Shaft kW | Charge K | Shaft turbo RPM |
| ---: | :---: | ---: | ---: | ---: | ---: | ---: |
| 4000 | no | 93.30 | 110.00 | 23.54 | — | — |
| 4000 | yes | 151.31 | 172.99 | 24.01 | 387.58 | 194130 |
| 7000 | no | 84.56 | 115.02 | 40.64 | — | — |
| 7000 | yes | 147.13 | 226.09 | 39.69 | 397.86 | 214361 |
Pressure and temperature costs are retained: boost does not guarantee more
shaft power. There is no forced power gain. Effective area/efficiency, gas
properties and the base engine's approximate combustion/overlap affect these
numbers strongly. They are prototype comparisons, not real engine predictions.
The benchmark used four solver substeps per output frame. The turbo cost .81
CPU seconds per simulated second at 4000 RPM and 1.28 at 7000 RPM, versus .53
for the unboosted twin. Sustained high-load real-time playback is an open issue;
monitor underruns. The mixed transient cost .84 wall seconds per simulated
second. Tests run headlessly; latest Windows runtime is unverified.
## Run
```sh
cmake --build build-desktop --target fluidsim_desktop -j 4
./build-desktop/fluidsim_desktop --scenario twin --turbo vz21 --scene field
cmake --build build-audit --target fluidsim_turbo_benchmark -j 4
./build-audit/fluidsim_turbo_benchmark
```
The startup menu also offers the turbo twin. WAV capture accepts
`--scenario twin --turbo vz21`. Blade-passing acoustics are the next requested
part: they must follow rotor geometry/loading and be filtered before 44.1 kHz
sampling, rather than adding an arbitrary sound or aliased pressure ripple.

View File

@@ -0,0 +1,108 @@
# YZ125 baseline verification
Linux Release checks, 2026-10-23. All six CTest suites passed. This is a
YZ125-sized simulation baseline, not a measured replica of a particular year.
## Changes and physical basis
Pipes already supported per-cell areas/lengths and the geometric momentum
source for varying area. The earlier exhaust was a small expansion chamber,
but the UI drew all pipes with pressure-dependent thickness. The display now
uses physical diameter and axial cell length. A shared profile builder preserves
exact conical-frustum volume across mesh refinements.
The new estimated racing pipe has these dimensions (metres):
| Section | Length | Inlet diameter | Outlet diameter |
| --- | ---: | ---: | ---: |
| Header | .160 | .036 | .036 |
| First diffuser | .180 | .036 | .060 |
| Second diffuser | .190 | .060 | .105 |
| Belly | .130 | .105 | .105 |
| Baffle cone | .210 | .105 | .022 |
| Stinger | .240 | .022 | .022 |
Default mesh maximum length is 10 mm. The straight comparison has the same
total length and a uniform 36 mm bore. These are design estimates, not Yamaha
factory pipe dimensions. The test pulse returned -34.33 Pa from a diffuser and
+27.52 Pa from a convergent, with no positivity repairs. The intended negative
scavenging pulse and later positive plugging pulse follow the mechanism in
[SAE 680469](https://saemobilus.sae.org/papers/investigation-tuning-exhaust-system-small-two-stroke-cycle-engines-680469).
Two-stroke port areas now come from piston travel past rectangular port roofs,
replacing the poppet-style curtain-area curve. Assumed transfer/exhaust durations
are 130/190 degrees and aggregate widths 50/36 mm. Trapped compression is 8.2:1;
clearance volume is derived at exhaust closure, rather than confusing this with
full-stroke geometric compression. The exhaust power valve remains fixed open.
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.
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
crank torque with one revolution per cycle. Coefficients are generic estimates
within the ranges discussed in this
[Chalmers engine-model study](https://publications.lib.chalmers.se/records/fulltext/147782.pdf),
not a YZ125 coast-down fit. Crankcase pumping work is already modeled separately.
No speed clamp or rev limiter was added.
Drivetrain ratios use Yamaha's
[2022 YZ125 specification](https://global.yamaha-motor.com/jp/news/2021/0730/yz125.html):
primary 64/19, final 49/13, and six ratios approximately 2.384, 1.933, 1.588,
1.352, 1.176, 1.055. Nominal rear radius is .3313 m from the 100/90-19 tire;
loaded rolling radius is unmeasured. Test mass is 95 kg plus an assumed 75 kg
rider. First/sixth overall reductions are 30.276/13.402, replacing the twin's
much taller drivetrain. Other road-load/clutch parameters remain estimates.
The dyno now records brake shaft torque/power before external load reaction;
previously it displayed acceleration torque/power, which becomes zero at loaded
steady speed. An analytic load-balance test checks 10 Nm and 1 kW at 100 rad/s.
## Measurements and limits
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
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 |
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.
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 first-order pipe flux damps high-frequency content; the compact source,
constant-gamma gas, ideal fueling, 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.
## Run and compare
```sh
cmake --build build-desktop --target fluidsim_desktop -j 4
./build-desktop/fluidsim_desktop --scenario two-stroke --exhaust racing --scene field
./build-desktop/fluidsim_desktop --scenario two-stroke --exhaust straight --scene field
cmake --build build-audit --target fluidsim_two_stroke_benchmark -j 4
./build-audit/fluidsim_two_stroke_benchmark
```
Hold C, shift to first with D, apply W, then release C. A/D shift, Z selects
neutral. Up/down adjusts persistent pedal request; R changes environment.
The capture tool accepts the same exhaust option. Current captures are under
`build/audio/yz125-{racing,straight}-{rendered,dry,unfiltered}.wav`.