From 589d22aa5e53adef26a7230e438583a9e4f9a97e Mon Sep 17 00:00:00 2001 From: grillkol Date: Fri, 23 Oct 2026 02:27:03 +0200 Subject: [PATCH] Add turbocharged twin scenario and cross-platform test harness --- .vscode/extensions.json | 6 + .vscode/launch.json | 38 ++ .vscode/settings.json | 6 + .vscode/tasks.json | 59 ++ CMakeLists.txt | 78 ++- CMakePresets.json | 41 ++ README.md | 213 ++++++- include/FluidSim/Atmosphere.hpp | 2 + include/FluidSim/AudioOutputStream.hpp | 7 + include/FluidSim/AudioProcessing.hpp | 12 +- include/FluidSim/Component.hpp | 2 + include/FluidSim/Crankcase.hpp | 2 + include/FluidSim/DesktopScenario.hpp | 87 +++ include/FluidSim/Ecu.hpp | 38 ++ include/FluidSim/Engine.hpp | 8 + include/FluidSim/Fluid.hpp | 753 ++++++++++++++++--------- include/FluidSim/HelmholtzScenario.hpp | 6 +- include/FluidSim/MotorcycleTest.hpp | 22 + include/FluidSim/OutdoorAudio.hpp | 72 +++ include/FluidSim/PipeProfile.hpp | 34 ++ include/FluidSim/ReedValve.hpp | 47 ++ include/FluidSim/Scenario.hpp | 87 ++- include/FluidSim/Solver.hpp | 3 +- include/FluidSim/SoundEngine.hpp | 1 + include/FluidSim/Turbocharger.hpp | 78 +++ include/FluidSim/TwoStrokeConfig.hpp | 38 ++ include/FluidSim/TwoStrokeCylinder.hpp | 9 +- include/FluidSim/TwoStrokeScenario.hpp | 36 +- include/FluidSim/Vehicle.hpp | 6 +- src/audio_output_stream.cpp | 51 +- src/audio_processing.cpp | 30 +- src/crankcase.cpp | 32 +- src/desktop_main.cpp | 362 +++++++++--- src/ecu.cpp | 56 ++ src/engine.cpp | 35 +- src/helmholtz_scenario.cpp | 3 +- src/main.cpp | 33 +- src/outdoor_audio.cpp | 171 ++++++ src/scenario.cpp | 327 ++++++----- src/simulation_ring_buffer.cpp | 8 +- src/solver.cpp | 33 +- src/turbocharger.cpp | 179 ++++++ src/two_stroke_cylinder.cpp | 103 ++-- src/two_stroke_scenario.cpp | 177 +++--- tests/b205_regression.cpp | 75 +++ tests/fluid_validation.cpp | 625 ++++++++++++++++++++ tests/intake_regression.cpp | 51 ++ tests/outdoor_audio_tests.cpp | 114 ++++ tests/physics_tests.cpp | 77 ++- tests/turbo_physics.cpp | 115 ++++ tests/turbo_twin_regression.cpp | 43 ++ tests/two_stroke_physics.cpp | 103 ++++ tests/two_stroke_regression.cpp | 87 +++ tools/audio_capture.cpp | 161 ++++++ tools/physics_benchmark.cpp | 62 ++ tools/thread_benchmark.cpp | 54 ++ tools/turbo_benchmark.cpp | 48 ++ tools/two_stroke_benchmark.cpp | 37 ++ verification/AUDIO_RENDERING.md | 97 ++++ verification/B205_BASELINE.md | 56 ++ verification/FLUID_PHYSICS_AUDIT.md | 261 +++++++++ verification/TURBO_VZ21.md | 109 ++++ verification/YZ125_BASELINE.md | 108 ++++ 63 files changed, 4903 insertions(+), 771 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 CMakePresets.json create mode 100644 include/FluidSim/DesktopScenario.hpp create mode 100644 include/FluidSim/Ecu.hpp create mode 100644 include/FluidSim/MotorcycleTest.hpp create mode 100644 include/FluidSim/OutdoorAudio.hpp create mode 100644 include/FluidSim/PipeProfile.hpp create mode 100644 include/FluidSim/ReedValve.hpp create mode 100644 include/FluidSim/Turbocharger.hpp create mode 100644 include/FluidSim/TwoStrokeConfig.hpp create mode 100644 src/ecu.cpp create mode 100644 src/outdoor_audio.cpp create mode 100644 src/turbocharger.cpp create mode 100644 tests/b205_regression.cpp create mode 100644 tests/fluid_validation.cpp create mode 100644 tests/intake_regression.cpp create mode 100644 tests/outdoor_audio_tests.cpp create mode 100644 tests/turbo_physics.cpp create mode 100644 tests/turbo_twin_regression.cpp create mode 100644 tests/two_stroke_physics.cpp create mode 100644 tests/two_stroke_regression.cpp create mode 100644 tools/audio_capture.cpp create mode 100644 tools/physics_benchmark.cpp create mode 100644 tools/thread_benchmark.cpp create mode 100644 tools/turbo_benchmark.cpp create mode 100644 tools/two_stroke_benchmark.cpp create mode 100644 verification/AUDIO_RENDERING.md create mode 100644 verification/B205_BASELINE.md create mode 100644 verification/FLUID_PHYSICS_AUDIT.md create mode 100644 verification/TURBO_VZ21.md create mode 100644 verification/YZ125_BASELINE.md diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..9710b19 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "ms-vscode.cpptools", + "ms-vscode.cmake-tools" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..d13d7c6 --- /dev/null +++ b/.vscode/launch.json @@ -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" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5ee88a7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "C_Cpp.debugShortcut": false, + "C_Cpp.default.configurationProvider": "ms-vscode.cmake-tools", + "cmake.useCMakePresets": "always", + "cmake.buildBeforeRun": true +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..fb941cd --- /dev/null +++ b/.vscode/tasks.json @@ -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" + ] + } + ] +} diff --git a/CMakeLists.txt b/CMakeLists.txt index 16af7a7..07f65ff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 "$/fonts" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${FLUIDSIM_FONT_FILE}" + "$/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 "$/fonts" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/../fonts/FiraCodeNerdFont-Medium.ttf" + "$/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() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..7ac3aa7 --- /dev/null +++ b/CMakePresets.json @@ -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" + } + ] +} diff --git a/README.md b/README.md index dd11872..6f66abb 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/include/FluidSim/Atmosphere.hpp b/include/FluidSim/Atmosphere.hpp index c9bc006..aa51d6c 100644 --- a/include/FluidSim/Atmosphere.hpp +++ b/include/FluidSim/Atmosphere.hpp @@ -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; diff --git a/include/FluidSim/AudioOutputStream.hpp b/include/FluidSim/AudioOutputStream.hpp index f3d4e41..e15ec66 100644 --- a/include/FluidSim/AudioOutputStream.hpp +++ b/include/FluidSim/AudioOutputStream.hpp @@ -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 temp_left_{}, temp_right_{}; std::array output_{}; + std::atomic underrun_blocks_{}; std::atomic 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; }; diff --git a/include/FluidSim/AudioProcessing.hpp b/include/FluidSim/AudioProcessing.hpp index a05a0e7..1ca507d 100644 --- a/include/FluidSim/AudioProcessing.hpp +++ b/include/FluidSim/AudioProcessing.hpp @@ -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); diff --git a/include/FluidSim/Component.hpp b/include/FluidSim/Component.hpp index 476cbd9..d99376d 100644 --- a/include/FluidSim/Component.hpp +++ b/include/FluidSim/Component.hpp @@ -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{}; }; diff --git a/include/FluidSim/Crankcase.hpp b/include/FluidSim/Crankcase.hpp index 10d9589..f285b22 100644 --- a/include/FluidSim/Crankcase.hpp +++ b/include/FluidSim/Crankcase.hpp @@ -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_{}; diff --git a/include/FluidSim/DesktopScenario.hpp b/include/FluidSim/DesktopScenario.hpp new file mode 100644 index 0000000..009991d --- /dev/null +++ b/include/FluidSim/DesktopScenario.hpp @@ -0,0 +1,87 @@ +#pragma once +#include "FluidSim/Scenario.hpp" +#include "FluidSim/TwoStrokeScenario.hpp" +#include +#include + +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(44100, config); + else + twin_ = std::make_unique(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 void visit(Function&& function) { + const std::lock_guard lock(mutex_); + if (twin_) function(*twin_); + else function(*two_); + } + std::unique_ptr twin_; + std::unique_ptr two_; + mutable std::mutex mutex_; +}; +} // namespace fluidsim::port diff --git a/include/FluidSim/Ecu.hpp b/include/FluidSim/Ecu.hpp new file mode 100644 index 0000000..01c6a24 --- /dev/null +++ b/include/FluidSim/Ecu.hpp @@ -0,0 +1,38 @@ +#pragma once +#include + +namespace fluidsim::port { + +// Ideal sensors and injectors; calibration parameters are not engine measurements. +class Ecu { +public: + struct IgnitionPoint { float rpm, degrees_btdc; }; + std::vector 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 diff --git a/include/FluidSim/Engine.hpp b/include/FluidSim/Engine.hpp index ce3ec1a..8343057 100644 --- a/include/FluidSim/Engine.hpp +++ b/include/FluidSim/Engine.hpp @@ -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(); diff --git a/include/FluidSim/Fluid.hpp b/include/FluidSim/Fluid.hpp index 3519766..880a897 100644 --- a/include/FluidSim/Fluid.hpp +++ b/include/FluidSim/Fluid.hpp @@ -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() { + Port &create_port() { ports_.emplace_back(); - auto& p = ports_.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,61 +45,93 @@ 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_) { + 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(); } void update_state(float dt) override { update(dt); } - std::vector ports() override { - std::vector result; + std::vector ports() override { + std::vector 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& ports() const { return ports_; } + const std::deque &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 ports_; - void sync_port(Port& p) const { + 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,286 +139,489 @@ 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 cell_areas, cell_lengths; std::vector cells; GasState left_ghost{}, right_ghost{}; - Pipe1D(float area_m2, float length_m, int cell_count) - : area(area_m2), dx(length_m / static_cast(cell_count)), - cell_areas(static_cast(cell_count), area_m2), - cell_lengths(static_cast(cell_count), dx), - cells(static_cast(cell_count)), next_cells_(static_cast(cell_count)) {} - Pipe1D(std::vector areas_m2, std::vector 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 areas, std::vector 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(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::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::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(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(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(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(idx)); } - float cell_length(int idx) const { return cell_lengths.at(static_cast(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(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(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(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(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 next_cells_; - GasState flux_state(int idx) const { - const auto& c = cells[static_cast(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 states_; + std::vector waves_; + std::vector faces_; + Flux left_flux_{}, right_flux_{}; + bool left_override_{}, right_override_{}, prepared_{}; + static std::vector uniform(float v, int n) { + if (n <= 0) + throw std::invalid_argument("pipe count must be positive"); + return std::vector(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))}; } }; class BoundarySystem { public: - explicit BoundarySystem(std::vector& 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); + explicit BoundarySystem(std::vector &pipes) : pipes_(pipes) {} + void add_orifice(Port &port, int pipe, bool left, float &area, float cd = 1.0f) { + 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(it - budgets_.begin()); + } + orifices_.push_back(o); } - 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; + 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(a) >= pipes_.size() || + static_cast(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::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{}; + 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 *area{}; + float cd{1}, length{}, loss{}, mdot{}; + std::size_t budget{std::numeric_limits::max()}; }; - 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& pipes_; + 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; + }; + std::vector &pipes_; std::vector orifices_; std::vector 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 connections_; + std::vector budgets_; + std::vector claims_; + void check_face(int pipe, bool left) const { + if (pipe < 0 || static_cast(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}); } - void resolve_orifice(Orifice& o, float dt) { - Pipe1D& pipe = pipes_.at(static_cast(o.pipe)); - const int idx = o.left ? 0 : static_cast(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; + static int end(const Pipe1D &p, bool left) { return left ? 0 : static_cast(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) { + 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; - } - 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; - } - 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; + 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); } - solution.mass_flow = mdot; - solution.rho = rho_up; + float mdot = static_cast(nozzle_flow(duct_pressure)); + const bool upstream_port = mdot >= 0; + const float pup = upstream_port ? v.pressure : static_cast(duct_pressure); + const float ratio = + static_cast(std::max((upstream_port ? duct_pressure : v.pressure) / pup, critical)); + const float throat_rho = + (upstream_port ? v.density : static_cast(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)); } - - 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; + 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); + } + 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(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; } - void resolve_open_end(Open& o, float dt) { - Pipe1D& pipe = pipes_.at(static_cast(o.pipe)); - const int idx = o.left ? 0 : static_cast(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_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_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}); } }; diff --git a/include/FluidSim/HelmholtzScenario.hpp b/include/FluidSim/HelmholtzScenario.hpp index e9eae17..87e48a9 100644 --- a/include/FluidSim/HelmholtzScenario.hpp +++ b/include/FluidSim/HelmholtzScenario.hpp @@ -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 namespace fluidsim::port { diff --git a/include/FluidSim/MotorcycleTest.hpp b/include/FluidSim/MotorcycleTest.hpp new file mode 100644 index 0000000..b55601b --- /dev/null +++ b/include/FluidSim/MotorcycleTest.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "FluidSim/Engine.hpp" +#include "FluidSim/Vehicle.hpp" +#include + +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 diff --git a/include/FluidSim/OutdoorAudio.hpp b/include/FluidSim/OutdoorAudio.hpp new file mode 100644 index 0000000..1bcd3aa --- /dev/null +++ b/include/FluidSim/OutdoorAudio.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include "FluidSim/AudioProcessing.hpp" +#include +#include +#include + +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 filtered, raw; + std::vector paths; + std::size_t position{}; + float previous_flow{}; + LowPass filter; + }; + struct TailLine { + std::vector buffer; + std::size_t position{}; + float gain{}, state{}; + }; + int rate_{}; + OutdoorAudioConfig config_; + OutdoorScene scene_{OutdoorScene::Wall}; + bool monitor_filter_{true}; + std::array emitters_; + std::array tail_; + std::array 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 diff --git a/include/FluidSim/PipeProfile.hpp b/include/FluidSim/PipeProfile.hpp new file mode 100644 index 0000000..cc3d0d6 --- /dev/null +++ b/include/FluidSim/PipeProfile.hpp @@ -0,0 +1,34 @@ +#pragma once +#include "FluidSim/Fluid.hpp" +#include + +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 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 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(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 diff --git a/include/FluidSim/ReedValve.hpp b/include/FluidSim/ReedValve.hpp new file mode 100644 index 0000000..2624498 --- /dev/null +++ b/include/FluidSim/ReedValve.hpp @@ -0,0 +1,47 @@ +#pragma once +#include +#include +#include + +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 diff --git a/include/FluidSim/Scenario.hpp b/include/FluidSim/Scenario.hpp index 46a4f6a..fed1c79 100644 --- a/include/FluidSim/Scenario.hpp +++ b/include/FluidSim/Scenario.hpp @@ -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 #include #include #include @@ -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 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, 6> pipe_states; + std::array, 12> pipe_states; + std::array, 12> pipe_areas, pipe_lengths; + void capture_pipes(const std::vector& 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(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 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 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 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 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 create_pipes(); + static std::vector 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 diff --git a/include/FluidSim/Solver.hpp b/include/FluidSim/Solver.hpp index 11248e4..54bb5ae 100644 --- a/include/FluidSim/Solver.hpp +++ b/include/FluidSim/Solver.hpp @@ -10,6 +10,7 @@ namespace fluidsim::port { class Solver { public: Solver(std::vector& 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& pipes_; BoundarySystem& boundaries_; std::vector components_; - std::uint64_t step_count_{}; + std::uint64_t step_count_{}, substep_count_{}; }; } // namespace fluidsim::port diff --git a/include/FluidSim/SoundEngine.hpp b/include/FluidSim/SoundEngine.hpp index 95a6e28..abf76a3 100644 --- a/include/FluidSim/SoundEngine.hpp +++ b/include/FluidSim/SoundEngine.hpp @@ -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: diff --git a/include/FluidSim/Turbocharger.hpp b/include/FluidSim/Turbocharger.hpp new file mode 100644 index 0000000..3998eeb --- /dev/null +++ b/include/FluidSim/Turbocharger.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include "FluidSim/Fluid.hpp" +#include +#include + +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 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& 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 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 internal_; + std::array 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 diff --git a/include/FluidSim/TwoStrokeConfig.hpp b/include/FluidSim/TwoStrokeConfig.hpp new file mode 100644 index 0000000..f6e5a38 --- /dev/null +++ b/include/FluidSim/TwoStrokeConfig.hpp @@ -0,0 +1,38 @@ +#pragma once +#include "FluidSim/PipeProfile.hpp" +#include + +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 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 diff --git a/include/FluidSim/TwoStrokeCylinder.hpp b/include/FluidSim/TwoStrokeCylinder.hpp index d511376..1128715 100644 --- a/include/FluidSim/TwoStrokeCylinder.hpp +++ b/include/FluidSim/TwoStrokeCylinder.hpp @@ -1,6 +1,7 @@ #pragma once #include "FluidSim/Crankcase.hpp" +#include 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); }; diff --git a/include/FluidSim/TwoStrokeScenario.hpp b/include/FluidSim/TwoStrokeScenario.hpp index b919dde..abc7341 100644 --- a/include/FluidSim/TwoStrokeScenario.hpp +++ b/include/FluidSim/TwoStrokeScenario.hpp @@ -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 #include @@ -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 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 create_pipes(); + static std::vector create_pipes(const TwoStrokeConfig& config); }; } // namespace fluidsim::port diff --git a/include/FluidSim/Vehicle.hpp b/include/FluidSim/Vehicle.hpp index 3633c80..94a96f3 100644 --- a/include/FluidSim/Vehicle.hpp +++ b/include/FluidSim/Vehicle.hpp @@ -1,14 +1,14 @@ #pragma once -#include +#include 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 gear_ratios{4.1f, 2.7f, 2.0f, 1.55f, 1.25f, 0.98f}; + std::vector 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}; diff --git a/src/audio_output_stream.cpp b/src/audio_output_stream.cpp index 34d5cd5..75a7359 100644 --- a/src/audio_output_stream.cpp +++ b/src/audio_output_stream.cpp @@ -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]; - 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(finite_left * 32767.0f); - output_[i * 2 + 1] = static_cast(finite_right * 32767.0f); + // 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; + if (starving_) { + transition_left_ = last_left_ - left; + transition_right_ = last_right_ - right; + transition_remaining_ = transition_samples; + starving_ = false; + } + const float weight = static_cast(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(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(last_left_ * 32767.0f); + output_[i * 2 + 1] = static_cast(last_right_ * 32767.0f); } - std::fill(output_.begin() + static_cast(read * 2), output_.end(), 0); data.samples = output_.data(); data.sampleCount = output_.size(); return true; diff --git a/src/audio_processing.cpp b/src/audio_processing.cpp index a132db9..de16d04 100644 --- a/src/audio_processing.cpp +++ b/src/audio_processing.cpp @@ -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(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(std::max(sample_rate, 1))) { const std::size_t max_delay = static_cast(std::max(1.0f, - 0.5f / std::max(speed_of_sound, 1.0f) * static_cast(sample_rate_))); + 100.0f / std::max(speed_of_sound, 1.0f) * static_cast(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& 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}; } diff --git a/src/crankcase.cpp b/src/crankcase.cpp index 152b9d6..d65e82d 100644 --- a/src/crankcase.cpp +++ b/src/crankcase.cpp @@ -2,6 +2,7 @@ #include #include +#include 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(); } diff --git a/src/desktop_main.cpp b/src/desktop_main.cpp index f837322..79cc543 100644 --- a/src/desktop_main.cpp +++ b/src/desktop_main.cpp @@ -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 #include #include +#include +#include #include #include #include @@ -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 choose_scenario(sf::RenderWindow& window, const sf::Font* font) { + const std::array 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 titles{"600 cc parallel twin", "YZ125 two-stroke", "Saab B205 / 9-5 wagon", "600 cc twin + VZ21-size turbo"}; + const std::array 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()) return std::nullopt; + if (const auto* key = event->getIf()) { + 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(selected); + default: break; + } + } + if (const auto* move = event->getIf()) { + 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(); + 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(i); + if (start_button.contains(point)) return static_cast(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(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(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, - float throttle_target, float brake, double playback_speed, std::size_t buffer_fill, - std::size_t buffer_capacity, bool time_warp, DynoCurve& dyno_curve) { +void draw_dashboard(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state, + float throttle_target, float brake, double playback_speed, std::size_t buffer_fill, + std::size_t buffer_capacity, bool time_warp, std::uint64_t underrun_blocks) { + std::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(state.rpm)) + + " Power " + std::to_string(state.average_power / 1000.0f) + " kW" + + " Vehicle kinetic energy " + std::to_string(static_cast(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<(state.rpm)) + - " Power " + std::to_string(state.average_power / 1000.0f) + " kW" + - " Bike kinetic energy " + std::to_string(static_cast(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 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()); - const sf::Font* font_ptr = has_font ? &font : nullptr; + std::vector 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)) { - const AudioFrame frame = scenario.process(); - if (!ring.write(frame.left, frame.right, stop)) break; + 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)) 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((static_cast(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(); diff --git a/src/ecu.cpp b/src/ecu.cpp new file mode 100644 index 0000000..c2e4cb0 --- /dev/null +++ b/src/ecu.cpp @@ -0,0 +1,56 @@ +#include "FluidSim/Ecu.hpp" +#include +#include + +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 diff --git a/src/engine.cpp b/src/engine.cpp index 328422c..6fcc378 100644 --- a/src/engine.cpp +++ b/src/engine.cpp @@ -1,4 +1,5 @@ #include "FluidSim/Engine.hpp" +#include "FluidSim/Ecu.hpp" #include #include @@ -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(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); - spark_advance = std::clamp(10.0f + rpm * 0.002f, 5.0f, 40.0f); - update_rev_limiter(rpm, dt); + 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; diff --git a/src/helmholtz_scenario.cpp b/src/helmholtz_scenario.cpp index 6b050ad..ffbf429 100644 --- a/src/helmholtz_scenario.cpp +++ b/src/helmholtz_scenario.cpp @@ -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_); diff --git a/src/main.cpp b/src/main.cpp index 8cacea4..9bb3d65 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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(duration_seconds * sample_rate); const auto report_interval = static_cast(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; diff --git a/src/outdoor_audio.cpp b/src/outdoor_audio.cpp new file mode 100644 index 0000000..2d5a770 --- /dev/null +++ b/src/outdoor_audio.cpp @@ -0,0 +1,171 @@ +#include "FluidSim/Constants.hpp" +#include "FluidSim/OutdoorAudio.hpp" +#include +#include +#include +#include + +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 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{{{-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 &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 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 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 diff --git a/src/scenario.cpp b/src/scenario.cpp index 396351d..b35e4e8 100644 --- a/src/scenario.cpp +++ b/src/scenario.cpp @@ -5,168 +5,184 @@ namespace fluidsim::port { -FourStrokeTwinScenario::FourStrokeTwinScenario(int sample_rate) - : pipes(create_pipes()), - dt_(1.0f / static_cast(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 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; + } + 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); + } } - - 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); + 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(); + 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 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 guard(mutex_); audio_.set_scene(scene); +} +void FourStrokeScenario::set_monitor_filter(bool enabled) { + const std::lock_guard guard(mutex_); audio_.set_monitor_filter(enabled); +} +AudioFrame FourStrokeScenario::dry_audio() const { + const std::lock_guard guard(mutex_); return audio_.dry_frame(); +} +AudioFrame FourStrokeScenario::unfiltered_audio() const { + const std::lock_guard guard(mutex_); return audio_.unfiltered_frame(); +} + +void FourStrokeScenario::set_controls(float throttle_value, float load_value, float clutch_value) { const std::lock_guard 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 guard(mutex_); mechanical_sound_enabled = !mechanical_sound_enabled; } -void FourStrokeTwinScenario::shift_up() { +void FourStrokeScenario::shift_up() { const std::lock_guard guard(mutex_); vehicle.shift_up(); } -void FourStrokeTwinScenario::shift_down() { +void FourStrokeScenario::shift_down() { const std::lock_guard guard(mutex_); vehicle.shift_down(); } -void FourStrokeTwinScenario::set_neutral() { +void FourStrokeScenario::set_neutral() { const std::lock_guard guard(mutex_); vehicle.set_neutral(); } -void FourStrokeTwinScenario::set_first_gear() { +void FourStrokeScenario::set_first_gear() { const std::lock_guard guard(mutex_); vehicle.set_first_gear(); } -ScenarioSnapshot FourStrokeTwinScenario::snapshot() const { +ScenarioSnapshot FourStrokeScenario::snapshot() const { const std::lock_guard 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,31 +213,39 @@ 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(cell))); + result.cylinder_count = cylinder_count(); + result.rev_limit_rpm = ecu.rev_limit_rpm; + const std::array 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 FourStrokeScenario::area_for_diameter(float diameter) { + return pi * diameter * diameter * 0.25f; +} + +std::vector FourStrokeScenario::create_pipes(FourStrokePreset preset, bool turbo_enabled) { + const bool saab = preset == FourStrokePreset::SaabB205; + const int count = saab ? 4 : 2; + std::vector result; + 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; } -float FourStrokeTwinScenario::area_for_diameter(float diameter) { - return pi * diameter * diameter * 0.25f; -} - -std::vector FourStrokeTwinScenario::create_pipes() { - const float intake_area = area_for_diameter(0.0275f); - const float exhaust_area = area_for_diameter(0.028f); - std::vector 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); - return result; -} - } // namespace fluidsim::port diff --git a/src/simulation_ring_buffer.cpp b/src/simulation_ring_buffer.cpp index d46f66c..35707ee 100644 --- a/src/simulation_ring_buffer.cpp +++ b/src/simulation_ring_buffer.cpp @@ -2,6 +2,7 @@ #include #include +#include #include 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(written - read); + const auto written = write_head_.load(std::memory_order_acquire); + return std::min(left_.size(), static_cast(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 dst_left, std::span 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(read_head_.load(std::memory_order_acquire)); @@ -70,7 +72,7 @@ std::size_t SimulationRingBuffer::read_interpolated(std::span dst_left, const float fraction = static_cast(read_position_ - static_cast(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(read_position_); const auto previous_read_head = read_head_.load(std::memory_order_relaxed); diff --git a/src/solver.cpp b/src/solver.cpp index 92a4b87..e502919 100644 --- a/src/solver.cpp +++ b/src/solver.cpp @@ -4,23 +4,34 @@ namespace fluidsim::port { -Solver::Solver(std::vector& pipes, BoundarySystem& boundaries) +Solver::Solver(std::vector &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(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(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(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_; } diff --git a/src/turbocharger.cpp b/src/turbocharger.cpp new file mode 100644 index 0000000..4651d39 --- /dev/null +++ b/src/turbocharger.cpp @@ -0,0 +1,179 @@ +#include "FluidSim/Turbocharger.hpp" +#include +#include +#include + +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& 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 indices{ci, co, ti, to}; + const std::array 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 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 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 diff --git a/src/two_stroke_cylinder.cpp b/src/two_stroke_cylinder.cpp index 2313d2c..9585a49 100644 --- a/src/two_stroke_cylinder.cpp +++ b/src/two_stroke_cylinder.cpp @@ -1,3 +1,4 @@ +#include "FluidSim/Ecu.hpp" #include "FluidSim/TwoStrokeCylinder.hpp" #include @@ -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*/) { +void TwoStrokeCylinder::pre_step(Crankshaft &crank, float dt, float /*throttle*/) { const float rpm = crank.omega * 60.0f / (2.0f * pi); - spark_advance = std::clamp(10.0f + rpm * 0.002f, 5.0f, 40.0f); - update_rev_limiter(rpm, dt); + 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; diff --git a/src/two_stroke_scenario.cpp b/src/two_stroke_scenario.cpp index b685f9b..a71bea2 100644 --- a/src/two_stroke_scenario.cpp +++ b/src/two_stroke_scenario.cpp @@ -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(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(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) { + 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(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(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(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, - cylinder.valve_area(crankshaft.angle * 180.0f / pi, true), - cylinder.valve_area(crankshaft.angle * 180.0f / pi, false), reed_open_ - }; + 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_, + 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 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 TwoStrokeScenario::create_pipes(const TwoStrokeConfig& config) { std::vector 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(exhaust_cells * header_length / total_length); - const int diffuser_cells = static_cast(exhaust_cells * diffuser_length / total_length); - const int belly_cells = static_cast(exhaust_cells * belly_length / total_length); - const int convergent_cells = static_cast(exhaust_cells * convergent_length / total_length); - const int stinger_cells = exhaust_cells - header_cells - diffuser_cells - belly_cells - convergent_cells; - std::vector exhaust_areas; - std::vector 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(count)); - } - }; - append_uniform(header_cells, header_diameter, header_length); - for (int i = 0; i < diffuser_cells; ++i) { - const float t = diffuser_cells > 1 ? static_cast(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(diffuser_cells)); + 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(belly_cells, belly_diameter, belly_length); - for (int i = 0; i < convergent_cells; ++i) { - const float t = convergent_cells > 1 ? static_cast(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(convergent_cells)); - } - append_uniform(stinger_cells, stinger_diameter, stinger_length); - result.emplace_back(std::move(exhaust_areas), std::move(exhaust_lengths)); return result; } diff --git a/tests/b205_regression.cpp b/tests/b205_regression.cpp new file mode 100644 index 0000000..b97f651 --- /dev/null +++ b/tests/b205_regression.cpp @@ -0,0 +1,75 @@ +#include "FluidSim/Scenario.hpp" +#include +#include +#include +#include +#include +#include + +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 burning{}; + std::vector 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 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(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"; +} diff --git a/tests/fluid_validation.cpp b/tests/fluid_validation.cpp new file mode 100644 index 0000000..774ccd0 --- /dev/null +++ b/tests/fluid_validation.cpp @@ -0,0 +1,625 @@ +#include "FluidSim/Atmosphere.hpp" +#include "FluidSim/Crankcase.hpp" +#include "FluidSim/Fluid.hpp" +#include "FluidSim/Solver.hpp" +#include +#include +#include +#include +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 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 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 a(40), l(40, .025f); + for (int i = 0; i < 40; i++) + a[i] = .001f * (1 + .5f * std::sin(i * .1f)); + std::vector 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 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 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 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 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 a(80), l(80, .01f); + for (int i = 0; i < 80; i++) + a[i] = .001f * (1 + .7f * std::sin(.1f * i)); + std::vector 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; +} diff --git a/tests/intake_regression.cpp b/tests/intake_regression.cpp new file mode 100644 index 0000000..517dbb3 --- /dev/null +++ b/tests/intake_regression.cpp @@ -0,0 +1,51 @@ +#include "FluidSim/Scenario.hpp" +#include +#include +#include +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'; +} diff --git a/tests/outdoor_audio_tests.cpp b/tests/outdoor_audio_tests.cpp new file mode 100644 index 0000000..f0ee35b --- /dev/null +++ b/tests/outdoor_audio_tests.cpp @@ -0,0 +1,114 @@ +#include "FluidSim/OutdoorAudio.hpp" +#include "FluidSim/ReedValve.hpp" +#include +#include +#include +#include +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"; +} diff --git a/tests/physics_tests.cpp b/tests/physics_tests.cpp index da7699f..173819f 100644 --- a/tests/physics_tests.cpp +++ b/tests/physics_tests.cpp @@ -1,4 +1,6 @@ #include "FluidSim/Scenario.hpp" +#include "FluidSim/SimulationRingBuffer.hpp" +#include #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 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(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(); diff --git a/tests/turbo_physics.cpp b/tests/turbo_physics.cpp new file mode 100644 index 0000000..717434c --- /dev/null +++ b/tests/turbo_physics.cpp @@ -0,0 +1,115 @@ +#include "FluidSim/Turbocharger.hpp" +#include "FluidSim/Atmosphere.hpp" +#include "FluidSim/Solver.hpp" +#include +#include + +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 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 areas; + const std::array 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"; +} diff --git a/tests/turbo_twin_regression.cpp b/tests/turbo_twin_regression.cpp new file mode 100644 index 0000000..bf06e97 --- /dev/null +++ b/tests/turbo_twin_regression.cpp @@ -0,0 +1,43 @@ +#include "FluidSim/Scenario.hpp" +#include +#include +#include +#include +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"; +} diff --git a/tests/two_stroke_physics.cpp b/tests/two_stroke_physics.cpp new file mode 100644 index 0000000..3eac4ac --- /dev/null +++ b/tests/two_stroke_physics.cpp @@ -0,0 +1,103 @@ +#include "FluidSim/TwoStrokeScenario.hpp" +#include +#include + +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 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 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'; +} diff --git a/tests/two_stroke_regression.cpp b/tests/two_stroke_regression.cpp new file mode 100644 index 0000000..e4c0752 --- /dev/null +++ b/tests/two_stroke_regression.cpp @@ -0,0 +1,87 @@ +#include "FluidSim/TwoStrokeScenario.hpp" +#include "FluidSim/DynoCurve.hpp" +#include +#include +#include +#include + +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"; +} diff --git a/tools/audio_capture.cpp b/tools/audio_capture.cpp new file mode 100644 index 0000000..4b228ee --- /dev/null +++ b/tools/audio_capture.cpp @@ -0,0 +1,161 @@ +#include "FluidSim/Scenario.hpp" +#include "FluidSim/TwoStrokeScenario.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +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::lround(std::clamp(x, -1.f, 1.f) * 32767)); + integer(static_cast(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 +void capture(const std::string &prefix, int frames, float pedal, OutdoorScene scene, TwoStrokeConfig config) { + std::unique_ptr s; + if constexpr (std::is_same_v) s = std::make_unique(44100, config); + else s = std::make_unique(); + 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(prefix, frames, pedal, selected, config); + else if (scenario == "twin") + capture(prefix, frames, pedal, selected, config); + else if (scenario == "b205") + capture(prefix, frames, pedal, selected, config); + else if (scenario == "two-stroke") + capture(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; + } +} diff --git a/tools/physics_benchmark.cpp b/tools/physics_benchmark.cpp new file mode 100644 index 0000000..80f7772 --- /dev/null +++ b/tools/physics_benchmark.cpp @@ -0,0 +1,62 @@ +#include "FluidSim/PulseTestScenario.hpp" +#include "FluidSim/Scenario.hpp" +#include +#include +#include +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(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(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(std::chrono::steady_clock::now() - t).count(); + std::cout << "twin seconds=" << sec << " realtime_factor=" << samples / 44100.0 / sec + << " checksum=" << sum << '\n'; +} diff --git a/tools/thread_benchmark.cpp b/tools/thread_benchmark.cpp new file mode 100644 index 0000000..92c9f97 --- /dev/null +++ b/tools/thread_benchmark.cpp @@ -0,0 +1,54 @@ +#include "FluidSim/Fluid.hpp" +#include +#include +#include +#include +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 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 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(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'; + } + } +} diff --git a/tools/turbo_benchmark.cpp b/tools/turbo_benchmark.cpp new file mode 100644 index 0000000..02d1094 --- /dev/null +++ b/tools/turbo_benchmark.cpp @@ -0,0 +1,48 @@ +#include "FluidSim/Scenario.hpp" +#include +#include +#include +#include +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(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; + } + } +} diff --git a/tools/two_stroke_benchmark.cpp b/tools/two_stroke_benchmark.cpp new file mode 100644 index 0000000..46863fc --- /dev/null +++ b/tools/two_stroke_benchmark.cpp @@ -0,0 +1,37 @@ +#include "FluidSim/TwoStrokeScenario.hpp" +#include +#include + +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(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; + } + } +} diff --git a/verification/AUDIO_RENDERING.md b/verification/AUDIO_RENDERING.md new file mode 100644 index 0000000..89376fb --- /dev/null +++ b/verification/AUDIO_RENDERING.md @@ -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. diff --git a/verification/B205_BASELINE.md b/verification/B205_BASELINE.md new file mode 100644 index 0000000..27eb374 --- /dev/null +++ b/verification/B205_BASELINE.md @@ -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. diff --git a/verification/FLUID_PHYSICS_AUDIT.md b/verification/FLUID_PHYSICS_AUDIT.md new file mode 100644 index 0000000..a9233c6 --- /dev/null +++ b/verification/FLUID_PHYSICS_AUDIT.md @@ -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. diff --git a/verification/TURBO_VZ21.md b/verification/TURBO_VZ21.md new file mode 100644 index 0000000..ffa8f22 --- /dev/null +++ b/verification/TURBO_VZ21.md @@ -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. diff --git a/verification/YZ125_BASELINE.md b/verification/YZ125_BASELINE.md new file mode 100644 index 0000000..60a8a39 --- /dev/null +++ b/verification/YZ125_BASELINE.md @@ -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`.