Add turbocharged twin scenario and cross-platform test harness
This commit is contained in:
6
.vscode/extensions.json
vendored
Normal file
6
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"ms-vscode.cpptools",
|
||||||
|
"ms-vscode.cmake-tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
38
.vscode/launch.json
vendored
Normal file
38
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "FluidSim desktop",
|
||||||
|
"type": "node-terminal",
|
||||||
|
"request": "launch",
|
||||||
|
"command": "./build-desktop/fluidsim_desktop",
|
||||||
|
"windows": {
|
||||||
|
"command": ".\\build\\windows\\Release\\fluidsim_desktop.exe"
|
||||||
|
},
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"preLaunchTask": "FluidSim: build desktop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FluidSim two-stroke",
|
||||||
|
"type": "node-terminal",
|
||||||
|
"request": "launch",
|
||||||
|
"command": "./build-desktop/fluidsim_desktop --scenario two-stroke",
|
||||||
|
"windows": {
|
||||||
|
"command": ".\\build\\windows\\Release\\fluidsim_desktop.exe --scenario two-stroke"
|
||||||
|
},
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"preLaunchTask": "FluidSim: build desktop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FluidSim Saab B205",
|
||||||
|
"type": "node-terminal",
|
||||||
|
"request": "launch",
|
||||||
|
"command": "./build-desktop/fluidsim_desktop --scenario b205",
|
||||||
|
"windows": {
|
||||||
|
"command": ".\\build\\windows\\Release\\fluidsim_desktop.exe --scenario b205"
|
||||||
|
},
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"preLaunchTask": "FluidSim: build desktop"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"C_Cpp.debugShortcut": false,
|
||||||
|
"C_Cpp.default.configurationProvider": "ms-vscode.cmake-tools",
|
||||||
|
"cmake.useCMakePresets": "always",
|
||||||
|
"cmake.buildBeforeRun": true
|
||||||
|
}
|
||||||
59
.vscode/tasks.json
vendored
Normal file
59
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "FluidSim: configure desktop",
|
||||||
|
"type": "process",
|
||||||
|
"command": "cmake",
|
||||||
|
"args": [
|
||||||
|
"--preset",
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"windows": {
|
||||||
|
"command": "cmake",
|
||||||
|
"args": [
|
||||||
|
"--preset",
|
||||||
|
"windows"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "FluidSim: build desktop",
|
||||||
|
"type": "process",
|
||||||
|
"command": "cmake",
|
||||||
|
"args": [
|
||||||
|
"--build",
|
||||||
|
"--preset",
|
||||||
|
"linux",
|
||||||
|
"--target",
|
||||||
|
"fluidsim_desktop"
|
||||||
|
],
|
||||||
|
"windows": {
|
||||||
|
"command": "cmake",
|
||||||
|
"args": [
|
||||||
|
"--build",
|
||||||
|
"--preset",
|
||||||
|
"windows",
|
||||||
|
"--target",
|
||||||
|
"fluidsim_desktop"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"dependsOn": "FluidSim: configure desktop",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
"problemMatcher": [
|
||||||
|
"$gcc",
|
||||||
|
"$msCompile"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ cmake_minimum_required(VERSION 3.28)
|
|||||||
project(FluidSimCpp VERSION 0.1.0 LANGUAGES CXX)
|
project(FluidSimCpp VERSION 0.1.0 LANGUAGES CXX)
|
||||||
|
|
||||||
option(FLUIDSIM_BUILD_DESKTOP "Build the SFML windowed simulator with keyboard controls and audio" ON)
|
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
|
add_library(fluidsim_core
|
||||||
|
src/ecu.cpp
|
||||||
|
src/turbocharger.cpp
|
||||||
src/engine.cpp
|
src/engine.cpp
|
||||||
src/crankcase.cpp
|
src/crankcase.cpp
|
||||||
src/two_stroke_cylinder.cpp
|
src/two_stroke_cylinder.cpp
|
||||||
@@ -11,6 +14,7 @@ add_library(fluidsim_core
|
|||||||
src/helmholtz_scenario.cpp
|
src/helmholtz_scenario.cpp
|
||||||
src/pulse_test_scenario.cpp
|
src/pulse_test_scenario.cpp
|
||||||
src/audio_processing.cpp
|
src/audio_processing.cpp
|
||||||
|
src/outdoor_audio.cpp
|
||||||
src/outdoor_exhaust_reverb.cpp
|
src/outdoor_exhaust_reverb.cpp
|
||||||
src/mechanical_sound_processor.cpp
|
src/mechanical_sound_processor.cpp
|
||||||
src/scenario.cpp
|
src/scenario.cpp
|
||||||
@@ -20,6 +24,18 @@ add_library(fluidsim_core
|
|||||||
)
|
)
|
||||||
target_include_directories(fluidsim_core PUBLIC include)
|
target_include_directories(fluidsim_core PUBLIC include)
|
||||||
target_compile_features(fluidsim_core PUBLIC cxx_std_20)
|
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)
|
add_executable(fluidsim_cpp src/main.cpp)
|
||||||
target_link_libraries(fluidsim_cpp PRIVATE fluidsim_core)
|
target_link_libraries(fluidsim_cpp PRIVATE fluidsim_core)
|
||||||
@@ -54,7 +70,23 @@ if(FLUIDSIM_BUILD_DESKTOP)
|
|||||||
else()
|
else()
|
||||||
target_compile_options(fluidsim_desktop PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(fluidsim_desktop PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
endif()
|
endif()
|
||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/../fonts" DESTINATION "${CMAKE_CURRENT_BINARY_DIR}")
|
if(FLUIDSIM_FONT_FILE)
|
||||||
|
if(NOT EXISTS "${FLUIDSIM_FONT_FILE}")
|
||||||
|
message(FATAL_ERROR "FLUIDSIM_FONT_FILE does not exist: ${FLUIDSIM_FONT_FILE}")
|
||||||
|
endif()
|
||||||
|
add_custom_command(TARGET fluidsim_desktop POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:fluidsim_desktop>/fonts"
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${FLUIDSIM_FONT_FILE}"
|
||||||
|
"$<TARGET_FILE_DIR:fluidsim_desktop>/fonts/FiraCodeNerdFont-Medium.ttf")
|
||||||
|
elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/../fonts/FiraCodeNerdFont-Medium.ttf")
|
||||||
|
add_custom_command(TARGET fluidsim_desktop POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:fluidsim_desktop>/fonts"
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/../fonts/FiraCodeNerdFont-Medium.ttf"
|
||||||
|
"$<TARGET_FILE_DIR:fluidsim_desktop>/fonts/FiraCodeNerdFont-Medium.ttf")
|
||||||
|
else()
|
||||||
|
message(STATUS "No bundled font; desktop will try installed system fonts. Set FLUIDSIM_FONT_FILE to bundle one.")
|
||||||
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if(MSVC)
|
if(MSVC)
|
||||||
@@ -73,3 +105,47 @@ if(BUILD_TESTING)
|
|||||||
target_link_libraries(fluidsim_physics_tests PRIVATE fluidsim_core)
|
target_link_libraries(fluidsim_physics_tests PRIVATE fluidsim_core)
|
||||||
add_test(NAME fluidsim_physics_tests COMMAND fluidsim_physics_tests)
|
add_test(NAME fluidsim_physics_tests COMMAND fluidsim_physics_tests)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
add_executable(fluidsim_physics_benchmark tools/physics_benchmark.cpp)
|
||||||
|
target_link_libraries(fluidsim_physics_benchmark PRIVATE fluidsim_core)
|
||||||
|
if(BUILD_TESTING)
|
||||||
|
add_executable(fluidsim_fluid_validation tests/fluid_validation.cpp)
|
||||||
|
target_link_libraries(fluidsim_fluid_validation PRIVATE fluidsim_core)
|
||||||
|
add_test(NAME fluidsim_fluid_validation COMMAND fluidsim_fluid_validation)
|
||||||
|
endif()
|
||||||
|
find_package(Threads REQUIRED)
|
||||||
|
target_link_libraries(fluidsim_core PUBLIC Threads::Threads)
|
||||||
|
add_executable(fluidsim_thread_benchmark tools/thread_benchmark.cpp)
|
||||||
|
target_link_libraries(fluidsim_thread_benchmark PRIVATE fluidsim_core)
|
||||||
|
if(BUILD_TESTING)
|
||||||
|
add_executable(fluidsim_intake_regression tests/intake_regression.cpp)
|
||||||
|
target_link_libraries(fluidsim_intake_regression PRIVATE fluidsim_core)
|
||||||
|
add_test(NAME fluidsim_intake_regression COMMAND fluidsim_intake_regression)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_executable(fluidsim_audio_capture tools/audio_capture.cpp)
|
||||||
|
target_link_libraries(fluidsim_audio_capture PRIVATE fluidsim_core)
|
||||||
|
add_executable(fluidsim_two_stroke_benchmark tools/two_stroke_benchmark.cpp)
|
||||||
|
target_link_libraries(fluidsim_two_stroke_benchmark PRIVATE fluidsim_core)
|
||||||
|
add_executable(fluidsim_turbo_benchmark tools/turbo_benchmark.cpp)
|
||||||
|
target_link_libraries(fluidsim_turbo_benchmark PRIVATE fluidsim_core)
|
||||||
|
if(BUILD_TESTING)
|
||||||
|
add_executable(fluidsim_outdoor_audio_tests tests/outdoor_audio_tests.cpp)
|
||||||
|
target_link_libraries(fluidsim_outdoor_audio_tests PRIVATE fluidsim_core)
|
||||||
|
add_test(NAME fluidsim_outdoor_audio_tests COMMAND fluidsim_outdoor_audio_tests)
|
||||||
|
add_executable(fluidsim_two_stroke_regression tests/two_stroke_regression.cpp)
|
||||||
|
target_link_libraries(fluidsim_two_stroke_regression PRIVATE fluidsim_core)
|
||||||
|
add_test(NAME fluidsim_two_stroke_regression COMMAND fluidsim_two_stroke_regression)
|
||||||
|
add_executable(fluidsim_two_stroke_physics tests/two_stroke_physics.cpp)
|
||||||
|
target_link_libraries(fluidsim_two_stroke_physics PRIVATE fluidsim_core)
|
||||||
|
add_test(NAME fluidsim_two_stroke_physics COMMAND fluidsim_two_stroke_physics)
|
||||||
|
add_executable(fluidsim_b205_regression tests/b205_regression.cpp)
|
||||||
|
target_link_libraries(fluidsim_b205_regression PRIVATE fluidsim_core)
|
||||||
|
add_test(NAME fluidsim_b205_regression COMMAND fluidsim_b205_regression)
|
||||||
|
add_executable(fluidsim_turbo_physics tests/turbo_physics.cpp)
|
||||||
|
target_link_libraries(fluidsim_turbo_physics PRIVATE fluidsim_core)
|
||||||
|
add_test(NAME fluidsim_turbo_physics COMMAND fluidsim_turbo_physics)
|
||||||
|
add_executable(fluidsim_turbo_twin_regression tests/turbo_twin_regression.cpp)
|
||||||
|
target_link_libraries(fluidsim_turbo_twin_regression PRIVATE fluidsim_core)
|
||||||
|
add_test(NAME fluidsim_turbo_twin_regression COMMAND fluidsim_turbo_twin_regression)
|
||||||
|
endif()
|
||||||
|
|||||||
41
CMakePresets.json
Normal file
41
CMakePresets.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"version": 6,
|
||||||
|
"cmakeMinimumRequired": {
|
||||||
|
"major": 3,
|
||||||
|
"minor": 28,
|
||||||
|
"patch": 0
|
||||||
|
},
|
||||||
|
"configurePresets": [
|
||||||
|
{
|
||||||
|
"name": "linux",
|
||||||
|
"displayName": "Linux (Make, desktop)",
|
||||||
|
"generator": "Unix Makefiles",
|
||||||
|
"binaryDir": "${sourceDir}/build-desktop",
|
||||||
|
"cacheVariables": {
|
||||||
|
"CMAKE_BUILD_TYPE": "Release",
|
||||||
|
"FLUIDSIM_BUILD_DESKTOP": "ON"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "windows",
|
||||||
|
"displayName": "Windows (Visual Studio 2022, desktop)",
|
||||||
|
"generator": "Visual Studio 17 2022",
|
||||||
|
"architecture": "x64",
|
||||||
|
"binaryDir": "${sourceDir}/build/windows",
|
||||||
|
"cacheVariables": {
|
||||||
|
"FLUIDSIM_BUILD_DESKTOP": "ON"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"buildPresets": [
|
||||||
|
{
|
||||||
|
"name": "linux",
|
||||||
|
"configurePreset": "linux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "windows",
|
||||||
|
"configurePreset": "windows",
|
||||||
|
"configuration": "Release"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
213
README.md
213
README.md
@@ -1,7 +1,16 @@
|
|||||||
# FluidSimCpp
|
# FluidSimCpp
|
||||||
|
|
||||||
Native C++20 port of FluidSim. The C# application remains the reference while
|
FluidSimCpp aims to reproduce sound from a physically accurate simulation of
|
||||||
the component models and behavior are being ported and checked against it.
|
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)
|
## Build (Windows / Visual Studio)
|
||||||
|
|
||||||
@@ -14,6 +23,28 @@ cmake --build build
|
|||||||
ctest --test-dir build --output-on-failure
|
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
|
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
|
window and audio stream. `-DFLUIDSIM_BUILD_DESKTOP=OFF` builds only the headless
|
||||||
runner and physics tests.
|
runner and physics tests.
|
||||||
@@ -47,6 +78,111 @@ cmake --build build-msvc --target fluidsim_parity_trace
|
|||||||
.\verification\Compare-ScenarioTrace.ps1
|
.\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
|
## Current port status
|
||||||
|
|
||||||
The C++ core now includes the shared `EngineCylinder` model with four-stroke and
|
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,
|
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
|
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
|
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
|
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
|
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.
|
pressure pulse) have headless C++ counterparts and can be selected by the CLI.
|
||||||
Remaining parity work includes completing the C# drawing/diagnostic controls in
|
Remaining parity work includes completing the C# drawing/diagnostic controls in
|
||||||
the desktop UI and resolving numerical differences reported by the comparison
|
the desktop UI and resolving numerical differences reported by the comparison
|
||||||
harness. The latest MSVC build completed, CTest passed, and the one-second
|
harness. Earlier Windows builds passed CTest, but the current audio and two-stroke
|
||||||
headless twin run completed with finite output. The desktop executable opened,
|
changes still need Windows runtime verification. The Linux desktop builds and
|
||||||
but its client area rendered blank in the captured run, so desktop rendering is
|
runs; installed-font fallbacks provide labels when no bundled font is present.
|
||||||
not yet verified.
|
|
||||||
|
|
||||||
The one-second twin trace remains substantially outside its 1% tolerance. The
|
The one-second twin trace remains substantially outside its 1% tolerance. The
|
||||||
latest comparison's largest relative difference is 40.6745 for cylinder 1
|
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,
|
thermodynamics, crankcase and atmosphere state, profiled-pipe geometry,
|
||||||
inertive and closed boundaries, gearing and clutch momentum transfer, finite
|
inertive and closed boundaries, gearing and clutch momentum transfer, finite
|
||||||
runs for every scenario, and finite audio/reverb output.
|
runs for every scenario, and finite audio/reverb output.
|
||||||
|
|
||||||
|
## ECU and throttle control
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
void update_port() {
|
void update_port() {
|
||||||
|
port.gamma_value = gamma_value;
|
||||||
|
port.gas_constant_value = gas_constant_value;
|
||||||
port.pressure = pressure;
|
port.pressure = pressure;
|
||||||
port.density = density();
|
port.density = density();
|
||||||
port.temperature = temperature;
|
port.temperature = temperature;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class AudioOutputStream final : public sf::SoundStream {
|
|||||||
public:
|
public:
|
||||||
explicit AudioOutputStream(SimulationRingBuffer& ring, unsigned int sample_rate = 44100);
|
explicit AudioOutputStream(SimulationRingBuffer& ring, unsigned int sample_rate = 44100);
|
||||||
void set_speed(double speed) { speed_.store(speed, std::memory_order_relaxed); }
|
void set_speed(double speed) { speed_.store(speed, std::memory_order_relaxed); }
|
||||||
|
std::uint64_t underrun_blocks() const { return underrun_blocks_.load(std::memory_order_relaxed); }
|
||||||
double speed() const { return speed_.load(std::memory_order_relaxed); }
|
double speed() const { return speed_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -19,10 +20,16 @@ private:
|
|||||||
SimulationRingBuffer& ring_;
|
SimulationRingBuffer& ring_;
|
||||||
std::array<float, block_size> temp_left_{}, temp_right_{};
|
std::array<float, block_size> temp_left_{}, temp_right_{};
|
||||||
std::array<std::int16_t, block_size * 2> output_{};
|
std::array<std::int16_t, block_size * 2> output_{};
|
||||||
|
std::atomic<std::uint64_t> underrun_blocks_{};
|
||||||
std::atomic<double> speed_{1.0};
|
std::atomic<double> speed_{1.0};
|
||||||
float previous_input_left_{}, previous_output_left_{};
|
float previous_input_left_{}, previous_output_left_{};
|
||||||
float previous_input_right_{}, previous_output_right_{};
|
float previous_input_right_{}, previous_output_right_{};
|
||||||
|
|
||||||
|
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;
|
bool onGetData(Chunk& data) override;
|
||||||
void onSeek(sf::Time time_offset) override;
|
void onSeek(sf::Time time_offset) override;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,21 +10,21 @@ struct Vec3 { float x{}, y{}, z{}; };
|
|||||||
// Flow-derivative sound source used by the C# two-stroke and pulse scenarios.
|
// Flow-derivative sound source used by the C# two-stroke and pulse scenarios.
|
||||||
class SoundProcessor {
|
class SoundProcessor {
|
||||||
public:
|
public:
|
||||||
float gain{1.0f};
|
float gain{0.005f};
|
||||||
explicit SoundProcessor(int sample_rate = 44100, float listener_distance = 1.0f);
|
explicit SoundProcessor(int sample_rate = 44100, float listener_distance = 1.0f);
|
||||||
float process(float mass_flow_out);
|
float process(float mass_flow_out);
|
||||||
private:
|
private:
|
||||||
float dt_{}, scale_factor_{}, lowpass_alpha_{}, derivative_alpha_{};
|
float dt_{}, scale_factor_{}, previous_flow_{};
|
||||||
float flow_lowpass_{}, previous_flow_{}, smooth_derivative_{};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class RadiationSource {
|
class RadiationSource {
|
||||||
public:
|
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 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};
|
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 ear_spacing{0.18f}, head_shadow_cutoff{0.0f}, speed_of_sound{343.0f};
|
||||||
float directivity_balance{0.30f}, air_absorption_exponent{0.002f}, min_air_cutoff{1500.0f};
|
float directivity_balance{0.0f}, air_absorption_exponent{0.0f}, min_air_cutoff{1500.0f};
|
||||||
|
|
||||||
explicit RadiationSource(int sample_rate = 44100);
|
explicit RadiationSource(int sample_rate = 44100);
|
||||||
AudioFrame process(float mass_flow);
|
AudioFrame process(float mass_flow);
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ struct Port {
|
|||||||
float pressure{101325.0f};
|
float pressure{101325.0f};
|
||||||
float density{1.1768f};
|
float density{1.1768f};
|
||||||
float temperature{300.0f};
|
float temperature{300.0f};
|
||||||
|
float gamma_value{1.4f}, gas_constant_value{287.0f};
|
||||||
float air_fraction{1.0f};
|
float air_fraction{1.0f};
|
||||||
|
float* owner_energy{};
|
||||||
float* owner_air_mass{};
|
float* owner_air_mass{};
|
||||||
float* owner_exhaust_mass{};
|
float* owner_exhaust_mass{};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public:
|
|||||||
float temperature() const { return temperature_; }
|
float temperature() const { return temperature_; }
|
||||||
float density() const { return mass_ / (volume_ > 1e-12f ? volume_ : 1e-12f); }
|
float density() const { return mass_ / (volume_ > 1e-12f ? volume_ : 1e-12f); }
|
||||||
float volume() const { return volume_; }
|
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 mass() const { return mass_; }
|
||||||
float air_fraction() const { return air_fraction_; }
|
float air_fraction() const { return air_fraction_; }
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ private:
|
|||||||
Crankshaft& crankshaft_;
|
Crankshaft& crankshaft_;
|
||||||
float crank_radius_{}, connecting_rod_length_{}, piston_area_{};
|
float crank_radius_{}, connecting_rod_length_{}, piston_area_{};
|
||||||
float clearance_volume_{}, obliquity_{};
|
float clearance_volume_{}, obliquity_{};
|
||||||
|
float geometry_work_{};
|
||||||
float mass_{}, energy_{}, air_fraction_{1.0f};
|
float mass_{}, energy_{}, air_fraction_{1.0f};
|
||||||
float pressure_{101325.0f}, temperature_{300.0f}, volume_{}, previous_volume_{};
|
float pressure_{101325.0f}, temperature_{300.0f}, volume_{}, previous_volume_{};
|
||||||
|
|
||||||
|
|||||||
87
include/FluidSim/DesktopScenario.hpp
Normal file
87
include/FluidSim/DesktopScenario.hpp
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "FluidSim/Scenario.hpp"
|
||||||
|
#include "FluidSim/TwoStrokeScenario.hpp"
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
namespace fluidsim::port {
|
||||||
|
// Shared desktop controls/snapshot contract. Two-stroke access is serialized;
|
||||||
|
// the twin already protects its own simulation state.
|
||||||
|
class DesktopScenario {
|
||||||
|
public:
|
||||||
|
explicit DesktopScenario(ScenarioKind kind, TwoStrokeConfig config = {}) {
|
||||||
|
if (kind == ScenarioKind::TwoStroke)
|
||||||
|
two_ = std::make_unique<TwoStrokeScenario>(44100, config);
|
||||||
|
else
|
||||||
|
twin_ = std::make_unique<FourStrokeScenario>(44100, kind == ScenarioKind::SaabB205 ?
|
||||||
|
FourStrokePreset::SaabB205 : FourStrokePreset::Twin, kind == ScenarioKind::TurboTwin);
|
||||||
|
}
|
||||||
|
AudioFrame process() {
|
||||||
|
if (twin_)
|
||||||
|
return twin_->process();
|
||||||
|
const std::lock_guard lock(mutex_);
|
||||||
|
return two_->process();
|
||||||
|
}
|
||||||
|
void set_controls(float pedal, float load, float clutch) {
|
||||||
|
visit([&](auto& scenario) { scenario.set_controls(pedal, load, clutch); });
|
||||||
|
}
|
||||||
|
void shift_up() { visit([](auto& scenario) { scenario.shift_up(); }); }
|
||||||
|
void shift_down() { visit([](auto& scenario) { scenario.shift_down(); }); }
|
||||||
|
void set_neutral() { visit([](auto& scenario) { scenario.set_neutral(); }); }
|
||||||
|
void toggle_mechanical_sound() {
|
||||||
|
if (twin_)
|
||||||
|
twin_->toggle_mechanical_sound();
|
||||||
|
}
|
||||||
|
void set_audio_scene(OutdoorScene scene) {
|
||||||
|
visit([&](auto& scenario) { scenario.set_audio_scene(scene); });
|
||||||
|
}
|
||||||
|
ScenarioSnapshot snapshot() const {
|
||||||
|
if (twin_)
|
||||||
|
return twin_->snapshot();
|
||||||
|
const std::lock_guard lock(mutex_);
|
||||||
|
const auto source = two_->snapshot();
|
||||||
|
ScenarioSnapshot s;
|
||||||
|
s.two_stroke = true;
|
||||||
|
s.cylinder_count = 1;
|
||||||
|
s.racing_exhaust = two_->configuration.exhaust == TwoStrokeExhaust::Racing;
|
||||||
|
s.rpm = source.rpm;
|
||||||
|
s.speed_kmh = two_->vehicle.speed * 3.6f;
|
||||||
|
s.kinetic_energy = two_->vehicle.kinetic_energy();
|
||||||
|
s.gear = two_->vehicle.current_gear;
|
||||||
|
s.clutch = two_->clutch;
|
||||||
|
s.average_power = two_->crankshaft.average_power;
|
||||||
|
s.average_torque = two_->crankshaft.average_torque;
|
||||||
|
s.cylinder_pressure1 = source.cylinder_pressure;
|
||||||
|
s.cylinder_temperature1 = two_->cylinder.temperature();
|
||||||
|
s.cylinder_piston_fraction1 =
|
||||||
|
(two_->cylinder.volume - two_->cylinder.clearance_volume()) / two_->cylinder.swept_volume();
|
||||||
|
s.crankcase_pressure = source.crankcase_pressure;
|
||||||
|
s.intake_pressure = source.intake_pressure;
|
||||||
|
s.exhaust_pressure = two_->pipes[3].state(0).pressure;
|
||||||
|
s.throttle = source.throttle;
|
||||||
|
s.pedal = source.pedal;
|
||||||
|
s.target_afr = two_->ecu.target_afr();
|
||||||
|
s.valve1_intake_area = source.transfer_area;
|
||||||
|
s.valve1_exhaust_area = source.exhaust_area;
|
||||||
|
s.reed_open = source.reed_open;
|
||||||
|
s.fuel_cut = source.fuel_cut;
|
||||||
|
s.idle_control = two_->ecu.idle_active();
|
||||||
|
s.rev_limit1 = two_->ecu.rev_limited();
|
||||||
|
s.audio_scene = source.audio_scene;
|
||||||
|
s.intake_flow = two_->intake_flow;
|
||||||
|
s.exhaust_flow = two_->exhaust_flow;
|
||||||
|
s.capture_pipes(two_->pipes);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
template<class Function> void visit(Function&& function) {
|
||||||
|
const std::lock_guard lock(mutex_);
|
||||||
|
if (twin_) function(*twin_);
|
||||||
|
else function(*two_);
|
||||||
|
}
|
||||||
|
std::unique_ptr<FourStrokeTwinScenario> twin_;
|
||||||
|
std::unique_ptr<TwoStrokeScenario> two_;
|
||||||
|
mutable std::mutex mutex_;
|
||||||
|
};
|
||||||
|
} // namespace fluidsim::port
|
||||||
38
include/FluidSim/Ecu.hpp
Normal file
38
include/FluidSim/Ecu.hpp
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace fluidsim::port {
|
||||||
|
|
||||||
|
// Ideal sensors and injectors; calibration parameters are not engine measurements.
|
||||||
|
class Ecu {
|
||||||
|
public:
|
||||||
|
struct IgnitionPoint { float rpm, degrees_btdc; };
|
||||||
|
std::vector<IgnitionPoint> ignition_map{{0, 10}, {15000, 40}};
|
||||||
|
float idle_rpm{1800.0f};
|
||||||
|
float idle_base_opening{0.08f}, idle_max_opening{0.25f};
|
||||||
|
float idle_kp{0.0001f}, idle_ki{0.00004f};
|
||||||
|
float throttle_time_constant{0.08f}, throttle_rate{2.0f}; // seconds, full travel/second
|
||||||
|
float rev_limit_rpm{10000.0f}, rev_resume_rpm{9750.0f};
|
||||||
|
bool rev_limit_enabled{true};
|
||||||
|
float overrun_cut_rpm{3500.0f}, overrun_resume_rpm{2400.0f};
|
||||||
|
float idle_spark_gain{0.0f}, idle_minimum_advance{-5.0f}; // degrees/RPM, degrees BTDC
|
||||||
|
float stoichiometric_afr{14.7f}, power_afr{12.8f};
|
||||||
|
|
||||||
|
void step(float dt, float pedal, float rpm);
|
||||||
|
float fuel_for_air(float trapped_air_mass) const;
|
||||||
|
float throttle_opening() const { return opening_; }
|
||||||
|
float throttle_target() const { return target_; }
|
||||||
|
float target_afr() const { return afr_; }
|
||||||
|
float spark_advance() const { return spark_; }
|
||||||
|
bool fuel_cut() const { return limiter_ || overrun_ || fault_; }
|
||||||
|
bool rev_limited() const { return limiter_; }
|
||||||
|
bool idle_active() const { return idle_; }
|
||||||
|
bool sensor_fault() const { return fault_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
float filtered_rpm_{};
|
||||||
|
bool initialized_{};
|
||||||
|
float opening_{}, target_{}, integral_{}, afr_{14.7f}, spark_{10.0f};
|
||||||
|
bool limiter_{}, overrun_{}, fault_{}, idle_{};
|
||||||
|
};
|
||||||
|
} // namespace fluidsim::port
|
||||||
@@ -8,11 +8,14 @@
|
|||||||
|
|
||||||
namespace fluidsim::port {
|
namespace fluidsim::port {
|
||||||
|
|
||||||
|
class Ecu;
|
||||||
|
|
||||||
class Crankshaft {
|
class Crankshaft {
|
||||||
public:
|
public:
|
||||||
float omega{400.0f * 2.0f * pi / 60.0f};
|
float omega{400.0f * 2.0f * pi / 60.0f};
|
||||||
float angle{}, previous_angle{}, inertia{0.2f};
|
float angle{}, previous_angle{}, inertia{0.2f};
|
||||||
float friction_constant{}, friction_viscous{};
|
float friction_constant{}, friction_viscous{};
|
||||||
|
float friction_quadratic{}; // Nm / (rad/s)^2, dissipative windage term
|
||||||
float load_torque{}, drivetrain_torque{};
|
float load_torque{}, drivetrain_torque{};
|
||||||
float average_power{}, average_torque{};
|
float average_power{}, average_torque{};
|
||||||
|
|
||||||
@@ -27,8 +30,10 @@ private:
|
|||||||
|
|
||||||
class EngineCylinder : public Component {
|
class EngineCylinder : public Component {
|
||||||
public:
|
public:
|
||||||
|
Ecu* ecu{}; // Optional ideal electronic fuel/ignition controller.
|
||||||
float bore{0.075f}, stroke{0.068f}, rod{0.112f}, compression{11.5f};
|
float bore{0.075f}, stroke{0.068f}, rod{0.112f}, compression{11.5f};
|
||||||
float intake_valve_diameter{0.030f}, exhaust_valve_diameter{0.028f};
|
float intake_valve_diameter{0.030f}, exhaust_valve_diameter{0.028f};
|
||||||
|
int intake_valve_count{1}, exhaust_valve_count{1};
|
||||||
float intake_valve_lift{0.005f}, exhaust_valve_lift{0.005f};
|
float intake_valve_lift{0.005f}, exhaust_valve_lift{0.005f};
|
||||||
float phase{};
|
float phase{};
|
||||||
float spark_advance{20.0f}, wiebe_a{5.0f}, wiebe_m{2.0f}, burn_duration{60.0f}, burn_start{5.0f};
|
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();
|
float random_unit();
|
||||||
static float wrap_degrees(float value, float cycle_degrees = 720.0f);
|
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);
|
static bool crossed_window_close(float previous, float current, float open, float close);
|
||||||
void update_rev_limiter(float rpm, float dt);
|
void update_rev_limiter(float rpm, float dt);
|
||||||
void sync_ports();
|
void sync_ports();
|
||||||
|
|||||||
@@ -17,18 +17,24 @@ public:
|
|||||||
float gas_constant_value = 287.0f, float gamma_value = 1.4f)
|
float gas_constant_value = 287.0f, float gamma_value = 1.4f)
|
||||||
: gamma(gamma_value), gas_constant(gas_constant_value), volume_(volume_m3),
|
: gamma(gamma_value), gas_constant(gas_constant_value), volume_(volume_m3),
|
||||||
air_mass_(pressure * volume_m3 / (gas_constant_value * temperature)),
|
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 gamma{1.4f};
|
||||||
float gas_constant{287.0f};
|
float gas_constant{287.0f};
|
||||||
float ambient_pressure{101325.0f};
|
float ambient_pressure{101325.0f};
|
||||||
float ambient_temperature{300.0f};
|
float ambient_temperature{300.0f};
|
||||||
float energy_relaxation_rate{10.0f};
|
float energy_relaxation_rate{0.0f};
|
||||||
float volume_change_rate{};
|
float volume_change_rate{};
|
||||||
|
|
||||||
Port& create_port() {
|
Port &create_port() {
|
||||||
ports_.emplace_back();
|
ports_.emplace_back();
|
||||||
auto& p = ports_.back();
|
auto &p = ports_.back();
|
||||||
|
p.owner_energy = &energy_;
|
||||||
p.owner_air_mass = &air_mass_;
|
p.owner_air_mass = &air_mass_;
|
||||||
p.owner_exhaust_mass = &exhaust_mass_;
|
p.owner_exhaust_mass = &exhaust_mass_;
|
||||||
sync_port(p);
|
sync_port(p);
|
||||||
@@ -39,61 +45,93 @@ public:
|
|||||||
float temperature() const { return pressure() / std::max(density() * gas_constant, 1e-9f); }
|
float temperature() const { return pressure() / std::max(density() * gas_constant, 1e-9f); }
|
||||||
float volume() const { return volume_; }
|
float volume() const { return volume_; }
|
||||||
float mass() const { return air_mass_ + exhaust_mass_; }
|
float mass() const { return air_mass_ + exhaust_mass_; }
|
||||||
|
float internal_energy() const { return energy_; }
|
||||||
float air_fraction() const { return air_mass_ / std::max(mass(), 1e-12f); }
|
float air_fraction() const { return air_mass_ / std::max(mass(), 1e-12f); }
|
||||||
void set_pressure(float value, float temp = -1.0f) {
|
void set_pressure(float value, float temp = -1.0f) {
|
||||||
|
if (!std::isfinite(value) || value <= 0 || !std::isfinite(temp))
|
||||||
|
throw std::invalid_argument("invalid volume pressure/temperature");
|
||||||
const float target_t = temp > 0.0f ? temp : temperature();
|
const float target_t = temp > 0.0f ? temp : temperature();
|
||||||
const float total = value * volume_ / (gas_constant * target_t);
|
const float total = value * volume_ / (gas_constant * target_t);
|
||||||
const float fraction = air_fraction();
|
const float fraction = air_fraction();
|
||||||
air_mass_ = total * fraction;
|
air_mass_ = total * fraction;
|
||||||
exhaust_mass_ = total * (1.0f - fraction);
|
exhaust_mass_ = total * (1.0f - fraction);
|
||||||
energy_ = value * volume_ / (gamma - 1.0f);
|
energy_ = value * volume_ / (gamma - 1.0f);
|
||||||
|
air_roundoff_ = exhaust_roundoff_ = energy_roundoff_ = 0;
|
||||||
sync_ports();
|
sync_ports();
|
||||||
}
|
}
|
||||||
void update(float dt) {
|
void update(float dt) {
|
||||||
if (dt <= 0.0f) return;
|
if (!std::isfinite(dt) || dt < 0)
|
||||||
float d_air = 0.0f, d_exhaust = 0.0f, d_energy = 0.0f;
|
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();
|
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;
|
const float flow_fraction = p.mass_flow >= 0.0f ? p.air_fraction : fraction;
|
||||||
d_air += p.mass_flow * flow_fraction;
|
d_air += double(p.mass_flow) * flow_fraction;
|
||||||
d_exhaust += p.mass_flow * (1.0f - flow_fraction);
|
d_exhaust += double(p.mass_flow) * (1.0f - flow_fraction);
|
||||||
d_energy += p.mass_flow * p.enthalpy;
|
d_energy += double(p.mass_flow) * p.enthalpy;
|
||||||
}
|
}
|
||||||
air_mass_ += d_air * dt;
|
accumulate(air_mass_, air_roundoff_, d_air * dt);
|
||||||
exhaust_mass_ += d_exhaust * dt;
|
accumulate(exhaust_mass_, exhaust_roundoff_, d_exhaust * dt);
|
||||||
energy_ += (d_energy - pressure() * volume_change_rate) * 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) {
|
if (energy_relaxation_rate > 0.0f && mass() > 1e-12f) {
|
||||||
const float target = mass() * gas_constant * ambient_temperature / (gamma - 1.0f);
|
const float target = mass() * gas_constant * ambient_temperature / (gamma - 1.0f);
|
||||||
energy_ = target + (energy_ - target) * std::exp(-energy_relaxation_rate * dt);
|
energy_ = target + (energy_ - target) * std::exp(-energy_relaxation_rate * dt);
|
||||||
|
energy_roundoff_ = 0;
|
||||||
}
|
}
|
||||||
if (mass() < 1e-9f || energy_ < 0.0f) {
|
if (!(mass() > 0) || !(energy_ > 0) || !std::isfinite(mass()) || !std::isfinite(energy_))
|
||||||
air_mass_ = 1e-9f;
|
throw std::runtime_error(
|
||||||
exhaust_mass_ = 0.0f;
|
"nonphysical volume state; reduce timestep or check external port fluxes");
|
||||||
energy_ = ambient_pressure * volume_ / (gamma - 1.0f);
|
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);
|
air_mass_ = std::max(air_mass_, 0.0f);
|
||||||
exhaust_mass_ = std::max(exhaust_mass_, 0.0f);
|
exhaust_mass_ = std::max(exhaust_mass_, 0.0f);
|
||||||
sync_ports();
|
sync_ports();
|
||||||
}
|
}
|
||||||
void update_state(float dt) override { update(dt); }
|
void update_state(float dt) override { update(dt); }
|
||||||
std::vector<Port*> ports() override {
|
std::vector<Port *> ports() override {
|
||||||
std::vector<Port*> result;
|
std::vector<Port *> result;
|
||||||
result.reserve(ports_.size());
|
result.reserve(ports_.size());
|
||||||
for (Port& port : ports_) result.push_back(&port);
|
for (Port &port : ports_)
|
||||||
|
result.push_back(&port);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
const std::deque<Port>& ports() const { return ports_; }
|
const std::deque<Port> &ports() const { return ports_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
float volume_, air_mass_{}, exhaust_mass_{}, energy_;
|
float volume_, air_mass_{}, exhaust_mass_{}, energy_;
|
||||||
|
// Preserve sub-ulp transfers without changing the float port/budget ABI.
|
||||||
|
double air_roundoff_{}, exhaust_roundoff_{}, energy_roundoff_{};
|
||||||
|
static void accumulate(float& value, double& roundoff, double increment) {
|
||||||
|
const double precise = double(value)+roundoff+increment;
|
||||||
|
value = float(precise);
|
||||||
|
roundoff = precise-value;
|
||||||
|
}
|
||||||
std::deque<Port> ports_;
|
std::deque<Port> 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.pressure = pressure();
|
||||||
p.density = density();
|
p.density = density();
|
||||||
p.temperature = temperature();
|
p.temperature = temperature();
|
||||||
p.enthalpy = gamma / (gamma - 1.0f) * p.pressure / std::max(p.density, 1e-12f);
|
p.enthalpy = gamma / (gamma - 1.0f) * p.pressure / std::max(p.density, 1e-12f);
|
||||||
p.air_fraction = air_fraction();
|
p.air_fraction = air_fraction();
|
||||||
}
|
}
|
||||||
void sync_ports() { for (Port& p : ports_) sync_port(p); }
|
void sync_ports() {
|
||||||
|
for (Port &p : ports_)
|
||||||
|
sync_port(p);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
struct GasCell {
|
struct GasCell {
|
||||||
@@ -101,286 +139,489 @@ struct GasCell {
|
|||||||
float energy{1.225f * (101325.0f / (pipe_gm1 * 1.225f))};
|
float energy{1.225f * (101325.0f / (pipe_gm1 * 1.225f))};
|
||||||
float air{1.0f};
|
float air{1.0f};
|
||||||
};
|
};
|
||||||
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 {
|
class Pipe1D {
|
||||||
public:
|
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 area{}, dx{};
|
||||||
float damping_multiplier{1.0f}, energy_relaxation_rate{0.5f}, ambient_pressure{101325.0f};
|
float damping_multiplier{0.0f}, energy_relaxation_rate{0.0f}, ambient_pressure{101325.0f};
|
||||||
std::vector<float> cell_areas, cell_lengths;
|
std::vector<float> cell_areas, cell_lengths;
|
||||||
std::vector<GasCell> cells;
|
std::vector<GasCell> cells;
|
||||||
GasState left_ghost{}, right_ghost{};
|
GasState left_ghost{}, right_ghost{};
|
||||||
Pipe1D(float area_m2, float length_m, int cell_count)
|
std::uint64_t positivity_repairs{};
|
||||||
: area(area_m2), dx(length_m / static_cast<float>(cell_count)),
|
Pipe1D(float a, float length, int count)
|
||||||
cell_areas(static_cast<std::size_t>(cell_count), area_m2),
|
: Pipe1D(uniform(a, count), uniform(length / std::max(count, 1), count)) {}
|
||||||
cell_lengths(static_cast<std::size_t>(cell_count), dx),
|
Pipe1D(std::vector<float> areas, std::vector<float> lengths)
|
||||||
cells(static_cast<std::size_t>(cell_count)), next_cells_(static_cast<std::size_t>(cell_count)) {}
|
: area(areas.empty() ? 0 : areas.front()), dx(lengths.empty() ? 0 : lengths.front()),
|
||||||
Pipe1D(std::vector<float> areas_m2, std::vector<float> lengths_m)
|
cell_areas(std::move(areas)), cell_lengths(std::move(lengths)), cells(cell_areas.size()),
|
||||||
: area(areas_m2.empty() ? 0.0f : areas_m2.front()),
|
next_cells_(cells.size()), states_(cells.size()), waves_(cells.size()), faces_(cells.size() + 1) {
|
||||||
dx(lengths_m.empty() ? 0.0f : lengths_m.front()),
|
if (cells.empty() || cell_lengths.size() != cells.size())
|
||||||
cell_areas(std::move(areas_m2)), cell_lengths(std::move(lengths_m)),
|
throw std::invalid_argument("pipe profiles must be nonempty and have equal length");
|
||||||
cells(cell_areas.size()), next_cells_(cell_areas.size()) {
|
for (std::size_t i = 0; i < cells.size(); ++i)
|
||||||
if (cell_areas.empty() || cell_lengths.size() != cell_areas.size())
|
if (!std::isfinite(cell_areas[i]) || !std::isfinite(cell_lengths[i]) || cell_areas[i] <= 0 ||
|
||||||
throw std::invalid_argument("pipe area and length profiles must be non-empty and have equal length");
|
cell_lengths[i] <= 0)
|
||||||
for (std::size_t i = 0; i < cell_areas.size(); ++i) {
|
throw std::invalid_argument("pipe geometry must be finite and positive");
|
||||||
if (cell_areas[i] <= 0.0f || cell_lengths[i] <= 0.0f)
|
}
|
||||||
throw std::invalid_argument("pipe cell area and length must be positive");
|
GasState state(int i) const { return primitive(cells.at(static_cast<std::size_t>(i))); }
|
||||||
|
float cell_mass(int i) const { return cells.at(i).rho * cell_areas.at(i) * cell_lengths.at(i); }
|
||||||
|
float cell_area(int i) const { return cell_areas.at(i); }
|
||||||
|
float cell_length(int i) const { return cell_lengths.at(i); }
|
||||||
|
float minimum_cell_length() const { return *std::min_element(cell_lengths.begin(), cell_lengths.end()); }
|
||||||
|
float stable_timestep(float cfl = 0.8f) const {
|
||||||
|
float dt = std::numeric_limits<float>::max();
|
||||||
|
for (std::size_t i = 0; i < cells.size(); ++i) {
|
||||||
|
auto s = primitive(cells[i]);
|
||||||
|
if (!(s.rho > 0) || !(s.pressure > 0) || !std::isfinite(s.velocity) || !std::isfinite(s.pressure))
|
||||||
|
throw std::runtime_error("nonphysical pipe state in CFL calculation");
|
||||||
|
dt = std::min(dt, cfl * cell_lengths[i] / wave(s));
|
||||||
|
}
|
||||||
|
return dt;
|
||||||
|
}
|
||||||
|
// Solver preparation is valid until advance; boundary resolution changes
|
||||||
|
// fluxes/ghosts, not cells. Standalone advance still prepares its own cache.
|
||||||
|
float prepare_step(float cfl = 0.8f) {
|
||||||
|
float dt = std::numeric_limits<float>::max();
|
||||||
|
for (std::size_t i = 0; i < cells.size(); ++i) {
|
||||||
|
states_[i] = primitive(cells[i]);
|
||||||
|
const auto &s = states_[i];
|
||||||
|
if (!(s.rho > 0) || !(s.pressure > 0) || !std::isfinite(s.velocity) || !std::isfinite(s.pressure))
|
||||||
|
throw std::runtime_error("nonphysical pipe state in preparation");
|
||||||
|
waves_[i] = wave(s);
|
||||||
|
dt = std::min(dt, cfl * cell_lengths[i] / waves_[i]);
|
||||||
|
}
|
||||||
|
prepared_ = true;
|
||||||
|
return dt;
|
||||||
|
}
|
||||||
|
int required_substeps(float dt, float cfl = 0.8f) const {
|
||||||
|
if (!std::isfinite(dt) || dt < 0 || !std::isfinite(cfl) || cfl <= 0 || cfl > 1)
|
||||||
|
throw std::invalid_argument("invalid timestep/CFL");
|
||||||
|
const double n = std::ceil(double(dt) / stable_timestep(cfl));
|
||||||
|
if (n > 1000000)
|
||||||
|
throw std::runtime_error("excessive pipe substeps");
|
||||||
|
return std::max(1, static_cast<int>(n));
|
||||||
|
}
|
||||||
|
void set_boundary_flux(bool left, Flux f) {
|
||||||
|
if (left) {
|
||||||
|
left_flux_ = f;
|
||||||
|
left_override_ = true;
|
||||||
|
} else {
|
||||||
|
right_flux_ = f;
|
||||||
|
right_override_ = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GasState state(int idx) const {
|
static GasState primitive(const GasCell &c) {
|
||||||
const auto& c = cells.at(static_cast<std::size_t>(idx));
|
const float u = c.momentum / std::max(c.rho, 1e-12f);
|
||||||
const float rho = c.rho;
|
return {c.rho, u, pipe_gm1 * (c.energy - .5f * c.momentum * u), std::clamp(c.air, 0.0f, 1.0f)};
|
||||||
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)};
|
|
||||||
}
|
}
|
||||||
float cell_mass(int idx) const {
|
static float wave(const GasState &s) {
|
||||||
const auto cell = static_cast<std::size_t>(idx);
|
return std::abs(s.velocity) + std::sqrt(gamma * s.pressure / s.rho);
|
||||||
return cells.at(cell).rho * cell_areas.at(cell) * cell_lengths.at(cell);
|
|
||||||
}
|
}
|
||||||
float cell_area(int idx) const { return cell_areas.at(static_cast<std::size_t>(idx)); }
|
static Flux riemann(const GasState &a, const GasState &b) {
|
||||||
float cell_length(int idx) const { return cell_lengths.at(static_cast<std::size_t>(idx)); }
|
return riemann(a, b, std::max(wave(a), wave(b)));
|
||||||
float minimum_cell_length() const {
|
|
||||||
return *std::min_element(cell_lengths.begin(), cell_lengths.end());
|
|
||||||
}
|
|
||||||
int required_substeps(float dt, float target_cfl = 0.8f) const {
|
|
||||||
float maximum_wave_speed = 0.0f;
|
|
||||||
for (int i = 0; i < static_cast<int>(cells.size()); ++i) {
|
|
||||||
const GasState s = state(i);
|
|
||||||
maximum_wave_speed = std::max(maximum_wave_speed,
|
|
||||||
std::abs(s.velocity) + std::sqrt(gamma * s.pressure / std::max(s.rho, 1e-12f)));
|
|
||||||
}
|
|
||||||
return std::max(1, static_cast<int>(std::ceil(dt * maximum_wave_speed /
|
|
||||||
(target_cfl * std::max(minimum_cell_length(), 1e-12f)))));
|
|
||||||
}
|
}
|
||||||
void advance(float dt) {
|
void advance(float dt) {
|
||||||
const int n = static_cast<int>(cells.size());
|
if (dt <= 0)
|
||||||
if (n == 0 || dt <= 0.0f) return;
|
return;
|
||||||
const float ambient_energy = ambient_pressure * pipe_gm1_inverse;
|
if (!std::isfinite(dt))
|
||||||
for (int i = 0; i < n; ++i) {
|
throw std::invalid_argument("nonfinite pipe timestep");
|
||||||
const GasState l = i == 0 ? left_ghost : flux_state(i - 1), r = flux_state(i);
|
const std::size_t n = cells.size();
|
||||||
const GasState a = flux_state(i), b = i == n - 1 ? right_ghost : flux_state(i + 1);
|
if (!prepared_)
|
||||||
const Flux fl = flux(l, r), fr = flux(a, b);
|
prepare_step();
|
||||||
const float factor = dt / cell_lengths[static_cast<std::size_t>(i)];
|
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 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);
|
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);
|
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(rho_air)) {
|
if (!std::isfinite(rho) || !std::isfinite(mom) || !std::isfinite(en) || !std::isfinite(air))
|
||||||
next_cells_[i] = cells[i];
|
throw std::runtime_error("nonfinite finite-volume update");
|
||||||
continue;
|
if (rho < 1e-12f) {
|
||||||
|
rho = 1e-12f;
|
||||||
|
++positivity_repairs;
|
||||||
}
|
}
|
||||||
rho = std::max(rho, 1e-12f);
|
if (damping_multiplier > 0)
|
||||||
const float damping = std::exp(-0.1f * damping_multiplier * dt /
|
mom *= std::exp(-.1f * damping_multiplier * dt / cells[i].rho);
|
||||||
std::max(cells[i].rho, 1e-12f));
|
if (energy_relaxation_rate > 0)
|
||||||
mom *= damping;
|
en = ambient_pressure * pipe_gm1_inverse +
|
||||||
if (energy_relaxation_rate > 0.0f) {
|
(en - ambient_pressure * pipe_gm1_inverse) * relaxation;
|
||||||
const float relax = std::exp(-energy_relaxation_rate * dt);
|
const float minimum = 1e-6f * pipe_gm1_inverse + .5f * mom * mom / rho;
|
||||||
en = ambient_energy + (en - ambient_energy) * relax;
|
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(air / rho, 0.0f, 1.0f)};
|
||||||
next_cells_[i] = {rho, mom, en, std::clamp(rho_air / rho, 0.0f, 1.0f)};
|
|
||||||
}
|
}
|
||||||
cells.swap(next_cells_);
|
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:
|
private:
|
||||||
struct Flux { float m{}, p{}, e{}, y{}; };
|
|
||||||
std::vector<GasCell> next_cells_;
|
std::vector<GasCell> next_cells_;
|
||||||
GasState flux_state(int idx) const {
|
std::vector<GasState> states_;
|
||||||
const auto& c = cells[static_cast<std::size_t>(idx)];
|
std::vector<float> waves_;
|
||||||
const float rho = c.rho;
|
std::vector<Flux> faces_;
|
||||||
const float inverse_rho = 1.0f / std::max(rho, 1e-12f);
|
Flux left_flux_{}, right_flux_{};
|
||||||
const float u = c.momentum * inverse_rho;
|
bool left_override_{}, right_override_{}, prepared_{};
|
||||||
const float p = pipe_gm1 * (c.energy - 0.5f * c.momentum * u);
|
static std::vector<float> uniform(float v, int n) {
|
||||||
return {rho, u, p, std::clamp(c.air, 0.0f, 1.0f)};
|
if (n <= 0)
|
||||||
|
throw std::invalid_argument("pipe count must be positive");
|
||||||
|
return std::vector<float>(n, v);
|
||||||
}
|
}
|
||||||
static Flux flux(const GasState& a, const GasState& b) {
|
static Flux riemann(const GasState &a, const GasState &b, float c) {
|
||||||
const float ua = a.velocity, ub = b.velocity;
|
const float ma = a.rho * a.velocity, mb = b.rho * b.velocity;
|
||||||
const float inv_rho_a = 1.0f / std::max(a.rho, 1e-12f);
|
const float ea = a.pressure * pipe_gm1_inverse + .5f * ma * a.velocity;
|
||||||
const float inv_rho_b = 1.0f / std::max(b.rho, 1e-12f);
|
const float eb = b.pressure * pipe_gm1_inverse + .5f * mb * b.velocity;
|
||||||
const float ea = a.pressure * pipe_gm1_inverse * inv_rho_a + 0.5f * ua * ua;
|
return {.5f * (ma + mb - c * (b.rho - a.rho)),
|
||||||
const float eb = b.pressure * pipe_gm1_inverse * inv_rho_b + 0.5f * ub * ub;
|
.5f * (ma * a.velocity + a.pressure + mb * b.velocity + b.pressure - c * (mb - ma)),
|
||||||
const float c = std::max(std::abs(ua) + std::sqrt(gamma * a.pressure * inv_rho_a),
|
.5f * ((ea + a.pressure) * a.velocity + (eb + b.pressure) * b.velocity - c * (eb - ea)),
|
||||||
std::abs(ub) + std::sqrt(gamma * b.pressure * inv_rho_b));
|
.5f * (ma * a.air + mb * b.air - c * (b.rho * b.air - a.rho * a.air))};
|
||||||
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)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
class BoundarySystem {
|
class BoundarySystem {
|
||||||
public:
|
public:
|
||||||
explicit BoundarySystem(std::vector<Pipe1D>& pipes) : pipes_(pipes) {}
|
explicit BoundarySystem(std::vector<Pipe1D> &pipes) : pipes_(pipes) {}
|
||||||
void add_orifice(Port& port, int pipe, bool left, float& area, float cd = 1.0f) {
|
void add_orifice(Port &port, int pipe, bool left, float &area, float cd = 1.0f) {
|
||||||
Orifice desc;
|
if (std::abs(port.gamma_value - gamma) > 1e-6f ||
|
||||||
desc.port = &port;
|
std::abs(port.gas_constant_value - gas_constant) > .001f)
|
||||||
desc.pipe = pipe;
|
throw std::invalid_argument("pipe coupling requires the same calorically perfect gas");
|
||||||
desc.left = left;
|
if (!std::isfinite(cd) || cd < 0 || cd > 1)
|
||||||
desc.area = &area;
|
throw std::invalid_argument("orifice Cd must be in [0,1]");
|
||||||
desc.cd = cd;
|
for (auto &o : orifices_)
|
||||||
orifices_.push_back(desc);
|
if (o.port == &port)
|
||||||
|
throw std::invalid_argument("each connection requires a distinct component port");
|
||||||
|
claim(pipe, left);
|
||||||
|
Orifice o;
|
||||||
|
o.port = &port;
|
||||||
|
o.pipe = pipe;
|
||||||
|
o.left = left;
|
||||||
|
o.area = &area;
|
||||||
|
o.cd = cd;
|
||||||
|
if (port.owner_air_mass) {
|
||||||
|
auto it = std::find_if(budgets_.begin(), budgets_.end(),
|
||||||
|
[&](const Budget &b) { return b.air == port.owner_air_mass; });
|
||||||
|
if (it == budgets_.end()) {
|
||||||
|
o.budget = budgets_.size();
|
||||||
|
budgets_.push_back({port.owner_air_mass, port.owner_exhaust_mass, port.owner_energy});
|
||||||
|
} else
|
||||||
|
o.budget = static_cast<std::size_t>(it - budgets_.begin());
|
||||||
|
}
|
||||||
|
orifices_.push_back(o);
|
||||||
}
|
}
|
||||||
void add_orifice_with_inertance(Port& port, int pipe, bool left, float& area,
|
void add_orifice_with_inertance(Port &p, int pipe, bool left, float &a, float cd, float length,
|
||||||
float cd, float effective_length, float loss_coefficient = 0.0f) {
|
float loss = 0) {
|
||||||
add_orifice(port, pipe, left, area, cd);
|
if (!std::isfinite(length) || length <= 0 || !std::isfinite(loss) || loss < 0)
|
||||||
auto& desc = orifices_.back();
|
throw std::invalid_argument("invalid inertance");
|
||||||
desc.use_inertance = true;
|
add_orifice(p, pipe, left, a, cd);
|
||||||
desc.effective_length = effective_length;
|
auto &o = orifices_.back();
|
||||||
desc.loss_coefficient = loss_coefficient;
|
o.length = length;
|
||||||
|
o.loss = loss;
|
||||||
}
|
}
|
||||||
void add_closed_end(int pipe, bool left) {
|
void add_closed_end(int pipe, bool left) {
|
||||||
Orifice desc;
|
claim(pipe, left);
|
||||||
desc.pipe = pipe;
|
Orifice o;
|
||||||
desc.left = left;
|
o.pipe = pipe;
|
||||||
desc.closed_end = true;
|
o.left = left;
|
||||||
orifices_.push_back(desc);
|
orifices_.push_back(o);
|
||||||
|
}
|
||||||
|
void add_open_end(int pipe, bool left, float p, float a) {
|
||||||
|
if (!std::isfinite(p) || p <= 0 || !std::isfinite(a) || a <= 0)
|
||||||
|
throw std::invalid_argument("invalid open boundary");
|
||||||
|
claim(pipe, left);
|
||||||
|
opens_.push_back({pipe, left, p, a});
|
||||||
|
}
|
||||||
|
// An ideal, zero-volume junction between two duct faces. Orientation is explicit.
|
||||||
|
void add_connection(int a, bool left_a, int b, bool left_b) {
|
||||||
|
if (a < 0 || b < 0 || static_cast<std::size_t>(a) >= pipes_.size() ||
|
||||||
|
static_cast<std::size_t>(b) >= pipes_.size())
|
||||||
|
throw std::invalid_argument("invalid connection pipe index");
|
||||||
|
const float aa = pipes_[a].cell_area(end(pipes_[a], left_a));
|
||||||
|
const float ab = pipes_[b].cell_area(end(pipes_[b], left_b));
|
||||||
|
if (std::abs(aa - ab) > 1e-6f * std::max(aa, ab))
|
||||||
|
throw std::invalid_argument("unequal-area junction needs a profiled pipe transition");
|
||||||
|
if (a == b && left_a == left_b)
|
||||||
|
throw std::invalid_argument("cannot join a face to itself");
|
||||||
|
check_face(a, left_a);
|
||||||
|
check_face(b, left_b);
|
||||||
|
claim(a, left_a);
|
||||||
|
claim(b, left_b);
|
||||||
|
connections_.push_back({a, b, left_a, left_b});
|
||||||
|
}
|
||||||
|
float open_end_mass_flow(std::size_t i) const { return opens_.at(i).last_mass_flow; }
|
||||||
|
float open_end_pressure(std::size_t i) const { return opens_.at(i).last_face_pressure; }
|
||||||
|
std::uint64_t limited_fluxes{}, nonlinear_iterations{};
|
||||||
|
void validate_topology() const {
|
||||||
|
if (claims_.size() != 2 * pipes_.size())
|
||||||
|
throw std::runtime_error("every pipe endpoint needs an explicit boundary or connection");
|
||||||
}
|
}
|
||||||
void add_open_end(int pipe, bool left, float pressure, float area) { opens_.push_back({pipe, left, pressure, area}); }
|
|
||||||
float open_end_mass_flow(std::size_t index) const { return opens_.at(index).last_mass_flow; }
|
|
||||||
float open_end_pressure(std::size_t index) const { return opens_.at(index).last_face_pressure; }
|
|
||||||
void resolve(float dt) {
|
void resolve(float dt) {
|
||||||
for (auto& o : orifices_) resolve_orifice(o, dt);
|
if (!std::isfinite(dt) || dt <= 0)
|
||||||
for (auto& o : opens_) resolve_open_end(o, dt);
|
throw std::invalid_argument("boundary timestep must be positive");
|
||||||
|
for (auto &b : budgets_) {
|
||||||
|
b.mass = .25f * (*b.air + (b.exhaust ? *b.exhaust : 0));
|
||||||
|
b.heat = b.energy ? .25f * (*b.energy) : std::numeric_limits<float>::max();
|
||||||
|
}
|
||||||
|
for (auto &o : orifices_)
|
||||||
|
resolve_orifice(o, dt);
|
||||||
|
for (auto &o : opens_)
|
||||||
|
resolve_open(o);
|
||||||
|
for (auto &o : connections_)
|
||||||
|
resolve_connection(o);
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
struct Budget {
|
||||||
|
float *air{}, *exhaust{}, *energy{};
|
||||||
|
float mass{}, heat{};
|
||||||
|
};
|
||||||
struct Orifice {
|
struct Orifice {
|
||||||
Port* port{};
|
Port *port{};
|
||||||
int pipe{};
|
int pipe{};
|
||||||
bool left{};
|
bool left{};
|
||||||
float* area{};
|
float *area{};
|
||||||
float cd{1.0f};
|
float cd{1}, length{}, loss{}, mdot{};
|
||||||
bool closed_end{}, use_inertance{};
|
std::size_t budget{std::numeric_limits<std::size_t>::max()};
|
||||||
float effective_length{}, loss_coefficient{}, current_mass_flow{};
|
|
||||||
};
|
};
|
||||||
struct Open { int pipe; bool left; float pressure, area; float last_mass_flow{}, last_face_pressure{}; };
|
struct Open {
|
||||||
struct OrificeSolution { float mass_flow{}, rho{}, velocity{}, pressure{}; };
|
int pipe;
|
||||||
std::vector<Pipe1D>& pipes_;
|
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<Pipe1D> &pipes_;
|
||||||
std::vector<Orifice> orifices_;
|
std::vector<Orifice> orifices_;
|
||||||
std::vector<Open> opens_;
|
std::vector<Open> opens_;
|
||||||
|
std::vector<Connection> connections_;
|
||||||
static OrificeSolution isentropic(float pup, float rho_up, float t_up, float pdown, float area, float cd) {
|
std::vector<Budget> budgets_;
|
||||||
OrificeSolution out{0.0f, rho_up, 0.0f, pup};
|
std::vector<Claim> claims_;
|
||||||
if (area <= 0.0f || pup <= 0.0f || rho_up <= 0.0f || t_up <= 0.0f) return out;
|
void check_face(int pipe, bool left) const {
|
||||||
float ratio = std::clamp(pdown / pup, 1e-6f, 1.0f);
|
if (pipe < 0 || static_cast<std::size_t>(pipe) >= pipes_.size())
|
||||||
const float critical = std::pow(2.0f / (gamma + 1.0f), gamma / (gamma - 1.0f));
|
throw std::invalid_argument("invalid boundary pipe index");
|
||||||
ratio = std::max(ratio, critical);
|
for (auto c : claims_)
|
||||||
const float mach = std::sqrt(std::max(0.0f, 2.0f / (gamma - 1.0f) *
|
if (c.pipe == pipe && c.left == left)
|
||||||
(std::pow(ratio, -(gamma - 1.0f) / gamma) - 1.0f)));
|
throw std::invalid_argument("pipe face already connected");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
static float available_mass(const Port& port) {
|
void claim(int pipe, bool left) {
|
||||||
return (port.owner_air_mass ? *port.owner_air_mass : 0.0f) +
|
check_face(pipe, left);
|
||||||
(port.owner_exhaust_mass ? *port.owner_exhaust_mass : 0.0f);
|
claims_.push_back({pipe, left});
|
||||||
}
|
}
|
||||||
void resolve_orifice(Orifice& o, float dt) {
|
static int end(const Pipe1D &p, bool left) { return left ? 0 : static_cast<int>(p.cells.size()) - 1; }
|
||||||
Pipe1D& pipe = pipes_.at(static_cast<std::size_t>(o.pipe));
|
static void ghost(Pipe1D &p, bool left, GasState s) {
|
||||||
const int idx = o.left ? 0 : static_cast<int>(pipe.cells.size()) - 1;
|
if (left)
|
||||||
const GasState interior = pipe.state(idx);
|
p.left_ghost = s;
|
||||||
const float area = o.area ? std::max(*o.area, 0.0f) : 0.0f;
|
else
|
||||||
if (o.closed_end || !o.port || area <= 1e-12f) {
|
p.right_ghost = s;
|
||||||
GasState wall = interior;
|
}
|
||||||
wall.velocity = -interior.velocity;
|
void resolve_orifice(Orifice &o, float dt) {
|
||||||
if (o.left) pipe.left_ghost = wall; else pipe.right_ghost = wall;
|
auto &p = pipes_[o.pipe];
|
||||||
if (o.port) o.port->mass_flow = 0.0f;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
auto &v = *o.port;
|
||||||
const float dp = o.port->pressure - interior.pressure;
|
if (std::abs(v.gamma_value - gamma) > 1e-6f || std::abs(v.gas_constant_value - gas_constant) > .001f)
|
||||||
OrificeSolution solution{0.0f, interior.rho, 0.0f, interior.pressure};
|
throw std::invalid_argument("pipe coupling requires the same calorically perfect gas");
|
||||||
// Preserve the C# tie-break: at equal pressure it treats the volume as
|
const float dp = v.pressure - s.pressure;
|
||||||
// upstream, retaining the volume-side density in the ghost state. That
|
const double g = gamma;
|
||||||
// matters when equal-pressure volume and pipe states have different
|
const double critical = std::pow(2.0 / (g + 1), g / (g - 1));
|
||||||
// temperatures/densities, as on the initialized intake plenum.
|
const double c = std::sqrt(g * s.pressure / s.rho);
|
||||||
if (o.port->pressure >= interior.pressure) {
|
const double ui = s.velocity * (o.left ? 1 : -1);
|
||||||
solution = isentropic(o.port->pressure, o.port->density, o.port->temperature, interior.pressure, area, o.cd);
|
const auto face_density = [&](double pressure) {
|
||||||
} else {
|
return s.rho * std::pow(pressure / s.pressure, 1 / g);
|
||||||
solution = isentropic(interior.pressure, interior.rho,
|
};
|
||||||
interior.pressure / std::max(interior.rho * gas_constant, 1e-12f), o.port->pressure, area, o.cd);
|
const auto nozzle_flow = [&](double pressure) {
|
||||||
solution.mass_flow = -solution.mass_flow;
|
const bool from = v.pressure >= pressure;
|
||||||
}
|
const double up = from ? v.pressure : pressure;
|
||||||
float mdot = solution.mass_flow; // positive means port component -> pipe
|
const double down = from ? pressure : v.pressure;
|
||||||
const float adjacent_mass = pipe.cell_mass(idx);
|
const double density = from ? v.density : face_density(pressure);
|
||||||
if (o.use_inertance) {
|
const double ratio = std::max(down / up, critical);
|
||||||
const float rho_up = o.current_mass_flow >= 0.0f ? o.port->density : interior.rho;
|
const double r = std::pow(ratio, 1 / g);
|
||||||
const float inertance = rho_up * o.effective_length / area;
|
const double speed2 = 2 * g / (g - 1) * up / density * (1 - std::pow(ratio, (g - 1) / g));
|
||||||
const float derivative = (dp - o.loss_coefficient * o.current_mass_flow) /
|
return (from ? 1 : -1) * density * r * std::sqrt(std::max(0.0, speed2)) * area * o.cd;
|
||||||
std::max(inertance, 1e-12f);
|
};
|
||||||
mdot = o.current_mass_flow + derivative * dt;
|
// Match nozzle discharge to the outgoing duct characteristic. Imposing
|
||||||
}
|
// sqrt(dp) against the *cell-centre* pressure is an unstable explicit
|
||||||
if (mdot > 0.0f && (o.port->owner_air_mass || o.port->owner_exhaust_mass))
|
// boundary near equilibrium and ignores the duct's acoustic impedance.
|
||||||
mdot = std::min(mdot, available_mass(*o.port) / std::max(dt, 1e-12f));
|
const double zero_sound = c - .5 * (g - 1) * ui;
|
||||||
if (mdot < 0.0f) mdot = std::max(mdot, -adjacent_mass / std::max(dt, 1e-12f));
|
if (zero_sound <= 0)
|
||||||
|
throw std::runtime_error("orifice characteristic outside subsonic model");
|
||||||
if (o.use_inertance) {
|
const double zero_pressure = s.pressure * std::pow(zero_sound / c, 2 * g / (g - 1));
|
||||||
o.current_mass_flow = mdot;
|
double low = std::min(zero_pressure, double(v.pressure));
|
||||||
const bool leaves_port = mdot >= 0.0f;
|
double high = std::max(zero_pressure, double(v.pressure));
|
||||||
const float rho_up = leaves_port ? o.port->density : interior.rho;
|
double duct_pressure = .5 * (low + high);
|
||||||
const float temperature_up = leaves_port ? o.port->temperature :
|
const double flow_sign = v.pressure >= zero_pressure ? 1 : -1;
|
||||||
interior.pressure / std::max(interior.rho * gas_constant, 1e-12f);
|
const double flow_scale = area * o.cd;
|
||||||
const float max_velocity = 0.9f * std::sqrt(gamma * gas_constant * temperature_up);
|
const double density_ratio_scale = std::pow(double(s.pressure) / v.pressure, 1 / g);
|
||||||
const float max_flow = rho_up * max_velocity * area;
|
const double critical_density_ratio = std::pow(critical, 1 / g);
|
||||||
mdot = std::clamp(mdot, -max_flow, max_flow);
|
// Squared-flow residual avoids the nozzle sqrt singularity at dp=0.
|
||||||
o.current_mass_flow = mdot;
|
// Safeguarded analytic Newton; bisection remains the fallback.
|
||||||
if (mdot >= 0.0f) {
|
if (o.length == 0 && high > low)
|
||||||
solution = isentropic(o.port->pressure, o.port->density, o.port->temperature,
|
for (int iteration = 0; iteration < 40; ++iteration) {
|
||||||
interior.pressure, area, o.cd);
|
++nonlinear_iterations;
|
||||||
} else {
|
const double density = face_density(duct_pressure);
|
||||||
solution = isentropic(interior.pressure, interior.rho, temperature_up,
|
const double sound = std::sqrt(g * duct_pressure / density);
|
||||||
o.port->pressure, area, o.cd);
|
const double velocity = ui + 2 * (sound - c) / (g - 1);
|
||||||
solution.mass_flow = -solution.mass_flow;
|
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;
|
float mdot = static_cast<float>(nozzle_flow(duct_pressure));
|
||||||
solution.rho = rho_up;
|
const bool upstream_port = mdot >= 0;
|
||||||
|
const float pup = upstream_port ? v.pressure : static_cast<float>(duct_pressure);
|
||||||
|
const float ratio =
|
||||||
|
static_cast<float>(std::max((upstream_port ? duct_pressure : v.pressure) / pup, critical));
|
||||||
|
const float throat_rho =
|
||||||
|
(upstream_port ? v.density : static_cast<float>(face_density(duct_pressure))) *
|
||||||
|
std::pow(ratio, 1 / gamma);
|
||||||
|
if (o.length > 0) {
|
||||||
|
// d(mdot)/dt = A/L dp - K mdot |mdot|/(2 rho A L).
|
||||||
|
const float donor_rho = o.mdot >= 0 ? v.density : s.rho;
|
||||||
|
// Semi-implicit resistance remains dissipative for large dt.
|
||||||
|
mdot = (o.mdot + area / o.length * dp * dt) /
|
||||||
|
(1 + dt * o.loss * std::abs(o.mdot) / (2 * donor_rho * area * o.length));
|
||||||
}
|
}
|
||||||
|
const bool from = mdot >= 0;
|
||||||
const bool from_port = mdot >= 0.0f;
|
const float h = from ? gamma / (gamma - 1) * v.pressure / v.density
|
||||||
const float rho_face = std::max(solution.rho, 1e-9f);
|
: gamma / (gamma - 1) * s.pressure / s.rho + .5f * s.velocity * s.velocity;
|
||||||
const float u = std::abs(mdot) / std::max(rho_face * area, 1e-12f);
|
const float requested_mdot = mdot;
|
||||||
GasState ghost{rho_face, 0.0f, std::max(solution.pressure, 1.0f), from_port ? o.port->air_fraction : interior.air};
|
if (from && o.budget < budgets_.size()) {
|
||||||
ghost.velocity = ((from_port == o.left) ? u : -u);
|
auto &b = budgets_[o.budget];
|
||||||
if (o.left) pipe.left_ghost = ghost; else pipe.right_ghost = ghost;
|
mdot = std::min(mdot, std::max(0.f, std::min(b.mass, b.heat / std::max(h, 1.f))) / dt);
|
||||||
o.port->mass_flow = -mdot;
|
b.mass -= mdot * dt;
|
||||||
if (!from_port) o.port->air_fraction = interior.air;
|
b.heat -= mdot * h * dt;
|
||||||
o.port->enthalpy = from_port
|
} else if (!from) {
|
||||||
? o.port->enthalpy
|
const float cell_energy = p.cells[i].energy * face_area * p.cell_length(i);
|
||||||
: gamma / (gamma - 1.0f) * interior.pressure / std::max(interior.rho, 1e-12f);
|
mdot = std::max(mdot, -.25f * std::min(p.cell_mass(i), cell_energy / std::max(h, 1.f)) / dt);
|
||||||
(void)dp;
|
}
|
||||||
|
if (mdot != requested_mdot)
|
||||||
|
++limited_fluxes;
|
||||||
|
o.mdot = mdot;
|
||||||
|
const float donor_rho = from ? v.density : s.rho;
|
||||||
|
const float density = o.length > 0 ? donor_rho : throat_rho;
|
||||||
|
const float velocity = mdot / (density * area);
|
||||||
|
const float face_pressure =
|
||||||
|
o.length > 0 ? .5f * (v.pressure + s.pressure) : static_cast<float>(duct_pressure);
|
||||||
|
const float direction = o.left ? 1.f : -1.f;
|
||||||
|
const float species = from ? v.air_fraction : s.air;
|
||||||
|
const float signed_mass = direction * mdot;
|
||||||
|
p.set_boundary_flux(o.left, {signed_mass,
|
||||||
|
mdot * velocity + face_pressure * area + s.pressure * (face_area - area),
|
||||||
|
signed_mass * h, signed_mass * species});
|
||||||
|
ghost(p, o.left, {density, direction * velocity, face_pressure, species});
|
||||||
|
v.mass_flow = -mdot;
|
||||||
|
v.enthalpy = h;
|
||||||
|
if (!from)
|
||||||
|
v.air_fraction = s.air;
|
||||||
}
|
}
|
||||||
void resolve_open_end(Open& o, float dt) {
|
void resolve_open(Open &o) {
|
||||||
Pipe1D& pipe = pipes_.at(static_cast<std::size_t>(o.pipe));
|
auto &p = pipes_[o.pipe];
|
||||||
const int idx = o.left ? 0 : static_cast<int>(pipe.cells.size()) - 1;
|
int i = end(p, o.left);
|
||||||
const GasState interior = pipe.state(idx);
|
auto s = p.state(i);
|
||||||
const float c = std::sqrt(gamma * interior.pressure / std::max(interior.rho, 1e-12f));
|
const float c = std::sqrt(gamma * s.pressure / s.rho), gm1 = gamma - 1;
|
||||||
const float gm1 = gamma - 1.0f;
|
if (o.left ? s.velocity >= c : s.velocity <= -c)
|
||||||
const float j_plus = interior.velocity + 2.0f * c / gm1;
|
throw std::runtime_error(
|
||||||
const float j_minus = interior.velocity - 2.0f * c / gm1;
|
"supersonic inlet needs full upstream state; pressure-only boundary is insufficient");
|
||||||
const float entropy = interior.pressure / std::pow(std::max(interior.rho, 1e-12f), gamma);
|
const float entropy = s.pressure / std::pow(s.rho, gamma);
|
||||||
const float rho_iso = std::pow(std::max(o.pressure / entropy, 1e-12f), 1.0f / gamma);
|
const float rho = std::pow(o.pressure / entropy, 1 / gamma);
|
||||||
const float c_iso = std::sqrt(gamma * o.pressure / std::max(rho_iso, 1e-12f));
|
const float cb = std::sqrt(gamma * o.pressure / rho);
|
||||||
const float u_iso = o.left ? j_minus + 2.0f * c_iso / gm1 : j_plus - 2.0f * c_iso / gm1;
|
const float u =
|
||||||
const bool supersonic = o.left ? (interior.velocity <= -c || u_iso <= -c_iso)
|
o.left ? s.velocity - 2 * c / gm1 + 2 * cb / gm1 : s.velocity + 2 * c / gm1 - 2 * cb / gm1;
|
||||||
: (interior.velocity >= c || u_iso >= c_iso);
|
const bool out = o.left ? s.velocity <= -c : s.velocity >= c;
|
||||||
GasState ghost = supersonic ? interior : GasState{rho_iso, u_iso, o.pressure,
|
GasState g = out ? s : GasState{rho, u, o.pressure, (o.left ? u >= 0 : u <= 0) ? 1.f : s.air};
|
||||||
((o.left ? u_iso >= 0.0f : u_iso <= 0.0f) ? 1.0f : interior.air)};
|
ghost(p, o.left, g);
|
||||||
|
const float a = std::min(o.area, p.cell_area(i));
|
||||||
const float cell_mass = pipe.cell_mass(idx);
|
auto f = Pipe1D::scaled(o.left ? Pipe1D::riemann(g, s) : Pipe1D::riemann(s, g), a);
|
||||||
float mdot_out = ghost.rho * ghost.velocity * o.area * (o.left ? -1.0f : 1.0f);
|
f.p += s.pressure * (p.cell_area(i) - a);
|
||||||
if (mdot_out > 0.0f) mdot_out = std::min(mdot_out, cell_mass / std::max(dt, 1e-12f));
|
p.set_boundary_flux(o.left, f);
|
||||||
else mdot_out = std::max(mdot_out, -10.0f * cell_mass / std::max(dt, 1e-12f));
|
o.last_mass_flow = f.m * (o.left ? -1 : 1);
|
||||||
const float speed = std::abs(mdot_out) / std::max(ghost.rho * o.area, 1e-12f);
|
o.last_face_pressure = f.p / p.cell_area(i);
|
||||||
ghost.velocity = o.left ? (mdot_out >= 0.0f ? -speed : speed)
|
}
|
||||||
: (mdot_out >= 0.0f ? speed : -speed);
|
void resolve_connection(const Connection &o) {
|
||||||
if (o.left) pipe.left_ghost = ghost; else pipe.right_ghost = ghost;
|
auto &a = pipes_[o.a];
|
||||||
o.last_mass_flow = mdot_out;
|
auto &b = pipes_[o.b];
|
||||||
o.last_face_pressure = ghost.pressure;
|
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});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "FluidSim\AudioProcessing.hpp"
|
#include "FluidSim/AudioProcessing.hpp"
|
||||||
#include "FluidSim\Fluid.hpp"
|
#include "FluidSim/Fluid.hpp"
|
||||||
#include "FluidSim\Solver.hpp"
|
#include "FluidSim/Solver.hpp"
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace fluidsim::port {
|
namespace fluidsim::port {
|
||||||
|
|||||||
22
include/FluidSim/MotorcycleTest.hpp
Normal file
22
include/FluidSim/MotorcycleTest.hpp
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "FluidSim/Engine.hpp"
|
||||||
|
#include "FluidSim/Vehicle.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace fluidsim::port {
|
||||||
|
// Shared longitudinal load and clutch integration for motorcycle/car presets.
|
||||||
|
class MotorcycleTest {
|
||||||
|
public:
|
||||||
|
Vehicle vehicle{};
|
||||||
|
float load{}, clutch{1.0f};
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void step_motorcycle(Crankshaft& crank, float dt, float maximum_load_torque) {
|
||||||
|
crank.load_torque = std::clamp(load, 0.0f, 1.0f) * maximum_load_torque;
|
||||||
|
vehicle.clutch_input = std::clamp(clutch, 0.0f, 1.0f);
|
||||||
|
crank.step(dt);
|
||||||
|
crank.drivetrain_torque = vehicle.update(crank.omega, crank.inertia, dt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} // namespace fluidsim::port
|
||||||
72
include/FluidSim/OutdoorAudio.hpp
Normal file
72
include/FluidSim/OutdoorAudio.hpp
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "FluidSim/AudioProcessing.hpp"
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace fluidsim::port {
|
||||||
|
enum class OutdoorScene { Dry, Field, Wall, Woodland };
|
||||||
|
const char *scene_name(OutdoorScene scene);
|
||||||
|
|
||||||
|
struct OutdoorAudioConfig {
|
||||||
|
Vec3 listener{1.6f, 1.0f, 2.0f};
|
||||||
|
Vec3 listener_right{1.0f, 0.0f, 0.0f};
|
||||||
|
Vec3 intake_position{-0.2f, 0.7f, 0.0f};
|
||||||
|
Vec3 exhaust_position{0.55f, 0.5f, 0.0f};
|
||||||
|
float ear_spacing{0.18f}, sound_speed{343.0f};
|
||||||
|
float ground_y{0.0f}, wall_x{-1.5f};
|
||||||
|
float ground_reflection{0.35f}, wall_reflection{0.65f};
|
||||||
|
float pressure_gain{0.005f}; // 200 Pa per device full scale, not speaker SPL.
|
||||||
|
float monitor_cutoff_hz{6000.0f}; // Second-order acquisition/rendering filter.
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static geometric direct/first-order reflection paths plus an explicitly
|
||||||
|
// approximate diffuse tail. No feedback into the fluid/combustion simulation.
|
||||||
|
class OutdoorAudio {
|
||||||
|
public:
|
||||||
|
explicit OutdoorAudio(int sample_rate = 44100, OutdoorAudioConfig config = {});
|
||||||
|
AudioFrame process(float intake_mass_flow, float exhaust_mass_flow);
|
||||||
|
void set_scene(OutdoorScene scene) { scene_ = scene; }
|
||||||
|
OutdoorScene scene() const { return scene_; }
|
||||||
|
void set_monitor_filter(bool enabled) { monitor_filter_ = enabled; } // Set before playback/capture.
|
||||||
|
AudioFrame dry_frame() const { return last_dry_; }
|
||||||
|
AudioFrame unfiltered_frame() const { return last_raw_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct LowPass {
|
||||||
|
float b0{}, b1{}, b2{}, a1{}, a2{}, z1{}, z2{};
|
||||||
|
float process(float x);
|
||||||
|
};
|
||||||
|
struct Path {
|
||||||
|
std::size_t delay_left{}, delay_right{};
|
||||||
|
float fraction_left{}, fraction_right{}, gain_left{}, gain_right{}, alpha{1};
|
||||||
|
float state_left{}, state_right{};
|
||||||
|
int group{}; // direct, ground, wall, trees
|
||||||
|
};
|
||||||
|
struct Emitter {
|
||||||
|
std::vector<float> filtered, raw;
|
||||||
|
std::vector<Path> paths;
|
||||||
|
std::size_t position{};
|
||||||
|
float previous_flow{};
|
||||||
|
LowPass filter;
|
||||||
|
};
|
||||||
|
struct TailLine {
|
||||||
|
std::vector<float> buffer;
|
||||||
|
std::size_t position{};
|
||||||
|
float gain{}, state{};
|
||||||
|
};
|
||||||
|
int rate_{};
|
||||||
|
OutdoorAudioConfig config_;
|
||||||
|
OutdoorScene scene_{OutdoorScene::Wall};
|
||||||
|
bool monitor_filter_{true};
|
||||||
|
std::array<Emitter, 2> emitters_;
|
||||||
|
std::array<TailLine, 4> tail_;
|
||||||
|
std::array<float, 4> weights_{1, 0, 0, 0};
|
||||||
|
float scene_alpha_{}, tail_alpha_{}, tail_mix_{};
|
||||||
|
AudioFrame last_dry_{}, last_raw_{};
|
||||||
|
void configure_emitter(Emitter &emitter, Vec3 position);
|
||||||
|
AudioFrame render(Emitter &emitter, float flow, AudioFrame &dry, AudioFrame &raw);
|
||||||
|
AudioFrame diffuse(float input);
|
||||||
|
};
|
||||||
|
} // namespace fluidsim::port
|
||||||
34
include/FluidSim/PipeProfile.hpp
Normal file
34
include/FluidSim/PipeProfile.hpp
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "FluidSim/Fluid.hpp"
|
||||||
|
#include <span>
|
||||||
|
|
||||||
|
namespace fluidsim::port {
|
||||||
|
struct PipeSection {
|
||||||
|
float length, inlet_diameter, outlet_diameter;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Linear diameter within each section. Cell area is the exact frustum-volume
|
||||||
|
// average, so refining the mesh preserves the specified chamber volume.
|
||||||
|
inline Pipe1D make_profiled_pipe(std::span<const PipeSection> sections, float maximum_cell_length = .01f) {
|
||||||
|
if (!std::isfinite(maximum_cell_length) || maximum_cell_length <= 0 || sections.empty())
|
||||||
|
throw std::invalid_argument("invalid pipe profile mesh");
|
||||||
|
std::vector<float> areas, lengths;
|
||||||
|
float previous_diameter = sections.front().inlet_diameter;
|
||||||
|
for (const auto& section : sections) {
|
||||||
|
if (!std::isfinite(section.length) || !std::isfinite(section.inlet_diameter) ||
|
||||||
|
!std::isfinite(section.outlet_diameter) || section.length <= 0 ||
|
||||||
|
section.inlet_diameter <= 0 || section.outlet_diameter <= 0 ||
|
||||||
|
std::abs(section.inlet_diameter - previous_diameter) > 1e-6f)
|
||||||
|
throw std::invalid_argument("invalid/discontinuous pipe profile");
|
||||||
|
const int count = static_cast<int>(std::ceil(section.length / maximum_cell_length));
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
const float d0 = std::lerp(section.inlet_diameter, section.outlet_diameter, float(i) / count);
|
||||||
|
const float d1 = std::lerp(section.inlet_diameter, section.outlet_diameter, float(i + 1) / count);
|
||||||
|
areas.push_back(pi / 12 * (d0*d0 + d0*d1 + d1*d1));
|
||||||
|
lengths.push_back(section.length / count);
|
||||||
|
}
|
||||||
|
previous_diameter = section.outlet_diameter;
|
||||||
|
}
|
||||||
|
return Pipe1D(std::move(areas), std::move(lengths));
|
||||||
|
}
|
||||||
|
} // namespace fluidsim::port
|
||||||
47
include/FluidSim/ReedValve.hpp
Normal file
47
include/FluidSim/ReedValve.hpp
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace fluidsim::port {
|
||||||
|
// Lumped cantilever-petal model; geometry/material values require calibration.
|
||||||
|
class ReedValve {
|
||||||
|
public:
|
||||||
|
float length{0.030f}, width{0.016f}, thickness{0.00035f};
|
||||||
|
float youngs_modulus{70e9f}, material_density{1600.0f};
|
||||||
|
float damping_ratio{0.30f}, maximum_lift{0.004f};
|
||||||
|
int petals{4};
|
||||||
|
float lift() const { return lift_; }
|
||||||
|
float step(float dt, float pressure_difference) {
|
||||||
|
if (!std::isfinite(dt) || dt <= 0 || !std::isfinite(pressure_difference))
|
||||||
|
throw std::invalid_argument("invalid reed timestep or pressure");
|
||||||
|
if (!(length > 0 && width > 0 && thickness > 0 && youngs_modulus > 0 && material_density > 0 &&
|
||||||
|
maximum_lift > 0 && petals > 0 && damping_ratio >= 0))
|
||||||
|
throw std::invalid_argument("invalid reed geometry/material");
|
||||||
|
const float mass = .236f * material_density * length * width * thickness * petals;
|
||||||
|
const float stiffness = petals * youngs_modulus * width * thickness * thickness * thickness /
|
||||||
|
(4 * length * length * length);
|
||||||
|
const float damping = 2 * damping_ratio * std::sqrt(mass * stiffness);
|
||||||
|
const float force = pressure_difference * (3.0f / 8.0f) * length * width * petals;
|
||||||
|
const int steps = std::max(1, int(std::ceil(dt * std::sqrt(stiffness / mass) / .15f)));
|
||||||
|
const float h = dt / steps;
|
||||||
|
for (int i = 0; i < steps; i++) {
|
||||||
|
velocity_ += (force - stiffness * lift_ - damping * velocity_) / mass * h;
|
||||||
|
lift_ += velocity_ * h;
|
||||||
|
if (lift_ < 0) {
|
||||||
|
lift_ = 0;
|
||||||
|
velocity_ = 0;
|
||||||
|
}
|
||||||
|
if (lift_ > maximum_lift) {
|
||||||
|
lift_ = maximum_lift;
|
||||||
|
velocity_ = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Front-edge plus triangular side-gap areas for all petals.
|
||||||
|
return petals * (width + length) * lift_;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
float lift_{}, velocity_{};
|
||||||
|
};
|
||||||
|
} // namespace fluidsim::port
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "FluidSim/AudioProcessing.hpp"
|
#include "FluidSim/AudioProcessing.hpp"
|
||||||
|
#include "FluidSim/Atmosphere.hpp"
|
||||||
#include "FluidSim/Engine.hpp"
|
#include "FluidSim/Engine.hpp"
|
||||||
|
#include "FluidSim/Ecu.hpp"
|
||||||
#include "FluidSim/Fluid.hpp"
|
#include "FluidSim/Fluid.hpp"
|
||||||
#include "FluidSim/MechanicalSoundProcessor.hpp"
|
#include "FluidSim/MechanicalSoundProcessor.hpp"
|
||||||
#include "FluidSim/OutdoorExhaustReverb.hpp"
|
#include "FluidSim/OutdoorAudio.hpp"
|
||||||
#include "FluidSim/Solver.hpp"
|
#include "FluidSim/Solver.hpp"
|
||||||
#include "FluidSim/Vehicle.hpp"
|
#include "FluidSim/Vehicle.hpp"
|
||||||
|
#include "FluidSim/MotorcycleTest.hpp"
|
||||||
|
#include "FluidSim/Turbocharger.hpp"
|
||||||
|
#include <memory>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -14,27 +19,58 @@
|
|||||||
|
|
||||||
namespace fluidsim::port {
|
namespace fluidsim::port {
|
||||||
|
|
||||||
|
enum class ScenarioKind { Twin, TwoStroke, SaabB205, TurboTwin };
|
||||||
|
enum class FourStrokePreset { Twin, SaabB205 };
|
||||||
|
|
||||||
struct ScenarioSnapshot {
|
struct ScenarioSnapshot {
|
||||||
|
bool turbo_enabled{};
|
||||||
|
TurboSnapshot turbo{};
|
||||||
|
int cylinder_count{2};
|
||||||
|
float rev_limit_rpm{10000};
|
||||||
|
std::array<float, 4> cylinder_pressures{}, piston_fractions{}, intake_areas{}, exhaust_areas{};
|
||||||
|
std::uint64_t solver_substeps{}, boundary_iterations{};
|
||||||
float rpm{}, speed_kmh{}, kinetic_energy{}, average_power{}, average_torque{};
|
float rpm{}, speed_kmh{}, kinetic_energy{}, average_power{}, average_torque{};
|
||||||
float cylinder_pressure1{}, cylinder_pressure2{};
|
float cylinder_pressure1{}, cylinder_pressure2{};
|
||||||
float cylinder_temperature1{}, cylinder_temperature2{};
|
float cylinder_temperature1{}, cylinder_temperature2{};
|
||||||
float cylinder_piston_fraction1{}, cylinder_piston_fraction2{};
|
float cylinder_piston_fraction1{}, cylinder_piston_fraction2{};
|
||||||
float valve1_intake_area{}, valve1_exhaust_area{}, valve2_intake_area{}, valve2_exhaust_area{};
|
float valve1_intake_area{}, valve1_exhaust_area{}, valve2_intake_area{}, valve2_exhaust_area{};
|
||||||
float intake_pressure{}, exhaust_pressure{};
|
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{};
|
int gear{};
|
||||||
bool rev_limit1{}, rev_limit2{}, mechanical_sound_enabled{};
|
bool rev_limit1{}, rev_limit2{}, mechanical_sound_enabled{};
|
||||||
std::array<std::vector<GasState>, 6> pipe_states;
|
std::array<std::vector<GasState>, 12> pipe_states;
|
||||||
|
std::array<std::vector<float>, 12> pipe_areas, pipe_lengths;
|
||||||
|
void capture_pipes(const std::vector<Pipe1D>& pipes) {
|
||||||
|
for (std::size_t k = 0; k < pipes.size(); ++k) {
|
||||||
|
pipe_areas[k] = pipes[k].cell_areas;
|
||||||
|
pipe_lengths[k] = pipes[k].cell_lengths;
|
||||||
|
pipe_states[k].reserve(pipes[k].cells.size());
|
||||||
|
for (std::size_t i = 0; i < pipes[k].cells.size(); ++i)
|
||||||
|
pipe_states[k].push_back(pipes[k].state(static_cast<int>(i)));
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Active C# SingleCylScenario topology: a 600 cc parallel twin, independent
|
// Shared four-stroke topology: independent runners/headers and shared plenums.
|
||||||
// intake runners, separate short exhaust headers, shared collector and tailpipe.
|
// Presets retain the original 600 cc twin and add a B205-sized inline-four.
|
||||||
class FourStrokeTwinScenario {
|
class FourStrokeScenario : public MotorcycleTest {
|
||||||
public:
|
public:
|
||||||
explicit FourStrokeTwinScenario(int sample_rate = 44100);
|
explicit FourStrokeScenario(int sample_rate = 44100, FourStrokePreset preset = FourStrokePreset::Twin,
|
||||||
|
bool turbo_enabled = false);
|
||||||
|
const FourStrokePreset preset;
|
||||||
|
int cylinder_count() const { return preset == FourStrokePreset::SaabB205 ? 4 : 2; }
|
||||||
|
std::array<FourStrokeCylinder*, 4> cylinders() { return {&cylinder1, &cylinder2, &cylinder3, &cylinder4}; }
|
||||||
AudioFrame process();
|
AudioFrame process();
|
||||||
void set_controls(float throttle_value, float load_value, float clutch_value);
|
void set_controls(float throttle_value, float load_value, float clutch_value);
|
||||||
void toggle_mechanical_sound();
|
void toggle_mechanical_sound();
|
||||||
|
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_up();
|
||||||
void shift_down();
|
void shift_down();
|
||||||
void set_neutral();
|
void set_neutral();
|
||||||
@@ -42,14 +78,13 @@ public:
|
|||||||
ScenarioSnapshot snapshot() const;
|
ScenarioSnapshot snapshot() const;
|
||||||
|
|
||||||
float throttle{};
|
float throttle{};
|
||||||
float load{};
|
|
||||||
float clutch{1.0f};
|
|
||||||
bool mechanical_sound_enabled{};
|
bool mechanical_sound_enabled{};
|
||||||
float mechanical_mix{0.02f}, reverb_send_gain{1.0f};
|
float mechanical_mix{0.02f};
|
||||||
float exhaust_send{0.5f}, intake_send{0.5f}, mechanical_send{};
|
Ecu ecu{};
|
||||||
|
std::unique_ptr<Turbocharger> turbo;
|
||||||
Crankshaft crankshaft{};
|
Crankshaft crankshaft{};
|
||||||
FourStrokeCylinder cylinder1{}, cylinder2{};
|
FourStrokeCylinder cylinder1{}, cylinder2{}, cylinder3{}, cylinder4{};
|
||||||
Vehicle vehicle{};
|
Atmosphere intake_atmosphere{};
|
||||||
Volume0D intake_plenum{1.0e-3f, 101325.0f, 300.0f};
|
Volume0D intake_plenum{1.0e-3f, 101325.0f, 300.0f};
|
||||||
Volume0D exhaust_collector{0.15e-3f, 101325.0f, 700.0f};
|
Volume0D exhaust_collector{0.15e-3f, 101325.0f, 700.0f};
|
||||||
std::vector<Pipe1D> pipes;
|
std::vector<Pipe1D> pipes;
|
||||||
@@ -58,25 +93,27 @@ public:
|
|||||||
std::uint64_t sample_count{};
|
std::uint64_t sample_count{};
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Port* throttle_port_{};
|
float intake_boundary_area_{}, throttle_area_{}, runner_area_{}, header_area_{}, tail_area_{};
|
||||||
Port* runner_feed1_{};
|
std::array<float, 4> intake_valve_areas_{}, exhaust_valve_areas_{};
|
||||||
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 dt_{};
|
float dt_{};
|
||||||
BoundarySystem boundaries_;
|
BoundarySystem boundaries_;
|
||||||
Solver solver_;
|
Solver solver_;
|
||||||
RadiationSource intake_radiation_;
|
OutdoorAudio audio_;
|
||||||
RadiationSource exhaust_radiation_;
|
|
||||||
OutdoorExhaustReverb reverb_;
|
|
||||||
MechanicalSoundProcessor mechanical_sound_;
|
MechanicalSoundProcessor mechanical_sound_;
|
||||||
mutable std::mutex mutex_;
|
mutable std::mutex mutex_;
|
||||||
|
|
||||||
static float area_for_diameter(float diameter);
|
static float area_for_diameter(float diameter);
|
||||||
static std::vector<Pipe1D> create_pipes();
|
static std::vector<Pipe1D> create_pipes(FourStrokePreset preset, bool turbo_enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
using FourStrokeTwinScenario = FourStrokeScenario;
|
||||||
|
class TurboTwinScenario : public FourStrokeScenario {
|
||||||
|
public:
|
||||||
|
explicit TurboTwinScenario(int sample_rate = 44100) : FourStrokeScenario(sample_rate, FourStrokePreset::Twin, true) {}
|
||||||
|
};
|
||||||
|
class SaabB205Scenario : public FourStrokeScenario {
|
||||||
|
public:
|
||||||
|
explicit SaabB205Scenario(int sample_rate = 44100) : FourStrokeScenario(sample_rate, FourStrokePreset::SaabB205) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace fluidsim::port
|
} // namespace fluidsim::port
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ namespace fluidsim::port {
|
|||||||
class Solver {
|
class Solver {
|
||||||
public:
|
public:
|
||||||
Solver(std::vector<Pipe1D>& pipes, BoundarySystem& boundaries);
|
Solver(std::vector<Pipe1D>& pipes, BoundarySystem& boundaries);
|
||||||
|
std::uint64_t substep_count() const { return substep_count_; }
|
||||||
int minimum_substeps{4};
|
int minimum_substeps{4};
|
||||||
void add_component(Component& component);
|
void add_component(Component& component);
|
||||||
void step(float dt);
|
void step(float dt);
|
||||||
@@ -18,7 +19,7 @@ private:
|
|||||||
std::vector<Pipe1D>& pipes_;
|
std::vector<Pipe1D>& pipes_;
|
||||||
BoundarySystem& boundaries_;
|
BoundarySystem& boundaries_;
|
||||||
std::vector<Component*> components_;
|
std::vector<Component*> components_;
|
||||||
std::uint64_t step_count_{};
|
std::uint64_t step_count_{}, substep_count_{};
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace fluidsim::port
|
} // namespace fluidsim::port
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public:
|
|||||||
double speed() const;
|
double speed() const;
|
||||||
void set_volume(float volume);
|
void set_volume(float volume);
|
||||||
float volume() const;
|
float volume() const;
|
||||||
|
std::uint64_t underrun_blocks() const { return stream_.underrun_blocks(); }
|
||||||
bool is_playing() const { return is_playing_; }
|
bool is_playing() const { return is_playing_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
78
include/FluidSim/Turbocharger.hpp
Normal file
78
include/FluidSim/Turbocharger.hpp
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "FluidSim/Fluid.hpp"
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace fluidsim::port {
|
||||||
|
|
||||||
|
// RHB31/VZ21-size mean-line approximation. Geometry, losses and coefficients
|
||||||
|
// are estimates, not a digitized or validated manufacturer performance map.
|
||||||
|
struct TurboConfig {
|
||||||
|
double shaft_inertia{4e-6}; // kg m^2, compressor+turbine+shaft rigid rotor
|
||||||
|
double compressor_radius{.018}, turbine_radius{.0135};
|
||||||
|
double compressor_head_coefficient{.60}, compressor_efficiency{.65};
|
||||||
|
double turbine_peak_efficiency{.65}, turbine_optimum_speed_ratio{.70};
|
||||||
|
double compressor_effective_area{110e-6}, turbine_effective_area{100e-6};
|
||||||
|
double bearing_viscous{1e-7}, windage{2e-11}; // Nm/(rad/s), Nm/(rad/s)^2
|
||||||
|
// Estimated pressure-can/spring wastegate. Set maximum lift to zero for a
|
||||||
|
// pure ungated turbo bench test. This changes bypass AREA, never boost.
|
||||||
|
double gate_diaphragm_area{8e-4}, gate_spring_preload{40}, gate_spring_stiffness{6000};
|
||||||
|
double gate_damping{120}, gate_diameter{.014}, gate_maximum_lift{.004};
|
||||||
|
double reference_pressure{101325};
|
||||||
|
float compressor_inlet_volume{20e-6f}, compressor_outlet_volume{30e-6f};
|
||||||
|
float turbine_inlet_volume{30e-6f}, turbine_outlet_volume{20e-6f};
|
||||||
|
std::array<float, 4> connection_diameters{.024f, .024f, .025f, .026f};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TurboSnapshot {
|
||||||
|
double rpm{}, angle{}, shaft_energy{}, turbine_power{}, compressor_power{}, bearing_power{};
|
||||||
|
double compressor_flow{}, turbine_flow{}, compressor_pressure_ratio{}, turbine_pressure_ratio{};
|
||||||
|
double turbine_inlet_pressure{}, turbine_outlet_pressure{}, charge_pressure{}, charge_temperature{};
|
||||||
|
double bearing_heat{}, energy_residual{};
|
||||||
|
std::uint64_t limited_transfers{};
|
||||||
|
double wastegate_flow{}, wastegate_lift{};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Four pressure-storage nodes joined by turbine/nozzle and centrifugal-pump
|
||||||
|
// characteristics. Existing pipe orifices exchange conserved mass/enthalpy.
|
||||||
|
// The whole turbo is ONE solver component; do not also register its volumes.
|
||||||
|
class Turbocharger final : public Component {
|
||||||
|
public:
|
||||||
|
explicit Turbocharger(TurboConfig config = {});
|
||||||
|
const TurboConfig config;
|
||||||
|
Volume0D compressor_inlet, compressor_outlet, turbine_inlet, turbine_outlet;
|
||||||
|
Port& compressor_inlet_port;
|
||||||
|
Port& compressor_outlet_port;
|
||||||
|
Port& turbine_inlet_port;
|
||||||
|
Port& turbine_outlet_port;
|
||||||
|
|
||||||
|
void connect(BoundarySystem& boundaries, const std::vector<Pipe1D>& pipes,
|
||||||
|
int compressor_in, bool compressor_in_left, int compressor_out, bool compressor_out_left,
|
||||||
|
int turbine_in, bool turbine_in_left, int turbine_out, bool turbine_out_left);
|
||||||
|
std::vector<Port*> ports() override;
|
||||||
|
void update_state(float dt) override;
|
||||||
|
TurboSnapshot snapshot() const;
|
||||||
|
double omega() const { return omega_; }
|
||||||
|
double shaft_energy() const { return .5*config.shaft_inertia*omega_*omega_; }
|
||||||
|
double fluid_energy() const;
|
||||||
|
// Intended for standalone initialization/tests, never an imposed running speed.
|
||||||
|
void set_initial_rpm(double rpm);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::array<Port*, 4> internal_;
|
||||||
|
std::array<float, 4> connection_areas_{};
|
||||||
|
double omega_{}, angle_{}, bearing_heat_{};
|
||||||
|
double turbine_power_{}, compressor_power_{}, bearing_power_{}, energy_residual_{};
|
||||||
|
double compressor_flow_{}, turbine_flow_{};
|
||||||
|
double wastegate_flow_{}, wastegate_lift_{};
|
||||||
|
std::uint64_t limited_transfers_{};
|
||||||
|
bool connected_{};
|
||||||
|
static double nozzle(double upstream_pressure, double upstream_temperature,
|
||||||
|
double downstream_pressure, double area);
|
||||||
|
double transfer_flow(Volume0D& inlet, Volume0D& outlet, Port& inlet_external,
|
||||||
|
Port& outlet_external, double requested, double dt);
|
||||||
|
static void set_transfer(Volume0D& inlet, Volume0D& outlet, Port& inlet_internal,
|
||||||
|
Port& outlet_internal, double flow, double shaft_work, double dt);
|
||||||
|
};
|
||||||
|
} // namespace fluidsim::port
|
||||||
38
include/FluidSim/TwoStrokeConfig.hpp
Normal file
38
include/FluidSim/TwoStrokeConfig.hpp
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "FluidSim/PipeProfile.hpp"
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
namespace fluidsim::port {
|
||||||
|
enum class TwoStrokeExhaust { Racing, Straight };
|
||||||
|
|
||||||
|
// YZ125-sized baseline. Port/chamber/combustion dimensions are explicit design
|
||||||
|
// estimates, not a measured Yamaha cylinder or factory exhaust/ignition map.
|
||||||
|
struct TwoStrokeConfig {
|
||||||
|
TwoStrokeExhaust exhaust{TwoStrokeExhaust::Racing};
|
||||||
|
float bore{.054f}, stroke{.0545f}, rod{.110f};
|
||||||
|
float trapped_compression{8.2f}; // exhaust-closure volume / clearance volume
|
||||||
|
float transfer_duration{130}, exhaust_duration{190};
|
||||||
|
float transfer_width{.050f}, exhaust_width{.036f}; // summed effective window widths
|
||||||
|
float carburetor_diameter{.0381f};
|
||||||
|
float minimum_burn_seconds{.0008f};
|
||||||
|
// Empirical mechanical FMEP = A + B*mean_piston_speed + C*speed^2.
|
||||||
|
// Generic correlation estimates, not a measured YZ125 coast-down fit.
|
||||||
|
float friction_bar{.4f}, friction_bar_per_mps{.09f}, friction_bar_per_mps2{.0009f};
|
||||||
|
float exhaust_cell_length{.01f};
|
||||||
|
std::array<PipeSection, 6> racing_pipe{{
|
||||||
|
{.160f, .036f, .036f}, // header
|
||||||
|
{.180f, .036f, .060f}, // shallow first diffuser
|
||||||
|
{.190f, .060f, .105f}, // second diffuser
|
||||||
|
{.130f, .105f, .105f}, // belly
|
||||||
|
{.210f, .105f, .022f}, // convergent / baffle cone
|
||||||
|
{.240f, .022f, .022f} // stinger
|
||||||
|
}};
|
||||||
|
float geometric_compression() const {
|
||||||
|
const float theta = (180 + exhaust_duration * .5f) * pi / 180;
|
||||||
|
const float radius = stroke * .5f, sine = std::sin(theta);
|
||||||
|
const float travel = radius * (1 - std::cos(theta)) + rod -
|
||||||
|
std::sqrt(rod*rod - radius*radius*sine*sine);
|
||||||
|
return 1 + (trapped_compression - 1) * stroke / travel;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} // namespace fluidsim::port
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "FluidSim/Crankcase.hpp"
|
#include "FluidSim/Crankcase.hpp"
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
namespace fluidsim::port {
|
namespace fluidsim::port {
|
||||||
|
|
||||||
@@ -18,6 +19,9 @@ public:
|
|||||||
float exhaust_open() const { return 180.0f - exhaust_duration_ * 0.5f; }
|
float exhaust_open() const { return 180.0f - exhaust_duration_ * 0.5f; }
|
||||||
float exhaust_close() const { return 180.0f + exhaust_duration_ * 0.5f; }
|
float exhaust_close() const { return 180.0f + exhaust_duration_ * 0.5f; }
|
||||||
void set_crankcase(Crankcase& crankcase) { crankcase_ = &crankcase; }
|
void set_crankcase(Crankcase& crankcase) { crankcase_ = &crankcase; }
|
||||||
|
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;
|
float valve_area(float cycle_degrees, bool inlet) const override;
|
||||||
void pre_step(Crankshaft& crank, float dt, float throttle) override;
|
void pre_step(Crankshaft& crank, float dt, float throttle) override;
|
||||||
@@ -26,7 +30,10 @@ private:
|
|||||||
float transfer_duration_{}, exhaust_duration_{};
|
float transfer_duration_{}, exhaust_duration_{};
|
||||||
Crankcase* crankcase_{};
|
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);
|
void handle_cycle_events(float previous_degrees, float current_degrees);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,14 @@
|
|||||||
#include "FluidSim/AudioProcessing.hpp"
|
#include "FluidSim/AudioProcessing.hpp"
|
||||||
#include "FluidSim/Crankcase.hpp"
|
#include "FluidSim/Crankcase.hpp"
|
||||||
#include "FluidSim/Fluid.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/Solver.hpp"
|
||||||
#include "FluidSim/TwoStrokeCylinder.hpp"
|
#include "FluidSim/TwoStrokeCylinder.hpp"
|
||||||
|
#include "FluidSim/TwoStrokeConfig.hpp"
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -14,19 +19,34 @@ namespace fluidsim::port {
|
|||||||
struct TwoStrokeSnapshot {
|
struct TwoStrokeSnapshot {
|
||||||
float rpm{}, cylinder_pressure{}, crankcase_pressure{}, intake_pressure{};
|
float rpm{}, cylinder_pressure{}, crankcase_pressure{}, intake_pressure{};
|
||||||
float throttle{}, transfer_area{}, exhaust_area{};
|
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
|
// Port of the C# 125 cc crankcase-scavenged two-stroke scenario. It retains
|
||||||
// the plenum / reed / crankcase / transfer / cylinder / exhaust topology.
|
// the plenum / reed / crankcase / transfer / cylinder / exhaust topology.
|
||||||
class TwoStrokeScenario {
|
class TwoStrokeScenario : public MotorcycleTest {
|
||||||
public:
|
public:
|
||||||
explicit TwoStrokeScenario(int sample_rate = 44100);
|
explicit TwoStrokeScenario(int sample_rate = 44100, TwoStrokeConfig config = {});
|
||||||
AudioFrame process();
|
AudioFrame process();
|
||||||
void set_throttle(float value);
|
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;
|
TwoStrokeSnapshot snapshot() const;
|
||||||
|
|
||||||
float throttle{};
|
float throttle{};
|
||||||
|
const TwoStrokeConfig configuration;
|
||||||
|
Ecu ecu{};
|
||||||
|
ReedValve reed{};
|
||||||
|
Atmosphere intake_atmosphere{};
|
||||||
|
float intake_flow{}, exhaust_flow{};
|
||||||
Crankshaft crankshaft{};
|
Crankshaft crankshaft{};
|
||||||
TwoStrokeCylinder cylinder;
|
TwoStrokeCylinder cylinder;
|
||||||
Crankcase crankcase;
|
Crankcase crankcase;
|
||||||
@@ -34,7 +54,7 @@ public:
|
|||||||
std::vector<Pipe1D> pipes;
|
std::vector<Pipe1D> pipes;
|
||||||
|
|
||||||
private:
|
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_{};
|
float reed_in_area_{}, reed_out_area_{}, transfer_pipe_area_{}, transfer_valve_area_{}, exhaust_valve_area_{};
|
||||||
bool reed_open_{};
|
bool reed_open_{};
|
||||||
std::uint64_t step_count_{};
|
std::uint64_t step_count_{};
|
||||||
@@ -42,12 +62,10 @@ private:
|
|||||||
Port* plenum_outlet_{};
|
Port* plenum_outlet_{};
|
||||||
BoundarySystem boundaries_;
|
BoundarySystem boundaries_;
|
||||||
Solver solver_;
|
Solver solver_;
|
||||||
SoundProcessor intake_sound_;
|
OutdoorAudio audio_;
|
||||||
SoundProcessor exhaust_sound_;
|
|
||||||
OutdoorExhaustReverb reverb_;
|
|
||||||
|
|
||||||
static float area_for_diameter(float diameter);
|
static float area_for_diameter(float diameter);
|
||||||
static std::vector<Pipe1D> create_pipes();
|
static std::vector<Pipe1D> create_pipes(const TwoStrokeConfig& config);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace fluidsim::port
|
} // namespace fluidsim::port
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <array>
|
#include <vector>
|
||||||
|
|
||||||
namespace fluidsim::port {
|
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 {
|
class Vehicle {
|
||||||
public:
|
public:
|
||||||
int current_gear{};
|
int current_gear{};
|
||||||
std::array<float, 6> gear_ratios{4.1f, 2.7f, 2.0f, 1.55f, 1.25f, 0.98f};
|
std::vector<float> gear_ratios{4.1f, 2.7f, 2.0f, 1.55f, 1.25f, 0.98f};
|
||||||
float final_drive_ratio{2.7f}, primary_reduction{1.8f};
|
float final_drive_ratio{2.7f}, primary_reduction{1.8f};
|
||||||
float clutch_input{1.0f}, clutch_capacity{80.0f}, clutch_engagement_rate{8.0f};
|
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};
|
float mass{160.0f}, wheel_radius{0.32f}, rotational_inertia{1.2f};
|
||||||
|
|||||||
@@ -12,20 +12,47 @@ AudioOutputStream::AudioOutputStream(SimulationRingBuffer& ring, unsigned int sa
|
|||||||
|
|
||||||
bool AudioOutputStream::onGetData(Chunk& data) {
|
bool AudioOutputStream::onGetData(Chunk& data) {
|
||||||
const std::size_t read = ring_.read_interpolated(temp_left_, temp_right_, speed());
|
const std::size_t read = ring_.read_interpolated(temp_left_, temp_right_, speed());
|
||||||
|
if (read < block_size) underrun_blocks_.fetch_add(1, std::memory_order_relaxed);
|
||||||
constexpr float dc_coefficient = 0.9995f;
|
constexpr float dc_coefficient = 0.9995f;
|
||||||
for (std::size_t i = 0; i < read; ++i) {
|
// Keep callback work bounded: no allocation, waits or simulation here.
|
||||||
const float left = temp_left_[i] - previous_input_left_ + dc_coefficient * previous_output_left_;
|
for (std::size_t i = 0; i < block_size; ++i) {
|
||||||
const float right = temp_right_[i] - previous_input_right_ + dc_coefficient * previous_output_right_;
|
float left{}, right{};
|
||||||
previous_input_left_ = temp_left_[i];
|
if (i < read) {
|
||||||
previous_input_right_ = temp_right_[i];
|
const float input_left = std::isfinite(temp_left_[i]) ? temp_left_[i] : 0.0f;
|
||||||
previous_output_left_ = left;
|
const float input_right = std::isfinite(temp_right_[i]) ? temp_right_[i] : 0.0f;
|
||||||
previous_output_right_ = right;
|
left = input_left - previous_input_left_ + dc_coefficient * previous_output_left_;
|
||||||
const float finite_left = std::isfinite(left) ? std::clamp(left, -1.0f, 1.0f) : 0.0f;
|
right = input_right - previous_input_right_ + dc_coefficient * previous_output_right_;
|
||||||
const float finite_right = std::isfinite(right) ? std::clamp(right, -1.0f, 1.0f) : 0.0f;
|
previous_input_left_ = input_left;
|
||||||
output_[i * 2] = static_cast<std::int16_t>(finite_left * 32767.0f);
|
previous_input_right_ = input_right;
|
||||||
output_[i * 2 + 1] = static_cast<std::int16_t>(finite_right * 32767.0f);
|
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<float>(transition_remaining_) / transition_samples;
|
||||||
|
left += transition_left_ * weight;
|
||||||
|
right += transition_right_ * weight;
|
||||||
|
} else {
|
||||||
|
if (!starving_) {
|
||||||
|
transition_left_ = last_left_;
|
||||||
|
transition_right_ = last_right_;
|
||||||
|
transition_remaining_ = transition_samples;
|
||||||
|
starving_ = true;
|
||||||
|
}
|
||||||
|
const float weight = static_cast<float>(transition_remaining_) / transition_samples;
|
||||||
|
left = transition_left_ * weight;
|
||||||
|
right = transition_right_ * weight;
|
||||||
|
}
|
||||||
|
if (transition_remaining_ > 0) --transition_remaining_;
|
||||||
|
// Device protection only; simulated pressure stays linear upstream.
|
||||||
|
last_left_ = std::clamp(left, -1.0f, 1.0f);
|
||||||
|
last_right_ = std::clamp(right, -1.0f, 1.0f);
|
||||||
|
output_[i * 2] = static_cast<std::int16_t>(last_left_ * 32767.0f);
|
||||||
|
output_[i * 2 + 1] = static_cast<std::int16_t>(last_right_ * 32767.0f);
|
||||||
}
|
}
|
||||||
std::fill(output_.begin() + static_cast<std::ptrdiff_t>(read * 2), output_.end(), 0);
|
|
||||||
data.samples = output_.data();
|
data.samples = output_.data();
|
||||||
data.sampleCount = output_.size();
|
data.sampleCount = output_.size();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -10,30 +10,27 @@ SoundProcessor::SoundProcessor(int sample_rate, float listener_distance) {
|
|||||||
const int safe_rate = std::max(sample_rate, 1);
|
const int safe_rate = std::max(sample_rate, 1);
|
||||||
dt_ = 1.0f / static_cast<float>(safe_rate);
|
dt_ = 1.0f / static_cast<float>(safe_rate);
|
||||||
scale_factor_ = 1.0f / (4.0f * pi * std::max(listener_distance, 1e-3f));
|
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) {
|
float SoundProcessor::process(float mass_flow_out) {
|
||||||
flow_lowpass_ = lowpass_alpha_ * flow_lowpass_ + (1.0f - lowpass_alpha_) * mass_flow_out;
|
// Compact monopole approximation: p(r,t) = (d mass_flow/dt) / (4 pi r).
|
||||||
const float raw_derivative = (flow_lowpass_ - previous_flow_) / dt_;
|
// gain maps pressure in pascals to device full scale (200 Pa by default).
|
||||||
previous_flow_ = flow_lowpass_;
|
const float derivative = (mass_flow_out - previous_flow_) / dt_;
|
||||||
smooth_derivative_ = derivative_alpha_ * smooth_derivative_ +
|
previous_flow_ = mass_flow_out;
|
||||||
(1.0f - derivative_alpha_) * raw_derivative;
|
return derivative * scale_factor_ * gain;
|
||||||
return std::tanh(smooth_derivative_ * scale_factor_ * gain);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RadiationSource::RadiationSource(int sample_rate)
|
RadiationSource::RadiationSource(int sample_rate)
|
||||||
: sample_rate_(std::max(sample_rate, 1)), dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))) {
|
: sample_rate_(std::max(sample_rate, 1)), dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))) {
|
||||||
const std::size_t max_delay = static_cast<std::size_t>(std::max(1.0f,
|
const std::size_t max_delay = static_cast<std::size_t>(std::max(1.0f,
|
||||||
0.5f / std::max(speed_of_sound, 1.0f) * static_cast<float>(sample_rate_)));
|
100.0f / std::max(speed_of_sound, 1.0f) * static_cast<float>(sample_rate_)));
|
||||||
left_delay_.resize(max_delay);
|
left_delay_.resize(max_delay);
|
||||||
right_delay_.resize(max_delay);
|
right_delay_.resize(max_delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioFrame RadiationSource::process(float mass_flow) {
|
AudioFrame RadiationSource::process(float mass_flow) {
|
||||||
const float flow_alpha = 1.0f - std::exp(-dt_ / std::max(flow_tau, 1e-5f));
|
const float flow_alpha = flow_tau > 0.0f ? 1.0f - std::exp(-dt_ / flow_tau) : 1.0f;
|
||||||
const float derivative_decay = std::exp(-dt_ / std::max(smooth_tau, 1e-5f));
|
const float derivative_decay = smooth_tau > 0.0f ? std::exp(-dt_ / smooth_tau) : 0.0f;
|
||||||
flow_lp_ += flow_alpha * (mass_flow - flow_lp_);
|
flow_lp_ += flow_alpha * (mass_flow - flow_lp_);
|
||||||
const float raw_derivative = (flow_lp_ - previous_flow_lp_) / dt_;
|
const float raw_derivative = (flow_lp_ - previous_flow_lp_) / dt_;
|
||||||
previous_flow_lp_ = flow_lp_;
|
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 Vec3 direction{to_listener.x / distance, to_listener.y / distance, to_listener.z / distance};
|
||||||
const float cosine = dot(pipe_axis, direction);
|
const float cosine = dot(pipe_axis, direction);
|
||||||
const float directivity = (1.0f - directivity_balance) + directivity_balance * cosine;
|
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,
|
const float air_cutoff = std::max(min_air_cutoff,
|
||||||
20000.0f * std::exp(-distance * air_absorption_exponent));
|
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,
|
const Vec3 ear_offset{listener_right.x * ear_spacing * 0.5f,
|
||||||
listener_right.y * ear_spacing * 0.5f,
|
listener_right.y * ear_spacing * 0.5f,
|
||||||
@@ -62,15 +60,15 @@ AudioFrame RadiationSource::process(float mass_flow) {
|
|||||||
|
|
||||||
const auto delay_sample = [this, filtered](std::vector<float>& buffer, std::size_t offset, float ear_distance) {
|
const auto delay_sample = [this, filtered](std::vector<float>& buffer, std::size_t offset, float ear_distance) {
|
||||||
const std::size_t read = (write_position_ + buffer.size() - offset) % buffer.size();
|
const 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);
|
buffer[write_position_] = filtered * (length(subtract(listener, pipe_end)) / ear_distance);
|
||||||
|
const float value = buffer[read];
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
float left = delay_sample(left_delay_, left_offset_, left_distance);
|
float left = delay_sample(left_delay_, left_offset_, left_distance);
|
||||||
float right = delay_sample(right_delay_, right_offset_, right_distance);
|
float right = delay_sample(right_delay_, right_offset_, right_distance);
|
||||||
write_position_ = (write_position_ + 1) % left_delay_.size();
|
write_position_ = (write_position_ + 1) % left_delay_.size();
|
||||||
if (left_distance > right_distance) left = one_pole(left, head_shadow_cutoff, shadow_left_);
|
if (head_shadow_cutoff > 0.0f && 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_);
|
else if (head_shadow_cutoff > 0.0f && right_distance > left_distance) right = one_pole(right, head_shadow_cutoff, shadow_right_);
|
||||||
return {left, right};
|
return {left, right};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
namespace fluidsim::port {
|
namespace fluidsim::port {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -26,6 +27,7 @@ Crankcase::Crankcase(Crankshaft& crankshaft, float crank_radius, float connectin
|
|||||||
temperature_ = initial_temperature;
|
temperature_ = initial_temperature;
|
||||||
mass_ = initial_pressure * volume_ / (gas_constant * initial_temperature);
|
mass_ = initial_pressure * volume_ / (gas_constant * initial_temperature);
|
||||||
energy_ = mass_ * cv * 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_;
|
intake_port.owner_air_mass = transfer_port.owner_air_mass = &mass_;
|
||||||
sync_ports();
|
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 displacement = crank_radius_ * (1.0f - cosine) + connecting_rod_length_ * (1.0f - term);
|
||||||
const float maximum_volume = clearance_volume_ + piston_area_ * 2.0f * crank_radius_;
|
const float maximum_volume = clearance_volume_ + piston_area_ * 2.0f * crank_radius_;
|
||||||
volume_ = std::max(maximum_volume - piston_area_ * displacement, 1e-12f);
|
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) {
|
if (mass_ > 1e-12f) {
|
||||||
temperature_ = energy_ / (mass_ * cv);
|
temperature_ = energy_ / (mass_ * cv);
|
||||||
pressure_ = mass_ * gas_constant * temperature_ / volume_;
|
pressure_ = mass_ * gas_constant * temperature_ / volume_;
|
||||||
@@ -59,31 +64,10 @@ void Crankcase::update_state(float dt) {
|
|||||||
energy_ += delta_energy;
|
energy_ += delta_energy;
|
||||||
if (mass_ > 1e-12f) air_fraction_ = std::clamp((old_air_mass + delta_air_mass) / mass_, 0.0f, 1.0f);
|
if (mass_ > 1e-12f) air_fraction_ = std::clamp((old_air_mass + delta_air_mass) / mass_, 0.0f, 1.0f);
|
||||||
|
|
||||||
const float volume_change = volume_ - previous_volume_;
|
if (!(mass_ > 0.0f) || !(energy_ > 0.0f) || !std::isfinite(mass_) || !std::isfinite(energy_))
|
||||||
energy_ -= pressure_ * volume_change;
|
throw std::runtime_error("nonphysical crankcase state");
|
||||||
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;
|
|
||||||
|
|
||||||
temperature_ = energy_ / (mass_ * cv);
|
temperature_ = energy_ / (mass_ * cv);
|
||||||
pressure_ = mass_ * gas_constant * temperature_ / std::max(volume_, 1e-12f);
|
pressure_ = mass_ * gas_constant * temperature_ / volume_;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sync_ports();
|
sync_ports();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#include "FluidSim/Scenario.hpp"
|
#include "FluidSim/DesktopScenario.hpp"
|
||||||
#include "FluidSim/SoundEngine.hpp"
|
#include "FluidSim/SoundEngine.hpp"
|
||||||
#include "FluidSim/DynoCurve.hpp"
|
#include "FluidSim/DynoCurve.hpp"
|
||||||
|
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
@@ -19,8 +21,8 @@ using namespace fluidsim::port;
|
|||||||
constexpr unsigned int window_width = 1280;
|
constexpr unsigned int window_width = 1280;
|
||||||
constexpr unsigned int window_height = 720;
|
constexpr unsigned int window_height = 720;
|
||||||
constexpr std::size_t ring_capacity = 8192;
|
constexpr std::size_t ring_capacity = 8192;
|
||||||
constexpr std::size_t target_fill = 1024;
|
constexpr std::size_t target_fill = 4096;
|
||||||
constexpr std::size_t preroll_samples = 1024;
|
constexpr std::size_t preroll_samples = 4096;
|
||||||
constexpr float throttle_lerp_rate = 10.0f;
|
constexpr float throttle_lerp_rate = 10.0f;
|
||||||
|
|
||||||
sf::Color pressure_color(float pressure) {
|
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);
|
window.draw(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::optional<ScenarioKind> choose_scenario(sf::RenderWindow& window, const sf::Font* font) {
|
||||||
|
const std::array<sf::FloatRect, 4> cards{{{{32, 110}, {656, 84}}, {{32, 206}, {656, 84}}, {{32, 302}, {656, 84}}, {{32, 398}, {656, 84}}}};
|
||||||
|
const sf::FloatRect start_button{{32, 506}, {656, 52}};
|
||||||
|
const std::array<const char*, 4> titles{"600 cc parallel twin", "YZ125 two-stroke", "Saab B205 / 9-5 wagon", "600 cc twin + VZ21-size turbo"};
|
||||||
|
const std::array<const char*, 4> descriptions{
|
||||||
|
"Four-stroke twin, motorcycle test and dyno",
|
||||||
|
"125 cc single, expansion chamber, motorcycle test and dyno",
|
||||||
|
"2.0 litre inline-four, no turbo yet, five-speed wagon and dyno",
|
||||||
|
"Coupled turbine/compressor, shaft inertia and pressure diagnostics"};
|
||||||
|
int selected = 0;
|
||||||
|
while (window.isOpen()) {
|
||||||
|
while (const auto event = window.pollEvent()) {
|
||||||
|
if (event->is<sf::Event::Closed>()) return std::nullopt;
|
||||||
|
if (const auto* key = event->getIf<sf::Event::KeyPressed>()) {
|
||||||
|
switch (key->code) {
|
||||||
|
case sf::Keyboard::Key::Escape: return std::nullopt;
|
||||||
|
case sf::Keyboard::Key::Up: selected = (selected + 3) % 4; break;
|
||||||
|
case sf::Keyboard::Key::Down:
|
||||||
|
case sf::Keyboard::Key::Tab: selected = (selected + 1) % 4; break;
|
||||||
|
case sf::Keyboard::Key::Num1: selected = 0; break;
|
||||||
|
case sf::Keyboard::Key::Num2: selected = 1; break;
|
||||||
|
case sf::Keyboard::Key::Num3: selected = 2; break;
|
||||||
|
case sf::Keyboard::Key::Num4: selected = 3; break;
|
||||||
|
case sf::Keyboard::Key::Enter: return static_cast<ScenarioKind>(selected);
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (const auto* move = event->getIf<sf::Event::MouseMoved>()) {
|
||||||
|
const auto point = window.mapPixelToCoords(move->position);
|
||||||
|
for (int i = 0; i < 4; ++i)
|
||||||
|
if (cards[i].contains(point)) selected = i;
|
||||||
|
}
|
||||||
|
if (const auto* click = event->getIf<sf::Event::MouseButtonReleased>();
|
||||||
|
click && click->button == sf::Mouse::Button::Left) {
|
||||||
|
const auto point = window.mapPixelToCoords(click->position);
|
||||||
|
for (int i = 0; i < 4; ++i)
|
||||||
|
if (cards[i].contains(point)) return static_cast<ScenarioKind>(i);
|
||||||
|
if (start_button.contains(point)) return static_cast<ScenarioKind>(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.clear({9, 12, 17});
|
||||||
|
draw_label(window, font, "FluidSim", {32, 24}, sf::Color::White, 28);
|
||||||
|
draw_label(window, font, "Choose a scenario to start", {32, 66}, {180, 190, 205}, 16);
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
sf::RectangleShape card(cards[i].size);
|
||||||
|
card.setPosition(cards[i].position);
|
||||||
|
card.setFillColor(i == selected ? sf::Color(24, 46, 60) : sf::Color(18, 24, 32));
|
||||||
|
card.setOutlineColor(i == selected ? sf::Color(90, 200, 210) : sf::Color(55, 65, 78));
|
||||||
|
card.setOutlineThickness(2);
|
||||||
|
window.draw(card);
|
||||||
|
draw_label(window, font, titles[i], cards[i].position + sf::Vector2f{20, 16},
|
||||||
|
sf::Color::White, 20);
|
||||||
|
draw_label(window, font, descriptions[i], cards[i].position + sf::Vector2f{20, 54},
|
||||||
|
{180, 190, 205}, 13);
|
||||||
|
}
|
||||||
|
sf::RectangleShape start(start_button.size);
|
||||||
|
start.setPosition(start_button.position);
|
||||||
|
start.setFillColor({35, 105, 115});
|
||||||
|
window.draw(start);
|
||||||
|
draw_label(window, font, "Start selected scenario [Enter]", {52, 519}, sf::Color::White, 17);
|
||||||
|
draw_label(window, font, "Click a scenario or use Up/Down. Esc: quit", {32, 574}, {150, 165, 180}, 12);
|
||||||
|
window.display();
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
void draw_volume(sf::RenderWindow& window, float pressure, sf::FloatRect bounds) {
|
void draw_volume(sf::RenderWindow& window, float pressure, sf::FloatRect bounds) {
|
||||||
sf::RectangleShape body({bounds.size.x, bounds.size.y});
|
sf::RectangleShape body({bounds.size.x, bounds.size.y});
|
||||||
body.setPosition(bounds.position);
|
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) {
|
float center_y, float x0, float x1) {
|
||||||
const auto& cells = snapshot.pipe_states[pipe_index];
|
const auto& cells = snapshot.pipe_states[pipe_index];
|
||||||
if (cells.empty()) return;
|
if (cells.empty()) return;
|
||||||
const float cell_width = (x1 - x0) / static_cast<float>(cells.size());
|
const auto& areas = snapshot.pipe_areas[pipe_index];
|
||||||
|
const auto& lengths = snapshot.pipe_lengths[pipe_index];
|
||||||
|
float total_length = 0;
|
||||||
|
for (float length : lengths) total_length += length;
|
||||||
|
float x = x0;
|
||||||
for (std::size_t i = 0; i < cells.size(); ++i) {
|
for (std::size_t i = 0; i < cells.size(); ++i) {
|
||||||
const auto& cell = cells[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});
|
sf::RectangleShape segment({cell_width + 1.0f, thickness});
|
||||||
segment.setPosition({x0 + static_cast<float>(i) * cell_width, center_y - thickness * 0.5f});
|
segment.setPosition({x, center_y - thickness * .5f});
|
||||||
segment.setFillColor(temperature_color(cell.pressure / std::max(cell.rho * 287.0f, 1e-9f)));
|
segment.setFillColor(temperature_color(cell.pressure / std::max(cell.rho * 287.0f, 1e-9f)));
|
||||||
window.draw(segment);
|
window.draw(segment);
|
||||||
|
x += cell_width;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void draw_cylinder(sf::RenderWindow& window, const sf::Font* font, float pressure,
|
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) {
|
float piston_fraction, float top, const char* name, bool intake_open, bool exhaust_open, float x = 470.0f) {
|
||||||
constexpr float x = 470.0f, width = 74.0f, height = 142.0f;
|
constexpr float width = 74.0f, height = 142.0f;
|
||||||
const float fraction = std::clamp(piston_fraction, 0.0f, 1.0f);
|
const float fraction = std::clamp(piston_fraction, 0.0f, 1.0f);
|
||||||
const float gas_height = height * fraction;
|
const float gas_height = height * fraction;
|
||||||
sf::RectangleShape wall({width, height});
|
sf::RectangleShape wall({width, height});
|
||||||
@@ -219,18 +294,95 @@ void draw_dyno_curve(sf::RenderWindow& window, const sf::Font* font, const DynoC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state,
|
void draw_dashboard(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state,
|
||||||
float throttle_target, float brake, double playback_speed, std::size_t buffer_fill,
|
float throttle_target, float brake, double playback_speed, std::size_t buffer_fill,
|
||||||
std::size_t buffer_capacity, bool time_warp, DynoCurve& dyno_curve) {
|
std::size_t buffer_capacity, bool time_warp, std::uint64_t underrun_blocks) {
|
||||||
|
std::ostringstream status;
|
||||||
|
status << (time_warp ? "[Space] normal speed" : "[Space] real time")
|
||||||
|
<< " Sim " << std::fixed << std::setprecision(3) << playback_speed << "x"
|
||||||
|
<< " Audio buffer " << buffer_fill << "/" << buffer_capacity
|
||||||
|
<< " Underruns " << underrun_blocks
|
||||||
|
<< (state.cylinder_count == 4 ? "\nWagon " : "\nBike ") << std::setprecision(1) << state.speed_kmh << " km/h Gear "
|
||||||
|
<< (state.gear == 0 ? "N" : std::to_string(state.gear))
|
||||||
|
<< " Throttle " << state.throttle * 100.0f << "% / target " << throttle_target * 100.0f << "% [W, up/down]"
|
||||||
|
<< " Clutch " << state.clutch * 100.0f << "% [C] Engine load " << brake * 100.0f << "% [left/right]"
|
||||||
|
<< " Shift [A/D], Neutral [Z]"
|
||||||
|
<< "\nScene [R]: " << scene_name(state.audio_scene) << " " << (state.fuel_cut ? "FUEL CUT" : (state.idle_control ? "IDLE CONTROL" : "RUNNING"));
|
||||||
|
if (!state.two_stroke)
|
||||||
|
status << " Mechanical [M]: " << (state.mechanical_sound_enabled ? "ON" : "OFF");
|
||||||
|
if (font) {
|
||||||
|
sf::RectangleShape panel({1260.0f, 92.0f});
|
||||||
|
panel.setPosition({10.0f, 10.0f});
|
||||||
|
panel.setFillColor({12, 18, 26, 236});
|
||||||
|
panel.setOutlineColor({80, 110, 130});
|
||||||
|
panel.setOutlineThickness(1.0f);
|
||||||
|
window.draw(panel);
|
||||||
|
draw_label(window, font, status.str(), {22.0f, 20.0f}, sf::Color::White, 14);
|
||||||
|
draw_label(window, font,
|
||||||
|
"RPM " + std::to_string(static_cast<int>(state.rpm)) +
|
||||||
|
" Power " + std::to_string(state.average_power / 1000.0f) + " kW" +
|
||||||
|
" Vehicle kinetic energy " + std::to_string(static_cast<int>(state.kinetic_energy)) + " J",
|
||||||
|
{24.0f, 122.0f}, {220, 230, 240}, 17);
|
||||||
|
draw_label(window, font, state.two_stroke ? "REV LIMIT DISABLED" :
|
||||||
|
((state.rev_limit1 || state.rev_limit2) ? std::string("REV CUT") : "REV LIMIT " + std::to_string(int(state.rev_limit_rpm)) + " rpm"),
|
||||||
|
{24.0f, 151.0f}, {120, 220, 120}, 14);
|
||||||
|
}
|
||||||
|
if (state.turbo_enabled) {
|
||||||
|
const auto& t = state.turbo;
|
||||||
|
std::ostringstream text;
|
||||||
|
text << std::fixed << std::setprecision(0) << "VZ21-size: " << t.rpm << " rpm "
|
||||||
|
<< std::setprecision(2) << "CPR " << t.compressor_pressure_ratio
|
||||||
|
<< " Charge " << t.charge_pressure/1000 << " kPa / " << std::setprecision(0) << t.charge_temperature << " K"
|
||||||
|
<< "\nTurbine inlet " << t.turbine_inlet_pressure/1000 << " kPa Outlet " << t.turbine_outlet_pressure/1000 << " kPa"
|
||||||
|
<< " Power " << t.turbine_power << " / " << t.compressor_power << " W";
|
||||||
|
draw_label(window, font, text.str(), {625, 125}, {130, 215, 220}, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void draw_two_stroke_scene(sf::RenderWindow& window,const sf::Font* font,const ScenarioSnapshot& s) {
|
||||||
|
window.clear({9,12,17});
|
||||||
|
draw_label(window,font,"YZ125 baseline | piston-controlled ports",
|
||||||
|
{650,185},sf::Color::White,14);
|
||||||
|
draw_cylinder(window,font,s.cylinder_pressure1,s.cylinder_piston_fraction1,220,"CYLINDER",
|
||||||
|
s.valve1_intake_area>0,s.valve1_exhaust_area>0);
|
||||||
|
draw_volume(window,s.crankcase_pressure,{{455,410},{110,95}});
|
||||||
|
draw_label(window,font,"CRANKCASE",{455,385},sf::Color::White,14);
|
||||||
|
draw_pipe(window,s,0,460,30,170);
|
||||||
|
draw_volume(window,s.intake_pressure,{{180,425},{95,70}});
|
||||||
|
draw_label(window,font,"INTAKE",{180,400},sf::Color::White,14);
|
||||||
|
draw_pipe(window,s,1,460,285,445);
|
||||||
|
draw_label(window,font,s.reed_open?"REED OPEN":"REED CLOSED",{290,485},s.reed_open?sf::Color::Green:sf::Color::Red,12);
|
||||||
|
draw_pipe(window,s,2,350,315,450);
|
||||||
|
draw_graph_line(window,{510,505},{510,530},{180,190,200});
|
||||||
|
draw_graph_line(window,{510,530},{315,530},{180,190,200});
|
||||||
|
draw_graph_line(window,{315,530},{315,350},{180,190,200});
|
||||||
|
draw_graph_line(window,{450,350},{450,245},{180,190,200});
|
||||||
|
draw_graph_line(window,{450,245},{467,245},{180,190,200});
|
||||||
|
draw_label(window,font,"TRANSFER",{320,325},sf::Color::White,12);
|
||||||
|
draw_graph_line(window,{551,235},{590,235},{180,190,200});
|
||||||
|
draw_pipe(window,s,3,235,590,1230);
|
||||||
|
draw_label(window,font,s.racing_exhaust ? "HEADER / DIFFUSERS / BELLY / BAFFLE / STINGER" : "STRAIGHT EXHAUST",{670,205},sf::Color::White,13);
|
||||||
|
std::ostringstream pressures;
|
||||||
|
pressures<<std::fixed<<std::setprecision(1)<<"Cylinder "<<s.cylinder_pressure1/1000<<" kPa Crankcase "
|
||||||
|
<<s.crankcase_pressure/1000<<" kPa Intake "<<s.intake_pressure/1000<<" kPa";
|
||||||
|
draw_label(window,font,pressures.str(),{22,580},sf::Color::White,17);
|
||||||
|
draw_label(window,font,"54 x 54.5 mm | 8.2:1 trapped compression | estimated pipe/ports/CDI | fixed-open power valve",
|
||||||
|
{22,625},{180,190,200},11);
|
||||||
|
}
|
||||||
|
|
||||||
|
void draw_twin_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state) {
|
||||||
constexpr float intake_y = 310.0f;
|
constexpr float intake_y = 310.0f;
|
||||||
constexpr float upper_y = 152.0f;
|
constexpr float upper_y = 152.0f;
|
||||||
constexpr float lower_y = 400.0f;
|
constexpr float lower_y = 400.0f;
|
||||||
window.clear({9, 12, 17});
|
window.clear({9, 12, 17});
|
||||||
const float power_kw = state.average_power / 1000.0f;
|
|
||||||
dyno_curve.update(state.rpm, power_kw, state.average_torque);
|
|
||||||
|
|
||||||
// Intake: ambient pipe → throttle → plenum → two cylinder runners.
|
// Intake: ambient pipe → throttle → plenum → two cylinder runners.
|
||||||
draw_pipe(window, state, 0, intake_y, 36.0f, 165.0f);
|
draw_pipe(window, state, 0, intake_y, state.turbo_enabled ? 135.f : 36.f, 165.0f);
|
||||||
|
if (state.turbo_enabled) {
|
||||||
|
draw_pipe(window, state, 6, intake_y, 36, 90);
|
||||||
|
draw_volume(window, float(state.turbo.charge_pressure), {{95, intake_y-18}, {35, 36}});
|
||||||
|
draw_label(window, font, "COMP", {92, intake_y+30}, {130,215,220}, 10);
|
||||||
|
}
|
||||||
sf::RectangleShape throttle({8.0f, 28.0f});
|
sf::RectangleShape throttle({8.0f, 28.0f});
|
||||||
throttle.setPosition({169.0f, intake_y - 14.0f});
|
throttle.setPosition({169.0f, intake_y - 14.0f});
|
||||||
throttle.setFillColor(sf::Color::Yellow);
|
throttle.setFillColor(sf::Color::Yellow);
|
||||||
@@ -247,7 +399,12 @@ void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSn
|
|||||||
draw_pipe(window, state, 3, upper_y + 71.0f, 556.0f, 820.0f);
|
draw_pipe(window, state, 3, upper_y + 71.0f, 556.0f, 820.0f);
|
||||||
draw_pipe(window, state, 4, lower_y + 71.0f, 556.0f, 820.0f);
|
draw_pipe(window, state, 4, lower_y + 71.0f, 556.0f, 820.0f);
|
||||||
draw_volume(window, state.exhaust_pressure, {{838.0f, 305.0f}, {70.0f, 82.0f}});
|
draw_volume(window, state.exhaust_pressure, {{838.0f, 305.0f}, {70.0f, 82.0f}});
|
||||||
draw_pipe(window, state, 5, 346.0f, 915.0f, 1236.0f);
|
draw_pipe(window, state, 5, 346.0f, 915.0f, state.turbo_enabled ? 1040.f : 1236.f);
|
||||||
|
if (state.turbo_enabled) {
|
||||||
|
draw_volume(window, float(state.turbo.turbine_inlet_pressure), {{1045, 323}, {45, 46}});
|
||||||
|
draw_label(window, font, "TURBINE", {1040, 380}, {130,215,220}, 10);
|
||||||
|
draw_pipe(window, state, 7, 346, 1100, 1236);
|
||||||
|
}
|
||||||
|
|
||||||
draw_label(window, font, "AMBIENT", {37.0f, intake_y + 22.0f}, {180, 190, 200}, 12);
|
draw_label(window, font, "AMBIENT", {37.0f, intake_y + 22.0f}, {180, 190, 200}, 12);
|
||||||
draw_label(window, font, "THROTTLE", {151.0f, intake_y + 24.0f}, {240, 210, 90}, 11);
|
draw_label(window, font, "THROTTLE", {151.0f, intake_y + 24.0f}, {240, 210, 90}, 11);
|
||||||
@@ -255,42 +412,49 @@ void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSn
|
|||||||
draw_label(window, font, "SHARED EXHAUST", {818.0f, 285.0f}, sf::Color::White, 11);
|
draw_label(window, font, "SHARED EXHAUST", {818.0f, 285.0f}, sf::Color::White, 11);
|
||||||
draw_label(window, font, "TAILPIPE", {1084.0f, 362.0f}, {180, 190, 200}, 11);
|
draw_label(window, font, "TAILPIPE", {1084.0f, 362.0f}, {180, 190, 200}, 11);
|
||||||
|
|
||||||
std::ostringstream status;
|
}
|
||||||
status << (time_warp ? "[Space] normal speed" : "[Space] real time")
|
|
||||||
<< " Sim " << std::fixed << std::setprecision(3) << playback_speed << "x"
|
|
||||||
<< " Audio buffer " << buffer_fill << "/" << buffer_capacity
|
|
||||||
<< "\nBike " << std::setprecision(1) << state.speed_kmh << " km/h Gear "
|
|
||||||
<< (state.gear == 0 ? "N" : std::to_string(state.gear))
|
|
||||||
<< " Throttle " << state.throttle * 100.0f << "% / target " << throttle_target * 100.0f << "% [W, up/down]"
|
|
||||||
<< " Clutch " << state.clutch * 100.0f << "% [C] Brake " << brake * 100.0f << "% [left/right]"
|
|
||||||
<< " Shift [A/D], Neutral [Z] Mechanical [M]: "
|
|
||||||
<< (state.mechanical_sound_enabled ? "ON" : "OFF");
|
|
||||||
if (font) {
|
|
||||||
sf::RectangleShape panel({1260.0f, 92.0f});
|
|
||||||
panel.setPosition({10.0f, 10.0f});
|
|
||||||
panel.setFillColor({12, 18, 26, 236});
|
|
||||||
panel.setOutlineColor({80, 110, 130});
|
|
||||||
panel.setOutlineThickness(1.0f);
|
|
||||||
window.draw(panel);
|
|
||||||
draw_label(window, font, status.str(), {22.0f, 20.0f}, sf::Color::White, 14);
|
|
||||||
draw_label(window, font,
|
|
||||||
"RPM " + std::to_string(static_cast<int>(state.rpm)) +
|
|
||||||
" Power " + std::to_string(state.average_power / 1000.0f) + " kW" +
|
|
||||||
" Bike kinetic energy " + std::to_string(static_cast<int>(state.kinetic_energy)) + " J",
|
|
||||||
{24.0f, 122.0f}, {220, 230, 240}, 17);
|
|
||||||
const sf::Color rev = state.rev_limit1 || state.rev_limit2 ? sf::Color::Red : sf::Color(120, 220, 120);
|
|
||||||
draw_label(window, font, (state.rev_limit1 || state.rev_limit2) ? "REV CUT" : "REV LIMIT 10000 rpm",
|
|
||||||
{24.0f, 151.0f}, rev, 14);
|
|
||||||
draw_label(window, font,
|
|
||||||
"Cylinder pressure " + std::to_string(state.cylinder_pressure1 / 1e5f) + " / " +
|
|
||||||
std::to_string(state.cylinder_pressure2 / 1e5f) + " bar Intake " +
|
|
||||||
std::to_string(state.intake_pressure / 1000.0f) + " kPa Exhaust " +
|
|
||||||
std::to_string(state.exhaust_pressure / 1000.0f) + " kPa",
|
|
||||||
{24.0f, 176.0f}, {205, 215, 225}, 13);
|
|
||||||
}
|
|
||||||
|
|
||||||
draw_dyno_curve(window, font, dyno_curve, 860.0f, 455.0f, 400.0f, 250.0f,
|
void draw_b205_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& s) {
|
||||||
state.rpm, power_kw);
|
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();
|
window.display();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,22 +462,72 @@ void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSn
|
|||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
using namespace fluidsim::port;
|
using namespace fluidsim::port;
|
||||||
sf::RenderWindow window(sf::VideoMode({window_width, window_height}), "FluidSim C++ - parallel twin");
|
ScenarioKind selected_kind=ScenarioKind::Twin;
|
||||||
|
bool scenario_from_args=false;
|
||||||
|
bool turbo_from_args=false;
|
||||||
|
TwoStrokeConfig two_stroke_config;
|
||||||
|
OutdoorScene selected_scene=OutdoorScene::Wall;
|
||||||
|
for(int i=1;i<argc;++i){
|
||||||
|
const std::string option=argv[i];
|
||||||
|
if((option=="--scenario"||option=="--scene"||option=="--exhaust"||option=="--turbo")&&i+1<argc){
|
||||||
|
const std::string value=argv[++i];
|
||||||
|
if(option=="--scenario"){
|
||||||
|
if(value!="twin"&&value!="two-stroke"&&value!="b205"){std::cerr<<"Choose twin, two-stroke or b205\n";return 2;}
|
||||||
|
selected_kind=value=="b205" ? ScenarioKind::SaabB205 :
|
||||||
|
(value=="two-stroke" ? ScenarioKind::TwoStroke : ScenarioKind::Twin);
|
||||||
|
scenario_from_args=true;
|
||||||
|
}else if(option=="--turbo"){
|
||||||
|
if(value!="none"&&value!="vz21"){std::cerr<<"Choose none or vz21 turbo\n";return 2;}
|
||||||
|
turbo_from_args=value=="vz21";
|
||||||
|
}else if(option=="--exhaust"){
|
||||||
|
if(value=="racing") two_stroke_config.exhaust=TwoStrokeExhaust::Racing;
|
||||||
|
else if(value=="straight") two_stroke_config.exhaust=TwoStrokeExhaust::Straight;
|
||||||
|
else { std::cerr<<"Choose racing or straight exhaust\n";return 2; }
|
||||||
|
}else{
|
||||||
|
if(value=="dry")selected_scene=OutdoorScene::Dry;
|
||||||
|
else if(value=="field")selected_scene=OutdoorScene::Field;
|
||||||
|
else if(value=="wall")selected_scene=OutdoorScene::Wall;
|
||||||
|
else if(value=="woodland")selected_scene=OutdoorScene::Woodland;
|
||||||
|
else {std::cerr<<"Choose dry, field, wall or woodland\n";return 2;}
|
||||||
|
}
|
||||||
|
}else{std::cerr<<"Usage: fluidsim_desktop [--scenario twin|two-stroke|b205] [--scene dry|field|wall|woodland] [--exhaust racing|straight] [--turbo none|vz21]\n";return 2;}
|
||||||
|
}
|
||||||
|
const sf::Vector2u initial_size = scenario_from_args ? sf::Vector2u{window_width, window_height}
|
||||||
|
: sf::Vector2u{720, 606};
|
||||||
|
sf::RenderWindow window(sf::VideoMode(initial_size), "FluidSim");
|
||||||
window.setVerticalSyncEnabled(false);
|
window.setVerticalSyncEnabled(false);
|
||||||
window.setFramerateLimit(60);
|
window.setFramerateLimit(60);
|
||||||
window.setKeyRepeatEnabled(false);
|
window.setKeyRepeatEnabled(false);
|
||||||
|
|
||||||
sf::Font font;
|
sf::Font font;
|
||||||
std::filesystem::path font_path{"fonts/FiraCodeNerdFont-Medium.ttf"};
|
std::vector<std::filesystem::path> font_paths{"fonts/FiraCodeNerdFont-Medium.ttf"};
|
||||||
if (!std::filesystem::exists(font_path) && argc > 0) {
|
if(argc>0)font_paths.push_back(std::filesystem::path(argv[0]).parent_path()/"fonts/FiraCodeNerdFont-Medium.ttf");
|
||||||
const auto executable_font = std::filesystem::path(argv[0]).parent_path() /
|
font_paths.emplace_back("/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf");
|
||||||
"fonts/FiraCodeNerdFont-Medium.ttf";
|
font_paths.emplace_back("/usr/share/fonts/TTF/DejaVuSansMono.ttf");
|
||||||
if (std::filesystem::exists(executable_font)) font_path = executable_font;
|
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");
|
||||||
const bool has_font = font.openFromFile(font_path.string());
|
bool has_font=false;
|
||||||
const sf::Font* font_ptr = has_font ? &font : nullptr;
|
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);
|
SimulationRingBuffer ring(ring_capacity);
|
||||||
for (std::size_t i = 0; i < preroll_samples; ++i) {
|
for (std::size_t i = 0; i < preroll_samples; ++i) {
|
||||||
const AudioFrame audio = scenario.process();
|
const AudioFrame audio = scenario.process();
|
||||||
@@ -325,17 +539,19 @@ int main(int argc, char** argv) {
|
|||||||
audio.set_volume(100.0f);
|
audio.set_volume(100.0f);
|
||||||
audio.start();
|
audio.start();
|
||||||
std::jthread simulation([&](std::stop_token stop) {
|
std::jthread simulation([&](std::stop_token stop) {
|
||||||
while (ring.wait_for_space(target_fill, stop)) {
|
constexpr std::size_t batch_size = 512;
|
||||||
const AudioFrame frame = scenario.process();
|
while (ring.wait_for_space(target_fill - batch_size, stop)) {
|
||||||
if (!ring.write(frame.left, frame.right, stop)) break;
|
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 current_speed = desired_speed;
|
||||||
double last_normal_speed = 0.1;
|
double last_normal_speed = 1.0;
|
||||||
bool time_warp = false;
|
bool time_warp = false;
|
||||||
bool real_time = false;
|
|
||||||
float throttle_target = 1.0f, throttle_current = 0.0f;
|
float throttle_target = 1.0f, throttle_current = 0.0f;
|
||||||
float brake_target = 0.0f, brake_current = 0.0f;
|
float brake_target = 0.0f, brake_current = 0.0f;
|
||||||
float clutch_current = 0.0f;
|
float clutch_current = 0.0f;
|
||||||
@@ -350,7 +566,7 @@ int main(int argc, char** argv) {
|
|||||||
switch (key->code) {
|
switch (key->code) {
|
||||||
case sf::Keyboard::Key::Space:
|
case sf::Keyboard::Key::Space:
|
||||||
time_warp = !time_warp;
|
time_warp = !time_warp;
|
||||||
if (!time_warp) { desired_speed = last_normal_speed; real_time = false; }
|
if (!time_warp) { desired_speed = last_normal_speed; }
|
||||||
break;
|
break;
|
||||||
case sf::Keyboard::Key::W: throttle_held = true; break;
|
case sf::Keyboard::Key::W: throttle_held = true; break;
|
||||||
case sf::Keyboard::Key::C: clutch_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::D: scenario.shift_up(); break;
|
||||||
case sf::Keyboard::Key::A: scenario.shift_down(); break;
|
case sf::Keyboard::Key::A: scenario.shift_down(); break;
|
||||||
case sf::Keyboard::Key::Z: scenario.set_neutral(); break;
|
case sf::Keyboard::Key::Z: scenario.set_neutral(); break;
|
||||||
|
case sf::Keyboard::Key::R:
|
||||||
|
selected_scene=static_cast<OutdoorScene>((static_cast<int>(selected_scene)+1)%4);
|
||||||
|
scenario.set_audio_scene(selected_scene);
|
||||||
|
break;
|
||||||
case sf::Keyboard::Key::M: scenario.toggle_mechanical_sound(); break;
|
case sf::Keyboard::Key::M: scenario.toggle_mechanical_sound(); break;
|
||||||
default: 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 *= 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);
|
desired_speed = std::clamp(desired_speed, 0.0001, 1.0);
|
||||||
last_normal_speed = desired_speed;
|
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);
|
audio.set_speed(current_speed);
|
||||||
|
|
||||||
const float wanted_throttle = throttle_held ? throttle_target : 0.0f;
|
const float wanted_throttle = throttle_held ? throttle_target : 0.0f;
|
||||||
if (wanted_throttle == 0.0f) throttle_current = 0.0f;
|
throttle_current = wanted_throttle; // ECU actuator evolves at the simulation sample rate.
|
||||||
else throttle_current += (wanted_throttle - throttle_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
|
|
||||||
brake_current += (brake_target - brake_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
|
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;
|
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));
|
clutch_current += (wanted_clutch - clutch_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
|
||||||
scenario.set_controls(throttle_current, brake_current, clutch_current);
|
scenario.set_controls(throttle_current, brake_current, clutch_current);
|
||||||
|
|
||||||
draw_scene(window, font_ptr, scenario.snapshot(), throttle_target, brake_current,
|
draw_scene(window, font_ptr, scenario.snapshot(), throttle_target, brake_current,
|
||||||
current_speed, ring.available_samples(), ring_capacity, time_warp, dyno_curve);
|
current_speed, ring.available_samples(), ring_capacity, time_warp, dyno_curve, audio.underrun_blocks());
|
||||||
}
|
}
|
||||||
|
|
||||||
simulation.request_stop();
|
simulation.request_stop();
|
||||||
|
|||||||
56
src/ecu.cpp
Normal file
56
src/ecu.cpp
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#include "FluidSim/Ecu.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace fluidsim::port {
|
||||||
|
void Ecu::step(float dt, float pedal, float rpm) {
|
||||||
|
fault_ = !std::isfinite(dt) || dt <= 0.0f || !std::isfinite(pedal) || !std::isfinite(rpm) || rpm < 0.0f;
|
||||||
|
if (fault_) { target_ = 0.0f; idle_ = false; return; }
|
||||||
|
pedal = std::clamp(pedal, 0.0f, 1.0f);
|
||||||
|
if (!rev_limit_enabled) limiter_ = false;
|
||||||
|
else if (rpm >= rev_limit_rpm) limiter_ = true;
|
||||||
|
else if (rpm <= rev_resume_rpm) limiter_ = false;
|
||||||
|
if (pedal > 0.005f || rpm < overrun_resume_rpm) overrun_ = false;
|
||||||
|
else if (rpm > overrun_cut_rpm) overrun_ = true;
|
||||||
|
|
||||||
|
if (!initialized_) { filtered_rpm_ = rpm; initialized_ = true; }
|
||||||
|
filtered_rpm_ += (rpm - filtered_rpm_) * (-std::expm1(-dt / 0.05f));
|
||||||
|
idle_ = pedal <= 0.005f && !overrun_;
|
||||||
|
const float error = idle_rpm - filtered_rpm_;
|
||||||
|
if (idle_) {
|
||||||
|
const float proposed = integral_ + idle_ki * error * dt;
|
||||||
|
const float command = idle_base_opening + idle_kp * error + proposed;
|
||||||
|
// Conditional integration prevents windup at either actuator limit.
|
||||||
|
if ((command >= 0.0f && command <= idle_max_opening) ||
|
||||||
|
(command > idle_max_opening && error < 0.0f) || (command < 0.0f && error > 0.0f))
|
||||||
|
integral_ = proposed;
|
||||||
|
} else {
|
||||||
|
integral_ *= std::exp(-dt / 0.5f);
|
||||||
|
}
|
||||||
|
const float idle_command = idle_ ? std::clamp(idle_base_opening + idle_kp * error + integral_,
|
||||||
|
0.0f, idle_max_opening) : 0.0f;
|
||||||
|
target_ = std::max(pedal, idle_command);
|
||||||
|
const float change = (target_ - opening_) * (-std::expm1(-dt / std::max(throttle_time_constant, 1e-4f)));
|
||||||
|
const float travel = std::max(throttle_rate, 0.0f) * dt;
|
||||||
|
opening_ = std::clamp(opening_ + std::clamp(change, -travel, travel), 0.0f, 1.0f);
|
||||||
|
const float enrichment = std::clamp((opening_ - 0.7f) / 0.3f, 0.0f, 1.0f);
|
||||||
|
afr_ = stoichiometric_afr + (power_afr - stoichiometric_afr) * enrichment;
|
||||||
|
if (ignition_map.empty()) {
|
||||||
|
spark_ = 10;
|
||||||
|
} else {
|
||||||
|
spark_ = ignition_map.front().degrees_btdc;
|
||||||
|
for (std::size_t i = 1; i < ignition_map.size(); ++i) {
|
||||||
|
const auto a = ignition_map[i-1], b = ignition_map[i];
|
||||||
|
const float t = std::clamp((rpm-a.rpm) / std::max(b.rpm-a.rpm, 1e-6f), 0.f, 1.f);
|
||||||
|
spark_ = std::lerp(a.degrees_btdc, b.degrees_btdc, t);
|
||||||
|
if (rpm <= b.rpm) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(idle_ && idle_spark_gain>0) spark_=std::clamp(spark_+idle_spark_gain*error,idle_minimum_advance,35.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
float Ecu::fuel_for_air(float air) const {
|
||||||
|
if (fuel_cut() || !std::isfinite(air) || air <= 0.0f) return 0.0f;
|
||||||
|
return air / std::max(afr_, 1.0f);
|
||||||
|
}
|
||||||
|
} // namespace fluidsim::port
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "FluidSim/Engine.hpp"
|
#include "FluidSim/Engine.hpp"
|
||||||
|
#include "FluidSim/Ecu.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
@@ -14,12 +15,15 @@ void Crankshaft::step(float dt) {
|
|||||||
if (!std::isfinite(external_torque_)) external_torque_ = 0.0f;
|
if (!std::isfinite(external_torque_)) external_torque_ = 0.0f;
|
||||||
previous_angle = angle;
|
previous_angle = angle;
|
||||||
const float sign = omega > 0.0f ? 1.0f : omega < 0.0f ? -1.0f : 0.0f;
|
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 net_torque = external_torque_ - friction;
|
||||||
const float total_torque = net_torque - std::max(load_torque, 0.0f) + drivetrain_torque;
|
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);
|
omega = std::max(omega + total_torque / std::max(inertia, 1e-6f) * dt, 0.0f);
|
||||||
angle = std::fmod(angle + omega * dt, 4.0f * pi);
|
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_];
|
if (power_count_ == power_buffer_.size()) power_sum_ -= power_buffer_[power_index_];
|
||||||
else ++power_count_;
|
else ++power_count_;
|
||||||
power_buffer_[power_index_] = instant_power;
|
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_];
|
if (torque_count_ == torque_buffer_.size()) torque_sum_ -= torque_buffer_[torque_index_];
|
||||||
else ++torque_count_;
|
else ++torque_count_;
|
||||||
torque_buffer_[torque_index_] = total_torque;
|
torque_buffer_[torque_index_] = net_torque;
|
||||||
torque_sum_ += total_torque;
|
torque_sum_ += net_torque;
|
||||||
torque_index_ = (torque_index_ + 1) % torque_buffer_.size();
|
torque_index_ = (torque_index_ + 1) % torque_buffer_.size();
|
||||||
average_torque = torque_sum_ / static_cast<float>(torque_count_);
|
average_torque = torque_sum_ / static_cast<float>(torque_count_);
|
||||||
external_torque_ = 0.0f;
|
external_torque_ = 0.0f;
|
||||||
@@ -77,6 +81,7 @@ void EngineCylinder::initialize(const Crankshaft& /*crank*/) {
|
|||||||
exhaust_mass = 0.0f;
|
exhaust_mass = 0.0f;
|
||||||
energy = 101325.0f * volume / (gamma - 1.0f);
|
energy = 101325.0f * volume / (gamma - 1.0f);
|
||||||
pressure = 101325.0f;
|
pressure = 101325.0f;
|
||||||
|
intake.owner_energy = exhaust.owner_energy = &energy;
|
||||||
intake.owner_air_mass = exhaust.owner_air_mass = &air_mass;
|
intake.owner_air_mass = exhaust.owner_air_mass = &air_mass;
|
||||||
intake.owner_exhaust_mass = exhaust.owner_exhaust_mass = &exhaust_mass;
|
intake.owner_exhaust_mass = exhaust.owner_exhaust_mass = &exhaust_mass;
|
||||||
sync_ports();
|
sync_ports();
|
||||||
@@ -106,13 +111,18 @@ float FourStrokeCylinder::valve_area(float cycle_degrees, bool inlet) const {
|
|||||||
const float t = (mapped - (opens + ramp + hold)) / ramp;
|
const float t = (mapped - (opens + ramp + hold)) / ramp;
|
||||||
lift = peak_lift * (1.0f - t) * (1.0f - t) * (1.0f + 2.0f * t);
|
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*/) {
|
void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*/) {
|
||||||
const float rpm = crank.omega * 60.0f / (2.0f * pi);
|
const float rpm = crank.omega * 60.0f / (2.0f * pi);
|
||||||
spark_advance = std::clamp(10.0f + rpm * 0.002f, 5.0f, 40.0f);
|
if (ecu) {
|
||||||
update_rev_limiter(rpm, dt);
|
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 theta = crank.angle + phase;
|
||||||
const float previous_volume = volume;
|
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 previous_degrees = wrap_degrees((crank.previous_angle + phase) * 180.0f / pi);
|
||||||
const float current_degrees = wrap_degrees(theta * 180.0f / pi);
|
const float current_degrees = wrap_degrees(theta * 180.0f / pi);
|
||||||
if (crossed_window_close(previous_degrees, current_degrees, intake_open, intake_close)) {
|
if (crossed_window_close(previous_degrees, current_degrees, intake_open, intake_close)) {
|
||||||
fuel_mass = air_mass / std::max(afr, 1e-6f);
|
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
|
||||||
fuel_injected = true;
|
fuel_injected = fuel_mass > 0.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
const float spark_angle = wrap_degrees(720.0f - spark_advance);
|
const float spark_angle = wrap_degrees(720.0f - spark_advance);
|
||||||
const bool crossed_spark = (previous_degrees < spark_angle && current_degrees >= spark_angle) ||
|
const bool crossed_spark = crossed_angle(previous_degrees,current_degrees,spark_angle);
|
||||||
(previous_degrees > spark_angle && current_degrees < spark_angle);
|
if (crossed_spark && fuel_injected && !combustion_active && !(ecu ? ecu->fuel_cut() : rev_limit_active) && random_unit() >= misfire_probability) {
|
||||||
if (crossed_spark && fuel_injected && !combustion_active && !rev_limit_active && random_unit() >= misfire_probability) {
|
|
||||||
combustion_active = true;
|
combustion_active = true;
|
||||||
burn_fraction = 0.0f;
|
burn_fraction = 0.0f;
|
||||||
energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.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_mass = 0.0f;
|
||||||
fuel_injected = false;
|
fuel_injected = false;
|
||||||
combustion_active = false;
|
combustion_active = false;
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ HelmholtzScenario::HelmholtzScenario(int sample_rate)
|
|||||||
boundaries_(pipes_), solver_(pipes_, boundaries_), sound_(sample_rate, 1.0f) {
|
boundaries_(pipes_), solver_(pipes_, boundaries_), sound_(sample_rate, 1.0f) {
|
||||||
pipes_.emplace_back(neck_area_, 0.050f, 20);
|
pipes_.emplace_back(neck_area_, 0.050f, 20);
|
||||||
cavity_port_ = &cavity_.create_port();
|
cavity_port_ = &cavity_.create_port();
|
||||||
boundaries_.add_orifice_with_inertance(*cavity_port_, 0, true, cavity_orifice_area_,
|
boundaries_.add_orifice(*cavity_port_, 0, true, cavity_orifice_area_, 0.9f);
|
||||||
0.9f, 0.050f);
|
|
||||||
boundaries_.add_open_end(0, false, 101325.0f, neck_area_);
|
boundaries_.add_open_end(0, false, 101325.0f, neck_area_);
|
||||||
solver_.minimum_substeps = 8;
|
solver_.minimum_substeps = 8;
|
||||||
solver_.add_component(cavity_);
|
solver_.add_component(cavity_);
|
||||||
|
|||||||
33
src/main.cpp
33
src/main.cpp
@@ -15,20 +15,34 @@ int main(int argc, char** argv) {
|
|||||||
float duration_seconds = 1.0f;
|
float duration_seconds = 1.0f;
|
||||||
float throttle = 0.35f;
|
float throttle = 0.35f;
|
||||||
std::string scenario_name = "twin";
|
std::string scenario_name = "twin";
|
||||||
|
TwoStrokeConfig two_stroke_config;
|
||||||
|
bool turbo_enabled = false;
|
||||||
for (int i = 1; i + 1 < argc; ++i) {
|
for (int i = 1; i + 1 < argc; ++i) {
|
||||||
const std::string option = argv[i];
|
const std::string option = argv[i];
|
||||||
if (option == "--seconds") duration_seconds = std::max(0.0f, std::strtof(argv[++i], nullptr));
|
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 == "--throttle") throttle = std::clamp(std::strtof(argv[++i], nullptr), 0.0f, 1.0f);
|
||||||
else if (option == "--scenario") scenario_name = argv[++i];
|
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;
|
constexpr int sample_rate = 44100;
|
||||||
const auto total_samples = static_cast<std::uint64_t>(duration_seconds * sample_rate);
|
const auto total_samples = static_cast<std::uint64_t>(duration_seconds * sample_rate);
|
||||||
const auto report_interval = static_cast<std::uint64_t>(sample_rate / 10);
|
const auto report_interval = static_cast<std::uint64_t>(sample_rate / 10);
|
||||||
if (scenario_name == "twin") {
|
if (scenario_name == "twin" || scenario_name == "b205") {
|
||||||
FourStrokeTwinScenario scenario;
|
FourStrokeScenario scenario(sample_rate, scenario_name == "b205" ? FourStrokePreset::SaabB205 : FourStrokePreset::Twin, turbo_enabled);
|
||||||
scenario.throttle = throttle;
|
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";
|
<< throttle * 100.0f << "%\n";
|
||||||
for (std::uint64_t i = 0; i < total_samples; ++i) {
|
for (std::uint64_t i = 0; i < total_samples; ++i) {
|
||||||
scenario.process();
|
scenario.process();
|
||||||
@@ -41,11 +55,18 @@ int main(int argc, char** argv) {
|
|||||||
<< " cyl=" << scenario.cylinder1.pressure / 1000.0f << "/"
|
<< " cyl=" << scenario.cylinder1.pressure / 1000.0f << "/"
|
||||||
<< scenario.cylinder2.pressure / 1000.0f << " kPa"
|
<< scenario.cylinder2.pressure / 1000.0f << " kPa"
|
||||||
<< " intake=" << scenario.intake_plenum.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") {
|
} else if (scenario_name == "two-stroke") {
|
||||||
TwoStrokeScenario scenario;
|
TwoStrokeScenario scenario(sample_rate, two_stroke_config);
|
||||||
scenario.set_throttle(throttle);
|
scenario.set_throttle(throttle);
|
||||||
std::cout << "FluidSimCpp | 125 cc crankcase-scavenged two-stroke | throttle "
|
std::cout << "FluidSimCpp | 125 cc crankcase-scavenged two-stroke | throttle "
|
||||||
<< std::fixed << std::setprecision(0) << throttle * 100.0f << "%\n";
|
<< std::fixed << std::setprecision(0) << throttle * 100.0f << "%\n";
|
||||||
@@ -88,7 +109,7 @@ int main(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
std::cerr << "Unknown scenario '" << scenario_name
|
std::cerr << "Unknown scenario '" << scenario_name
|
||||||
<< "'. Choose twin, two-stroke, helmholtz, or pulse.\n";
|
<< "'. Choose twin, two-stroke, b205, helmholtz, or pulse.\n";
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
171
src/outdoor_audio.cpp
Normal file
171
src/outdoor_audio.cpp
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
#include "FluidSim/Constants.hpp"
|
||||||
|
#include "FluidSim/OutdoorAudio.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <bit>
|
||||||
|
#include <cmath>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace fluidsim::port {
|
||||||
|
namespace {
|
||||||
|
Vec3 sub(Vec3 a, Vec3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
|
||||||
|
float length(Vec3 a) { return std::sqrt(a.x * a.x + a.y * a.y + a.z * a.z); }
|
||||||
|
float distance(Vec3 a, Vec3 b) { return length(sub(a, b)); }
|
||||||
|
AudioFrame add(AudioFrame a, AudioFrame b) { return {a.left + b.left, a.right + b.right}; }
|
||||||
|
} // namespace
|
||||||
|
const char *scene_name(OutdoorScene scene) {
|
||||||
|
switch (scene) {
|
||||||
|
case OutdoorScene::Dry:
|
||||||
|
return "Dry";
|
||||||
|
case OutdoorScene::Field:
|
||||||
|
return "Open field";
|
||||||
|
case OutdoorScene::Wall:
|
||||||
|
return "Nearby wall";
|
||||||
|
case OutdoorScene::Woodland:
|
||||||
|
return "Woodland";
|
||||||
|
}
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
float OutdoorAudio::LowPass::process(float x) {
|
||||||
|
const float y = b0 * x + z1;
|
||||||
|
z1 = b1 * x - a1 * y + z2;
|
||||||
|
z2 = b2 * x - a2 * y;
|
||||||
|
if (std::abs(z1) < 1e-25f)
|
||||||
|
z1 = 0;
|
||||||
|
if (std::abs(z2) < 1e-25f)
|
||||||
|
z2 = 0;
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
OutdoorAudio::OutdoorAudio(int rate, OutdoorAudioConfig config) : rate_(rate), config_(config) {
|
||||||
|
if (rate < 8000 || !std::isfinite(config.sound_speed) || config.sound_speed <= 0 ||
|
||||||
|
!std::isfinite(config.monitor_cutoff_hz) || config.monitor_cutoff_hz <= 0 ||
|
||||||
|
!std::isfinite(config.pressure_gain) || config.pressure_gain < 0)
|
||||||
|
throw std::invalid_argument("invalid outdoor audio configuration");
|
||||||
|
const float norm = length(config_.listener_right);
|
||||||
|
if (!std::isfinite(norm) || norm < 1e-6f)
|
||||||
|
throw std::invalid_argument("invalid listener right vector");
|
||||||
|
config_.listener_right = {config_.listener_right.x / norm, config_.listener_right.y / norm,
|
||||||
|
config_.listener_right.z / norm};
|
||||||
|
configure_emitter(emitters_[0], config_.intake_position);
|
||||||
|
configure_emitter(emitters_[1], config_.exhaust_position);
|
||||||
|
constexpr std::array<int, 4> lengths{1499, 2111, 2633, 3251};
|
||||||
|
for (std::size_t i = 0; i < tail_.size(); ++i) {
|
||||||
|
auto &line = tail_[i];
|
||||||
|
line.buffer.resize(std::max(1, int(std::lround(lengths[i] * rate_ / 44100.0))));
|
||||||
|
line.gain = std::pow(10.0f, -3.0f * (float(line.buffer.size()) / rate_) / 0.35f);
|
||||||
|
}
|
||||||
|
scene_alpha_ = 1 - std::exp(-1.0f / (rate_ * .02f));
|
||||||
|
tail_alpha_ = 1 - std::exp(-2 * pi * 2500 / rate_);
|
||||||
|
}
|
||||||
|
void OutdoorAudio::configure_emitter(Emitter &emitter, Vec3 source) {
|
||||||
|
emitter.filtered.resize(std::bit_ceil(std::size_t(rate_)));
|
||||||
|
emitter.raw.resize(emitter.filtered.size()); // One second, validated path range.
|
||||||
|
const auto ear = [&](float sign) {
|
||||||
|
return Vec3{config_.listener.x + sign * config_.listener_right.x * config_.ear_spacing * .5f,
|
||||||
|
config_.listener.y + sign * config_.listener_right.y * config_.ear_spacing * .5f,
|
||||||
|
config_.listener.z + sign * config_.listener_right.z * config_.ear_spacing * .5f};
|
||||||
|
};
|
||||||
|
const auto path = [&](Vec3 image, float coefficient, int group, float cutoff, float extra = 0,
|
||||||
|
float source_distance = 1) {
|
||||||
|
Path p;
|
||||||
|
p.group = group;
|
||||||
|
const float dl = std::max(distance(image, ear(-1)), .1f), dr = std::max(distance(image, ear(1)), .1f);
|
||||||
|
const float sl = (dl + extra) * rate_ / config_.sound_speed,
|
||||||
|
sr = (dr + extra) * rate_ / config_.sound_speed;
|
||||||
|
if (!std::isfinite(sl) || !std::isfinite(sr) || sl >= rate_ - 2 || sr >= rate_ - 2)
|
||||||
|
throw std::invalid_argument("outdoor propagation path exceeds one second");
|
||||||
|
p.delay_left = std::size_t(sl);
|
||||||
|
p.delay_right = std::size_t(sr);
|
||||||
|
p.fraction_left = sl - p.delay_left;
|
||||||
|
p.fraction_right = sr - p.delay_right;
|
||||||
|
p.gain_left = coefficient / (dl * source_distance);
|
||||||
|
p.gain_right = coefficient / (dr * source_distance);
|
||||||
|
p.alpha = cutoff > 0 ? 1 - std::exp(-2 * pi * cutoff / rate_) : 1;
|
||||||
|
emitter.paths.push_back(p);
|
||||||
|
};
|
||||||
|
path(source, 1, 0, 0);
|
||||||
|
path({source.x, 2 * config_.ground_y - source.y, source.z}, config_.ground_reflection, 1, 1800);
|
||||||
|
path({2 * config_.wall_x - source.x, source.y, source.z}, config_.wall_reflection, 2, 4500);
|
||||||
|
// Single-bounce point scatterers: geometric paths, empirical scattering strengths.
|
||||||
|
for (Vec3 tree :
|
||||||
|
std::array<Vec3, 6>{{{-4, 1, 3}, {3, 1, 5}, {5, 1, -2}, {-6, 2, -3}, {-2, 1, 7}, {6, 2, 6}}}) {
|
||||||
|
const float first = std::max(distance(source, tree), .1f);
|
||||||
|
path(tree, .35f, 3, 2400, first, first);
|
||||||
|
}
|
||||||
|
const float cutoff = std::min(config_.monitor_cutoff_hz, rate_ * .45f);
|
||||||
|
const float w = 2 * pi * cutoff / rate_, cs = std::cos(w), alpha = std::sin(w) / std::sqrt(2.0f),
|
||||||
|
a0 = 1 + alpha;
|
||||||
|
emitter.filter = {(1 - cs) * .5f / a0, (1 - cs) / a0, (1 - cs) * .5f / a0, -2 * cs / a0,
|
||||||
|
(1 - alpha) / a0};
|
||||||
|
}
|
||||||
|
AudioFrame OutdoorAudio::render(Emitter &e, float flow, AudioFrame &dry, AudioFrame &raw) {
|
||||||
|
const float pressure = (flow - e.previous_flow) * rate_ * config_.pressure_gain / (4 * pi);
|
||||||
|
e.previous_flow = flow;
|
||||||
|
const float filtered = e.filter.process(pressure);
|
||||||
|
e.raw[e.position] = pressure;
|
||||||
|
e.filtered[e.position] = monitor_filter_ ? filtered : pressure;
|
||||||
|
const auto read = [&](const std::vector<float> &data, std::size_t delay, float fraction) {
|
||||||
|
const auto i = (e.position + data.size() - delay) & (data.size() - 1);
|
||||||
|
const auto j = (i + data.size() - 1) & (data.size() - 1);
|
||||||
|
return data[i] + (data[j] - data[i]) * fraction;
|
||||||
|
};
|
||||||
|
AudioFrame result{};
|
||||||
|
for (auto &p : e.paths) {
|
||||||
|
const float l = read(e.filtered, p.delay_left, p.fraction_left) * p.gain_left;
|
||||||
|
const float r = read(e.filtered, p.delay_right, p.fraction_right) * p.gain_right;
|
||||||
|
p.state_left += p.alpha * (l - p.state_left);
|
||||||
|
p.state_right += p.alpha * (r - p.state_right);
|
||||||
|
if (std::abs(p.state_left) < 1e-25f)
|
||||||
|
p.state_left = 0;
|
||||||
|
if (std::abs(p.state_right) < 1e-25f)
|
||||||
|
p.state_right = 0;
|
||||||
|
result.left += p.state_left * weights_[p.group];
|
||||||
|
result.right += p.state_right * weights_[p.group];
|
||||||
|
if (p.group == 0) {
|
||||||
|
dry = add(dry, {l, r});
|
||||||
|
raw = add(raw, {read(e.raw, p.delay_left, p.fraction_left) * p.gain_left,
|
||||||
|
read(e.raw, p.delay_right, p.fraction_right) * p.gain_right});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.position = (e.position + 1) & (e.filtered.size() - 1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
AudioFrame OutdoorAudio::diffuse(float input) {
|
||||||
|
std::array<float, 4> values{};
|
||||||
|
float sum = 0;
|
||||||
|
for (std::size_t i = 0; i < 4; ++i) {
|
||||||
|
auto &line = tail_[i];
|
||||||
|
values[i] = line.buffer[line.position];
|
||||||
|
sum += values[i];
|
||||||
|
}
|
||||||
|
for (std::size_t i = 0; i < 4; ++i) {
|
||||||
|
auto &line = tail_[i];
|
||||||
|
const float scattered = values[i] - .5f * sum;
|
||||||
|
line.state += tail_alpha_ * (scattered - line.state);
|
||||||
|
if (std::abs(line.state) < 1e-25f)
|
||||||
|
line.state = 0;
|
||||||
|
line.buffer[line.position] = line.gain * line.state + .5f * input;
|
||||||
|
line.position = (line.position + 1) % line.buffer.size();
|
||||||
|
}
|
||||||
|
return {(values[0] + values[2]) * .35f, (values[1] + values[3]) * .35f};
|
||||||
|
}
|
||||||
|
AudioFrame OutdoorAudio::process(float intake, float exhaust) {
|
||||||
|
if (!std::isfinite(intake) || !std::isfinite(exhaust))
|
||||||
|
throw std::runtime_error("nonfinite acoustic source flow");
|
||||||
|
const bool wet = scene_ != OutdoorScene::Dry;
|
||||||
|
const std::array<float, 4> target{1, wet ? 1.f : 0.f, scene_ == OutdoorScene::Wall ? 1.f : 0.f,
|
||||||
|
scene_ == OutdoorScene::Woodland ? 1.f : 0.f};
|
||||||
|
for (std::size_t i = 0; i < 4; ++i)
|
||||||
|
weights_[i] += (target[i] - weights_[i]) * scene_alpha_;
|
||||||
|
const float target_tail =
|
||||||
|
scene_ == OutdoorScene::Wall
|
||||||
|
? .14f
|
||||||
|
: (scene_ == OutdoorScene::Woodland ? .25f : (scene_ == OutdoorScene::Field ? .025f : 0));
|
||||||
|
tail_mix_ += (target_tail - tail_mix_) * scene_alpha_;
|
||||||
|
last_dry_ = {};
|
||||||
|
last_raw_ = {};
|
||||||
|
const auto a = render(emitters_[0], intake, last_dry_, last_raw_);
|
||||||
|
const auto b = render(emitters_[1], exhaust, last_dry_, last_raw_);
|
||||||
|
const auto tail = diffuse((last_dry_.left + last_dry_.right) * .5f);
|
||||||
|
return {a.left + b.left + tail.left * tail_mix_, a.right + b.right + tail.right * tail_mix_};
|
||||||
|
}
|
||||||
|
} // namespace fluidsim::port
|
||||||
327
src/scenario.cpp
327
src/scenario.cpp
@@ -5,168 +5,184 @@
|
|||||||
|
|
||||||
namespace fluidsim::port {
|
namespace fluidsim::port {
|
||||||
|
|
||||||
FourStrokeTwinScenario::FourStrokeTwinScenario(int sample_rate)
|
FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selected_preset, bool turbo_enabled)
|
||||||
: pipes(create_pipes()),
|
: preset(selected_preset),
|
||||||
dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))),
|
intake_plenum(selected_preset == FourStrokePreset::SaabB205 ? 3e-3f : 1e-3f, 101325, 300),
|
||||||
boundaries_(pipes), solver_(pipes, boundaries_),
|
exhaust_collector(selected_preset == FourStrokePreset::SaabB205 ? .6e-3f : .15e-3f, 101325, 700),
|
||||||
intake_radiation_(sample_rate), exhaust_radiation_(sample_rate),
|
pipes(create_pipes(selected_preset, turbo_enabled)), dt_(1.f / std::max(sample_rate, 1)),
|
||||||
reverb_(sample_rate), mechanical_sound_(sample_rate) {
|
boundaries_(pipes), solver_(pipes, boundaries_), audio_(sample_rate), mechanical_sound_(sample_rate) {
|
||||||
crankshaft.omega = 2000.0f * 2.0f * pi / 60.0f;
|
const bool saab = preset == FourStrokePreset::SaabB205;
|
||||||
crankshaft.inertia = 0.04f;
|
const int count = cylinder_count();
|
||||||
crankshaft.friction_constant = 5.0f;
|
crankshaft.omega = (saab ? 1200 : 2000) * 2 * pi / 60;
|
||||||
crankshaft.friction_viscous = 0.003f;
|
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.set_neutral();
|
||||||
vehicle.clutch_input = clutch;
|
vehicle.clutch_input = clutch;
|
||||||
|
const auto engines = cylinders();
|
||||||
cylinder1.intake_valve_diameter = 0.030f;
|
const std::array<float, 4> phases{0, pi, 3*pi, 2*pi}; // I4 firing: 1-3-4-2
|
||||||
cylinder1.intake_valve_lift = 0.0080f;
|
for (int i = 0; i < count; ++i) {
|
||||||
cylinder1.exhaust_valve_diameter = 0.026f;
|
auto& c = *engines[i];
|
||||||
cylinder1.exhaust_valve_lift = 0.0075f;
|
c.phase = phases[i];
|
||||||
cylinder2.phase = pi;
|
if (saab) {
|
||||||
cylinder2.intake_valve_diameter = 0.030f;
|
c.bore = .090f;
|
||||||
cylinder2.intake_valve_lift = 0.0080f;
|
c.stroke = .078f;
|
||||||
cylinder2.exhaust_valve_diameter = 0.026f;
|
c.rod = .153f; // estimated, not a verified B205 rod measurement
|
||||||
cylinder2.exhaust_valve_lift = 0.0075f;
|
c.compression = 8.8f;
|
||||||
cylinder1.rev_limit_enabled = cylinder2.rev_limit_enabled = true;
|
c.intake_valve_count = c.exhaust_valve_count = 2;
|
||||||
cylinder1.rev_limit_rpm = cylinder2.rev_limit_rpm = 10000.0f;
|
}
|
||||||
cylinder1.rev_limit_cut_time = cylinder2.rev_limit_cut_time = 0.020f;
|
c.intake_valve_diameter = saab ? .032f : .030f;
|
||||||
cylinder1.rev_limit_hysteresis = cylinder2.rev_limit_hysteresis = 250.0f;
|
c.intake_valve_lift = saab ? .0085f : .008f;
|
||||||
cylinder1.initialize(crankshaft);
|
c.exhaust_valve_diameter = saab ? .028f : .026f;
|
||||||
cylinder2.initialize(crankshaft);
|
c.exhaust_valve_lift = saab ? .008f : .0075f;
|
||||||
|
c.rev_limit_enabled = true;
|
||||||
const float intake_area = area_for_diameter(0.0275f);
|
c.rev_limit_rpm = ecu.rev_limit_rpm;
|
||||||
const float exhaust_area = area_for_diameter(0.028f);
|
c.rev_limit_hysteresis = 250;
|
||||||
runner_area_ = intake_area;
|
c.rev_limit_cut_time = .020f;
|
||||||
header_area_ = tail_area_ = exhaust_area;
|
c.ecu = &ecu;
|
||||||
for (auto& pipe : pipes) {
|
c.initialize(crankshaft);
|
||||||
pipe.damping_multiplier = 1.0f;
|
if (saab) {
|
||||||
pipe.energy_relaxation_rate = 0.5f;
|
// Initialize each phased cylinder at its actual piston position.
|
||||||
pipe.ambient_pressure = 101325.0f;
|
c.volume = c.compute_volume(c.phase);
|
||||||
|
c.air_mass = ambient_density * c.volume;
|
||||||
|
c.energy = 101325 * c.volume / (gamma - 1);
|
||||||
|
c.update_state(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
intake_boundary_area_ = pipes.front().area;
|
||||||
throttle_port_ = &intake_plenum.create_port();
|
runner_area_ = pipes[1].area;
|
||||||
runner_feed1_ = &intake_plenum.create_port();
|
header_area_ = pipes[1+count].area;
|
||||||
runner_feed2_ = &intake_plenum.create_port();
|
tail_area_ = pipes[1+2*count].area;
|
||||||
collector_in1_ = &exhaust_collector.create_port();
|
for (auto& pipe : pipes) {
|
||||||
collector_in2_ = &exhaust_collector.create_port();
|
pipe.damping_multiplier = saab ? 0.f : 1.f;
|
||||||
collector_out_ = &exhaust_collector.create_port();
|
pipe.energy_relaxation_rate = 0;
|
||||||
|
}
|
||||||
intake_radiation_.pipe_end = {-0.20f, 0.0f, 0.0f};
|
auto& throttle_port = intake_plenum.create_port();
|
||||||
intake_radiation_.pipe_axis = {-1.0f, 0.0f, 0.0f};
|
solver_.add_component(intake_atmosphere);
|
||||||
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);
|
|
||||||
solver_.add_component(intake_plenum);
|
solver_.add_component(intake_plenum);
|
||||||
solver_.add_component(exhaust_collector);
|
solver_.add_component(exhaust_collector);
|
||||||
|
if (turbo_enabled) {
|
||||||
boundaries_.add_open_end(0, true, 101325.0f, intake_area);
|
turbo = std::make_unique<Turbocharger>();
|
||||||
boundaries_.add_open_end(5, false, 101325.0f, exhaust_area);
|
const int fresh = 2+2*count, exhaust_exit = fresh+1;
|
||||||
boundaries_.add_orifice(*throttle_port_, 0, false, throttle_area_, 0.7f);
|
boundaries_.add_orifice(intake_atmosphere.port, fresh, true, intake_boundary_area_);
|
||||||
boundaries_.add_orifice(*runner_feed1_, 1, true, runner_area_);
|
turbo->connect(boundaries_, pipes, fresh, false, 0, true, 1+2*count, false, exhaust_exit, true);
|
||||||
boundaries_.add_orifice(*runner_feed2_, 2, true, runner_area_);
|
solver_.add_component(*turbo);
|
||||||
boundaries_.add_orifice(cylinder1.intake, 1, false, valve1_in_);
|
} else {
|
||||||
boundaries_.add_orifice(cylinder2.intake, 2, false, valve2_in_);
|
boundaries_.add_orifice(intake_atmosphere.port, 0, true, intake_boundary_area_);
|
||||||
boundaries_.add_orifice(cylinder1.exhaust, 3, true, valve1_out_);
|
}
|
||||||
boundaries_.add_orifice(cylinder2.exhaust, 4, true, valve2_out_);
|
boundaries_.add_orifice(throttle_port, 0, false, throttle_area_, .7f);
|
||||||
boundaries_.add_orifice(*collector_in1_, 3, false, header_area_);
|
for (int i = 0; i < count; ++i) {
|
||||||
boundaries_.add_orifice(*collector_in2_, 4, false, header_area_);
|
auto& feed = intake_plenum.create_port();
|
||||||
boundaries_.add_orifice(*collector_out_, 5, true, tail_area_);
|
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() {
|
AudioFrame FourStrokeScenario::process() {
|
||||||
const std::lock_guard<std::mutex> guard(mutex_);
|
const std::lock_guard guard(mutex_);
|
||||||
crankshaft.load_torque = std::clamp(load, 0.0f, 1.0f) * 30.0f;
|
step_motorcycle(crankshaft, dt_, preset == FourStrokePreset::SaabB205 ? 200.f : 30.f);
|
||||||
vehicle.clutch_input = std::clamp(clutch, 0.0f, 1.0f);
|
ecu.step(dt_, throttle, crankshaft.omega * 60 / (2*pi));
|
||||||
crankshaft.step(dt_);
|
const auto engines = cylinders();
|
||||||
crankshaft.drivetrain_torque = vehicle.update(crankshaft.omega, crankshaft.inertia, dt_);
|
for (int i = 0; i < cylinder_count(); ++i) {
|
||||||
cylinder1.pre_step(crankshaft, dt_, throttle);
|
auto& c = *engines[i];
|
||||||
cylinder2.pre_step(crankshaft, dt_, throttle);
|
c.pre_step(crankshaft, dt_, throttle);
|
||||||
|
const float degrees = std::fmod((crankshaft.angle+c.phase)*180/pi, 720.f);
|
||||||
const float throttle_fraction = std::clamp(throttle, 0.0f, 1.0f);
|
intake_valve_areas_[i] = c.valve_area(degrees, true);
|
||||||
throttle_area_ = throttle_fraction <= 0.001f ? 0.0f : area_for_diameter(0.026f) * throttle_fraction;
|
exhaust_valve_areas_[i] = c.valve_area(degrees, false);
|
||||||
const auto cycle_degrees = [this](float phase) {
|
}
|
||||||
float value = std::fmod((crankshaft.angle + phase) * 180.0f / pi, 720.0f);
|
const float angle = ecu.throttle_opening() * pi * .5f;
|
||||||
return value < 0.0f ? value + 720.0f : value;
|
throttle_area_ = area_for_diameter(preset == FourStrokePreset::SaabB205 ? .052f : .026f) * (1-std::cos(angle));
|
||||||
};
|
|
||||||
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);
|
|
||||||
|
|
||||||
solver_.step(dt_);
|
solver_.step(dt_);
|
||||||
intake_flow = boundaries_.open_end_mass_flow(0);
|
intake_flow = intake_atmosphere.port.mass_flow;
|
||||||
exhaust_flow = boundaries_.open_end_mass_flow(1);
|
exhaust_flow = boundaries_.open_end_mass_flow(0);
|
||||||
const AudioFrame intake_audio = intake_radiation_.process(intake_flow);
|
const auto rendered = audio_.process(intake_flow, exhaust_flow);
|
||||||
const AudioFrame exhaust_audio = exhaust_radiation_.process(exhaust_flow);
|
|
||||||
const float mechanical = mechanical_sound_enabled
|
const float mechanical = mechanical_sound_enabled
|
||||||
? mechanical_sound_.process(crankshaft, cylinder1, load) * mechanical_mix : 0.0f;
|
? mechanical_sound_.process(crankshaft, cylinder1, load) * mechanical_mix : 0;
|
||||||
const float reverb_input = intake_radiation_.last_mono_pressure() * intake_send +
|
last_mix_l = rendered.left + mechanical;
|
||||||
exhaust_radiation_.last_mono_pressure() * exhaust_send +
|
last_mix_r = rendered.right + mechanical;
|
||||||
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;
|
|
||||||
++sample_count;
|
++sample_count;
|
||||||
return {last_mix_l, last_mix_r};
|
return {last_mix_l, last_mix_r};
|
||||||
}
|
}
|
||||||
|
|
||||||
void FourStrokeTwinScenario::set_controls(float throttle_value, float load_value, float clutch_value) {
|
void FourStrokeScenario::set_audio_scene(OutdoorScene scene) {
|
||||||
|
const std::lock_guard<std::mutex> guard(mutex_); audio_.set_scene(scene);
|
||||||
|
}
|
||||||
|
void FourStrokeScenario::set_monitor_filter(bool enabled) {
|
||||||
|
const std::lock_guard<std::mutex> guard(mutex_); audio_.set_monitor_filter(enabled);
|
||||||
|
}
|
||||||
|
AudioFrame FourStrokeScenario::dry_audio() const {
|
||||||
|
const std::lock_guard<std::mutex> guard(mutex_); return audio_.dry_frame();
|
||||||
|
}
|
||||||
|
AudioFrame FourStrokeScenario::unfiltered_audio() const {
|
||||||
|
const std::lock_guard<std::mutex> guard(mutex_); return audio_.unfiltered_frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
void FourStrokeScenario::set_controls(float throttle_value, float load_value, float clutch_value) {
|
||||||
const std::lock_guard<std::mutex> guard(mutex_);
|
const std::lock_guard<std::mutex> guard(mutex_);
|
||||||
throttle = std::clamp(throttle_value, 0.0f, 1.0f);
|
throttle = std::clamp(throttle_value, 0.0f, 1.0f);
|
||||||
load = std::clamp(load_value, 0.0f, 1.0f);
|
load = std::clamp(load_value, 0.0f, 1.0f);
|
||||||
clutch = std::clamp(clutch_value, 0.0f, 1.0f);
|
clutch = std::clamp(clutch_value, 0.0f, 1.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
void FourStrokeTwinScenario::toggle_mechanical_sound() {
|
void FourStrokeScenario::toggle_mechanical_sound() {
|
||||||
const std::lock_guard<std::mutex> guard(mutex_);
|
const std::lock_guard<std::mutex> guard(mutex_);
|
||||||
mechanical_sound_enabled = !mechanical_sound_enabled;
|
mechanical_sound_enabled = !mechanical_sound_enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
void FourStrokeTwinScenario::shift_up() {
|
void FourStrokeScenario::shift_up() {
|
||||||
const std::lock_guard<std::mutex> guard(mutex_);
|
const std::lock_guard<std::mutex> guard(mutex_);
|
||||||
vehicle.shift_up();
|
vehicle.shift_up();
|
||||||
}
|
}
|
||||||
|
|
||||||
void FourStrokeTwinScenario::shift_down() {
|
void FourStrokeScenario::shift_down() {
|
||||||
const std::lock_guard<std::mutex> guard(mutex_);
|
const std::lock_guard<std::mutex> guard(mutex_);
|
||||||
vehicle.shift_down();
|
vehicle.shift_down();
|
||||||
}
|
}
|
||||||
|
|
||||||
void FourStrokeTwinScenario::set_neutral() {
|
void FourStrokeScenario::set_neutral() {
|
||||||
const std::lock_guard<std::mutex> guard(mutex_);
|
const std::lock_guard<std::mutex> guard(mutex_);
|
||||||
vehicle.set_neutral();
|
vehicle.set_neutral();
|
||||||
}
|
}
|
||||||
|
|
||||||
void FourStrokeTwinScenario::set_first_gear() {
|
void FourStrokeScenario::set_first_gear() {
|
||||||
const std::lock_guard<std::mutex> guard(mutex_);
|
const std::lock_guard<std::mutex> guard(mutex_);
|
||||||
vehicle.set_first_gear();
|
vehicle.set_first_gear();
|
||||||
}
|
}
|
||||||
|
|
||||||
ScenarioSnapshot FourStrokeTwinScenario::snapshot() const {
|
ScenarioSnapshot FourStrokeScenario::snapshot() const {
|
||||||
const std::lock_guard<std::mutex> guard(mutex_);
|
const std::lock_guard<std::mutex> guard(mutex_);
|
||||||
ScenarioSnapshot result;
|
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.rpm = crankshaft.omega * 60.0f / (2.0f * pi);
|
||||||
result.speed_kmh = vehicle.speed * 3.6f;
|
result.speed_kmh = vehicle.speed * 3.6f;
|
||||||
result.kinetic_energy = vehicle.kinetic_energy();
|
result.kinetic_energy = vehicle.kinetic_energy();
|
||||||
@@ -178,13 +194,18 @@ ScenarioSnapshot FourStrokeTwinScenario::snapshot() const {
|
|||||||
result.cylinder_temperature2 = cylinder2.temperature();
|
result.cylinder_temperature2 = cylinder2.temperature();
|
||||||
result.cylinder_piston_fraction1 = (cylinder1.volume - cylinder1.clearance_volume()) / cylinder1.swept_volume();
|
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.cylinder_piston_fraction2 = (cylinder2.volume - cylinder2.clearance_volume()) / cylinder2.swept_volume();
|
||||||
result.valve1_intake_area = valve1_in_;
|
result.valve1_intake_area = intake_valve_areas_[0];
|
||||||
result.valve1_exhaust_area = valve1_out_;
|
result.valve1_exhaust_area = exhaust_valve_areas_[0];
|
||||||
result.valve2_intake_area = valve2_in_;
|
result.valve2_intake_area = intake_valve_areas_[1];
|
||||||
result.valve2_exhaust_area = valve2_out_;
|
result.valve2_exhaust_area = exhaust_valve_areas_[1];
|
||||||
result.intake_pressure = intake_plenum.pressure();
|
result.intake_pressure = intake_plenum.pressure();
|
||||||
result.exhaust_pressure = exhaust_collector.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.clutch = clutch;
|
||||||
result.intake_flow = intake_flow;
|
result.intake_flow = intake_flow;
|
||||||
result.exhaust_flow = exhaust_flow;
|
result.exhaust_flow = exhaust_flow;
|
||||||
@@ -192,31 +213,39 @@ ScenarioSnapshot FourStrokeTwinScenario::snapshot() const {
|
|||||||
result.rev_limit1 = cylinder1.rev_limit_active;
|
result.rev_limit1 = cylinder1.rev_limit_active;
|
||||||
result.rev_limit2 = cylinder2.rev_limit_active;
|
result.rev_limit2 = cylinder2.rev_limit_active;
|
||||||
result.mechanical_sound_enabled = mechanical_sound_enabled;
|
result.mechanical_sound_enabled = mechanical_sound_enabled;
|
||||||
for (std::size_t pipe_index = 0; pipe_index < pipes.size(); ++pipe_index) {
|
result.cylinder_count = cylinder_count();
|
||||||
auto& states = result.pipe_states[pipe_index];
|
result.rev_limit_rpm = ecu.rev_limit_rpm;
|
||||||
states.reserve(pipes[pipe_index].cells.size());
|
const std::array<const FourStrokeCylinder*, 4> engines{&cylinder1, &cylinder2, &cylinder3, &cylinder4};
|
||||||
for (std::size_t cell = 0; cell < pipes[pipe_index].cells.size(); ++cell)
|
for (int i = 0; i < cylinder_count(); ++i) {
|
||||||
states.push_back(pipes[pipe_index].state(static_cast<int>(cell)));
|
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<Pipe1D> FourStrokeScenario::create_pipes(FourStrokePreset preset, bool turbo_enabled) {
|
||||||
|
const bool saab = preset == FourStrokePreset::SaabB205;
|
||||||
|
const int count = saab ? 4 : 2;
|
||||||
|
std::vector<Pipe1D> result;
|
||||||
|
result.reserve(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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
float FourStrokeTwinScenario::area_for_diameter(float diameter) {
|
|
||||||
return pi * diameter * diameter * 0.25f;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<Pipe1D> FourStrokeTwinScenario::create_pipes() {
|
|
||||||
const float intake_area = area_for_diameter(0.0275f);
|
|
||||||
const float exhaust_area = area_for_diameter(0.028f);
|
|
||||||
std::vector<Pipe1D> 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
|
} // namespace fluidsim::port
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
|
||||||
namespace fluidsim::port {
|
namespace fluidsim::port {
|
||||||
@@ -13,9 +14,9 @@ SimulationRingBuffer::SimulationRingBuffer(std::size_t capacity)
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::size_t SimulationRingBuffer::available_samples() const {
|
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);
|
const auto read = read_head_.load(std::memory_order_acquire);
|
||||||
return static_cast<std::size_t>(written - read);
|
const auto written = write_head_.load(std::memory_order_acquire);
|
||||||
|
return std::min(left_.size(), static_cast<std::size_t>(written - read));
|
||||||
}
|
}
|
||||||
|
|
||||||
std::size_t SimulationRingBuffer::free_space() const {
|
std::size_t SimulationRingBuffer::free_space() const {
|
||||||
@@ -52,6 +53,7 @@ void SimulationRingBuffer::reset_consumer() {
|
|||||||
std::size_t SimulationRingBuffer::read_interpolated(std::span<float> dst_left,
|
std::size_t SimulationRingBuffer::read_interpolated(std::span<float> dst_left,
|
||||||
std::span<float> dst_right,
|
std::span<float> dst_right,
|
||||||
double speed) {
|
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());
|
const std::size_t count = std::min(dst_left.size(), dst_right.size());
|
||||||
if (!consumer_initialized_) {
|
if (!consumer_initialized_) {
|
||||||
read_position_ = static_cast<double>(read_head_.load(std::memory_order_acquire));
|
read_position_ = static_cast<double>(read_head_.load(std::memory_order_acquire));
|
||||||
@@ -70,7 +72,7 @@ std::size_t SimulationRingBuffer::read_interpolated(std::span<float> dst_left,
|
|||||||
const float fraction = static_cast<float>(read_position_ - static_cast<double>(floor_position));
|
const float fraction = static_cast<float>(read_position_ - static_cast<double>(floor_position));
|
||||||
dst_left[produced] = left0 + (left1 - left0) * fraction;
|
dst_left[produced] = left0 + (left1 - left0) * fraction;
|
||||||
dst_right[produced] = right0 + (right1 - right0) * fraction;
|
dst_right[produced] = right0 + (right1 - right0) * fraction;
|
||||||
read_position_ += std::clamp(speed, 0.0001, 1.0);
|
read_position_ += speed;
|
||||||
}
|
}
|
||||||
const auto new_read_head = static_cast<std::uint64_t>(read_position_);
|
const auto new_read_head = static_cast<std::uint64_t>(read_position_);
|
||||||
const auto previous_read_head = read_head_.load(std::memory_order_relaxed);
|
const auto previous_read_head = read_head_.load(std::memory_order_relaxed);
|
||||||
|
|||||||
@@ -4,23 +4,34 @@
|
|||||||
|
|
||||||
namespace fluidsim::port {
|
namespace fluidsim::port {
|
||||||
|
|
||||||
Solver::Solver(std::vector<Pipe1D>& pipes, BoundarySystem& boundaries)
|
Solver::Solver(std::vector<Pipe1D> &pipes, BoundarySystem &boundaries)
|
||||||
: pipes_(pipes), boundaries_(boundaries) {}
|
: pipes_(pipes), boundaries_(boundaries) {}
|
||||||
|
|
||||||
void Solver::add_component(Component& component) {
|
void Solver::add_component(Component &component) { components_.push_back(&component); }
|
||||||
components_.push_back(&component);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Solver::step(float dt) {
|
void Solver::step(float dt) {
|
||||||
if (dt <= 0.0f) return;
|
if (!std::isfinite(dt) || dt < 0)
|
||||||
int substeps = std::max(minimum_substeps, 1);
|
throw std::invalid_argument("invalid solver timestep");
|
||||||
for (const auto& pipe : pipes_) substeps = std::max(substeps, pipe.required_substeps(dt, 0.8f));
|
if (dt == 0)
|
||||||
const float sub_dt = dt / static_cast<float>(substeps);
|
return;
|
||||||
for (int i = 0; i < substeps; ++i) {
|
boundaries_.validate_topology();
|
||||||
|
const float maximum_dt = dt / static_cast<float>(std::max(minimum_substeps, 1));
|
||||||
|
double remaining = dt;
|
||||||
|
int count = 0;
|
||||||
|
while (remaining > double(dt) * 1e-7) {
|
||||||
|
float sub_dt = std::min(maximum_dt, static_cast<float>(remaining));
|
||||||
|
for (auto &pipe : pipes_)
|
||||||
|
sub_dt = std::min(sub_dt, pipe.prepare_step());
|
||||||
|
if (!(sub_dt > 0) || ++count > 1000000)
|
||||||
|
throw std::runtime_error("solver timestep failed to progress");
|
||||||
|
++substep_count_;
|
||||||
boundaries_.resolve(sub_dt);
|
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_;
|
++step_count_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
179
src/turbocharger.cpp
Normal file
179
src/turbocharger.cpp
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
#include "FluidSim/Turbocharger.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace fluidsim::port {
|
||||||
|
namespace {
|
||||||
|
constexpr double cp = double(gamma)*gas_constant/(gamma-1);
|
||||||
|
constexpr double exponent = double(gamma-1)/gamma;
|
||||||
|
TurboConfig checked(TurboConfig c) {
|
||||||
|
for (double x : {c.shaft_inertia, c.compressor_radius, c.turbine_radius,
|
||||||
|
c.compressor_head_coefficient, c.turbine_optimum_speed_ratio,
|
||||||
|
c.compressor_effective_area, c.turbine_effective_area, c.gate_diaphragm_area,
|
||||||
|
c.gate_spring_stiffness, c.gate_damping, c.gate_diameter, c.reference_pressure})
|
||||||
|
if (!std::isfinite(x) || x <= 0) throw std::invalid_argument("invalid turbo geometry/inertia");
|
||||||
|
for (double x : {c.compressor_efficiency, c.turbine_peak_efficiency})
|
||||||
|
if (!std::isfinite(x) || x <= 0 || x > 1) throw std::invalid_argument("invalid turbo efficiency");
|
||||||
|
for (double x : {c.bearing_viscous, c.windage, c.gate_spring_preload, c.gate_maximum_lift})
|
||||||
|
if (!std::isfinite(x) || x < 0) throw std::invalid_argument("invalid turbo loss coefficient");
|
||||||
|
for (float x : c.connection_diameters)
|
||||||
|
if (!std::isfinite(x) || x <= 0) throw std::invalid_argument("invalid turbo connection diameter");
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Turbocharger::Turbocharger(TurboConfig configuration)
|
||||||
|
: config(checked(configuration)),
|
||||||
|
compressor_inlet(config.compressor_inlet_volume), compressor_outlet(config.compressor_outlet_volume),
|
||||||
|
turbine_inlet(config.turbine_inlet_volume), turbine_outlet(config.turbine_outlet_volume),
|
||||||
|
compressor_inlet_port(compressor_inlet.create_port()),
|
||||||
|
compressor_outlet_port(compressor_outlet.create_port()),
|
||||||
|
turbine_inlet_port(turbine_inlet.create_port()), turbine_outlet_port(turbine_outlet.create_port()),
|
||||||
|
internal_{&compressor_inlet.create_port(), &compressor_outlet.create_port(),
|
||||||
|
&turbine_inlet.create_port(), &turbine_outlet.create_port()} {}
|
||||||
|
|
||||||
|
void Turbocharger::connect(BoundarySystem& b, const std::vector<Pipe1D>& pipes,
|
||||||
|
int ci, bool cil, int co, bool col, int ti, bool til, int to, bool tol) {
|
||||||
|
if (connected_) throw std::logic_error("turbo already connected");
|
||||||
|
const std::array<int, 4> indices{ci, co, ti, to};
|
||||||
|
const std::array<bool, 4> left{cil, col, til, tol};
|
||||||
|
auto external = ports();
|
||||||
|
for (std::size_t i = 0; i < 4; ++i) {
|
||||||
|
const auto& p = pipes.at(indices[i]);
|
||||||
|
const float diameter = config.connection_diameters[i];
|
||||||
|
connection_areas_[i] = std::min(p.cell_area(left[i] ? 0 : int(p.cells.size())-1), pi*diameter*diameter*.25f);
|
||||||
|
b.add_orifice(*external[i], indices[i], left[i], connection_areas_[i]);
|
||||||
|
}
|
||||||
|
connected_ = true;
|
||||||
|
}
|
||||||
|
std::vector<Port*> Turbocharger::ports() {
|
||||||
|
return {&compressor_inlet_port, &compressor_outlet_port, &turbine_inlet_port, &turbine_outlet_port};
|
||||||
|
}
|
||||||
|
double Turbocharger::fluid_energy() const {
|
||||||
|
return double(compressor_inlet.internal_energy()) + compressor_outlet.internal_energy() +
|
||||||
|
turbine_inlet.internal_energy() + turbine_outlet.internal_energy();
|
||||||
|
}
|
||||||
|
void Turbocharger::set_initial_rpm(double rpm) {
|
||||||
|
if (!std::isfinite(rpm) || rpm < 0) throw std::invalid_argument("invalid initial turbo speed");
|
||||||
|
omega_ = rpm*2*pi/60;
|
||||||
|
}
|
||||||
|
double Turbocharger::nozzle(double p, double t, double back, double area) {
|
||||||
|
if (back >= p) return 0;
|
||||||
|
const double critical = std::pow(2/(gamma+1), gamma/(gamma-1));
|
||||||
|
const double ratio = std::max(back/p, critical);
|
||||||
|
const double factor = 2*gamma/(gas_constant*(gamma-1)) *
|
||||||
|
(std::pow(ratio, 2/gamma)-std::pow(ratio, (gamma+1)/gamma));
|
||||||
|
return area*p/std::sqrt(t)*std::sqrt(std::max(0., factor));
|
||||||
|
}
|
||||||
|
double Turbocharger::transfer_flow(Volume0D& in, Volume0D& out, Port& in_ext,
|
||||||
|
Port& out_ext, double requested, double dt) {
|
||||||
|
const bool forward = requested >= 0;
|
||||||
|
const auto& donor = forward ? in : out;
|
||||||
|
const auto& external = forward ? in_ext : out_ext;
|
||||||
|
const double h = cp*donor.temperature();
|
||||||
|
// Reserve the mass/energy already promised to the external pipe boundary.
|
||||||
|
const double mass = donor.mass()+std::min(0.f, external.mass_flow)*dt;
|
||||||
|
const double energy = donor.internal_energy()+std::min(0.f, external.mass_flow)*external.enthalpy*dt;
|
||||||
|
const double bound = .25*std::max(0., std::min(mass, energy/h))/dt;
|
||||||
|
const double actual = std::clamp(requested, -bound, bound);
|
||||||
|
if (actual != requested) ++limited_transfers_;
|
||||||
|
return actual;
|
||||||
|
}
|
||||||
|
void Turbocharger::set_transfer(Volume0D& in, Volume0D& out, Port& ip, Port& op,
|
||||||
|
double flow, double work, double dt) {
|
||||||
|
const auto& donor = flow >= 0 ? in : out;
|
||||||
|
const double h = cp*donor.temperature();
|
||||||
|
ip.mass_flow = float(-flow);
|
||||||
|
op.mass_flow = float(flow);
|
||||||
|
ip.air_fraction = op.air_fraction = donor.air_fraction();
|
||||||
|
ip.enthalpy = op.enthalpy = float(h);
|
||||||
|
// Positive work means energy delivered to the shaft. Only the receiving
|
||||||
|
// fluid enthalpy changes; the donor loses precisely its stagnation enthalpy.
|
||||||
|
if (std::abs(flow) > 0) {
|
||||||
|
auto& receiver = flow >= 0 ? op : ip;
|
||||||
|
receiver.enthalpy = float(h-work/(std::abs(flow)*dt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void Turbocharger::update_state(float timestep) {
|
||||||
|
if (!std::isfinite(timestep) || timestep < 0) throw std::invalid_argument("invalid turbo timestep");
|
||||||
|
if (timestep == 0) return;
|
||||||
|
const double dt = timestep, initial_fluid = fluid_energy(), initial_shaft = shaft_energy();
|
||||||
|
const std::array<const Port*, 4> external{&compressor_inlet_port, &compressor_outlet_port,
|
||||||
|
&turbine_inlet_port, &turbine_outlet_port};
|
||||||
|
double external_work = 0;
|
||||||
|
for (const auto* p : external) external_work += double(p->mass_flow)*p->enthalpy*dt;
|
||||||
|
|
||||||
|
const double pin = compressor_inlet.pressure(), tin = compressor_inlet.temperature();
|
||||||
|
const double head = config.compressor_head_coefficient*std::pow(omega_*config.compressor_radius, 2);
|
||||||
|
const double pump_pressure = pin*std::pow(1+config.compressor_efficiency*head/(cp*tin), 1/exponent);
|
||||||
|
double mc = compressor_outlet.pressure() <= pump_pressure
|
||||||
|
? nozzle(pump_pressure, tin+head/cp, compressor_outlet.pressure(), config.compressor_effective_area)
|
||||||
|
: -nozzle(compressor_outlet.pressure(), compressor_outlet.temperature(), pump_pressure,
|
||||||
|
config.compressor_effective_area);
|
||||||
|
mc = transfer_flow(compressor_inlet, compressor_outlet, compressor_inlet_port, compressor_outlet_port, mc, dt);
|
||||||
|
|
||||||
|
const double tp = turbine_inlet.pressure(), tout = turbine_outlet.pressure();
|
||||||
|
const double can_force = std::max(0., double(compressor_outlet.pressure())-config.reference_pressure)*
|
||||||
|
config.gate_diaphragm_area;
|
||||||
|
const double equilibrium_lift = (can_force-config.gate_spring_preload)/config.gate_spring_stiffness;
|
||||||
|
// Overdamped spring-can linkage: b*dx/dt + k*x = F-Fpreload, with seats.
|
||||||
|
wastegate_lift_ = std::clamp(equilibrium_lift+(wastegate_lift_-equilibrium_lift)*
|
||||||
|
std::exp(-dt*config.gate_spring_stiffness/config.gate_damping), 0., config.gate_maximum_lift);
|
||||||
|
const double gate_area = pi*config.gate_diameter*wastegate_lift_;
|
||||||
|
double mt = tp >= tout ? nozzle(tp, turbine_inlet.temperature(), tout, config.turbine_effective_area)
|
||||||
|
: -nozzle(tout, turbine_outlet.temperature(), tp, config.turbine_effective_area);
|
||||||
|
double mw = tp >= tout ? nozzle(tp, turbine_inlet.temperature(), tout, gate_area)
|
||||||
|
: -nozzle(tout, turbine_outlet.temperature(), tp, gate_area);
|
||||||
|
const double requested = mt+mw;
|
||||||
|
const double actual = transfer_flow(turbine_inlet, turbine_outlet, turbine_inlet_port, turbine_outlet_port, requested, dt);
|
||||||
|
if (requested != 0) { mt *= actual/requested; mw *= actual/requested; }
|
||||||
|
const double ideal_drop = tp > tout ? cp*turbine_inlet.temperature()*(1-std::pow(tout/tp, exponent)) : 0;
|
||||||
|
const double jet = std::sqrt(2*ideal_drop), radius = config.turbine_radius;
|
||||||
|
const double ratio = config.turbine_optimum_speed_ratio, eta = config.turbine_peak_efficiency;
|
||||||
|
const double drive = std::max(0., mt)*eta*jet*radius/ratio;
|
||||||
|
const double turbine_drag = std::max(0., mt)*eta*.5*radius*radius/(ratio*ratio);
|
||||||
|
const double compressor_drag = std::max(0., mc)*config.compressor_head_coefficient*
|
||||||
|
config.compressor_radius*config.compressor_radius;
|
||||||
|
// Implicit midpoint solves I*domega/dt = drive - b*omega - c*omega^2.
|
||||||
|
// Evaluating work at midpoint conserves rotor kinetic energy, even at rest.
|
||||||
|
double wt = 0, wc = 0, loss = 0, remaining = dt;
|
||||||
|
int steps = 0;
|
||||||
|
while (remaining > dt*1e-12) {
|
||||||
|
const double drag = turbine_drag+compressor_drag+config.bearing_viscous+config.windage*omega_;
|
||||||
|
const double h = std::min(remaining, config.shaft_inertia/std::max(drag, 1e-300));
|
||||||
|
if (!(h > 0) || ++steps > 1000000) throw std::runtime_error("turbo shaft timestep failed");
|
||||||
|
const double b = 2*config.shaft_inertia/h+turbine_drag+compressor_drag+config.bearing_viscous;
|
||||||
|
const double rhs = 2*config.shaft_inertia/h*omega_+drive;
|
||||||
|
const double mid = 2*rhs/(b+std::sqrt(b*b+4*config.windage*rhs));
|
||||||
|
const double next = 2*mid-omega_;
|
||||||
|
if (next < 0) throw std::runtime_error("turbo shaft integration reversed unexpectedly");
|
||||||
|
wt += (drive-turbine_drag*mid)*mid*h;
|
||||||
|
wc += compressor_drag*mid*mid*h;
|
||||||
|
loss += (config.bearing_viscous+config.windage*mid)*mid*mid*h;
|
||||||
|
omega_ = next;
|
||||||
|
angle_ = std::fmod(angle_+mid*h, 2*double(pi));
|
||||||
|
remaining -= h;
|
||||||
|
}
|
||||||
|
bearing_heat_ += loss;
|
||||||
|
turbine_power_ = wt/dt;
|
||||||
|
compressor_power_ = wc/dt;
|
||||||
|
bearing_power_ = loss/dt;
|
||||||
|
turbine_flow_ = mt;
|
||||||
|
wastegate_flow_ = mw;
|
||||||
|
compressor_flow_ = mc;
|
||||||
|
set_transfer(compressor_inlet, compressor_outlet, *internal_[0], *internal_[1], mc, -wc, dt);
|
||||||
|
set_transfer(turbine_inlet, turbine_outlet, *internal_[2], *internal_[3], mt+mw, wt, dt);
|
||||||
|
compressor_inlet.update_state(timestep);
|
||||||
|
compressor_outlet.update_state(timestep);
|
||||||
|
turbine_inlet.update_state(timestep);
|
||||||
|
turbine_outlet.update_state(timestep);
|
||||||
|
energy_residual_ += fluid_energy()-initial_fluid + shaft_energy()-initial_shaft+loss-external_work;
|
||||||
|
}
|
||||||
|
TurboSnapshot Turbocharger::snapshot() const {
|
||||||
|
return {omega_*60/(2*pi), angle_, shaft_energy(), turbine_power_, compressor_power_, bearing_power_,
|
||||||
|
compressor_flow_, turbine_flow_, compressor_outlet.pressure()/compressor_inlet.pressure(),
|
||||||
|
turbine_inlet.pressure()/turbine_outlet.pressure(), turbine_inlet.pressure(),
|
||||||
|
turbine_outlet.pressure(), compressor_outlet.pressure(), compressor_outlet.temperature(),
|
||||||
|
bearing_heat_, energy_residual_, limited_transfers_, wastegate_flow_, wastegate_lift_};
|
||||||
|
}
|
||||||
|
} // namespace fluidsim::port
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#include "FluidSim/Ecu.hpp"
|
||||||
#include "FluidSim/TwoStrokeCylinder.hpp"
|
#include "FluidSim/TwoStrokeCylinder.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -6,12 +7,12 @@
|
|||||||
|
|
||||||
namespace fluidsim::port {
|
namespace fluidsim::port {
|
||||||
|
|
||||||
TwoStrokeCylinder::TwoStrokeCylinder(float bore_value, float stroke_value,
|
TwoStrokeCylinder::TwoStrokeCylinder(float bore_value, float stroke_value, float connecting_rod_length,
|
||||||
float connecting_rod_length, float compression_ratio,
|
float compression_ratio, float transfer_duration_degrees,
|
||||||
float transfer_duration_degrees, float exhaust_duration_degrees)
|
float exhaust_duration_degrees)
|
||||||
: transfer_duration_(transfer_duration_degrees), exhaust_duration_(exhaust_duration_degrees) {
|
: transfer_duration_(transfer_duration_degrees), exhaust_duration_(exhaust_duration_degrees) {
|
||||||
if (transfer_duration_ <= 0.0f || transfer_duration_ >= 360.0f ||
|
if (transfer_duration_ <= 0.0f || transfer_duration_ >= 360.0f || exhaust_duration_ <= 0.0f ||
|
||||||
exhaust_duration_ <= 0.0f || exhaust_duration_ >= 360.0f)
|
exhaust_duration_ >= 360.0f)
|
||||||
throw std::invalid_argument("two-stroke port durations must be between 0 and 360 degrees");
|
throw std::invalid_argument("two-stroke port durations must be between 0 and 360 degrees");
|
||||||
bore = bore_value;
|
bore = bore_value;
|
||||||
stroke = stroke_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");
|
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 {
|
float TwoStrokeCylinder::valve_area(float cycle_degrees, bool inlet) const {
|
||||||
const float opens = inlet ? transfer_open() : exhaust_open();
|
const float opens = inlet ? transfer_open() : exhaust_open();
|
||||||
const float closes = inlet ? transfer_close() : exhaust_close();
|
const float width = inlet ? transfer_width : exhaust_width;
|
||||||
const float diameter = inlet ? intake_valve_diameter : exhaust_valve_diameter;
|
const float piston_area = pi * bore * bore * .25f;
|
||||||
const float peak_lift = inlet ? intake_valve_lift : exhaust_valve_lift;
|
const float port_top = compute_volume(opens * pi / 180);
|
||||||
return pi * diameter * lift(cycle_degrees, opens, closes, peak_lift);
|
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);
|
const float rpm = crank.omega * 60.0f / (2.0f * pi);
|
||||||
spark_advance = std::clamp(10.0f + rpm * 0.002f, 5.0f, 40.0f);
|
current_rpm_ = rpm;
|
||||||
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 theta = crank.angle + phase;
|
||||||
const float previous_volume = volume;
|
const float previous_volume = volume;
|
||||||
volume = compute_volume(theta);
|
volume = compute_volume(theta);
|
||||||
pressure = (gamma - 1.0f) * energy / std::max(volume, 1e-12f);
|
// Exact closed-gas geometry work; use the same work for crank torque.
|
||||||
const float dvolume = volume - previous_volume;
|
const float old_energy=energy;
|
||||||
const float radius = stroke * 0.5f;
|
energy*=std::pow(previous_volume/volume,gamma-1.0f);
|
||||||
const float obliquity = radius / rod;
|
const float gas_work=old_energy-energy;
|
||||||
const float sine = std::sin(theta), cosine = std::cos(theta);
|
const float back_work=crankcase_?crankcase_->geometry_work():-101325.0f*(volume-previous_volume);
|
||||||
const float term = std::sqrt(std::max(1.0f - obliquity * obliquity * sine * sine, 1e-12f));
|
const float angle_step=crank.omega*dt;
|
||||||
const float dx_dtheta = radius * sine * (1.0f + obliquity * cosine / term);
|
if(angle_step>1e-9f)crank.add_torque((gas_work+back_work)/angle_step);
|
||||||
const float piston_area = pi * 0.25f * bore * bore;
|
pressure=(gamma-1)*energy/volume;
|
||||||
const float back_pressure = crankcase_ ? crankcase_->pressure() : 101325.0f;
|
|
||||||
crank.add_torque((pressure - back_pressure) * piston_area * dx_dtheta);
|
|
||||||
energy -= pressure * dvolume;
|
|
||||||
|
|
||||||
const float previous_degrees = wrap_degrees((crank.previous_angle + phase) * 180.0f / pi, 360.0f);
|
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);
|
const float current_degrees = wrap_degrees(theta * 180.0f / pi, 360.0f);
|
||||||
handle_cycle_events(previous_degrees, current_degrees);
|
handle_cycle_events(previous_degrees, current_degrees);
|
||||||
|
|
||||||
if (rev_limit_active) {
|
if (!ecu && rev_limit_active) {
|
||||||
fuel_mass = 0.0f;
|
fuel_mass = 0.0f;
|
||||||
fuel_injected = false;
|
fuel_injected = false;
|
||||||
combustion_active = 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) {
|
void TwoStrokeCylinder::handle_cycle_events(float previous_degrees, float current_degrees) {
|
||||||
if (previous_degrees >= transfer_open() && previous_degrees < transfer_close() &&
|
const float trapped_angle = std::max(transfer_close(), exhaust_close());
|
||||||
current_degrees >= transfer_close()) {
|
if (previous_degrees < trapped_angle && current_degrees >= trapped_angle) {
|
||||||
fuel_mass = air_mass / std::max(afr, 1e-6f);
|
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
|
||||||
fuel_injected = true;
|
fuel_injected = fuel_mass > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const float spark_angle = wrap_degrees(360.0f - spark_advance, 360.0f);
|
const float spark_angle = wrap_degrees(360.0f - spark_advance, 360.0f);
|
||||||
const bool crossed_spark = (previous_degrees < spark_angle && current_degrees >= spark_angle) ||
|
const bool crossed_spark = crossed_angle(previous_degrees,current_degrees,spark_angle);
|
||||||
(previous_degrees > spark_angle && current_degrees < spark_angle);
|
if (crossed_spark && fuel_injected && !combustion_active && !(ecu ? ecu->fuel_cut() : rev_limit_active) &&
|
||||||
if (crossed_spark && fuel_injected && !combustion_active && !rev_limit_active &&
|
|
||||||
random_unit() >= misfire_probability) {
|
random_unit() >= misfire_probability) {
|
||||||
combustion_active = true;
|
combustion_active = true;
|
||||||
burn_fraction = 0.0f;
|
burn_fraction = 0.0f;
|
||||||
|
++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);
|
energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!combustion_active) return;
|
if (!combustion_active)
|
||||||
float angle_since_spark = current_degrees - spark_angle;
|
return;
|
||||||
if (angle_since_spark < 0.0f) angle_since_spark += 360.0f;
|
float angle_since_spark = current_degrees - active_spark_angle_;
|
||||||
const float phi = (angle_since_spark - burn_start) / std::max(burn_duration, 1e-6f);
|
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));
|
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));
|
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;
|
fraction = 1.0f;
|
||||||
combustion_active = false;
|
combustion_active = false;
|
||||||
exhaust_mass += air_mass;
|
exhaust_mass += air_mass;
|
||||||
|
|||||||
@@ -5,37 +5,60 @@
|
|||||||
|
|
||||||
namespace fluidsim::port {
|
namespace fluidsim::port {
|
||||||
|
|
||||||
TwoStrokeScenario::TwoStrokeScenario(int sample_rate)
|
TwoStrokeScenario::TwoStrokeScenario(int sample_rate, TwoStrokeConfig config)
|
||||||
: cylinder(0.054f, 0.0545f, 0.110f, 7.2f, 140.0f, 195.0f),
|
: configuration(config),
|
||||||
crankcase(crankshaft, 0.0545f * 0.5f, 0.110f, 0.054f, 150e-6f),
|
cylinder(config.bore, config.stroke, config.rod, config.geometric_compression(),
|
||||||
pipes(create_pipes()), dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))),
|
config.transfer_duration, config.exhaust_duration),
|
||||||
max_throttle_area_(area_for_diameter(0.042f)),
|
crankcase(crankshaft, config.stroke * .5f, config.rod, config.bore, 150e-6f), pipes(create_pipes(config)),
|
||||||
reed_in_area_(area_for_diameter(0.040f)),
|
dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))), max_throttle_area_(area_for_diameter(config.carburetor_diameter)),
|
||||||
reed_out_area_(0.0f), transfer_pipe_area_(area_for_diameter(0.040f)),
|
reed_in_area_(area_for_diameter(0.040f)), reed_out_area_(0.0f),
|
||||||
transfer_valve_area_(0.0f), exhaust_valve_area_(0.0f), boundaries_(pipes), solver_(pipes, boundaries_),
|
transfer_pipe_area_(area_for_diameter(0.032f)), transfer_valve_area_(0.0f), exhaust_valve_area_(0.0f),
|
||||||
intake_sound_(sample_rate), exhaust_sound_(sample_rate), reverb_(sample_rate) {
|
boundaries_(pipes), solver_(pipes, boundaries_), audio_(sample_rate) {
|
||||||
crankshaft.omega = 3000.0f * 2.0f * pi / 60.0f;
|
crankshaft.omega = 3000.0f * 2.0f * pi / 60.0f;
|
||||||
crankshaft.inertia = 0.01f;
|
crankshaft.inertia = 0.01f;
|
||||||
crankshaft.friction_constant = 1.0f;
|
const float pressure_to_torque = cylinder.swept_volume() * 1e5f / (2 * pi);
|
||||||
crankshaft.friction_viscous = 0.002f;
|
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.transfer_width = config.transfer_width;
|
||||||
cylinder.intake_valve_lift = 0.010f;
|
cylinder.exhaust_width = config.exhaust_width;
|
||||||
cylinder.exhaust_valve_diameter = 0.040f;
|
cylinder.minimum_burn_seconds = config.minimum_burn_seconds;
|
||||||
cylinder.exhaust_valve_lift = 0.010f;
|
// 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.initialize(crankshaft);
|
||||||
cylinder.set_crankcase(crankcase);
|
cylinder.set_crankcase(crankcase);
|
||||||
|
|
||||||
for (auto& pipe : pipes) {
|
for (auto &pipe : pipes) {
|
||||||
pipe.damping_multiplier = 0.8f;
|
pipe.damping_multiplier = 0.8f;
|
||||||
pipe.energy_relaxation_rate = 0.4f;
|
pipe.energy_relaxation_rate = 0.0f;
|
||||||
pipe.ambient_pressure = 101325.0f;
|
pipe.ambient_pressure = 101325.0f;
|
||||||
}
|
}
|
||||||
plenum_inlet_ = &intake_plenum.create_port();
|
plenum_inlet_ = &intake_plenum.create_port();
|
||||||
plenum_outlet_ = &intake_plenum.create_port();
|
plenum_outlet_ = &intake_plenum.create_port();
|
||||||
|
|
||||||
boundaries_.add_open_end(0, true, 101325.0f, pipes[0].area);
|
intake_boundary_area_ = pipes[0].area;
|
||||||
boundaries_.add_open_end(3, false, 101325.0f, pipes[3].cell_area(static_cast<int>(pipes[3].cells.size()) - 1));
|
boundaries_.add_orifice(intake_atmosphere.port, 0, true, intake_boundary_area_);
|
||||||
|
boundaries_.add_open_end(3, false, 101325.0f,
|
||||||
|
pipes[3].cell_area(static_cast<int>(pipes[3].cells.size()) - 1));
|
||||||
boundaries_.add_orifice(*plenum_inlet_, 0, false, max_throttle_area_, 0.72f);
|
boundaries_.add_orifice(*plenum_inlet_, 0, false, max_throttle_area_, 0.72f);
|
||||||
boundaries_.add_orifice(*plenum_outlet_, 1, true, reed_in_area_);
|
boundaries_.add_orifice(*plenum_outlet_, 1, true, reed_in_area_);
|
||||||
boundaries_.add_orifice(crankcase.intake_port, 1, false, reed_out_area_, 0.9f);
|
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.intake, 2, false, transfer_valve_area_);
|
||||||
boundaries_.add_orifice(cylinder.exhaust, 3, true, exhaust_valve_area_);
|
boundaries_.add_orifice(cylinder.exhaust, 3, true, exhaust_valve_area_);
|
||||||
solver_.minimum_substeps = 4;
|
solver_.minimum_substeps = 4;
|
||||||
|
solver_.add_component(intake_atmosphere);
|
||||||
solver_.add_component(intake_plenum);
|
solver_.add_component(intake_plenum);
|
||||||
solver_.add_component(crankcase);
|
solver_.add_component(crankcase);
|
||||||
solver_.add_component(cylinder);
|
solver_.add_component(cylinder);
|
||||||
|
|
||||||
intake_sound_.gain = 4.5f;
|
|
||||||
exhaust_sound_.gain = 4.5f;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioFrame TwoStrokeScenario::process() {
|
AudioFrame TwoStrokeScenario::process() {
|
||||||
const float reed_margin = 200.0f;
|
const float upstream = pipes[1].state(static_cast<int>(pipes[1].cells.size()) - 1).pressure;
|
||||||
if (crankcase.pressure() < intake_plenum.pressure() - reed_margin) reed_open_ = true;
|
reed_out_area_ = std::min(reed.step(dt_, upstream - crankcase.pressure()), reed_in_area_);
|
||||||
else if (crankcase.pressure() > intake_plenum.pressure() + reed_margin) reed_open_ = false;
|
reed_open_ = reed.lift() > 1e-6f;
|
||||||
|
ecu.step(dt_, throttle, crankshaft.omega * 60 / (2 * pi));
|
||||||
const float throttle_fraction = std::clamp(throttle, 0.0f, 1.0f);
|
const float angle = ecu.throttle_opening() * pi * .5f;
|
||||||
max_throttle_area_ = throttle_fraction < 0.001f ? 0.0f : area_for_diameter(0.042f) * throttle_fraction;
|
max_throttle_area_ = area_for_diameter(configuration.carburetor_diameter) * (1 - std::cos(angle));
|
||||||
reed_out_area_ = reed_open_ ? reed_in_area_ : 0.0f;
|
step_motorcycle(crankshaft, dt_, 10.0f);
|
||||||
if (step_count_ < 20000) crankshaft.add_torque(5.0f);
|
|
||||||
crankshaft.step(dt_);
|
|
||||||
crankcase.pre_step(dt_);
|
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);
|
transfer_valve_area_ = cylinder.valve_area(crankshaft.angle * 180.0f / pi, true);
|
||||||
exhaust_valve_area_ = cylinder.valve_area(crankshaft.angle * 180.0f / pi, false);
|
exhaust_valve_area_ = cylinder.valve_area(crankshaft.angle * 180.0f / pi, false);
|
||||||
solver_.step(dt_);
|
solver_.step(dt_);
|
||||||
++step_count_;
|
++step_count_;
|
||||||
|
|
||||||
const float intake = intake_sound_.process(boundaries_.open_end_mass_flow(0));
|
intake_flow = intake_atmosphere.port.mass_flow;
|
||||||
const float exhaust = exhaust_sound_.process(boundaries_.open_end_mass_flow(1));
|
exhaust_flow = boundaries_.open_end_mass_flow(0);
|
||||||
const float reverb_input = 0.5f * (intake + exhaust);
|
return audio_.process(intake_flow, exhaust_flow);
|
||||||
return reverb_.process_stereo(reverb_input);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void TwoStrokeScenario::set_throttle(float value) {
|
void TwoStrokeScenario::set_throttle(float value) { throttle = std::clamp(value, 0.0f, 1.0f); }
|
||||||
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 {
|
TwoStrokeSnapshot TwoStrokeScenario::snapshot() const {
|
||||||
return {
|
return {crankshaft.omega * 60.0f / (2.0f * pi),
|
||||||
crankshaft.omega * 60.0f / (2.0f * pi), cylinder.pressure,
|
cylinder.pressure,
|
||||||
crankcase.pressure(), intake_plenum.pressure(), throttle,
|
crankcase.pressure(),
|
||||||
cylinder.valve_area(crankshaft.angle * 180.0f / pi, true),
|
intake_plenum.pressure(),
|
||||||
cylinder.valve_area(crankshaft.angle * 180.0f / pi, false), reed_open_
|
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) {
|
float TwoStrokeScenario::area_for_diameter(float diameter) { return pi * diameter * diameter * 0.25f; }
|
||||||
return pi * diameter * diameter * 0.25f;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<Pipe1D> TwoStrokeScenario::create_pipes() {
|
std::vector<Pipe1D> TwoStrokeScenario::create_pipes(const TwoStrokeConfig& config) {
|
||||||
const float intake_area = area_for_diameter(0.042f);
|
|
||||||
const float reed_area = area_for_diameter(0.040f);
|
|
||||||
const float transfer_area = area_for_diameter(0.040f);
|
|
||||||
std::vector<Pipe1D> result;
|
std::vector<Pipe1D> result;
|
||||||
result.reserve(4);
|
result.reserve(4);
|
||||||
result.emplace_back(intake_area, 0.100f, 8);
|
result.emplace_back(area_for_diameter(config.carburetor_diameter), .100f, 10);
|
||||||
result.emplace_back(reed_area, 0.030f, 4);
|
result.emplace_back(area_for_diameter(.040f), .030f, 4);
|
||||||
result.emplace_back(transfer_area, 0.200f, 8);
|
result.emplace_back(area_for_diameter(.032f), .060f, 6);
|
||||||
|
if (config.exhaust == TwoStrokeExhaust::Racing) {
|
||||||
constexpr int exhaust_cells = 60;
|
result.push_back(make_profiled_pipe(config.racing_pipe, config.exhaust_cell_length));
|
||||||
constexpr float header_length = 0.130f, diffuser_length = 0.250f;
|
} else {
|
||||||
constexpr float belly_length = 0.220f, convergent_length = 0.160f;
|
float length = 0;
|
||||||
constexpr float stinger_length = 0.080f;
|
for (const auto& section : config.racing_pipe) length += section.length;
|
||||||
constexpr float header_diameter = 0.040f, belly_diameter = 0.070f;
|
const PipeSection straight{length, config.racing_pipe.front().inlet_diameter,
|
||||||
constexpr float stinger_diameter = 0.028f;
|
config.racing_pipe.front().inlet_diameter};
|
||||||
constexpr float total_length = header_length + diffuser_length + belly_length +
|
result.push_back(make_profiled_pipe(std::span(&straight, 1), config.exhaust_cell_length));
|
||||||
convergent_length + stinger_length;
|
|
||||||
const int header_cells = static_cast<int>(exhaust_cells * header_length / total_length);
|
|
||||||
const int diffuser_cells = static_cast<int>(exhaust_cells * diffuser_length / total_length);
|
|
||||||
const int belly_cells = static_cast<int>(exhaust_cells * belly_length / total_length);
|
|
||||||
const int convergent_cells = static_cast<int>(exhaust_cells * convergent_length / total_length);
|
|
||||||
const int stinger_cells = exhaust_cells - header_cells - diffuser_cells - belly_cells - convergent_cells;
|
|
||||||
std::vector<float> exhaust_areas;
|
|
||||||
std::vector<float> exhaust_lengths;
|
|
||||||
exhaust_areas.reserve(exhaust_cells);
|
|
||||||
exhaust_lengths.reserve(exhaust_cells);
|
|
||||||
const auto append_uniform = [&](int count, float diameter, float length) {
|
|
||||||
for (int i = 0; i < count; ++i) {
|
|
||||||
exhaust_areas.push_back(area_for_diameter(diameter));
|
|
||||||
exhaust_lengths.push_back(length / static_cast<float>(count));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
append_uniform(header_cells, header_diameter, header_length);
|
|
||||||
for (int i = 0; i < diffuser_cells; ++i) {
|
|
||||||
const float t = diffuser_cells > 1 ? static_cast<float>(i) / (diffuser_cells - 1) : 0.0f;
|
|
||||||
const float diameter = header_diameter + (belly_diameter - header_diameter) * t;
|
|
||||||
exhaust_areas.push_back(area_for_diameter(diameter));
|
|
||||||
exhaust_lengths.push_back(diffuser_length / static_cast<float>(diffuser_cells));
|
|
||||||
}
|
}
|
||||||
append_uniform(belly_cells, belly_diameter, belly_length);
|
|
||||||
for (int i = 0; i < convergent_cells; ++i) {
|
|
||||||
const float t = convergent_cells > 1 ? static_cast<float>(i) / (convergent_cells - 1) : 0.0f;
|
|
||||||
const float diameter = belly_diameter + (stinger_diameter - belly_diameter) * t;
|
|
||||||
exhaust_areas.push_back(area_for_diameter(diameter));
|
|
||||||
exhaust_lengths.push_back(convergent_length / static_cast<float>(convergent_cells));
|
|
||||||
}
|
|
||||||
append_uniform(stinger_cells, stinger_diameter, stinger_length);
|
|
||||||
result.emplace_back(std::move(exhaust_areas), std::move(exhaust_lengths));
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
75
tests/b205_regression.cpp
Normal file
75
tests/b205_regression.cpp
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#include "FluidSim/Scenario.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace fluidsim::port;
|
||||||
|
namespace {
|
||||||
|
void check(bool condition, const char* message) {
|
||||||
|
if (!condition) throw std::runtime_error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int main() {
|
||||||
|
SaabB205Scenario s;
|
||||||
|
const auto cylinders = s.cylinders();
|
||||||
|
check(s.cylinder_count() == 4 && s.pipes.size() == 10, "B205 topology is not four-cylinder");
|
||||||
|
check(std::abs(4*s.cylinder1.swept_volume()*1e6f-1984.9f) < 1, "B205 displacement incorrect");
|
||||||
|
check(s.vehicle.gear_ratios.size() == 5 && s.vehicle.mass == 1675, "wagon configuration incorrect");
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
check(cylinders[i]->compression == 8.8f && cylinders[i]->intake_valve_count == 2 &&
|
||||||
|
cylinders[i]->exhaust_valve_count == 2, "B205 compression/valve count incorrect");
|
||||||
|
}
|
||||||
|
s.set_controls(.5f, 0, 1);
|
||||||
|
std::array<bool, 4> burning{};
|
||||||
|
std::vector<int> firing_order;
|
||||||
|
float peak = 0;
|
||||||
|
const auto started = std::chrono::steady_clock::now();
|
||||||
|
const auto step = [&] {
|
||||||
|
const auto audio = s.process();
|
||||||
|
check(std::isfinite(audio.left) && std::isfinite(audio.right), "nonfinite B205 audio");
|
||||||
|
peak = std::max({peak, std::abs(audio.left), std::abs(audio.right)});
|
||||||
|
for (const auto* c : cylinders)
|
||||||
|
check(std::isfinite(c->pressure) && c->pressure > 0 && c->total_mass() > 0,
|
||||||
|
"nonphysical B205 cylinder");
|
||||||
|
};
|
||||||
|
// Prescribed-speed absorbing dyno isolates phasing from acceleration.
|
||||||
|
for (int sample = 0; sample < 44100; ++sample) {
|
||||||
|
s.crankshaft.omega = 2000 * 2*pi/60;
|
||||||
|
step();
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
if (sample > 22050 && cylinders[i]->combustion_active && !burning[i])
|
||||||
|
firing_order.push_back(i);
|
||||||
|
burning[i] = cylinders[i]->combustion_active;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(firing_order.size() >= 28 && firing_order.size() <= 36, "B205 did not fire twice per revolution");
|
||||||
|
constexpr std::array<int, 4> next{2, 0, 3, 1};
|
||||||
|
for (std::size_t i = 1; i < firing_order.size(); ++i)
|
||||||
|
check(firing_order[i] == next[firing_order[i-1]], "B205 firing order is not 1-3-4-2");
|
||||||
|
s.set_first_gear();
|
||||||
|
for (int sample = 0; sample < 4*44100; ++sample) {
|
||||||
|
const float clutch = std::max(0.f, 1-sample/(.8f*44100));
|
||||||
|
s.set_controls(.6f, 0, clutch);
|
||||||
|
step();
|
||||||
|
}
|
||||||
|
check(s.vehicle.speed > 1 && s.vehicle.current_gear == 1, "B205 failed to launch wagon");
|
||||||
|
const float speed = s.vehicle.speed*3.6f;
|
||||||
|
for (int i = 0; i < 8; ++i) s.shift_up();
|
||||||
|
check(s.vehicle.current_gear == 5, "wagon has a phantom sixth gear");
|
||||||
|
check(std::abs(s.vehicle.total_gear_ratio()-2.67f) < 1e-5f, "wagon top overall ratio incorrect");
|
||||||
|
s.set_neutral();
|
||||||
|
s.set_controls(0, 0, 1);
|
||||||
|
for (int sample = 0; sample < 3*44100; ++sample) step();
|
||||||
|
const auto final = s.snapshot();
|
||||||
|
check(final.rpm > 300 && final.rpm < 2500, "B205 did not recover toward idle");
|
||||||
|
check(final.cylinder_count == 4 && !final.pipe_states[9].empty(), "B205 desktop snapshot incomplete");
|
||||||
|
for (const auto& pipe : s.pipes)
|
||||||
|
check(pipe.positivity_repairs == 0, "B205 required pipe positivity repairs");
|
||||||
|
const double wall = std::chrono::duration<double>(std::chrono::steady_clock::now()-started).count();
|
||||||
|
std::cout << "B205 passed: firing events=" << firing_order.size() << ", wagon speed=" << speed
|
||||||
|
<< " km/h, recovered RPM=" << final.rpm << ", audio peak=" << peak
|
||||||
|
<< ", wall seconds=" << wall << " for 8 simulated seconds\n";
|
||||||
|
}
|
||||||
625
tests/fluid_validation.cpp
Normal file
625
tests/fluid_validation.cpp
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
#include "FluidSim/Atmosphere.hpp"
|
||||||
|
#include "FluidSim/Crankcase.hpp"
|
||||||
|
#include "FluidSim/Fluid.hpp"
|
||||||
|
#include "FluidSim/Solver.hpp"
|
||||||
|
#include <cmath>
|
||||||
|
#include <functional>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
using namespace fluidsim::port;
|
||||||
|
namespace {
|
||||||
|
void check(bool b, const std::string &s) {
|
||||||
|
if (!b)
|
||||||
|
throw std::runtime_error(s);
|
||||||
|
}
|
||||||
|
void near(double a, double b, double tol, const char *s) {
|
||||||
|
check(std::isfinite(a) && std::abs(a - b) <= tol, s);
|
||||||
|
}
|
||||||
|
void set(Pipe1D &p, int i, float pressure, float rho, float u = 0, float air = 1) {
|
||||||
|
p.cells[i] = {rho, rho * u, pressure / pipe_gm1 + .5f * rho * u * u, air};
|
||||||
|
}
|
||||||
|
void lossless(Pipe1D &p) {
|
||||||
|
p.damping_multiplier = 0;
|
||||||
|
p.energy_relaxation_rate = 0;
|
||||||
|
}
|
||||||
|
double mass(const Pipe1D &p) {
|
||||||
|
double v = 0;
|
||||||
|
for (int i = 0; i < int(p.cells.size()); i++)
|
||||||
|
v += p.cell_mass(i);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
double energy(const Pipe1D &p) {
|
||||||
|
double v = 0;
|
||||||
|
for (int i = 0; i < int(p.cells.size()); i++)
|
||||||
|
v += double(p.cells[i].energy) * p.cell_area(i) * p.cell_length(i);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
double vol_energy(const Volume0D &v) { return v.pressure() * v.volume() / (v.gamma - 1); }
|
||||||
|
} // namespace
|
||||||
|
int main() {
|
||||||
|
int failed = 0, total = 0;
|
||||||
|
auto test = [&](const char *n, std::function<void()> fn) {
|
||||||
|
++total;
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
std::cout << "PASS " << n << '\n';
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
++failed;
|
||||||
|
std::cout << "FAIL " << n << ": " << e.what() << '\n';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
test("invalid uniform geometry", [] {
|
||||||
|
bool caught = false;
|
||||||
|
try {
|
||||||
|
Pipe1D p(0, .1f, 4);
|
||||||
|
} catch (const std::invalid_argument &) {
|
||||||
|
caught = true;
|
||||||
|
}
|
||||||
|
check(caught, "zero area accepted");
|
||||||
|
});
|
||||||
|
test("closed pipe conservation", [] {
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, 1, 50);
|
||||||
|
lossless(p[0]);
|
||||||
|
for (int i = 0; i < 50; i++)
|
||||||
|
set(p[0], i, i < 25 ? 120000 : 100000, 1.2f);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
b.add_closed_end(0, true);
|
||||||
|
b.add_closed_end(0, false);
|
||||||
|
Solver s(p, b);
|
||||||
|
auto m = mass(p[0]), e = energy(p[0]);
|
||||||
|
for (int i = 0; i < 2000; i++)
|
||||||
|
s.step(1.f / 44100);
|
||||||
|
near(mass(p[0]), m, m * 2e-5, "closed mass drift");
|
||||||
|
near(energy(p[0]), e, e * 2e-5, "closed energy drift");
|
||||||
|
});
|
||||||
|
test("profiled pipe conservation", [] {
|
||||||
|
std::vector<float> a(40), l(40, .025f);
|
||||||
|
for (int i = 0; i < 40; i++)
|
||||||
|
a[i] = .001f * (1 + .5f * std::sin(i * .1f));
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(a, l);
|
||||||
|
lossless(p[0]);
|
||||||
|
for (int i = 0; i < 40; i++)
|
||||||
|
set(p[0], i, i < 20 ? 120000 : 100000, 1.2f);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
b.add_closed_end(0, true);
|
||||||
|
b.add_closed_end(0, false);
|
||||||
|
Solver s(p, b);
|
||||||
|
auto m = mass(p[0]), e = energy(p[0]);
|
||||||
|
for (int i = 0; i < 1000; i++)
|
||||||
|
s.step(1.f / 44100);
|
||||||
|
near(mass(p[0]), m, m * 3e-5, "profile mass drift");
|
||||||
|
near(energy(p[0]), e, e * 3e-5, "profile energy drift");
|
||||||
|
});
|
||||||
|
test("volume pipe sealed conservation", [] {
|
||||||
|
Volume0D v(.001f, 180000, 400);
|
||||||
|
v.energy_relaxation_rate = 0;
|
||||||
|
auto &port = v.create_port();
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.0003f, .5f, 25);
|
||||||
|
lossless(p[0]);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
float a = .0001f;
|
||||||
|
b.add_orifice(port, 0, true, a);
|
||||||
|
b.add_closed_end(0, false);
|
||||||
|
Solver s(p, b);
|
||||||
|
s.add_component(v);
|
||||||
|
auto m = mass(p[0]) + v.mass(), e = energy(p[0]) + vol_energy(v);
|
||||||
|
for (int i = 0; i < 2000; i++)
|
||||||
|
s.step(1.f / 44100);
|
||||||
|
near(mass(p[0]) + v.mass(), m, m * 3e-5, "coupling creates/deletes mass");
|
||||||
|
near(energy(p[0]) + vol_energy(v), e, e * 3e-5, "coupling creates/deletes energy");
|
||||||
|
});
|
||||||
|
test("equal pressure hot contact", [] {
|
||||||
|
Volume0D v(.001f, 101325, 600);
|
||||||
|
v.energy_relaxation_rate = 0;
|
||||||
|
auto &port = v.create_port();
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, .2f, 20);
|
||||||
|
lossless(p[0]);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
float a = .001f;
|
||||||
|
b.add_orifice(port, 0, true, a);
|
||||||
|
b.add_closed_end(0, false);
|
||||||
|
Solver s(p, b);
|
||||||
|
s.add_component(v);
|
||||||
|
double m = mass(p[0]) + v.mass();
|
||||||
|
for (int i = 0; i < 500; i++)
|
||||||
|
s.step(1.f / 44100);
|
||||||
|
near(mass(p[0]) + v.mass(), m, m * 2e-5, "contact produces mass");
|
||||||
|
near(v.pressure(), 101325, 5, "stationary contact pressure changes");
|
||||||
|
});
|
||||||
|
test("NASA choked nozzle mass flow", [] {
|
||||||
|
Atmosphere v;
|
||||||
|
v.pressure = 200000;
|
||||||
|
v.update_state(0);
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, .2f, 10);
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
set(p[0], i, 10000, 10000 / (gas_constant * 300));
|
||||||
|
BoundarySystem b(p);
|
||||||
|
float a = .0001f;
|
||||||
|
b.add_orifice(v.port, 0, true, a, .8f);
|
||||||
|
b.resolve(1e-8f);
|
||||||
|
double expected = .8 * a * 200000 / std::sqrt(300.) *
|
||||||
|
std::sqrt(fluidsim::port::gamma / gas_constant) *
|
||||||
|
std::pow(2 / (fluidsim::port::gamma + 1),
|
||||||
|
(fluidsim::port::gamma + 1) / (2 * (fluidsim::port::gamma - 1)));
|
||||||
|
near(-v.port.mass_flow, expected, expected * 2e-5,
|
||||||
|
"choked mdot differs from isentropic sonic-throat formula");
|
||||||
|
});
|
||||||
|
test("inertance units", [] {
|
||||||
|
Atmosphere v;
|
||||||
|
v.pressure = 102325;
|
||||||
|
v.update_state(0);
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, .1f, 10);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
float a = .0001f;
|
||||||
|
b.add_orifice_with_inertance(v.port, 0, true, a, 1, .05f);
|
||||||
|
b.resolve(1e-6f);
|
||||||
|
double expected = a / .05 * 1000 * 1e-6;
|
||||||
|
near(-v.port.mass_flow, expected, expected * 1e-4,
|
||||||
|
"mass-flow inertance must satisfy d(mdot)/dt=A*dp/L");
|
||||||
|
});
|
||||||
|
test("moving volume adiabatic law", [] {
|
||||||
|
Volume0D v(.001f);
|
||||||
|
v.energy_relaxation_rate = 0;
|
||||||
|
v.volume_change_rate = .0001f;
|
||||||
|
for (int i = 0; i < 1000; i++)
|
||||||
|
v.update(1e-3f);
|
||||||
|
near(v.volume(), .0011, 1e-8, "volume work did not change geometric volume");
|
||||||
|
near(v.pressure(), 101325 * std::pow(.001 / .0011, 1.4), 3, "p V^gamma not conserved");
|
||||||
|
});
|
||||||
|
test("profile at rest is well balanced", [] {
|
||||||
|
std::vector<float> a(80), l(80, .01f);
|
||||||
|
for (int i = 0; i < 80; i++)
|
||||||
|
a[i] = .001f * (1 + .7f * std::sin(.1f * i));
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(a, l);
|
||||||
|
lossless(p[0]);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
b.add_closed_end(0, true);
|
||||||
|
b.add_closed_end(0, false);
|
||||||
|
Solver s(p, b);
|
||||||
|
for (int j = 0; j < 1000; j++)
|
||||||
|
s.step(1.f / 44100);
|
||||||
|
for (int i = 0; i < 80; i++) {
|
||||||
|
near(p[0].state(i).velocity, 0, 1e-3, "stationary nozzle accelerates");
|
||||||
|
near(p[0].state(i).pressure, 101325, 2, "stationary nozzle pressure drifts");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("pipe connection all four orientations", [] {
|
||||||
|
for (bool la : {false, true})
|
||||||
|
for (bool lb : {false, true}) {
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, .3f, 20);
|
||||||
|
p.emplace_back(.001f, .3f, 20);
|
||||||
|
for (auto &q : p)
|
||||||
|
lossless(q);
|
||||||
|
for (int i = 0; i < 20; i++)
|
||||||
|
set(p[0], i, 130000, 1.4f, 0, .2f);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
b.add_connection(0, la, 1, lb);
|
||||||
|
b.add_closed_end(0, !la);
|
||||||
|
b.add_closed_end(1, !lb);
|
||||||
|
Solver s(p, b);
|
||||||
|
double m = mass(p[0]) + mass(p[1]), e = energy(p[0]) + energy(p[1]);
|
||||||
|
for (int i = 0; i < 2000; i++)
|
||||||
|
s.step(1.f / 44100);
|
||||||
|
near(mass(p[0]) + mass(p[1]), m, m * 4e-5, "junction mass drift");
|
||||||
|
near(energy(p[0]) + energy(p[1]), e, e * 4e-5, "junction energy drift");
|
||||||
|
for (auto &q : p)
|
||||||
|
check(q.positivity_repairs == 0, "junction positivity repair");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("split pipe matches unsplit pipe", [] {
|
||||||
|
std::vector<Pipe1D> a, b;
|
||||||
|
a.emplace_back(.001f, 1, 80);
|
||||||
|
b.emplace_back(.001f, .5f, 40);
|
||||||
|
b.emplace_back(.001f, .5f, 40);
|
||||||
|
lossless(a[0]);
|
||||||
|
lossless(b[0]);
|
||||||
|
lossless(b[1]);
|
||||||
|
for (int i = 0; i < 80; i++) {
|
||||||
|
float pr = 101325 + 100 * std::exp(-std::pow((i - 30) / 5.f, 2));
|
||||||
|
set(a[0], i, pr, 1.2f);
|
||||||
|
set(b[i / 40], i % 40, pr, 1.2f);
|
||||||
|
}
|
||||||
|
BoundarySystem ba(a), bb(b);
|
||||||
|
ba.add_closed_end(0, true);
|
||||||
|
ba.add_closed_end(0, false);
|
||||||
|
bb.add_closed_end(0, true);
|
||||||
|
bb.add_closed_end(1, false);
|
||||||
|
bb.add_connection(0, false, 1, true);
|
||||||
|
Solver sa(a, ba), sb(b, bb);
|
||||||
|
for (int j = 0; j < 300; j++) {
|
||||||
|
sa.step(1.f / 44100);
|
||||||
|
sb.step(1.f / 44100);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 80; i++)
|
||||||
|
near(a[0].state(i).pressure, b[i / 40].state(i % 40).pressure, .1,
|
||||||
|
"split junction changes solution");
|
||||||
|
});
|
||||||
|
test("three volumes shared donor conservation", [] {
|
||||||
|
Volume0D a(.000001f, 1e6f, 500), b(.001f), c(.001f);
|
||||||
|
a.energy_relaxation_rate = b.energy_relaxation_rate = c.energy_relaxation_rate = 0;
|
||||||
|
auto &ap = a.create_port();
|
||||||
|
auto &ap2 = a.create_port();
|
||||||
|
auto &bp = b.create_port();
|
||||||
|
auto &cp = c.create_port();
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, .1f, 10);
|
||||||
|
p.emplace_back(.001f, .1f, 10);
|
||||||
|
for (auto &q : p)
|
||||||
|
lossless(q);
|
||||||
|
BoundarySystem bs(p);
|
||||||
|
float ar = .001f;
|
||||||
|
bs.add_orifice(ap, 0, true, ar);
|
||||||
|
bs.add_orifice(ap2, 1, true, ar);
|
||||||
|
bs.add_orifice(bp, 0, false, ar);
|
||||||
|
bs.add_orifice(cp, 1, false, ar);
|
||||||
|
Solver s(p, bs);
|
||||||
|
s.add_component(a);
|
||||||
|
s.add_component(b);
|
||||||
|
s.add_component(c);
|
||||||
|
double m = mass(p[0]) + mass(p[1]) + a.mass() + b.mass() + c.mass();
|
||||||
|
double e = energy(p[0]) + energy(p[1]) + vol_energy(a) + vol_energy(b) + vol_energy(c);
|
||||||
|
for (int j = 0; j < 3000; j++)
|
||||||
|
s.step(1.f / 44100);
|
||||||
|
near(mass(p[0]) + mass(p[1]) + a.mass() + b.mass() + c.mass(), m, m * 5e-5,
|
||||||
|
"shared donor mass drift");
|
||||||
|
near(energy(p[0]) + energy(p[1]) + vol_energy(a) + vol_energy(b) + vol_energy(c), e, e * 5e-5,
|
||||||
|
"shared donor energy drift");
|
||||||
|
check(a.pressure() > 0, "donor vacuum");
|
||||||
|
});
|
||||||
|
test("open boundary reports actual conserved flux both ends", [] {
|
||||||
|
for (bool left : {false, true}) {
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, .1f, 10);
|
||||||
|
lossless(p[0]);
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
set(p[0], i, 105000, 1.2f);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
b.add_open_end(0, left, 101325, .001f);
|
||||||
|
b.add_closed_end(0, !left);
|
||||||
|
double m = mass(p[0]);
|
||||||
|
b.resolve(1e-6f);
|
||||||
|
double dm = -b.open_end_mass_flow(0) * 1e-6;
|
||||||
|
p[0].advance(1e-6f);
|
||||||
|
near(mass(p[0]) - m, dm, 1e-11,
|
||||||
|
"reported radiation flow differs from finite-volume mass exchange");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("duplicate and invalid boundary rejection", [] {
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, 1, 10);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
b.add_closed_end(0, true);
|
||||||
|
bool caught = false;
|
||||||
|
try {
|
||||||
|
b.add_open_end(0, true, 101325, .001f);
|
||||||
|
} catch (const std::invalid_argument &) {
|
||||||
|
caught = true;
|
||||||
|
}
|
||||||
|
check(caught, "duplicate face accepted");
|
||||||
|
caught = false;
|
||||||
|
try {
|
||||||
|
b.add_closed_end(9, true);
|
||||||
|
} catch (const std::invalid_argument &) {
|
||||||
|
caught = true;
|
||||||
|
}
|
||||||
|
check(caught, "invalid pipe accepted");
|
||||||
|
});
|
||||||
|
test("supersonic outlet extrapolates", [] {
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, 1, 10);
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
set(p[0], i, 101325, 1.2f, 700);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
b.add_open_end(0, false, 50000, .001f);
|
||||||
|
b.resolve(1e-6f);
|
||||||
|
near(b.open_end_mass_flow(0), .001 * 1.2 * 700, 1e-5,
|
||||||
|
"supersonic outlet responds to downstream pressure");
|
||||||
|
});
|
||||||
|
test("underdefined supersonic inlet rejected", [] {
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, 1, 10);
|
||||||
|
set(p[0], 0, 101325, 1.2f, 700);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
b.add_open_end(0, true, 101325, .001f);
|
||||||
|
bool caught = false;
|
||||||
|
try {
|
||||||
|
b.resolve(1e-6f);
|
||||||
|
} catch (const std::runtime_error &) {
|
||||||
|
caught = true;
|
||||||
|
}
|
||||||
|
check(caught, "supersonic inlet silently accepted pressure-only data");
|
||||||
|
});
|
||||||
|
test("closed and open acoustic reflection signs", [] {
|
||||||
|
for (bool closed : {false, true}) {
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, 1, 400);
|
||||||
|
lossless(p[0]);
|
||||||
|
const double c = std::sqrt(1.4 * 101325 / 1.2);
|
||||||
|
for (int i = 0; i < 400; i++) {
|
||||||
|
double x = (i + .5) / 400.;
|
||||||
|
float dp = 100 * std::exp(-std::pow((x - .3) / .04, 2));
|
||||||
|
set(p[0], i, 101325 + dp, 1.2f + dp / (c * c), dp / (1.2 * c));
|
||||||
|
}
|
||||||
|
BoundarySystem b(p);
|
||||||
|
b.add_closed_end(0, true);
|
||||||
|
if (closed)
|
||||||
|
b.add_closed_end(0, false);
|
||||||
|
else
|
||||||
|
b.add_open_end(0, false, 101325, .001f);
|
||||||
|
Solver s(p, b);
|
||||||
|
s.minimum_substeps = 1;
|
||||||
|
const int steps = int((1.4 / c) * 44100);
|
||||||
|
for (int j = 0; j < steps; j++)
|
||||||
|
s.step(1.f / 44100);
|
||||||
|
float dp = p[0].state(119).pressure - 101325;
|
||||||
|
check(closed ? dp > 30 : dp < -30, "wrong reflected pressure sign/amplitude");
|
||||||
|
check(p[0].positivity_repairs == 0, "acoustic positivity repair");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("Sod shock tube convergence", [] {
|
||||||
|
double previous = 1e9;
|
||||||
|
for (int n : {100, 200, 400}) {
|
||||||
|
Pipe1D p(1, 1, n);
|
||||||
|
lossless(p);
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
set(p, i, i < n / 2 ? 1 : .1f, i < n / 2 ? 1 : .125f);
|
||||||
|
double t = 0;
|
||||||
|
while (t < .2) {
|
||||||
|
p.left_ghost = p.state(0);
|
||||||
|
p.right_ghost = p.state(n - 1);
|
||||||
|
float dt = std::min(.2 - t, double(p.stable_timestep(.7f)));
|
||||||
|
p.advance(dt);
|
||||||
|
t += dt;
|
||||||
|
}
|
||||||
|
double error = 0;
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
double xi = ((i + .5) / n - .5) / .2;
|
||||||
|
double rho;
|
||||||
|
if (xi < -std::sqrt(1.4))
|
||||||
|
rho = 1;
|
||||||
|
else if (xi < -.0702728)
|
||||||
|
rho = std::pow((2 / (1.4 + 1)) + (1.4 - 1) / (1.4 + 1) * (-xi / std::sqrt(1.4)),
|
||||||
|
2 / (1.4 - 1));
|
||||||
|
else if (xi < .9274526)
|
||||||
|
rho = .4263194;
|
||||||
|
else if (xi < 1.7521557)
|
||||||
|
rho = .2655737;
|
||||||
|
else
|
||||||
|
rho = .125;
|
||||||
|
error += std::abs(p.state(i).rho - rho) / n;
|
||||||
|
}
|
||||||
|
std::cout << " Sod n=" << n << " density L1=" << error << '\n';
|
||||||
|
check(error < previous * .9, "shock density error does not decrease on refinement");
|
||||||
|
previous = error;
|
||||||
|
check(p.positivity_repairs == 0, "Sod positivity repair");
|
||||||
|
}
|
||||||
|
check(previous < .025, "Sod density error excessive");
|
||||||
|
});
|
||||||
|
test("strong pulse and near-vacuum rarefaction", [] {
|
||||||
|
for (bool vacuum : {false, true}) {
|
||||||
|
Pipe1D p(1, 1, 200);
|
||||||
|
lossless(p);
|
||||||
|
for (int i = 0; i < 200; i++)
|
||||||
|
set(p, i, vacuum ? .4f : (i < 100 ? 1000 : .01f), 1, vacuum ? (i < 100 ? -2 : 2) : 0);
|
||||||
|
double t = 0;
|
||||||
|
while (t < .005) {
|
||||||
|
p.left_ghost = p.state(0);
|
||||||
|
p.right_ghost = p.state(199);
|
||||||
|
float dt = std::min(.005 - t, double(p.stable_timestep(.5f)));
|
||||||
|
p.advance(dt);
|
||||||
|
t += dt;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 200; i++)
|
||||||
|
check(p.state(i).rho > 0 && p.state(i).pressure > 0, "negative shock/vacuum state");
|
||||||
|
check(p.positivity_repairs == 0, "stress case required conservation-breaking positivity repair");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("small-signal Helmholtz resonance", [] {
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
float area = .000314159f;
|
||||||
|
p.emplace_back(area, .05f, 40);
|
||||||
|
Volume0D v(.001f, 101335, 300);
|
||||||
|
auto &port = v.create_port();
|
||||||
|
BoundarySystem b(p);
|
||||||
|
b.add_orifice(port, 0, true, area, 1);
|
||||||
|
b.add_open_end(0, false, 101325, area);
|
||||||
|
Solver solver(p, b);
|
||||||
|
solver.minimum_substeps = 1;
|
||||||
|
solver.add_component(v);
|
||||||
|
double prev = v.pressure() - 101325, first = 0, last = 0;
|
||||||
|
int count = 0;
|
||||||
|
for (int i = 0; i < 4410; i++) {
|
||||||
|
solver.step(1.f / 44100);
|
||||||
|
double now = v.pressure() - 101325;
|
||||||
|
check(std::abs(now) < 15, "small-signal cavity grows instead of oscillating/decaying");
|
||||||
|
if (prev > 0 && now <= 0 && i > 441) {
|
||||||
|
double t = (i + prev / (prev - now)) / 44100;
|
||||||
|
if (!count)
|
||||||
|
first = t;
|
||||||
|
last = t;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
prev = now;
|
||||||
|
}
|
||||||
|
double frequency = (count - 1) / (last - first);
|
||||||
|
double expected =
|
||||||
|
std::sqrt(1.4 * 287 * 300) / (2 * 3.141592653589793) * std::sqrt(area / (.001 * .05));
|
||||||
|
std::cout << " Helmholtz frequency=" << frequency << " analytic lumped=" << expected << '\n';
|
||||||
|
check(count > 5, "not enough cavity oscillations");
|
||||||
|
near(frequency, expected, expected * .05, "Helmholtz mode frequency error exceeds 5%");
|
||||||
|
check(p[0].positivity_repairs == 0, "cavity requires positivity repairs");
|
||||||
|
});
|
||||||
|
test("invalid volume and timestep rejection", [] {
|
||||||
|
bool caught = false;
|
||||||
|
try {
|
||||||
|
Volume0D v(-1);
|
||||||
|
} catch (const std::invalid_argument &) {
|
||||||
|
caught = true;
|
||||||
|
}
|
||||||
|
check(caught, "negative volume accepted");
|
||||||
|
Pipe1D p(.001f, .1f, 10);
|
||||||
|
caught = false;
|
||||||
|
try {
|
||||||
|
p.required_substeps(std::nanf(""));
|
||||||
|
} catch (const std::invalid_argument &) {
|
||||||
|
caught = true;
|
||||||
|
}
|
||||||
|
check(caught, "NaN timestep accepted");
|
||||||
|
});
|
||||||
|
test("fresh-air species conservation across connection", [] {
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, .5f, 40);
|
||||||
|
p.emplace_back(.001f, .5f, 40);
|
||||||
|
for (int k = 0; k < 2; k++) {
|
||||||
|
lossless(p[k]);
|
||||||
|
for (int i = 0; i < 40; i++)
|
||||||
|
set(p[k], i, k ? 100000 : 120000, 1.2f, 0, k ? .9f : .1f);
|
||||||
|
}
|
||||||
|
auto air_mass = [&] {
|
||||||
|
double sum = 0;
|
||||||
|
for (auto &q : p)
|
||||||
|
for (int i = 0; i < 40; i++)
|
||||||
|
sum += q.cell_mass(i) * q.cells[i].air;
|
||||||
|
return sum;
|
||||||
|
};
|
||||||
|
double before = air_mass();
|
||||||
|
BoundarySystem b(p);
|
||||||
|
b.add_closed_end(0, true);
|
||||||
|
b.add_closed_end(1, false);
|
||||||
|
b.add_connection(0, false, 1, true);
|
||||||
|
Solver solver(p, b);
|
||||||
|
for (int i = 0; i < 3000; i++)
|
||||||
|
solver.step(1.f / 44100);
|
||||||
|
near(air_mass(), before, before * 4e-5, "species created at shared face");
|
||||||
|
});
|
||||||
|
test("unsupported unequal junction rejected", [] {
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, .5f, 20);
|
||||||
|
p.emplace_back(.002f, .5f, 20);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
bool caught = false;
|
||||||
|
try {
|
||||||
|
b.add_connection(0, false, 1, true);
|
||||||
|
} catch (const std::invalid_argument &) {
|
||||||
|
caught = true;
|
||||||
|
}
|
||||||
|
check(caught, "unvalidated sudden area junction silently accepted");
|
||||||
|
});
|
||||||
|
test("unconnected endpoint rejected by solver", [] {
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, .5f, 20);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
b.add_closed_end(0, true);
|
||||||
|
Solver solver(p, b);
|
||||||
|
bool caught = false;
|
||||||
|
try {
|
||||||
|
solver.step(1.f / 44100);
|
||||||
|
} catch (const std::runtime_error &) {
|
||||||
|
caught = true;
|
||||||
|
}
|
||||||
|
check(caught, "unconnected face silently used default ghost");
|
||||||
|
});
|
||||||
|
test("closed duct acoustic eigenfrequency", [] {
|
||||||
|
for (int n : {50, 100, 200}) {
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, 1, n);
|
||||||
|
double c = std::sqrt(1.4 * 101325 / 1.2);
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
double dp = 100 * std::cos(3.141592653589793 * (i + .5) / n);
|
||||||
|
set(p[0], i, 101325 + dp, 1.2f + dp / (c * c));
|
||||||
|
}
|
||||||
|
BoundarySystem b(p);
|
||||||
|
b.add_closed_end(0, true);
|
||||||
|
b.add_closed_end(0, false);
|
||||||
|
Solver solver(p, b);
|
||||||
|
solver.minimum_substeps = 1;
|
||||||
|
double prev = 1, first = 0, last = 0;
|
||||||
|
int count = 0;
|
||||||
|
for (int j = 0; j < 1764; j++) {
|
||||||
|
solver.step(1.f / 44100);
|
||||||
|
double projection = 0;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
projection +=
|
||||||
|
(p[0].state(i).pressure - 101325) * std::cos(3.141592653589793 * (i + .5) / n);
|
||||||
|
if (prev > 0 && projection <= 0) {
|
||||||
|
double t = (j + prev / (prev - projection)) / 44100;
|
||||||
|
if (!count)
|
||||||
|
first = t;
|
||||||
|
last = t;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
prev = projection;
|
||||||
|
}
|
||||||
|
double f = (count - 1) / (last - first);
|
||||||
|
std::cout << " closed mode n=" << n << " f=" << f << " analytic=" << c / 2 << '\n';
|
||||||
|
check(count > 4, "insufficient acoustic cycles");
|
||||||
|
near(f, c / 2, c / 2 * .01, "closed duct frequency exceeds 1% error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("inertance impulse and dissipative resistance", [] {
|
||||||
|
Atmosphere v;
|
||||||
|
v.pressure = 102325;
|
||||||
|
v.update_state(0);
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, .1f, 10);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
float area = .0001f;
|
||||||
|
b.add_orifice_with_inertance(v.port, 0, true, area, 1, .05f, 1);
|
||||||
|
for (int j = 0; j < 10000; j++)
|
||||||
|
b.resolve(.0001f);
|
||||||
|
double equilibrium = std::sqrt(2 * v.density() * area * area * 1000);
|
||||||
|
near(-v.port.mass_flow, equilibrium, equilibrium * .001,
|
||||||
|
"inertance resistance steady flow has wrong units or sign");
|
||||||
|
});
|
||||||
|
test("incompatible gas coupling rejected", [] {
|
||||||
|
Volume0D v(.001f, 101325, 300, 300, 1.3f);
|
||||||
|
auto &port = v.create_port();
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
p.emplace_back(.001f, .1f, 10);
|
||||||
|
BoundarySystem b(p);
|
||||||
|
float area = .0001f;
|
||||||
|
bool caught = false;
|
||||||
|
try {
|
||||||
|
b.add_orifice(port, 0, true, area);
|
||||||
|
} catch (const std::invalid_argument &) {
|
||||||
|
caught = true;
|
||||||
|
}
|
||||||
|
check(caught, "different gamma gases silently coupled");
|
||||||
|
});
|
||||||
|
test("thermal relaxation matches analytic energy decay", [] {
|
||||||
|
Volume0D v(.001f, 101325, 600);
|
||||||
|
v.energy_relaxation_rate = 2;
|
||||||
|
double initial = vol_energy(v), target = v.mass() * 287 * 300 / (1.4 - 1);
|
||||||
|
for (int i = 0; i < 1000; i++)
|
||||||
|
v.update(.001f);
|
||||||
|
near(vol_energy(v), target + (initial - target) * std::exp(-2), initial * 3e-5,
|
||||||
|
"thermal relaxation differs from exact exponential");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sealed crankcase compression conserves mass above five bar", [] {
|
||||||
|
Crankshaft crank;
|
||||||
|
crank.angle = 0;
|
||||||
|
Crankcase v(crank, .02725f, .11f, .054f, 1e-5f);
|
||||||
|
double m = v.mass(), invariant = v.pressure() * std::pow(v.volume(), 1.4);
|
||||||
|
for (int i = 1; i <= 1000; i++) {
|
||||||
|
crank.angle = pi * i / 1000;
|
||||||
|
v.pre_step(1e-5f);
|
||||||
|
v.update_state(1e-5f);
|
||||||
|
}
|
||||||
|
near(v.mass(), m, m * 1e-6, "crankcase pressure cap deletes mass");
|
||||||
|
near(v.pressure() * std::pow(v.volume(), 1.4), invariant, invariant * 1e-4,
|
||||||
|
"crankcase compression violates adiabatic invariant");
|
||||||
|
check(v.pressure() > 5e5, "compression did not exercise old hard pressure cap");
|
||||||
|
});
|
||||||
|
std::cout << total - failed << "/" << total << " passed\n";
|
||||||
|
return failed ? 1 : 0;
|
||||||
|
}
|
||||||
51
tests/intake_regression.cpp
Normal file
51
tests/intake_regression.cpp
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#include "FluidSim/Scenario.hpp"
|
||||||
|
#include <cmath>
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
using namespace fluidsim::port;
|
||||||
|
namespace {
|
||||||
|
void check(bool condition, const char *message) {
|
||||||
|
if (!condition)
|
||||||
|
throw std::runtime_error(message);
|
||||||
|
}
|
||||||
|
void run(FourStrokeTwinScenario &s, float seconds, float pedal) {
|
||||||
|
s.throttle = pedal;
|
||||||
|
for (int i = 0; i < int(std::lround(seconds * 44100)); ++i) {
|
||||||
|
const auto audio = s.process();
|
||||||
|
check(std::isfinite(audio.left) && std::isfinite(audio.right), "nonfinite transient audio");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
int main() {
|
||||||
|
FourStrokeTwinScenario s;
|
||||||
|
run(s, 3, 0);
|
||||||
|
run(s, .5f, .8f);
|
||||||
|
run(s, 4.5f, 0);
|
||||||
|
const float idle_map = s.intake_plenum.pressure();
|
||||||
|
check(idle_map > 20000 && idle_map < 80000, "idle MAP did not recover after first blip");
|
||||||
|
run(s, 1, 1);
|
||||||
|
const float open_map = s.intake_plenum.pressure();
|
||||||
|
const float open_temperature = s.intake_plenum.temperature();
|
||||||
|
const auto inlet = s.pipes.front().state(0);
|
||||||
|
const float inlet_temperature = inlet.pressure / (inlet.rho * gas_constant);
|
||||||
|
check(open_map > 75000 && open_map > idle_map + 20000,
|
||||||
|
"opening throttle did not relieve manifold vacuum");
|
||||||
|
check(open_temperature > 250 && open_temperature < 400,
|
||||||
|
"fresh charge heats progressively after repeated blips");
|
||||||
|
check(inlet_temperature > 280 && inlet_temperature < 320,
|
||||||
|
"inlet no longer supplies near-ambient fresh air");
|
||||||
|
check(std::abs(s.intake_atmosphere.port.temperature - 300) < .01f, "reservoir temperature drift");
|
||||||
|
check(std::abs(s.intake_atmosphere.port.air_fraction - 1) < 1e-6, "reservoir admits exhaust composition");
|
||||||
|
run(s, 7, 0);
|
||||||
|
const auto recovered = s.snapshot();
|
||||||
|
check(recovered.rpm > 1000 && recovered.rpm < 2600, "engine did not recover idle after second blip");
|
||||||
|
check(recovered.intake_pressure > 20000 && recovered.intake_pressure < 80000,
|
||||||
|
"manifold remains evacuated after idle recovery");
|
||||||
|
for (const auto &p : s.pipes)
|
||||||
|
check(p.positivity_repairs == 0, "intake transient required positivity repairs");
|
||||||
|
std::cout << "Intake regression passed: idle_MAP=" << idle_map / 1000
|
||||||
|
<< " kPa, open_MAP=" << open_map / 1000 << " kPa, open_T=" << open_temperature
|
||||||
|
<< " K, inlet_T=" << inlet_temperature
|
||||||
|
<< " K, recovered_MAP=" << recovered.intake_pressure / 1000
|
||||||
|
<< " kPa, recovered_RPM=" << recovered.rpm << '\n';
|
||||||
|
}
|
||||||
114
tests/outdoor_audio_tests.cpp
Normal file
114
tests/outdoor_audio_tests.cpp
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#include "FluidSim/OutdoorAudio.hpp"
|
||||||
|
#include "FluidSim/ReedValve.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
using namespace fluidsim::port;
|
||||||
|
namespace {
|
||||||
|
void check(bool c, const char *m) {
|
||||||
|
if (!c)
|
||||||
|
throw std::runtime_error(m);
|
||||||
|
}
|
||||||
|
OutdoorAudioConfig config() {
|
||||||
|
OutdoorAudioConfig c;
|
||||||
|
c.exhaust_position = {0, 1, 0};
|
||||||
|
c.listener = {0, 1, 3};
|
||||||
|
c.ear_spacing = 0;
|
||||||
|
c.wall_x = -2;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
int main() {
|
||||||
|
// Geometric reflections must not arrive before the direct path or before
|
||||||
|
// the ground image's path length, even with the diffuse network enabled.
|
||||||
|
OutdoorAudio dry(44100, config()), wall(44100, config());
|
||||||
|
dry.set_scene(OutdoorScene::Dry);
|
||||||
|
dry.set_monitor_filter(false);
|
||||||
|
wall.set_monitor_filter(false);
|
||||||
|
for (int i = 0; i < 4410; i++) {
|
||||||
|
dry.process(0, 0);
|
||||||
|
wall.process(0, 0);
|
||||||
|
}
|
||||||
|
int first = -1, first_reflection = -1;
|
||||||
|
double tail_energy = 0, late_energy = 0;
|
||||||
|
float peak = 0;
|
||||||
|
for (int i = 0; i < 88200; i++) {
|
||||||
|
const auto d = dry.process(0, .001f), w = wall.process(0, .001f);
|
||||||
|
if (first < 0 && std::abs(d.left) > 1e-9)
|
||||||
|
first = i;
|
||||||
|
if (first_reflection < 0 && std::abs(w.left - d.left) > 1e-9)
|
||||||
|
first_reflection = i;
|
||||||
|
peak = std::max(peak, std::abs(w.left));
|
||||||
|
if (i > 2000 && i < 10000)
|
||||||
|
tail_energy += w.left * w.left + w.right * w.right;
|
||||||
|
if (i > 60000)
|
||||||
|
late_energy += w.left * w.left + w.right * w.right;
|
||||||
|
check(std::isfinite(w.left) && std::isfinite(w.right), "unstable outdoor tail");
|
||||||
|
}
|
||||||
|
check(first == 385, "direct fractional propagation delay is wrong");
|
||||||
|
check(first_reflection >= 463 && first_reflection <= 465, "ground image reflection time is wrong");
|
||||||
|
check(tail_energy > 0 && late_energy < tail_energy * 1e-6, "outdoor tail does not decay");
|
||||||
|
check(peak < 1, "impulse reflection exceeds device range");
|
||||||
|
|
||||||
|
OutdoorAudio filtered(44100, config()), unfiltered(44100, config()), doubled(44100, config());
|
||||||
|
for (auto *p : {&filtered, &unfiltered, &doubled})
|
||||||
|
p->set_scene(OutdoorScene::Dry);
|
||||||
|
unfiltered.set_monitor_filter(false);
|
||||||
|
float prevf = 0, prevu = 0, maxf = 0, maxu = 0;
|
||||||
|
for (int i = 0; i < 1000; i++) {
|
||||||
|
const float q = i == 0 ? .001f : 0;
|
||||||
|
auto f = filtered.process(0, q), u = unfiltered.process(0, q), twice = doubled.process(0, 2 * q);
|
||||||
|
maxf = std::max(maxf, std::abs(f.left - prevf));
|
||||||
|
maxu = std::max(maxu, std::abs(u.left - prevu));
|
||||||
|
prevf = f.left;
|
||||||
|
prevu = u.left;
|
||||||
|
check(std::abs(twice.left - 2 * f.left) < 1e-7, "monitoring path is nonlinear");
|
||||||
|
}
|
||||||
|
check(maxf < .6f * maxu, "monitor bandwidth fails to reduce adjacent-sample impulse steps");
|
||||||
|
|
||||||
|
auto near_config = config(), far_config = config();
|
||||||
|
near_config.listener.z = 1;
|
||||||
|
far_config.listener.z = 2;
|
||||||
|
OutdoorAudio near(44100, near_config), far(44100, far_config);
|
||||||
|
near.set_scene(OutdoorScene::Dry);
|
||||||
|
far.set_scene(OutdoorScene::Dry);
|
||||||
|
near.set_monitor_filter(false);
|
||||||
|
far.set_monitor_filter(false);
|
||||||
|
double sum_near = 0, sum_far = 0;
|
||||||
|
for (int i = 0; i < 1000; i++) {
|
||||||
|
sum_near += near.process(0, .001f).left;
|
||||||
|
sum_far += far.process(0, .001f).left;
|
||||||
|
}
|
||||||
|
check(std::abs(sum_near - 2 * sum_far) < 1e-7, "direct spreading does not follow inverse distance");
|
||||||
|
|
||||||
|
auto stereo_config = config();
|
||||||
|
stereo_config.ear_spacing = .18f;
|
||||||
|
stereo_config.exhaust_position.x = 1;
|
||||||
|
OutdoorAudio stereo(44100, stereo_config);
|
||||||
|
stereo.set_scene(OutdoorScene::Dry);
|
||||||
|
stereo.set_monitor_filter(false);
|
||||||
|
int left = -1, right = -1;
|
||||||
|
for (int i = 0; i < 1000; i++) {
|
||||||
|
auto a = stereo.process(0, .001f);
|
||||||
|
if (left < 0 && std::abs(a.left) > 1e-9)
|
||||||
|
left = i;
|
||||||
|
if (right < 0 && std::abs(a.right) > 1e-9)
|
||||||
|
right = i;
|
||||||
|
}
|
||||||
|
check(right < left, "source on right does not reach right ear first");
|
||||||
|
|
||||||
|
ReedValve reed;
|
||||||
|
for (int i = 0; i < 10000; i++)
|
||||||
|
reed.step(1.f / 44100, 100);
|
||||||
|
const double inertia = reed.width * std::pow(reed.thickness, 3) / 12;
|
||||||
|
const double expected = 100 * reed.width * std::pow(reed.length, 4) / (8 * reed.youngs_modulus * inertia);
|
||||||
|
check(std::abs(reed.lift() - expected) < expected * .001,
|
||||||
|
"reed static compliance differs from cantilever equation");
|
||||||
|
for (int i = 0; i < 1000; i++)
|
||||||
|
reed.step(1.f / 44100, -1000);
|
||||||
|
check(reed.lift() == 0, "reed does not seat under reverse pressure");
|
||||||
|
std::cout << "Outdoor audio tests passed: direct=" << first << " samples, ground=" << first_reflection
|
||||||
|
<< " samples, filtered step ratio=" << maxf / maxu << ", stereo lead=" << left - right
|
||||||
|
<< " samples\n";
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
#include "FluidSim/Scenario.hpp"
|
#include "FluidSim/Scenario.hpp"
|
||||||
|
#include "FluidSim/SimulationRingBuffer.hpp"
|
||||||
|
#include <array>
|
||||||
#include "FluidSim/Atmosphere.hpp"
|
#include "FluidSim/Atmosphere.hpp"
|
||||||
#include "FluidSim/Crankcase.hpp"
|
#include "FluidSim/Crankcase.hpp"
|
||||||
#include "FluidSim/DynoCurve.hpp"
|
#include "FluidSim/DynoCurve.hpp"
|
||||||
@@ -26,6 +28,60 @@ void require(bool condition, const char* message) {
|
|||||||
int main() {
|
int main() {
|
||||||
using namespace fluidsim::port;
|
using namespace fluidsim::port;
|
||||||
|
|
||||||
|
Ecu controller;
|
||||||
|
constexpr float ecu_dt = 1.0f / 44100.0f;
|
||||||
|
controller.step(ecu_dt, 1.0f, 2000.0f);
|
||||||
|
require(controller.throttle_opening() > 0.0f && controller.throttle_opening() <= 2.0f * ecu_dt,
|
||||||
|
"ECU throttle must obey its per-second travel limit");
|
||||||
|
for (int i = 0; i < 44100; ++i) controller.step(ecu_dt, 1.0f, 2000.0f);
|
||||||
|
require(controller.throttle_opening() > 0.99f && controller.target_afr() < 13.0f,
|
||||||
|
"ECU must reach full opening and enrich the high-load mixture");
|
||||||
|
const float before_close = controller.throttle_opening();
|
||||||
|
controller.step(ecu_dt, 0.0f, 2000.0f);
|
||||||
|
require(controller.throttle_opening() >= before_close - 2.01f * ecu_dt,
|
||||||
|
"ECU must also rate-limit throttle closing");
|
||||||
|
controller.step(ecu_dt, 1.0f, 10001.0f);
|
||||||
|
require(controller.fuel_cut() && controller.fuel_for_air(0.001f) == 0.0f,
|
||||||
|
"ECU rev limit must inhibit injection");
|
||||||
|
controller.step(ecu_dt, 1.0f, 9900.0f);
|
||||||
|
require(controller.rev_limited(), "ECU rev limiter must latch through hysteresis");
|
||||||
|
controller.rev_limit_enabled = false;
|
||||||
|
controller.step(ecu_dt, 1.0f, 15000.0f);
|
||||||
|
require(!controller.rev_limited() && !controller.fuel_cut() && controller.fuel_for_air(.001f) > 0,
|
||||||
|
"disabling a latched limiter must restore fuel above the old limit");
|
||||||
|
controller.rev_limit_enabled = true;
|
||||||
|
controller.step(ecu_dt, 1.0f, 9700.0f);
|
||||||
|
require(!controller.fuel_cut() && controller.fuel_for_air(0.001f) > 0.0f,
|
||||||
|
"ECU must resume injection below the rev recovery threshold");
|
||||||
|
controller.step(ecu_dt, 0.0f, 4000.0f);
|
||||||
|
require(controller.fuel_cut(), "closed-pedal overrun must cut injection");
|
||||||
|
controller.step(ecu_dt, 0.0f, 1300.0f);
|
||||||
|
require(!controller.fuel_cut() && controller.idle_active(), "idle catch must restore injection");
|
||||||
|
controller.step(ecu_dt, 0.0f, std::nanf(""));
|
||||||
|
require(controller.sensor_fault() && controller.fuel_cut(), "invalid sensor data must inhibit fuel");
|
||||||
|
|
||||||
|
// Unit-rate playback must preserve every sample across callback boundaries.
|
||||||
|
SimulationRingBuffer audio_ring(8);
|
||||||
|
for (int i = 0; i < 8; ++i) audio_ring.write(float(i), -float(i));
|
||||||
|
std::array<float, 4> left{}, right{};
|
||||||
|
require(audio_ring.read_interpolated(left, right, 1.0) == 4,
|
||||||
|
"unit-rate ring read must fill a block");
|
||||||
|
for (int i = 0; i < 4; ++i)
|
||||||
|
require(left[i] == float(i) && right[i] == -float(i), "ring must preserve stereo samples");
|
||||||
|
require(audio_ring.read_interpolated(left, right, 1.0) == 3,
|
||||||
|
"ring underrun must stop before unavailable interpolation lookahead");
|
||||||
|
for (int i = 8; i < 12; ++i) audio_ring.write(float(i), -float(i));
|
||||||
|
require(audio_ring.read_interpolated(left, right, 1.0) == 4 && left[0] == 7.0f && left[3] == 10.0f,
|
||||||
|
"ring must resume continuously after underrun and wraparound");
|
||||||
|
SoundProcessor linear_source(44100, 1.0f);
|
||||||
|
linear_source.process(0.0f);
|
||||||
|
const float response = linear_source.process(0.01f);
|
||||||
|
SoundProcessor doubled_source(44100, 1.0f);
|
||||||
|
doubled_source.process(0.0f);
|
||||||
|
require(std::abs(doubled_source.process(0.02f) - 2.0f * response) < 1e-5f,
|
||||||
|
"radiation source must remain linear instead of saturating");
|
||||||
|
|
||||||
|
|
||||||
require(std::abs(units::celsius(25.0) - 298.15) < 1e-9 &&
|
require(std::abs(units::celsius(25.0) - 298.15) < 1e-9 &&
|
||||||
std::abs(units::area_from_diameter(0.02) - static_cast<double>(pi) * 1e-4) < 1e-12,
|
std::abs(units::area_from_diameter(0.02) - static_cast<double>(pi) * 1e-4) < 1e-12,
|
||||||
"unit helpers must preserve the C# conversions");
|
"unit helpers must preserve the C# conversions");
|
||||||
@@ -42,6 +98,15 @@ int main() {
|
|||||||
"dyno bins must retain their peak power and torque");
|
"dyno bins must retain their peak power and torque");
|
||||||
dyno_curve.reset();
|
dyno_curve.reset();
|
||||||
require(dyno_curve.bins().empty(), "dyno reset must clear accumulated history");
|
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;
|
ThreadLoadTracker thread_load;
|
||||||
thread_load.record(1.0, 2.0);
|
thread_load.record(1.0, 2.0);
|
||||||
require(std::abs(thread_load.load_percent() - 2.5) < 1e-9 &&
|
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();
|
const float before_volume_work = configurable_volume.pressure();
|
||||||
configurable_volume.update_state(1e-3f);
|
configurable_volume.update_state(1e-3f);
|
||||||
require(configurable_volume.pressure() < before_volume_work &&
|
require(configurable_volume.pressure() < before_volume_work &&
|
||||||
std::abs(configurable_volume.pressure() - before_volume_work * 0.997f) < 1.0f,
|
std::abs(configurable_volume.pressure() - before_volume_work * std::pow(1.0f / 1.01f, 1.3f)) < 1.0f,
|
||||||
"moving-volume work must remove p*dV energy using the configured volume rate");
|
"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}};
|
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 &&
|
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,
|
require(scenario.cylinder1.pressure > 0.0f && scenario.cylinder2.pressure > 0.0f,
|
||||||
"both cylinders must retain positive pressure");
|
"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();
|
scenario.toggle_mechanical_sound();
|
||||||
for (int i = 0; i < 1000; ++i) {
|
for (int i = 0; i < 1000; ++i) {
|
||||||
const AudioFrame frame = scenario.process();
|
const AudioFrame frame = scenario.process();
|
||||||
|
|||||||
115
tests/turbo_physics.cpp
Normal file
115
tests/turbo_physics.cpp
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#include "FluidSim/Turbocharger.hpp"
|
||||||
|
#include "FluidSim/Atmosphere.hpp"
|
||||||
|
#include "FluidSim/Solver.hpp"
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
using namespace fluidsim::port;
|
||||||
|
namespace {
|
||||||
|
void check(bool condition, const char* message) { if (!condition) throw std::runtime_error(message); }
|
||||||
|
double mass(const Turbocharger& t) {
|
||||||
|
return double(t.compressor_inlet.mass())+t.compressor_outlet.mass()+t.turbine_inlet.mass()+t.turbine_outlet.mass();
|
||||||
|
}
|
||||||
|
double energy(const Turbocharger& t) { return t.fluid_energy()+t.shaft_energy()+t.snapshot().bearing_heat; }
|
||||||
|
void closed_balance(double initial_rpm) {
|
||||||
|
Turbocharger t;
|
||||||
|
t.set_initial_rpm(initial_rpm);
|
||||||
|
t.turbine_inlet.set_pressure(250000, 900);
|
||||||
|
const double before = energy(t), initial_mass = mass(t);
|
||||||
|
for (int i = 0; i < 10000; ++i) t.update_state(1e-6f);
|
||||||
|
check(std::abs(mass(t)-initial_mass) < initial_mass*3e-5, "turbo created/lost gas mass");
|
||||||
|
check(std::abs(energy(t)-before) < before*2e-4, "fluid + shaft + bearing heat energy drift");
|
||||||
|
check(t.omega() > 0, "turbine did not start its shaft from pressure energy");
|
||||||
|
const auto s = t.snapshot();
|
||||||
|
check(std::abs(s.energy_residual) < before*2e-4, "turbo energy residual excessive");
|
||||||
|
std::cout << "Closed balance initial_rpm=" << initial_rpm << " mass_error=" << mass(t)-initial_mass
|
||||||
|
<< " energy_error=" << energy(t)-before << " J\n";
|
||||||
|
}
|
||||||
|
double turbine_flow(float downstream) {
|
||||||
|
Turbocharger t;
|
||||||
|
t.turbine_inlet.set_pressure(300000, 900);
|
||||||
|
t.turbine_outlet.set_pressure(downstream, 600);
|
||||||
|
t.update_state(1e-7f);
|
||||||
|
return t.snapshot().turbine_flow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int main() {
|
||||||
|
Turbocharger rest;
|
||||||
|
const double e = energy(rest);
|
||||||
|
for (int i = 0; i < 1000; ++i) rest.update_state(1.f/44100);
|
||||||
|
check(rest.omega() == 0 && energy(rest) == e && rest.snapshot().compressor_pressure_ratio == 1,
|
||||||
|
"stationary equilibrium generated boost/shaft energy");
|
||||||
|
closed_balance(0);
|
||||||
|
closed_balance(120000);
|
||||||
|
check(std::abs(turbine_flow(100000)-turbine_flow(50000)) < 1e-10, "choked turbine flow depends on back pressure");
|
||||||
|
check(turbine_flow(250000) < turbine_flow(100000), "higher backpressure did not reduce subcritical flow");
|
||||||
|
Turbocharger reverse;
|
||||||
|
reverse.compressor_outlet.set_pressure(150000, 350);
|
||||||
|
reverse.turbine_outlet.set_pressure(150000, 600);
|
||||||
|
const auto reverse_energy = energy(reverse);
|
||||||
|
reverse.update_state(1e-6f);
|
||||||
|
check(reverse.snapshot().compressor_flow < 0 && reverse.snapshot().turbine_flow < 0,
|
||||||
|
"turbo acted as an artificial one-way valve");
|
||||||
|
check(std::abs(energy(reverse)-reverse_energy) < reverse_energy*1e-6, "reverse flow creates energy");
|
||||||
|
Turbocharger gate;
|
||||||
|
gate.compressor_outlet.set_pressure(220000, 350);
|
||||||
|
gate.turbine_inlet.set_pressure(250000, 900);
|
||||||
|
gate.update_state(1e-6f);
|
||||||
|
check(gate.snapshot().wastegate_lift > 0 && gate.snapshot().wastegate_flow > 0,
|
||||||
|
"pressure can did not open the wastegate bypass");
|
||||||
|
check(std::abs(gate.snapshot().energy_residual) < 1e-4, "wastegate bypass created energy");
|
||||||
|
Turbocharger coarse;
|
||||||
|
coarse.set_initial_rpm(200000);
|
||||||
|
const double coarse_energy = energy(coarse);
|
||||||
|
coarse.update_state(.5f);
|
||||||
|
check(coarse.omega() >= 0 && std::abs(energy(coarse)-coarse_energy) < coarse_energy*1e-5,
|
||||||
|
"large turbo timestep creates energy or reverses shaft");
|
||||||
|
bool caught = false;
|
||||||
|
try { TurboConfig bad; bad.shaft_inertia = 0; Turbocharger invalid(bad); }
|
||||||
|
catch (const std::invalid_argument&) { caught = true; }
|
||||||
|
check(caught, "zero rotor inertia accepted");
|
||||||
|
|
||||||
|
// Hot pulsing reservoir -> real pipes -> turbine -> exhaust reservoir.
|
||||||
|
// Compressor feeds a pressure reservoir representing a loaded charge duct.
|
||||||
|
std::vector<Pipe1D> pipes;
|
||||||
|
for (int i = 0; i < 4; ++i) pipes.emplace_back(pi*.024f*.024f*.25f, .08f, 8);
|
||||||
|
Atmosphere fresh, charge, hot, exhaust;
|
||||||
|
charge.pressure = 130000;
|
||||||
|
hot.temperature = 900;
|
||||||
|
hot.pressure = 220000;
|
||||||
|
charge.update_state(0); hot.update_state(0);
|
||||||
|
BoundarySystem boundaries(pipes);
|
||||||
|
Turbocharger turbo;
|
||||||
|
turbo.connect(boundaries, pipes, 0, false, 1, true, 2, false, 3, true);
|
||||||
|
std::array<float, 4> areas;
|
||||||
|
const std::array<Atmosphere*, 4> reservoirs{&fresh, &charge, &hot, &exhaust};
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
areas[i] = pipes[i].area;
|
||||||
|
boundaries.add_orifice(reservoirs[i]->port, i, i%2 == 0, areas[i]);
|
||||||
|
}
|
||||||
|
Solver solver(pipes, boundaries);
|
||||||
|
solver.add_component(turbo);
|
||||||
|
float minimum_power = 1e9f, maximum_power = -1e9f;
|
||||||
|
double initial_speed = 0;
|
||||||
|
for (int i = 0; i < 44100; ++i) {
|
||||||
|
hot.pressure = 220000+60000*std::sin(2*pi*80*i/44100);
|
||||||
|
hot.update_state(0);
|
||||||
|
solver.step(1.f/44100);
|
||||||
|
const auto s = turbo.snapshot();
|
||||||
|
if (i == 100) initial_speed = s.rpm;
|
||||||
|
if (i > 40000) {
|
||||||
|
minimum_power = std::min(minimum_power, float(s.turbine_power));
|
||||||
|
maximum_power = std::max(maximum_power, float(s.turbine_power));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const auto result = turbo.snapshot();
|
||||||
|
check(result.rpm > initial_speed+10000, "hot gas did not spool the coupled turbo");
|
||||||
|
check(result.compressor_flow > 0 && result.compressor_power > 0 && result.charge_temperature > 300,
|
||||||
|
"loaded compressor did not deliver heated charge using shaft work");
|
||||||
|
check(maximum_power-minimum_power > 100, "exhaust pulses were averaged away before turbine torque");
|
||||||
|
for (const auto& pipe : pipes) check(pipe.positivity_repairs == 0, "turbo bench required pipe repairs");
|
||||||
|
check(result.limited_transfers == 0, "normal turbo bench depleted a storage node");
|
||||||
|
std::cout << "Turbo pipe bench passed: rpm=" << result.rpm << " compressor flow=" << result.compressor_flow
|
||||||
|
<< " kg/s, charge T=" << result.charge_temperature << " K, turbine power range="
|
||||||
|
<< minimum_power << ".." << maximum_power << " W, residual=" << result.energy_residual << " J\n";
|
||||||
|
}
|
||||||
43
tests/turbo_twin_regression.cpp
Normal file
43
tests/turbo_twin_regression.cpp
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#include "FluidSim/Scenario.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
using namespace fluidsim::port;
|
||||||
|
namespace {
|
||||||
|
void check(bool condition, const char* message) { if (!condition) throw std::runtime_error(message); }
|
||||||
|
}
|
||||||
|
int main() {
|
||||||
|
TurboTwinScenario s;
|
||||||
|
double maximum_speed = 0, maximum_charge = 0, maximum_turbine_pressure = 0;
|
||||||
|
double maximum_gate = 0, peak_audio = 0, coast_speed = 0;
|
||||||
|
for (int sample = 0; sample < 7*44100; ++sample) {
|
||||||
|
// Idle, accelerate/limiter operation, abrupt pedal release, then recover.
|
||||||
|
s.set_controls(sample >= 44100 && sample < 4*44100 ? .7f : 0, 0, 1);
|
||||||
|
const auto audio = s.process();
|
||||||
|
check(std::isfinite(audio.left) && std::isfinite(audio.right), "nonfinite turbo twin audio");
|
||||||
|
peak_audio = std::max({peak_audio, double(std::abs(audio.left)), double(std::abs(audio.right))});
|
||||||
|
const auto t = s.turbo->snapshot();
|
||||||
|
check(t.rpm >= 0 && std::isfinite(t.rpm) && t.charge_temperature > 0 &&
|
||||||
|
t.turbine_inlet_pressure > 0 && t.turbine_outlet_pressure > 0,
|
||||||
|
"turbo twin developed nonphysical turbo state");
|
||||||
|
maximum_speed = std::max(maximum_speed, t.rpm);
|
||||||
|
maximum_charge = std::max(maximum_charge, t.charge_pressure);
|
||||||
|
maximum_turbine_pressure = std::max(maximum_turbine_pressure, t.turbine_inlet_pressure);
|
||||||
|
maximum_gate = std::max(maximum_gate, t.wastegate_lift);
|
||||||
|
if (sample == 5*44100) coast_speed = t.rpm;
|
||||||
|
}
|
||||||
|
const auto final = s.turbo->snapshot();
|
||||||
|
check(maximum_speed > 50000 && maximum_charge > 120000, "turbo did not spool and generate boost");
|
||||||
|
check(maximum_turbine_pressure > maximum_charge, "twin has no turbine backpressure");
|
||||||
|
check(maximum_gate > 0, "wastegate never opened under boost");
|
||||||
|
check(final.rpm < coast_speed, "turbo did not coast down after throttle release");
|
||||||
|
check(final.limited_transfers == 0, "turbo twin depleted its internal volumes");
|
||||||
|
check(std::abs(final.energy_residual) < .1, "turbo twin accumulated excessive energy residual");
|
||||||
|
check(peak_audio < 1, "turbo twin audio exceeds device range");
|
||||||
|
for (const auto& p : s.pipes) check(p.positivity_repairs == 0, "turbo twin required pipe repairs");
|
||||||
|
std::cout << "Turbo twin passed: max_rpm=" << maximum_speed << ", max_charge=" << maximum_charge/1000
|
||||||
|
<< " kPa, max_turbine_pressure=" << maximum_turbine_pressure/1000 << " kPa, gate=" << maximum_gate*1000
|
||||||
|
<< " mm, final_rpm=" << final.rpm << ", peak_audio=" << peak_audio
|
||||||
|
<< ", energy_residual=" << final.energy_residual << " J\n";
|
||||||
|
}
|
||||||
103
tests/two_stroke_physics.cpp
Normal file
103
tests/two_stroke_physics.cpp
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
#include "FluidSim/TwoStrokeScenario.hpp"
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
using namespace fluidsim::port;
|
||||||
|
namespace {
|
||||||
|
void check(bool condition, const char* message) {
|
||||||
|
if (!condition) throw std::runtime_error(message);
|
||||||
|
}
|
||||||
|
float reflected_pulse(bool expanding) {
|
||||||
|
const float inlet = expanding ? .03f : .08f, outlet = expanding ? .08f : .03f;
|
||||||
|
const std::array<PipeSection, 3> sections{{{.1f, inlet, inlet}, {.1f, inlet, outlet}, {.4f, outlet, outlet}}};
|
||||||
|
auto pipe = make_profiled_pipe(sections, .002f);
|
||||||
|
const float rho = 1.2f, pressure = 101325, c = std::sqrt(fluidsim::port::gamma*pressure/rho);
|
||||||
|
float x = 0;
|
||||||
|
for (std::size_t i = 0; i < pipe.cells.size(); ++i) {
|
||||||
|
const float center = x + .5f * pipe.cell_lengths[i];
|
||||||
|
const float dp = 500 * std::exp(-std::pow((center-.04f)/.006f, 2));
|
||||||
|
const float density = rho + dp/(c*c), velocity = dp/(rho*c);
|
||||||
|
pipe.cells[i] = {density, density*velocity,
|
||||||
|
(pressure+dp)/(fluidsim::port::gamma-1) + .5f*density*velocity*velocity, 1};
|
||||||
|
x += pipe.cell_lengths[i];
|
||||||
|
}
|
||||||
|
pipe.left_ghost = pipe.right_ghost = {rho, 0, pressure, 1};
|
||||||
|
float extremum = 0;
|
||||||
|
for (float time = 0; time < .0012f;) {
|
||||||
|
const float dt = pipe.stable_timestep(.5f);
|
||||||
|
pipe.advance(dt);
|
||||||
|
time += dt;
|
||||||
|
if (time > .00025f) {
|
||||||
|
const float dp = pipe.state(29).pressure-pressure;
|
||||||
|
extremum = expanding ? std::min(extremum, dp) : std::max(extremum, dp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(pipe.positivity_repairs == 0, "taper pulse needed positivity repairs");
|
||||||
|
return extremum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int main() {
|
||||||
|
const std::array<PipeSection, 1> cone{{{.4f, .03f, .10f}}};
|
||||||
|
const float expected = pi/12 * .4f * (.03f*.03f + .03f*.10f + .10f*.10f);
|
||||||
|
for (float mesh : {.03f, .01f, .004f}) {
|
||||||
|
const auto pipe = make_profiled_pipe(cone, mesh);
|
||||||
|
double volume = 0;
|
||||||
|
for (std::size_t i = 0; i < pipe.cells.size(); ++i)
|
||||||
|
volume += pipe.cell_areas[i] * pipe.cell_lengths[i];
|
||||||
|
check(std::abs(volume-expected) < expected*2e-6, "frustum mesh changes physical volume");
|
||||||
|
}
|
||||||
|
const float negative = reflected_pulse(true), positive = reflected_pulse(false);
|
||||||
|
check(negative < -5, "diffuser did not return a rarefaction");
|
||||||
|
check(positive > 5, "convergent did not return a compression");
|
||||||
|
|
||||||
|
TwoStrokeScenario s;
|
||||||
|
auto& cylinder = s.cylinder;
|
||||||
|
const float closed = cylinder.compute_volume(cylinder.exhaust_close()*pi/180);
|
||||||
|
check(std::abs(closed/cylinder.clearance_volume()-8.2f) < 1e-4f,
|
||||||
|
"trapped compression was confused with geometric compression");
|
||||||
|
for (bool intake : {false, true}) {
|
||||||
|
const float opens = intake ? cylinder.transfer_open() : cylinder.exhaust_open();
|
||||||
|
const float width = intake ? cylinder.transfer_width : cylinder.exhaust_width;
|
||||||
|
const float area = pi*cylinder.bore*cylinder.bore*.25f;
|
||||||
|
const float expected_area = width * (cylinder.compute_volume(pi)-cylinder.compute_volume(opens*pi/180))/area;
|
||||||
|
check(std::abs(cylinder.valve_area(180, intake)-expected_area) < 1e-8f,
|
||||||
|
"port is not a piston-uncovered window");
|
||||||
|
float previous = 0;
|
||||||
|
for (float angle = opens; angle <= 180; angle += .5f) {
|
||||||
|
const float current = cylinder.valve_area(angle, intake);
|
||||||
|
check(current >= previous, "port does not open monotonically toward BDC");
|
||||||
|
check(std::abs(current-cylinder.valve_area(360-angle, intake)) < 1e-8f,
|
||||||
|
"piston port area is not symmetric");
|
||||||
|
previous = current;
|
||||||
|
}
|
||||||
|
check(cylinder.valve_area(0, intake) == 0 && cylinder.valve_area(360, intake) == 0,
|
||||||
|
"port is open at TDC");
|
||||||
|
}
|
||||||
|
s.ecu.step(1.f/44100, 1, 6000);
|
||||||
|
const float low_spark = s.ecu.spark_advance();
|
||||||
|
s.ecu.step(1.f/44100, 1, 17000);
|
||||||
|
check(s.ecu.spark_advance() < low_spark && !s.ecu.fuel_cut(),
|
||||||
|
"two-stroke high-speed ignition does not retard without cutting fuel");
|
||||||
|
s.vehicle.current_gear = 1;
|
||||||
|
const float first = s.vehicle.total_gear_ratio();
|
||||||
|
s.vehicle.current_gear = 6;
|
||||||
|
check(std::abs(first-30.27f)<.03f && std::abs(s.vehicle.total_gear_ratio()-13.4f)<.03f,
|
||||||
|
"YZ125 primary/final/gear reduction incorrect");
|
||||||
|
|
||||||
|
Crankshaft crank;
|
||||||
|
crank.omega = 100;
|
||||||
|
crank.load_torque = 10;
|
||||||
|
crank.friction_constant = 2;
|
||||||
|
crank.add_torque(12);
|
||||||
|
crank.step(1.f/44100);
|
||||||
|
check(std::abs(crank.omega-100)<1e-6 && std::abs(crank.average_power-1000)<.01f &&
|
||||||
|
std::abs(crank.average_torque-10)<1e-6,
|
||||||
|
"loaded steady-speed dyno lost shaft power");
|
||||||
|
crank.load_torque = 0;
|
||||||
|
crank.friction_constant = 0;
|
||||||
|
crank.friction_quadratic = .001f;
|
||||||
|
crank.step(1.f/44100);
|
||||||
|
check(crank.omega < 100 && crank.average_power < 1000, "windage is not dissipative");
|
||||||
|
std::cout << "Two-stroke physics passed: diffuser reflection=" << negative
|
||||||
|
<< " Pa, baffle reflection=" << positive << " Pa, first reduction=" << first << '\n';
|
||||||
|
}
|
||||||
87
tests/two_stroke_regression.cpp
Normal file
87
tests/two_stroke_regression.cpp
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
#include "FluidSim/TwoStrokeScenario.hpp"
|
||||||
|
#include "FluidSim/DynoCurve.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <iostream>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
using namespace fluidsim::port;
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
TwoStrokeScenario scenario;
|
||||||
|
bool reed_open_seen = false, reed_closed_seen = false, above_old_limit = false;
|
||||||
|
DynoCurve dyno;
|
||||||
|
float peak = 0, maximum_rpm = 0;
|
||||||
|
for (int sample = 0; sample < 14 * 44100; ++sample) {
|
||||||
|
scenario.set_throttle(sample >= 2 * 44100 && sample < 6 * 44100 ? .6f : 0.f);
|
||||||
|
const auto audio = scenario.process();
|
||||||
|
if (!std::isfinite(audio.left) || !std::isfinite(audio.right))
|
||||||
|
throw std::runtime_error("Nonfinite two-stroke audio");
|
||||||
|
peak = std::max({peak, std::abs(audio.left), std::abs(audio.right)});
|
||||||
|
if (sample % 44 != 0)
|
||||||
|
continue;
|
||||||
|
const auto state = scenario.snapshot();
|
||||||
|
if (!std::isfinite(state.rpm) || !(state.cylinder_pressure > 0) ||
|
||||||
|
!(state.crankcase_pressure > 0) || !(state.intake_pressure > 0))
|
||||||
|
throw std::runtime_error("Invalid two-stroke physical state");
|
||||||
|
if (scenario.ecu.rev_limited() || scenario.cylinder.rev_limit_active)
|
||||||
|
throw std::runtime_error("Two-stroke rev limiter is active");
|
||||||
|
if (sample >= 2 * 44100 && sample < 6 * 44100) {
|
||||||
|
if (state.fuel_cut)
|
||||||
|
throw std::runtime_error("Two-stroke cut fuel with the pedal open");
|
||||||
|
above_old_limit |= state.rpm > 10200;
|
||||||
|
}
|
||||||
|
dyno.update(state.rpm, scenario.crankshaft.average_power / 1000,
|
||||||
|
scenario.crankshaft.average_torque);
|
||||||
|
reed_open_seen |= state.reed_open;
|
||||||
|
reed_closed_seen |= !state.reed_open;
|
||||||
|
maximum_rpm = std::max(maximum_rpm, state.rpm);
|
||||||
|
}
|
||||||
|
const auto final = scenario.snapshot();
|
||||||
|
if (!above_old_limit)
|
||||||
|
throw std::runtime_error("Two-stroke did not exceed the old rev limit");
|
||||||
|
if (!reed_open_seen || !reed_closed_seen)
|
||||||
|
throw std::runtime_error("Reed did not open and close");
|
||||||
|
if (final.rpm < 2000 || final.rpm > 4000)
|
||||||
|
throw std::runtime_error("Two-stroke idle did not recover after throttle release");
|
||||||
|
if (peak >= 1)
|
||||||
|
throw std::runtime_error("Two-stroke audio exceeds device range");
|
||||||
|
for (const auto &pipe : scenario.pipes)
|
||||||
|
if (pipe.positivity_repairs != 0)
|
||||||
|
throw std::runtime_error("Two-stroke required pipe positivity repairs");
|
||||||
|
// Launch the same motorcycle used by the twin, then exercise gear controls.
|
||||||
|
scenario.shift_up();
|
||||||
|
scenario.set_controls(.7f, 0, 0);
|
||||||
|
for (int sample = 0; sample < 6 * 44100; ++sample) {
|
||||||
|
const auto audio = scenario.process();
|
||||||
|
if (!std::isfinite(audio.left) || !std::isfinite(audio.right))
|
||||||
|
throw std::runtime_error("Nonfinite motorcycle launch audio");
|
||||||
|
if (scenario.ecu.rev_limited())
|
||||||
|
throw std::runtime_error("Limiter re-enabled under motorcycle load");
|
||||||
|
}
|
||||||
|
if (scenario.vehicle.current_gear != 1 || scenario.vehicle.speed < 1 ||
|
||||||
|
!std::isfinite(scenario.vehicle.kinetic_energy()) || scenario.vehicle.engagement < .9f)
|
||||||
|
throw std::runtime_error("Two-stroke motorcycle did not launch with clutch engaged");
|
||||||
|
scenario.shift_up();
|
||||||
|
if (scenario.vehicle.current_gear != 2)
|
||||||
|
throw std::runtime_error("Two-stroke did not shift up");
|
||||||
|
scenario.shift_down();
|
||||||
|
if (scenario.vehicle.current_gear != 1)
|
||||||
|
throw std::runtime_error("Two-stroke did not shift down");
|
||||||
|
scenario.set_neutral();
|
||||||
|
if (scenario.vehicle.current_gear != 0)
|
||||||
|
throw std::runtime_error("Two-stroke neutral selection failed");
|
||||||
|
bool positive_power = false, positive_torque = false;
|
||||||
|
for (const auto& point : dyno.bins()) {
|
||||||
|
positive_power |= point.power_kw > 0;
|
||||||
|
positive_torque |= point.torque_nm > 0;
|
||||||
|
}
|
||||||
|
if (!positive_power || !positive_torque)
|
||||||
|
throw std::runtime_error("Two-stroke dyno did not record power and torque");
|
||||||
|
for (const auto& pipe : scenario.pipes)
|
||||||
|
if (pipe.positivity_repairs != 0)
|
||||||
|
throw std::runtime_error("Motorcycle launch required pipe positivity repairs");
|
||||||
|
std::cout << "Two-stroke regression passed: peak=" << peak << ", maximum RPM=" << maximum_rpm
|
||||||
|
<< ", recovered RPM=" << final.rpm << ", bike speed=" << scenario.vehicle.speed * 3.6f
|
||||||
|
<< " km/h\n";
|
||||||
|
}
|
||||||
161
tools/audio_capture.cpp
Normal file
161
tools/audio_capture.cpp
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
#include "FluidSim/Scenario.hpp"
|
||||||
|
#include "FluidSim/TwoStrokeScenario.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <type_traits>
|
||||||
|
using namespace fluidsim::port;
|
||||||
|
namespace {
|
||||||
|
class Wav {
|
||||||
|
std::ofstream out_;
|
||||||
|
void integer(std::uint32_t n, int bytes) {
|
||||||
|
for (int i = 0; i < bytes; i++)
|
||||||
|
out_.put(char(n >> (8 * i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
Wav(const std::string &path, int frames) : out_(path, std::ios::binary) {
|
||||||
|
if (!out_)
|
||||||
|
throw std::runtime_error("cannot open WAV: " + path);
|
||||||
|
out_.write("RIFF", 4);
|
||||||
|
integer(36 + frames * 4, 4);
|
||||||
|
out_.write("WAVEfmt ", 8);
|
||||||
|
integer(16, 4);
|
||||||
|
integer(1, 2);
|
||||||
|
integer(2, 2);
|
||||||
|
integer(44100, 4);
|
||||||
|
integer(44100 * 4, 4);
|
||||||
|
integer(4, 2);
|
||||||
|
integer(16, 2);
|
||||||
|
out_.write("data", 4);
|
||||||
|
integer(frames * 4, 4);
|
||||||
|
}
|
||||||
|
void sample(AudioFrame f) {
|
||||||
|
for (float x : {f.left, f.right}) {
|
||||||
|
const auto v = static_cast<std::int16_t>(std::lround(std::clamp(x, -1.f, 1.f) * 32767));
|
||||||
|
integer(static_cast<std::uint16_t>(v), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
struct Metrics {
|
||||||
|
double sum{}, peak{}, delta{};
|
||||||
|
std::uint64_t samples{}, clips{};
|
||||||
|
AudioFrame previous{};
|
||||||
|
void add(AudioFrame f) {
|
||||||
|
for (float x : {f.left, f.right}) {
|
||||||
|
if (!std::isfinite(x))
|
||||||
|
throw std::runtime_error("nonfinite capture");
|
||||||
|
sum += x * x;
|
||||||
|
peak = std::max(peak, double(std::abs(x)));
|
||||||
|
clips += std::abs(x) > 1;
|
||||||
|
++samples;
|
||||||
|
}
|
||||||
|
delta = std::max(
|
||||||
|
{delta, double(std::abs(f.left - previous.left)), double(std::abs(f.right - previous.right))});
|
||||||
|
previous = f;
|
||||||
|
}
|
||||||
|
void print(const char *name) const {
|
||||||
|
std::cout << name << " peak=" << peak << " rms=" << std::sqrt(sum / samples)
|
||||||
|
<< " max_sample_delta=" << delta << " clipping_samples=" << clips << '\n';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
template <class Scenario>
|
||||||
|
void capture(const std::string &prefix, int frames, float pedal, OutdoorScene scene, TwoStrokeConfig config) {
|
||||||
|
std::unique_ptr<Scenario> s;
|
||||||
|
if constexpr (std::is_same_v<Scenario, TwoStrokeScenario>) s = std::make_unique<Scenario>(44100, config);
|
||||||
|
else s = std::make_unique<Scenario>();
|
||||||
|
s->throttle = pedal;
|
||||||
|
s->set_audio_scene(scene);
|
||||||
|
Wav rendered(prefix + "-rendered.wav", frames), dry(prefix + "-dry.wav", frames),
|
||||||
|
raw(prefix + "-unfiltered.wav", frames);
|
||||||
|
Metrics m, d, r;
|
||||||
|
int cuts = 0;
|
||||||
|
for (int i = 0; i < frames; i++) {
|
||||||
|
auto f = s->process(), df = s->dry_audio(), rf = s->unfiltered_audio();
|
||||||
|
m.add(f);
|
||||||
|
d.add(df);
|
||||||
|
r.add(rf);
|
||||||
|
rendered.sample(f);
|
||||||
|
dry.sample(df);
|
||||||
|
raw.sample(rf);
|
||||||
|
if (i % 4410 == 0)
|
||||||
|
cuts += s->snapshot().fuel_cut;
|
||||||
|
}
|
||||||
|
m.print("rendered");
|
||||||
|
d.print("dry filtered");
|
||||||
|
r.print("unfiltered spatial");
|
||||||
|
std::cout << "fuel-cut checkpoints=" << cuts << " final_rpm=" << s->snapshot().rpm
|
||||||
|
<< " scene=" << scene_name(scene) << '\n';
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
try {
|
||||||
|
std::string scenario = "twin", prefix = "build/audio/twin", scene = "wall";
|
||||||
|
float seconds = 6, pedal = 1;
|
||||||
|
TwoStrokeConfig config;
|
||||||
|
bool turbo_enabled = false;
|
||||||
|
for (int i = 1; i < argc; i++) {
|
||||||
|
std::string key = argv[i];
|
||||||
|
if (i + 1 >= argc)
|
||||||
|
throw std::runtime_error("missing option value");
|
||||||
|
std::string value = argv[++i];
|
||||||
|
if (key == "--scenario")
|
||||||
|
scenario = value;
|
||||||
|
else if (key == "--out")
|
||||||
|
prefix = value;
|
||||||
|
else if (key == "--seconds")
|
||||||
|
seconds = std::stof(value);
|
||||||
|
else if (key == "--throttle")
|
||||||
|
pedal = std::stof(value);
|
||||||
|
else if (key == "--turbo") {
|
||||||
|
if (value != "none" && value != "vz21") throw std::runtime_error("choose none or vz21 turbo");
|
||||||
|
turbo_enabled = value == "vz21";
|
||||||
|
}
|
||||||
|
else if (key == "--exhaust") {
|
||||||
|
if (value == "racing") config.exhaust = TwoStrokeExhaust::Racing;
|
||||||
|
else if (value == "straight") config.exhaust = TwoStrokeExhaust::Straight;
|
||||||
|
else throw std::runtime_error("choose racing or straight exhaust");
|
||||||
|
}
|
||||||
|
else if (key == "--scene")
|
||||||
|
scene = value;
|
||||||
|
else
|
||||||
|
throw std::runtime_error("unknown capture option");
|
||||||
|
}
|
||||||
|
if (!std::isfinite(seconds) || seconds <= 0 || seconds > 600 || !std::isfinite(pedal) || pedal < 0 ||
|
||||||
|
pedal > 1)
|
||||||
|
throw std::runtime_error("invalid capture duration or throttle");
|
||||||
|
OutdoorScene selected;
|
||||||
|
if (scene == "dry")
|
||||||
|
selected = OutdoorScene::Dry;
|
||||||
|
else if (scene == "field")
|
||||||
|
selected = OutdoorScene::Field;
|
||||||
|
else if (scene == "wall")
|
||||||
|
selected = OutdoorScene::Wall;
|
||||||
|
else if (scene == "woodland")
|
||||||
|
selected = OutdoorScene::Woodland;
|
||||||
|
else
|
||||||
|
throw std::runtime_error("invalid scene");
|
||||||
|
if (!std::filesystem::path(prefix).parent_path().empty())
|
||||||
|
std::filesystem::create_directories(std::filesystem::path(prefix).parent_path());
|
||||||
|
const int frames = int(std::lround(seconds * 44100));
|
||||||
|
if (turbo_enabled && scenario != "twin") throw std::runtime_error("turbo test uses the twin scenario");
|
||||||
|
if (scenario == "twin" && turbo_enabled)
|
||||||
|
capture<TurboTwinScenario>(prefix, frames, pedal, selected, config);
|
||||||
|
else if (scenario == "twin")
|
||||||
|
capture<FourStrokeTwinScenario>(prefix, frames, pedal, selected, config);
|
||||||
|
else if (scenario == "b205")
|
||||||
|
capture<SaabB205Scenario>(prefix, frames, pedal, selected, config);
|
||||||
|
else if (scenario == "two-stroke")
|
||||||
|
capture<TwoStrokeScenario>(prefix, frames, pedal, selected, config);
|
||||||
|
else
|
||||||
|
throw std::runtime_error("choose twin, two-stroke or b205");
|
||||||
|
} catch (const std::exception &e) {
|
||||||
|
std::cerr << e.what() << '\n';
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
tools/physics_benchmark.cpp
Normal file
62
tools/physics_benchmark.cpp
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#include "FluidSim/PulseTestScenario.hpp"
|
||||||
|
#include "FluidSim/Scenario.hpp"
|
||||||
|
#include <chrono>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
using namespace fluidsim::port;
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
// Track the hot-engine cost over time; a one-second benchmark misses it.
|
||||||
|
if (argc > 1 && std::string(argv[1]) == "--soak") {
|
||||||
|
const int seconds = argc > 2 ? std::stoi(argv[2]) : 12;
|
||||||
|
for (float pedal : {0.0f, 1.0f}) {
|
||||||
|
FourStrokeTwinScenario scenario;
|
||||||
|
scenario.throttle = pedal;
|
||||||
|
for (int second = 0; second < seconds; ++second) {
|
||||||
|
const auto before = scenario.snapshot();
|
||||||
|
const auto start = std::chrono::steady_clock::now();
|
||||||
|
for (int sample = 0; sample < 44100; ++sample)
|
||||||
|
scenario.process();
|
||||||
|
const double wall =
|
||||||
|
std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
|
||||||
|
const auto after = scenario.snapshot();
|
||||||
|
std::cout << "pedal=" << pedal << " second=" << second + 1 << " wall_seconds=" << wall
|
||||||
|
<< " rpm=" << after.rpm
|
||||||
|
<< " substeps/frame=" << (after.solver_substeps - before.solver_substeps) / 44100.0
|
||||||
|
<< " boundary_iterations/frame="
|
||||||
|
<< (after.boundary_iterations - before.boundary_iterations) / 44100.0 << std::endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int samples = argc > 1 ? std::stoi(argv[1]) : 44100;
|
||||||
|
for (int n : {64, 256, 1024}) {
|
||||||
|
Pipe1D p(.001f, 2.f, n);
|
||||||
|
p.damping_multiplier = 0;
|
||||||
|
p.energy_relaxation_rate = 0;
|
||||||
|
double sum = 0;
|
||||||
|
auto t = std::chrono::steady_clock::now();
|
||||||
|
for (int i = 0; i < samples; i++) {
|
||||||
|
auto l = p.state(0), r = p.state(n - 1);
|
||||||
|
l.velocity = -l.velocity;
|
||||||
|
r.velocity = -r.velocity;
|
||||||
|
p.left_ghost = l;
|
||||||
|
p.right_ghost = r;
|
||||||
|
p.advance(0.0000005f);
|
||||||
|
sum += p.state(n / 2).pressure;
|
||||||
|
}
|
||||||
|
double sec = std::chrono::duration<double>(std::chrono::steady_clock::now() - t).count();
|
||||||
|
std::cout << "pipe cells=" << n << " steps=" << samples << " seconds=" << sec
|
||||||
|
<< " Mcells/s=" << n * double(samples) / sec / 1e6 << " checksum=" << sum << '\n';
|
||||||
|
}
|
||||||
|
FourStrokeTwinScenario s;
|
||||||
|
s.throttle = .35f;
|
||||||
|
double sum = 0;
|
||||||
|
auto t = std::chrono::steady_clock::now();
|
||||||
|
for (int i = 0; i < samples; i++) {
|
||||||
|
auto a = s.process();
|
||||||
|
sum += a.left;
|
||||||
|
}
|
||||||
|
double sec = std::chrono::duration<double>(std::chrono::steady_clock::now() - t).count();
|
||||||
|
std::cout << "twin seconds=" << sec << " realtime_factor=" << samples / 44100.0 / sec
|
||||||
|
<< " checksum=" << sum << '\n';
|
||||||
|
}
|
||||||
54
tools/thread_benchmark.cpp
Normal file
54
tools/thread_benchmark.cpp
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#include "FluidSim/Fluid.hpp"
|
||||||
|
#include <barrier>
|
||||||
|
#include <chrono>
|
||||||
|
#include <iostream>
|
||||||
|
#include <thread>
|
||||||
|
using namespace fluidsim::port;
|
||||||
|
int main() {
|
||||||
|
for (auto size : {std::pair{6, 10}, std::pair{64, 256}}) {
|
||||||
|
const int steps = 1000;
|
||||||
|
for (int workers : {1, 2, 4}) {
|
||||||
|
std::vector<Pipe1D> p;
|
||||||
|
for (int i = 0; i < size.first; i++)
|
||||||
|
p.emplace_back(.001f, 2, size.second);
|
||||||
|
auto advance = [&](int worker) {
|
||||||
|
for (int k = worker; k < size.first; k += workers) {
|
||||||
|
auto &a = p[k];
|
||||||
|
auto l = a.state(0), r = a.state(size.second - 1);
|
||||||
|
l.velocity = -l.velocity;
|
||||||
|
r.velocity = -r.velocity;
|
||||||
|
a.left_ghost = l;
|
||||||
|
a.right_ghost = r;
|
||||||
|
a.advance(.0000005f);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
std::barrier sync(workers + 1);
|
||||||
|
std::vector<std::jthread> pool;
|
||||||
|
if (workers > 1)
|
||||||
|
for (int k = 0; k < workers; k++)
|
||||||
|
pool.emplace_back([&, k] {
|
||||||
|
for (int i = 0; i < steps; i++) {
|
||||||
|
sync.arrive_and_wait();
|
||||||
|
advance(k);
|
||||||
|
sync.arrive_and_wait();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
auto start = std::chrono::steady_clock::now();
|
||||||
|
for (int i = 0; i < steps; i++) {
|
||||||
|
if (workers == 1)
|
||||||
|
advance(0);
|
||||||
|
else {
|
||||||
|
sync.arrive_and_wait();
|
||||||
|
sync.arrive_and_wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
double sec = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
|
||||||
|
pool.clear();
|
||||||
|
double sum = 0;
|
||||||
|
for (auto &q : p)
|
||||||
|
sum += q.state(0).pressure;
|
||||||
|
std::cout << "pipes=" << size.first << " cells/pipe=" << size.second << " workers=" << workers
|
||||||
|
<< " steps=" << steps << " seconds=" << sec << " checksum=" << sum << '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
tools/turbo_benchmark.cpp
Normal file
48
tools/turbo_benchmark.cpp
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#include "FluidSim/Scenario.hpp"
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <iostream>
|
||||||
|
#include <ctime>
|
||||||
|
using namespace fluidsim::port;
|
||||||
|
int main() {
|
||||||
|
std::cout << "engine_rpm,turbo,MAP_kPa,exhaust_kPa,shaft_kW,turbo_rpm,charge_kPa,charge_K,turbine_power_ripple_W,gate_mm,limited,energy_residual_J,substeps_per_frame,iterations_per_frame,wall_s,cpu_s\n";
|
||||||
|
for (float rpm : {4000.f, 7000.f}) {
|
||||||
|
for (bool enabled : {false, true}) {
|
||||||
|
FourStrokeScenario s(44100, FourStrokePreset::Twin, enabled);
|
||||||
|
s.throttle = .7f;
|
||||||
|
double map = 0, exhaust = 0, power = 0, turbo_rpm = 0, charge_p = 0, charge_t = 0, gate = 0;
|
||||||
|
double min_power = 1e30, max_power = -1e30;
|
||||||
|
const auto start = std::chrono::steady_clock::now();
|
||||||
|
const auto cpu_start = std::clock();
|
||||||
|
for (int i = 0; i < 2*44100; ++i) {
|
||||||
|
// Ideal speed-holding dyno. The turbo remains completely free.
|
||||||
|
s.crankshaft.omega = rpm*2*pi/60;
|
||||||
|
s.process();
|
||||||
|
if (i >= 3*44100/2) {
|
||||||
|
constexpr double samples = 22050;
|
||||||
|
map += s.intake_plenum.pressure()/samples/1000;
|
||||||
|
exhaust += s.exhaust_collector.pressure()/samples/1000;
|
||||||
|
power += s.crankshaft.average_power/samples/1000;
|
||||||
|
if (s.turbo) {
|
||||||
|
const auto t = s.turbo->snapshot();
|
||||||
|
turbo_rpm += t.rpm/samples;
|
||||||
|
charge_p += t.charge_pressure/samples/1000;
|
||||||
|
charge_t += t.charge_temperature/samples;
|
||||||
|
gate += t.wastegate_lift/samples*1000;
|
||||||
|
min_power = std::min(min_power, t.turbine_power);
|
||||||
|
max_power = std::max(max_power, t.turbine_power);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const auto t = s.turbo ? s.turbo->snapshot() : TurboSnapshot{};
|
||||||
|
const auto state = s.snapshot();
|
||||||
|
const double cpu = double(std::clock()-cpu_start)/CLOCKS_PER_SEC;
|
||||||
|
const auto wall = std::chrono::duration<double>(std::chrono::steady_clock::now()-start).count();
|
||||||
|
std::cout << rpm << ',' << enabled << ',' << map << ',' << exhaust << ',' << power << ','
|
||||||
|
<< turbo_rpm << ',' << charge_p << ',' << charge_t << ','
|
||||||
|
<< (enabled ? max_power-min_power : 0) << ',' << gate << ',' << t.limited_transfers
|
||||||
|
<< ',' << t.energy_residual << ',' << state.solver_substeps/88200.0 << ','
|
||||||
|
<< state.boundary_iterations/88200.0 << ',' << wall << ',' << cpu << std::endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
tools/two_stroke_benchmark.cpp
Normal file
37
tools/two_stroke_benchmark.cpp
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#include "FluidSim/TwoStrokeScenario.hpp"
|
||||||
|
#include <chrono>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
using namespace fluidsim::port;
|
||||||
|
int main() {
|
||||||
|
std::cout << "exhaust,rpm,shaft_kW,shaft_Nm,firings_per_revolution,repairs,wall_seconds\n";
|
||||||
|
for (auto exhaust : {TwoStrokeExhaust::Straight, TwoStrokeExhaust::Racing}) {
|
||||||
|
for (float rpm : {6000.f, 8000.f, 10000.f, 12000.f, 14000.f, 17000.f}) {
|
||||||
|
TwoStrokeConfig config;
|
||||||
|
config.exhaust = exhaust;
|
||||||
|
TwoStrokeScenario s(44100, config);
|
||||||
|
s.set_throttle(1);
|
||||||
|
const auto start = std::chrono::steady_clock::now();
|
||||||
|
double torque = 0, power = 0;
|
||||||
|
std::uint64_t initial_firings = 0;
|
||||||
|
// An ideal absorbing/motoring dyno imposes speed, exchanging shaft
|
||||||
|
// work externally. It never changes gas energy or adds combustion.
|
||||||
|
for (int i = 0; i < 44100; ++i) {
|
||||||
|
s.crankshaft.omega = rpm * 2 * pi / 60;
|
||||||
|
s.process();
|
||||||
|
if (i == 22049) initial_firings = s.cylinder.combustion_count();
|
||||||
|
if (i >= 22050) {
|
||||||
|
torque += s.crankshaft.average_torque / 22050;
|
||||||
|
power += s.crankshaft.average_power / (22050 * 1000.f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unsigned long long repairs = 0;
|
||||||
|
for (const auto& pipe : s.pipes) repairs += pipe.positivity_repairs;
|
||||||
|
const double wall = std::chrono::duration<double>(std::chrono::steady_clock::now()-start).count();
|
||||||
|
std::cout << (exhaust == TwoStrokeExhaust::Racing ? "racing" : "straight") << ',' << rpm
|
||||||
|
<< ',' << power << ',' << torque << ','
|
||||||
|
<< (s.cylinder.combustion_count()-initial_firings) / (rpm / 120)
|
||||||
|
<< ',' << repairs << ',' << wall << std::endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
verification/AUDIO_RENDERING.md
Normal file
97
verification/AUDIO_RENDERING.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Static outdoor audio and two-stroke verification
|
||||||
|
|
||||||
|
Verified on Linux in Release mode, 2026-10-23. The target remains fluid-derived
|
||||||
|
sound at 44.1 kHz. No synthetic bang, saturation, or combustion event is added
|
||||||
|
by the audio renderer. Acoustic processing does not feed energy into the solver.
|
||||||
|
|
||||||
|
## Renderer
|
||||||
|
|
||||||
|
`OutdoorAudio` replaces the active twin and two-stroke legacy reverb paths.
|
||||||
|
Each intake/exhaust mass-flow derivative drives a compact monopole. Separate
|
||||||
|
left/right paths use inverse distance and fractional-sample delays at 343 m/s.
|
||||||
|
The listener and sources are static. Default listener position is (1.6, 1, 2) m;
|
||||||
|
intake is (-0.2, .7, 0), exhaust (.55, .5, 0), ground y=0, wall x=-1.5.
|
||||||
|
Ground and wall reflection coefficients are .35 and .65. These are configurable
|
||||||
|
assumptions rather than measurements of a particular location.
|
||||||
|
|
||||||
|
A second-order 6 kHz monitoring low-pass reduces sharp acquisition transients;
|
||||||
|
it is linear and does not create afterfire. Early ground/wall reflections use
|
||||||
|
image paths. Woodland uses six approximate scatterers. A stable four-line
|
||||||
|
feedback-delay network supplies an empirical diffuse tail; it does not solve
|
||||||
|
outdoor wave propagation or vegetation scattering. Scene changes smooth over
|
||||||
|
20 ms. The old `OutdoorExhaustReverb` remains for legacy tests but is inactive
|
||||||
|
in both engine scenarios.
|
||||||
|
|
||||||
|
Pipe-end radiation impedance, calibrated directivity, alias-free acquisition,
|
||||||
|
and exhaust afterfire chemistry remain unresolved. PCM output is not calibrated
|
||||||
|
speaker SPL. The monitoring filter cannot recover information lost to aliasing.
|
||||||
|
|
||||||
|
## Measured captures
|
||||||
|
|
||||||
|
Four seconds at full pedal, wall environment, 44.1 kHz stereo. Each row's raw
|
||||||
|
and rendered signals come from the same simulation run. Measurements are
|
||||||
|
before PCM clipping; the raw signal includes direct spatial propagation.
|
||||||
|
|
||||||
|
| Engine | Raw maximum adjacent-sample change | Rendered change | Rendered peak | Clipping samples |
|
||||||
|
| --- | ---: | ---: | ---: | ---: |
|
||||||
|
| Twin | .569144 | .0881295 | .119947 | 0 |
|
||||||
|
| Two-stroke | .412956 | .138741 | .390789 | 0 |
|
||||||
|
|
||||||
|
These measurements establish transient reduction, not a subjective judgment
|
||||||
|
that limiter events sound like realistic bangs. Listening on the target audio
|
||||||
|
device is still required. Reproduce from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build-audit --target fluidsim_audio_capture -j 4
|
||||||
|
./build-audit/fluidsim_audio_capture --scenario twin --out build/audio/twin-wall --seconds 4 --throttle 1 --scene wall
|
||||||
|
./build-audit/fluidsim_audio_capture --scenario two-stroke --out build/audio/two-stroke-wall --seconds 4 --throttle 1 --scene wall
|
||||||
|
```
|
||||||
|
|
||||||
|
Each produces `-rendered.wav`, `-dry.wav` (filtered direct paths) and
|
||||||
|
`-unfiltered.wav` for comparison. Capture files are build artifacts.
|
||||||
|
|
||||||
|
## Two-stroke and regression checks
|
||||||
|
|
||||||
|
The existing 125 cc geometry now uses the shared ECU and finite-response
|
||||||
|
throttle, fuel dosing after the last scavenging port closes, 3000 RPM idle
|
||||||
|
control with ignition trim, and a dynamic damped cantilever reed with contacts.
|
||||||
|
Cylinder/crankcase geometry work uses adiabatic energy differences. An active
|
||||||
|
charge finishes combustion when a fuel cut begins. Ignition event crossing
|
||||||
|
handles wraparound without spuriously firing retarded ignition at zero degrees.
|
||||||
|
Reed parameters, combustion, scavenging and controller calibrations have not
|
||||||
|
been validated against a measured engine.
|
||||||
|
|
||||||
|
Both desktop engines share `MotorcycleTest` for clutch/vehicle integration,
|
||||||
|
common control dispatch and a common dashboard/dyno renderer. The two-stroke
|
||||||
|
limiter is disabled explicitly in the ECU and cylinder; the twin limiter is
|
||||||
|
unchanged. Idle and overrun controls remain enabled. The motorcycle and gearing
|
||||||
|
reuse the existing twin test configuration, not a measured 125 cc motorcycle.
|
||||||
|
The rolling peak dyno reports crank power and torque versus RPM.
|
||||||
|
|
||||||
|
All five CTest suites passed: basic physics/control, fluid boundary validation,
|
||||||
|
intake regression, outdoor audio, and two-stroke regression. Audio tests cover
|
||||||
|
fractional direct arrival, image reflection arrival, stereo ordering, distance
|
||||||
|
gain, linearity, filtering, causal decaying tails, and reed static compliance
|
||||||
|
and seating. The two-stroke sequence idles for two seconds, holds .6 pedal for
|
||||||
|
four, then releases for eight. It checks fuel stays enabled at open pedal above
|
||||||
|
the old limit, reed opening/seating, positive states, finite audio and idle
|
||||||
|
recovery, with no pipe positivity repairs. It then launches the motorcycle for
|
||||||
|
six seconds and checks shifting, neutral and positive dyno power/torque.
|
||||||
|
|
||||||
|
Latest Release results: free-rev maximum 22471.3 RPM, recovered idle 3236.08 RPM,
|
||||||
|
free-rev audio peak .512784, motorcycle speed 51.3773 km/h after launch. The high
|
||||||
|
unloaded RPM exposes the uncalibrated high-speed friction/combustion behavior;
|
||||||
|
removing the limiter does not validate this as realistic engine performance.
|
||||||
|
Unit checks also verify disabling an already latched limiter restores fueling.
|
||||||
|
The earlier four-second capture table above predates limiter removal and is
|
||||||
|
retained as an audio-renderer measurement, not a current engine baseline.
|
||||||
|
Windows execution of the latest changes is pending.
|
||||||
|
|
||||||
|
## Reference basis
|
||||||
|
|
||||||
|
- [Image source geometry](https://www.acs.psu.edu/noahparker/acs/img-src/img-src.html)
|
||||||
|
- [Feedback delay networks](https://msp.ucsd.edu/techniques/latest/book-html/node111.html)
|
||||||
|
- [Beam theory](https://mechanics.ju.se/SolidMechanics/BeamTheory.html), including uniform-load cantilever deflection used for reed compliance.
|
||||||
|
|
||||||
|
The later YZ125 baseline changes these engine calibrations and measurements; see
|
||||||
|
[YZ125 baseline verification](YZ125_BASELINE.md) for current results.
|
||||||
56
verification/B205_BASELINE.md
Normal file
56
verification/B205_BASELINE.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Saab B205 / 9-5 wagon baseline
|
||||||
|
|
||||||
|
Linux Release, 2026-10-23. This quick preset reuses the four-stroke scenario,
|
||||||
|
ECU, compressible pipes/volumes, vehicle coupling, spatial audio and dyno.
|
||||||
|
The original twin remains available through its backward-compatible type alias.
|
||||||
|
There are four actual cylinders and ten pipes, not copies of an audio waveform.
|
||||||
|
|
||||||
|
## References and assumptions
|
||||||
|
|
||||||
|
[Saab WIS B205 general data](https://saabwisonline.com/9-3-9400/2001/2-engine/basic-engine-4-cylinder-petrol/technical-data/general-data)
|
||||||
|
supplies 90 mm bore, 78 mm stroke, approximately 1985 cc, 8.8:1 compression,
|
||||||
|
four valves per cylinder and 1-3-4-2 firing order. Cylinder phases in physical
|
||||||
|
1/2/3/4 order are 0/180/540/360 degrees; pistons 1/4 move together and 2/3 move
|
||||||
|
together. Phased cylinders initialize at their actual piston positions.
|
||||||
|
|
||||||
|
[Saab WIS 9-5 gearbox data](https://saabwisonline.com/9-5-9600/2005/4-transmission/manual-gearbox/technical-data/gear-ratio)
|
||||||
|
supplies FM55 B05 overall reductions 13.70, 7.12, 4.77, 3.62 and 2.67 with
|
||||||
|
4.05 final reduction. The vehicle configuration factors out final reduction
|
||||||
|
and has five selectable forward gears, no motorcycle primary reduction.
|
||||||
|
|
||||||
|
Other settings are estimates: 1600 kg wagon + 75 kg driver, .317 m loaded tire
|
||||||
|
radius, Cd .32, frontal area 2.2 square metres, wheel-equivalent rotational
|
||||||
|
inertia 3.6 kg m^2, 300 Nm clutch capacity, crank inertia .18 kg m^2, and
|
||||||
|
12 Nm constant + .012 Nm/(rad/s) viscous friction. Road rolling resistance
|
||||||
|
retains the shared .015 coefficient. No attempt is made to identify a particular
|
||||||
|
year/trim with these estimates.
|
||||||
|
|
||||||
|
The intake uses a 3 litre plenum, estimated 52 mm throttle, four 32 mm x 250 mm
|
||||||
|
runners, 34 mm x 280 mm headers, .6 litre collector, and a 60 mm x 1.4 m open
|
||||||
|
tailpipe. Estimated valves are 32/28 mm intake/exhaust, 8.5/8 mm lift, two each.
|
||||||
|
Cams/combustion retain the shared four-stroke model. The ideal ECU targets
|
||||||
|
850 RPM idle, with estimated 6500/6250 RPM cut/resume and 2000/1300 overrun
|
||||||
|
thresholds. These are scenario calibrations, not a factory Trionic map.
|
||||||
|
|
||||||
|
There is no compressor, turbine, intercooler, wastegate, catalyst or muffler in
|
||||||
|
this first version. Intake boundary pressure is ambient. Consequently factory
|
||||||
|
turbo torque/power and factory exhaust sound are not reproduced. Thermal,
|
||||||
|
combustion and single-zone mixing limitations from the fluid audit still apply.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The six existing suites passed after the shared four-stroke/vehicle changes.
|
||||||
|
The added B205 regression passed independently: it checks engine displacement,
|
||||||
|
valve count, cylinder positivity, ten-pipe topology, firing order/cadence, wagon
|
||||||
|
launch, five-gear selection, final ratio, idle recovery, finite audio and zero
|
||||||
|
pipe positivity repairs. A prescribed 2000 RPM dyno counted 33 firings in half a
|
||||||
|
second, consistent with two firing events per revolution. The eight-second
|
||||||
|
sequence reached 56.57 km/h in first under load, recovered to 1311 RPM after
|
||||||
|
three seconds of unloaded release, and peaked at .18645 audio full scale.
|
||||||
|
It cost 7.26 wall seconds while another headless run was also active. Idle
|
||||||
|
convergence and engine calibration still need refinement; these checks establish
|
||||||
|
basic operation, not measured Saab performance. Windows runtime remains untested.
|
||||||
|
|
||||||
|
Run `fluidsim_desktop --scenario b205` or select the third startup card.
|
||||||
|
Headless CLI and WAV capture also accept `--scenario b205`. The graph reports
|
||||||
|
crankshaft power, not power measured at the wheels.
|
||||||
261
verification/FLUID_PHYSICS_AUDIT.md
Normal file
261
verification/FLUID_PHYSICS_AUDIT.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
# Fluid physics audit — 23 October 2026
|
||||||
|
|
||||||
|
This pass repairs conservation and boundary coupling, establishes repeatable
|
||||||
|
physical tests, and measures kernel and threading performance. It is not a
|
||||||
|
certification that every engine or acoustic subsystem is physically validated.
|
||||||
|
|
||||||
|
## Findings and changes
|
||||||
|
|
||||||
|
1. **Pipe–volume mass/energy mismatch.** Previously, the volume used an orifice's
|
||||||
|
requested discharge while the pipe used a different Rusanov ghost-cell flux.
|
||||||
|
Each boundary now supplies one integrated mass, energy and fresh-air flux;
|
||||||
|
the volume receives the exact opposite exchange. Stationary walls transfer
|
||||||
|
momentum but no mass or energy. Open-end audio reports the mass flux actually
|
||||||
|
applied by the finite-volume update.
|
||||||
|
2. **Substep integration.** Components previously received only the final
|
||||||
|
boundary flow after several pipe substeps. Every component now updates after
|
||||||
|
every pipe substep. CFL is recalculated as the solution evolves.
|
||||||
|
3. **Profiled pipes.** Geometry was stored but absent from the conservation
|
||||||
|
update. Shared face fluxes now include face area and divide by cell volume;
|
||||||
|
momentum includes the quasi-1D pressure source `p dA/dx`. Constant-pressure
|
||||||
|
rest remains stationary while closed profiled pipes conserve mass/energy.
|
||||||
|
4. **Orifice choking.** The old formula used the upstream sound speed with
|
||||||
|
throat Mach number, overestimating discharge. The nozzle calculation now
|
||||||
|
uses the isentropic throat temperature and satisfies the sonic-flow test.
|
||||||
|
5. **Unstable small-signal boundary.** Evaluating `sqrt(dp)` using only the
|
||||||
|
adjacent cell-centre pressure destabilized a small Helmholtz excitation.
|
||||||
|
The nozzle discharge is now matched to the outgoing duct characteristic
|
||||||
|
using a safeguarded analytic Newton solve of a squared-flow residual.
|
||||||
|
Bisection remains the fallback. Subsonic, isentropic characteristic and
|
||||||
|
quasi-steady nozzle assumptions still limit this model.
|
||||||
|
6. **Inertance units.** For mass flow, `d(mdot)/dt = A dp/L`, not
|
||||||
|
`A dp/(rho L)`. Dimensionless quadratic resistance is integrated with a
|
||||||
|
dissipative semi-implicit term. Removed the additional lumped inertance in
|
||||||
|
the Helmholtz scenario: its resolved neck already carries inertance.
|
||||||
|
7. **Moving volumes.** Volume0D previously applied work without changing its
|
||||||
|
geometric volume. Closed adiabatic work now follows `E V^(gamma-1)=constant`
|
||||||
|
and updates volume. Crankcase geometry work is applied once per geometry
|
||||||
|
change rather than again for each solver substep. Removed its five-bar cap,
|
||||||
|
which silently deleted gas during high compression.
|
||||||
|
8. **Invalid configurations.** Reject invalid geometry, timesteps, duplicate or
|
||||||
|
missing endpoints and incompatible gas constants. A pressure-only boundary
|
||||||
|
cannot specify a supersonic inlet; that case now fails explicitly. Direct
|
||||||
|
pipe joints support equal endpoint areas and all orientation combinations;
|
||||||
|
unequal-area direct joints are rejected. Use a profiled transition instead.
|
||||||
|
9. **Performance.** Cache primitive states/wave speeds, evaluate each shared
|
||||||
|
interior face once, and hoist uniform relaxation outside the cell loop.
|
||||||
|
Compiler SIMD is enabled without fast-math. No per-step allocations were
|
||||||
|
introduced into the pipe/boundary kernels.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
`fluidsim_fluid_validation` contains 29 named cases, in addition to the existing
|
||||||
|
physics/audio/ECU regression executable. Coverage includes:
|
||||||
|
|
||||||
|
- Closed pipes, variable-area equilibrium/conservation, stationary hot contacts.
|
||||||
|
- Sealed volume–pipe exchange, several volumes and a shared tiny donor.
|
||||||
|
- Direct joints in four orientations and split/unsplit equivalence.
|
||||||
|
- Fresh-air species conservation, actual open-end mass accounting on both ends.
|
||||||
|
- Sonic choking against the analytic ideal-gas formula; inertance units and
|
||||||
|
steady quadratic resistance.
|
||||||
|
- Adiabatic moving volumes, thermal exponential relaxation and sealed crankcase
|
||||||
|
compression beyond five bar without mass deletion.
|
||||||
|
- Closed/open acoustic reflection signs, closed-duct mode frequencies at
|
||||||
|
50/100/200 cells, and small-signal cavity resonance.
|
||||||
|
- Sod shock-tube density error at 100/200/400 cells, strong pressure pulses,
|
||||||
|
expanding-flow near-vacuum states, positivity-repair checks.
|
||||||
|
- Invalid geometry/configuration, incompatible gas coupling, unsupported
|
||||||
|
supersonic inlet and missing/duplicate endpoint rejection.
|
||||||
|
|
||||||
|
Representative measured results:
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| Sod density L1, 100 / 200 / 400 cells | 0.02332 / 0.01595 / 0.01052 |
|
||||||
|
| Closed-duct fundamental | approximately 171.9 Hz; analytic 171.91 Hz |
|
||||||
|
| Cavity fundamental | approximately 136.2 Hz; lumped estimate 138.51 Hz |
|
||||||
|
| Closed mass/energy relative tolerances | 2e-5 to 5e-5, depending on case |
|
||||||
|
| Cavity frequency tolerance | 5%, with growth and positivity checks |
|
||||||
|
| Closed-duct frequency tolerance | 1% across three resolutions |
|
||||||
|
|
||||||
|
The cavity estimate assumes a compact, lossless neck without radiation end
|
||||||
|
correction; the discretized model includes neck compressibility and numerical
|
||||||
|
losses. These comparisons are verification of stated assumptions, not a
|
||||||
|
comparison with measured engine hardware.
|
||||||
|
|
||||||
|
## Benchmarks and SIMD
|
||||||
|
|
||||||
|
Machine: Intel Core i5-12600K, GCC 16.2.1, Linux x86-64, Release. These are local
|
||||||
|
wall-clock microbenchmarks; CPU frequency and background load affect timings.
|
||||||
|
The pipe benchmark advances an equilibrium lossless duct with reflected ends;
|
||||||
|
the twin benchmark runs one simulated second including its nonlinear boundaries.
|
||||||
|
|
||||||
|
Before this audit, isolated kernels measured about 49–52 million cell updates/s
|
||||||
|
and the twin about 3.84 times real time. Cached shared-face evaluation raised
|
||||||
|
pipe throughput to roughly 110–140 million updates/s. The final twin is around
|
||||||
|
1.7 times real time: it does substantially more boundary work than the original,
|
||||||
|
physically inconsistent solver. Early faster twin numbers from before the
|
||||||
|
characteristic/nozzle correction must not be treated as final results.
|
||||||
|
|
||||||
|
A SIMD comparison measured roughly 111–139 million updates/s with compiler
|
||||||
|
vectorization versus 84–102 million with vectorization disabled. GCC's
|
||||||
|
vectorization report confirms 16-byte SIMD in the primitive-state and face-flux
|
||||||
|
loops. `FLUIDSIM_ENABLE_SIMD=ON` permits vectorization and disables math errno;
|
||||||
|
it does **not** enable fast-math, approximate reciprocal/square-root intrinsics,
|
||||||
|
or machine-specific instruction sets. OFF disables compiler vectorization for
|
||||||
|
repeatable comparison. Scalar and SIMD builds run the same physics tests.
|
||||||
|
Windows/MSVC settings are provided but were not executed in this Linux audit.
|
||||||
|
|
||||||
|
## Multithreading investigation
|
||||||
|
|
||||||
|
`fluidsim_thread_benchmark` measures persistent workers with two barriers per
|
||||||
|
iteration. Boundaries/components must remain in ordered phases; pipes can be
|
||||||
|
advanced independently only after all boundary fluxes are resolved.
|
||||||
|
|
||||||
|
| Independent pipe workload, 1000 steps | Serial | 2 workers | 4 workers |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| 6 pipes × 10 cells | 0.00093 s | 0.00272 s | 0.00625 s |
|
||||||
|
| 64 pipes × 256 cells | 0.1275 s | 0.0700 s | 0.0410 s |
|
||||||
|
|
||||||
|
This is an optimistic stage-only benchmark with equilibrium data, excluding
|
||||||
|
worker startup and a real graph's serial boundary work. It supports further
|
||||||
|
work on persistent pools for large networks; it does not justify threading the
|
||||||
|
current small twin. No fluid worker pool is enabled in production. The existing
|
||||||
|
simulation/audio/UI separation remains.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake -S . -B build-audit -DFLUIDSIM_BUILD_DESKTOP=OFF -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build build-audit -j 4
|
||||||
|
ctest --test-dir build-audit --output-on-failure
|
||||||
|
./build-audit/fluidsim_physics_benchmark 44100
|
||||||
|
./build-audit/fluidsim_thread_benchmark
|
||||||
|
```
|
||||||
|
|
||||||
|
For a scalar comparison configure a separate directory with
|
||||||
|
`-DFLUIDSIM_ENABLE_SIMD=OFF`. On GCC/Clang, use another directory with
|
||||||
|
`-DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer"` to
|
||||||
|
reproduce the sanitizer run. Final Linux desktop regressions, all 29 scalar fluid cases, and both test
|
||||||
|
executables under AddressSanitizer/UndefinedBehaviorSanitizer passed. The final
|
||||||
|
sanitizer run completed in about 13 seconds.
|
||||||
|
|
||||||
|
Compiler vectorization diagnostics can be produced
|
||||||
|
with GCC `-fopt-info-vec-optimized`.
|
||||||
|
|
||||||
|
## Remaining limits and next work
|
||||||
|
|
||||||
|
- First-order Rusanov flux is dissipative. Spatial/time resolution convergence
|
||||||
|
and attenuation across the full audible band are not yet established. A
|
||||||
|
higher-order limited scheme is a separate change, not silently enabled here.
|
||||||
|
- The default four-substep floor was retained: removing it failed a tiny-volume
|
||||||
|
stress case. CFL alone is not a positivity guarantee for arbitrary stiff
|
||||||
|
pipe/volume networks. Conservative donor budgets can limit transfer; inspect
|
||||||
|
`BoundarySystem::limited_fluxes` and refine time resolution when they activate.
|
||||||
|
Budgets for shared donors are allocated in registration order. Pipe positivity
|
||||||
|
repairs are counted by `Pipe1D::positivity_repairs`; a nonzero count invalidates
|
||||||
|
a claim of exact conservation for that run.
|
||||||
|
- Pipe geometry/cell storage remains public for initialization. Changing its
|
||||||
|
topology or resizing cells after connections have been built is unsupported.
|
||||||
|
- The pressure-only open boundary extrapolates interior entropy. It is an ideal
|
||||||
|
pressure-release model, not a complete external atmosphere, anechoic boundary,
|
||||||
|
or frequency-dependent radiation impedance. Strong inlet flows need richer
|
||||||
|
thermodynamic boundary data.
|
||||||
|
- Junctions and orifices are quasi-1D approximations. Jet separation, vena
|
||||||
|
contracta, discharge coefficients and strong multidimensional area changes
|
||||||
|
need validation; equal-area joint tests do not validate arbitrary fittings.
|
||||||
|
- The optional lumped inertance law is dimensionally checked, but its stored
|
||||||
|
neck kinetic energy is not a separately tracked thermodynamic state. Use
|
||||||
|
resolved pipe cells when evaluating physical neck acoustics and energy.
|
||||||
|
- Core pipe damping and volume thermal relaxation default to zero. Explicit
|
||||||
|
scenario damping/relaxation still represents empirical source terms and must
|
||||||
|
be calibrated before claiming real dissipation or heat transfer.
|
||||||
|
- Engine cylinders retain pressure/temperature safety clamps, approximate
|
||||||
|
combustion/species bookkeeping and unvalidated mechanical work integration.
|
||||||
|
ECU behavior and the broader engine model are not certified by this audit.
|
||||||
|
- Sound radiation remains the documented compact-source approximation. These
|
||||||
|
fluid tests do not establish microphone SPL, high-frequency radiation,
|
||||||
|
thermoviscous losses or full mechanical/chemical energy conservation.
|
||||||
|
|
||||||
|
## Physical references
|
||||||
|
|
||||||
|
- [Clawpack: approximate Euler Riemann solvers](https://www.clawpack.org/riemann_book/html/Euler_approximate.html)
|
||||||
|
— conservative Euler fluxes and total-energy equation of state.
|
||||||
|
- [Clawpack: Euler equations and exact Riemann solutions](https://www.clawpack.org/riemann_book/html/Euler.html)
|
||||||
|
— shock/contact/rarefaction verification and the Sod problem.
|
||||||
|
- [NASA Glenn: mass-flow choking](https://www.grc.nasa.gov/www/k-12/BGP/mflchk.html)
|
||||||
|
— isentropic throat relations and maximum mass flow at Mach one.
|
||||||
|
- [EPFL: quasi-1D Laval nozzle model](https://www.epfl.ch/labs/mathicse/wp-content/uploads/2018/10/25.2016_MP-FN-PL.pdf)
|
||||||
|
— variable-area Euler fluxes and geometric pressure source.
|
||||||
|
|
||||||
|
|
||||||
|
## Sustained-load follow-up
|
||||||
|
|
||||||
|
A one-second run missed a runtime deadline problem: at full throttle the heated
|
||||||
|
gas raised average CFL work from 4 to approximately 7 substeps per audio frame.
|
||||||
|
By simulated second 5, each second needed about 1.04 seconds of CPU time, so a
|
||||||
|
finite startup buffer could not prevent eventual underruns. Idle stayed near
|
||||||
|
0.6 seconds per simulated second.
|
||||||
|
|
||||||
|
The solver now reuses its CFL primitive-state/wave-speed preparation in the
|
||||||
|
finite-volume update. Boundary Newton residual tolerance is matched to the
|
||||||
|
single-precision conserved state (`4e-7` in the normalized squared-flow
|
||||||
|
residual), rather than solving to `1e-12` relative residual each time. The same
|
||||||
|
CFL limits and four-substep floor remain. On the same machine, a nine-second
|
||||||
|
full-throttle run measured approximately 0.74–0.81 seconds per simulated second
|
||||||
|
in the hot portion; idle measured approximately 0.49–0.52 seconds. All 29 fluid
|
||||||
|
cases and existing regressions still pass. This is measured headroom, not a
|
||||||
|
hard real-time guarantee on other workloads or machines.
|
||||||
|
|
||||||
|
Reproduce sustained-load timings with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./build-audit/fluidsim_physics_benchmark --soak 12
|
||||||
|
```
|
||||||
|
|
||||||
|
The benchmark reports each simulated second, average substeps/frame and
|
||||||
|
boundary iterations/frame. Desktop audio also counts underrun blocks; the
|
||||||
|
counter is shown with the audio-buffer status when a UI font is available.
|
||||||
|
|
||||||
|
## Intake reservoir and repeated-blip regression
|
||||||
|
|
||||||
|
The twin's upstream pressure-only boundary inherited pipe entropy during
|
||||||
|
inflow. Repeated blips could therefore admit progressively hotter gas rather
|
||||||
|
than fresh ambient air: a captured sequence reached approximately 1140 K in
|
||||||
|
the plenum at wide-open throttle and lost fresh charge density. The intake now
|
||||||
|
connects to an explicit infinite `Atmosphere` reservoir (101325 Pa, 300 K,
|
||||||
|
fresh-air fraction one), updated on every fluid substep. Intake radiation uses
|
||||||
|
that reservoir's actual signed mass exchange. The remaining pressure-release
|
||||||
|
open-end index now refers only to the exhaust.
|
||||||
|
|
||||||
|
The twin also disables pipe relaxation toward ambient **pressure** energy.
|
||||||
|
That source can heat low-density evacuated cells without a physical wall heat
|
||||||
|
transfer law. This does not impose any pressure floor or artificial manifold
|
||||||
|
refill. Hot residual-gas reversion, closed-throttle pumping and pressure waves
|
||||||
|
remain possible; their engine-model accuracy still needs further validation.
|
||||||
|
|
||||||
|
`fluidsim_intake_regression` runs 16 simulated seconds: idle, an 80% blip,
|
||||||
|
recovery, a full-throttle opening, and return to idle. It checks pressure
|
||||||
|
recovery, fresh-air temperature/composition, finite audio, zero pipe positivity
|
||||||
|
repairs and final idle recovery. Representative results after the fix:
|
||||||
|
|
||||||
|
- Before the second opening: 35.76 kPa manifold absolute pressure.
|
||||||
|
- After opening: 85.45 kPa near 9950 RPM; manifold 298.96 K, inlet 296.92 K.
|
||||||
|
- After return to idle: 34.51 kPa at 1820 RPM.
|
||||||
|
|
||||||
|
Manifold vacuum at small/closed throttle is expected and is not removed by the
|
||||||
|
fix. An actual obstruction/regression is distinguished by failure to recover
|
||||||
|
pressure and fresh-charge density when reopening the throttle. The inlet
|
||||||
|
reservoir controls the upstream gas state; it does not thermostat the manifold.
|
||||||
|
See [AEM's engine-management manual](https://documents.aemelectronics.com/techlibrary_30-6905_universal_programmable_engine_management_system.pdf)
|
||||||
|
for the qualitative throttle-angle/manifold-pressure relationship. These are
|
||||||
|
scenario regression bounds, not universal pressure limits for real engines.
|
||||||
|
|
||||||
|
All three CTest executables pass after this change (existing regressions,
|
||||||
|
29-case fluid suite, and the new intake transient). A six-second sustained
|
||||||
|
full-throttle follow-up stayed at four substeps/frame and approximately
|
||||||
|
0.51–0.53 seconds of wall time per simulated second, with RPM around the ECU
|
||||||
|
rev limit. This also identifies the earlier hot-gas CFL growth as a consequence
|
||||||
|
of the incorrect inlet thermodynamic state, not solely an unavoidable engine
|
||||||
|
workload increase. The earlier timing sections are historical measurements of
|
||||||
|
those intermediate implementations.
|
||||||
109
verification/TURBO_VZ21.md
Normal file
109
verification/TURBO_VZ21.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# VZ21-size turbo prototype
|
||||||
|
|
||||||
|
Linux Release, 2026-10-23. Core is ready for an early interactive test. Further
|
||||||
|
map fitting, acoustic work and performance tuning are deferred at the user's
|
||||||
|
request so testing can start. No blade-passing whistle has been implemented yet.
|
||||||
|
|
||||||
|
## Model
|
||||||
|
|
||||||
|
Four finite 0-D gas volumes connect compressor inlet/outlet and turbine
|
||||||
|
inlet/outlet to existing `BoundarySystem` pipe orifices. Register the turbo once
|
||||||
|
with `Solver`; do not separately register its internal volumes. Internal flows
|
||||||
|
are bidirectional and choked where appropriate. Donor budgets preserve positive
|
||||||
|
mass/energy. Compensated 0-D volume accumulation prevents sub-ulp mass drift.
|
||||||
|
|
||||||
|
The compressor's estimated Euler head is psi*(omega*R)^2. Efficiency maps this
|
||||||
|
to a virtual stagnation pressure; a compressible restriction determines actual
|
||||||
|
flow against the downstream pressure. The turbine flow follows an effective
|
||||||
|
nozzle. Its torque uses a mean-line speed-ratio efficiency parabola, with finite
|
||||||
|
starting torque. Reverse flow is passive; deep surge/reverse-wheel aerodynamics
|
||||||
|
are not validated. These correlations are not a measured compressor/turbine map.
|
||||||
|
|
||||||
|
A rigid shaft obeys I*domega/dt = turbine torque - compressor torque - bearing
|
||||||
|
and windage torque. Implicit midpoint work balances the exact change in rotor
|
||||||
|
kinetic energy, including starting from rest. Adaptive shaft steps prevent
|
||||||
|
large timesteps from reversing the rotor numerically. Turbine work is removed
|
||||||
|
from gas enthalpy; compressor work is added to charge enthalpy. Bearing loss is
|
||||||
|
tracked as heat outside the gas circuits. No shaft-speed or boost clamp is used.
|
||||||
|
|
||||||
|
Estimated default parameters: inertia 4e-6 kg m^2, compressor effective radius
|
||||||
|
18 mm, turbine mean effective radius 13.5 mm, efficiencies .65, compressor head
|
||||||
|
coefficient .6, effective compressor/turbine flow areas 110/100 mm^2. Housing
|
||||||
|
volumes are 20/30/30/20 cc. Connection bores are 24/24/25/26 mm. Viscous bearing
|
||||||
|
coefficient is 1e-7 Nm/(rad/s), windage coefficient 2e-11 Nm/(rad/s)^2.
|
||||||
|
|
||||||
|
The estimated wastegate has an 800 mm^2 pressure diaphragm, 40 N preload,
|
||||||
|
6000 N/m spring, 120 Ns/m overdamped linkage, 14 mm valve and 4 mm maximum lift.
|
||||||
|
Compressor delivery gauge pressure drives its opening. Bypass gas transfers
|
||||||
|
enthalpy without delivering turbine shaft work. The actuator is an idealized
|
||||||
|
external pressure-control linkage; its mechanical/thermal energy is outside the
|
||||||
|
audited gas/rotor system. There is no intercooler, blow-off valve, oil-temperature
|
||||||
|
model, knock model or thermal/wheel failure model.
|
||||||
|
|
||||||
|
## Reference basis
|
||||||
|
|
||||||
|
[Ecotrons VZ21 specification](https://ecotrons.com/files/ECOTRONS%20Turbo%20VZ21%20technical%20specl.pdf)
|
||||||
|
provides rough applications and casing dimensions; its performance illustrations
|
||||||
|
explicitly refer to a similar IHI family rather than measured VZ21 maps. Those
|
||||||
|
figures cannot validate the coefficients used here. A
|
||||||
|
[supplier's VZ21 listing](https://www.maxpeedingrods.nl/en/products/rhb31-vz21-turbocharger-junta-100ps-for-motor-pequeno-rhino-motocicleta-at-vz21-npvl)
|
||||||
|
lists 24.2/37.1 mm compressor and 35/26.7 mm turbine inducer/exducer diameters.
|
||||||
|
The model's effective radii remain estimates.
|
||||||
|
[NASA compressor/turbine work relations](https://www.grc.nasa.gov/WWW/K-12/BGP/ctmatch.html)
|
||||||
|
provide the ideal-gas thermodynamic basis. Conservation checks establish
|
||||||
|
numerical consistency, not predictive accuracy for a real VZ21.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
All nine CTest suites passed after the changes. New checks cover stationary
|
||||||
|
equilibrium, startup, closed mass/energy balance, a pre-spun rotor, large shaft
|
||||||
|
timestep passivity, reverse flow, choking, subcritical backpressure response,
|
||||||
|
wastegate bypass, hot pulsing pipe coupling and twin throttle/limiter/release.
|
||||||
|
|
||||||
|
Closed 10 ms runs: measured mass drift zero; energy error -0.16 microjoule from
|
||||||
|
rest and -0.71 microjoule with an initially 120000 RPM shaft. Energy totals
|
||||||
|
include all gas, rotor and bearing heat. A hot 80 Hz pulsing pipe bench reached
|
||||||
|
189122 RPM, delivered .03067 kg/s charge at 376 K, and retained turbine power
|
||||||
|
pulses of 1189..4022 W. No internal transfer limiting or pipe repairs occurred.
|
||||||
|
|
||||||
|
The seven-second twin transient reached 236218 RPM, peak charge pressure
|
||||||
|
181.46 kPa absolute, peak instantaneous turbine-inlet pressure 674.10 kPa and
|
||||||
|
2.78 mm gate lift. It coasted down to 151755 RPM after release. Audio peak was
|
||||||
|
.02933 full scale, with no clipping; accumulated turbo energy residual was
|
||||||
|
-8.8 microjoules. High shaft speeds are model output, not validated operating
|
||||||
|
limits or evidence that a real VZ21 would survive them.
|
||||||
|
|
||||||
|
Same-engine-speed dyno comparison, .7 pedal, averaging the last half-second of
|
||||||
|
each two-second run:
|
||||||
|
|
||||||
|
| RPM | Turbo | MAP kPa | Exhaust collector kPa | Shaft kW | Charge K | Shaft turbo RPM |
|
||||||
|
| ---: | :---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| 4000 | no | 93.30 | 110.00 | 23.54 | — | — |
|
||||||
|
| 4000 | yes | 151.31 | 172.99 | 24.01 | 387.58 | 194130 |
|
||||||
|
| 7000 | no | 84.56 | 115.02 | 40.64 | — | — |
|
||||||
|
| 7000 | yes | 147.13 | 226.09 | 39.69 | 397.86 | 214361 |
|
||||||
|
|
||||||
|
Pressure and temperature costs are retained: boost does not guarantee more
|
||||||
|
shaft power. There is no forced power gain. Effective area/efficiency, gas
|
||||||
|
properties and the base engine's approximate combustion/overlap affect these
|
||||||
|
numbers strongly. They are prototype comparisons, not real engine predictions.
|
||||||
|
|
||||||
|
The benchmark used four solver substeps per output frame. The turbo cost .81
|
||||||
|
CPU seconds per simulated second at 4000 RPM and 1.28 at 7000 RPM, versus .53
|
||||||
|
for the unboosted twin. Sustained high-load real-time playback is an open issue;
|
||||||
|
monitor underruns. The mixed transient cost .84 wall seconds per simulated
|
||||||
|
second. Tests run headlessly; latest Windows runtime is unverified.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build-desktop --target fluidsim_desktop -j 4
|
||||||
|
./build-desktop/fluidsim_desktop --scenario twin --turbo vz21 --scene field
|
||||||
|
cmake --build build-audit --target fluidsim_turbo_benchmark -j 4
|
||||||
|
./build-audit/fluidsim_turbo_benchmark
|
||||||
|
```
|
||||||
|
|
||||||
|
The startup menu also offers the turbo twin. WAV capture accepts
|
||||||
|
`--scenario twin --turbo vz21`. Blade-passing acoustics are the next requested
|
||||||
|
part: they must follow rotor geometry/loading and be filtered before 44.1 kHz
|
||||||
|
sampling, rather than adding an arbitrary sound or aliased pressure ripple.
|
||||||
108
verification/YZ125_BASELINE.md
Normal file
108
verification/YZ125_BASELINE.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# YZ125 baseline verification
|
||||||
|
|
||||||
|
Linux Release checks, 2026-10-23. All six CTest suites passed. This is a
|
||||||
|
YZ125-sized simulation baseline, not a measured replica of a particular year.
|
||||||
|
|
||||||
|
## Changes and physical basis
|
||||||
|
|
||||||
|
Pipes already supported per-cell areas/lengths and the geometric momentum
|
||||||
|
source for varying area. The earlier exhaust was a small expansion chamber,
|
||||||
|
but the UI drew all pipes with pressure-dependent thickness. The display now
|
||||||
|
uses physical diameter and axial cell length. A shared profile builder preserves
|
||||||
|
exact conical-frustum volume across mesh refinements.
|
||||||
|
|
||||||
|
The new estimated racing pipe has these dimensions (metres):
|
||||||
|
|
||||||
|
| Section | Length | Inlet diameter | Outlet diameter |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| Header | .160 | .036 | .036 |
|
||||||
|
| First diffuser | .180 | .036 | .060 |
|
||||||
|
| Second diffuser | .190 | .060 | .105 |
|
||||||
|
| Belly | .130 | .105 | .105 |
|
||||||
|
| Baffle cone | .210 | .105 | .022 |
|
||||||
|
| Stinger | .240 | .022 | .022 |
|
||||||
|
|
||||||
|
Default mesh maximum length is 10 mm. The straight comparison has the same
|
||||||
|
total length and a uniform 36 mm bore. These are design estimates, not Yamaha
|
||||||
|
factory pipe dimensions. The test pulse returned -34.33 Pa from a diffuser and
|
||||||
|
+27.52 Pa from a convergent, with no positivity repairs. The intended negative
|
||||||
|
scavenging pulse and later positive plugging pulse follow the mechanism in
|
||||||
|
[SAE 680469](https://saemobilus.sae.org/papers/investigation-tuning-exhaust-system-small-two-stroke-cycle-engines-680469).
|
||||||
|
|
||||||
|
Two-stroke port areas now come from piston travel past rectangular port roofs,
|
||||||
|
replacing the poppet-style curtain-area curve. Assumed transfer/exhaust durations
|
||||||
|
are 130/190 degrees and aggregate widths 50/36 mm. Trapped compression is 8.2:1;
|
||||||
|
clearance volume is derived at exhaust closure, rather than confusing this with
|
||||||
|
full-stroke geometric compression. The exhaust power valve remains fixed open.
|
||||||
|
These port dimensions and compression interpretation require measured validation.
|
||||||
|
|
||||||
|
Combustion has an estimated .8 ms minimum burn duration; timing and duration are
|
||||||
|
frozen for each active charge. The estimated ignition curve retards above 6000
|
||||||
|
RPM rather than continuing to advance toward 40 degrees. Neither is a measured
|
||||||
|
factory CDI/flame model. Fuel remains ideally calculated at port closure.
|
||||||
|
|
||||||
|
Mechanical friction now includes a quadratic windage term. The two-stroke uses
|
||||||
|
FMEP [bar] = .4 + .09*mean_piston_speed + .0009*mean_piston_speed^2, converted to
|
||||||
|
crank torque with one revolution per cycle. Coefficients are generic estimates
|
||||||
|
within the ranges discussed in this
|
||||||
|
[Chalmers engine-model study](https://publications.lib.chalmers.se/records/fulltext/147782.pdf),
|
||||||
|
not a YZ125 coast-down fit. Crankcase pumping work is already modeled separately.
|
||||||
|
No speed clamp or rev limiter was added.
|
||||||
|
|
||||||
|
Drivetrain ratios use Yamaha's
|
||||||
|
[2022 YZ125 specification](https://global.yamaha-motor.com/jp/news/2021/0730/yz125.html):
|
||||||
|
primary 64/19, final 49/13, and six ratios approximately 2.384, 1.933, 1.588,
|
||||||
|
1.352, 1.176, 1.055. Nominal rear radius is .3313 m from the 100/90-19 tire;
|
||||||
|
loaded rolling radius is unmeasured. Test mass is 95 kg plus an assumed 75 kg
|
||||||
|
rider. First/sixth overall reductions are 30.276/13.402, replacing the twin's
|
||||||
|
much taller drivetrain. Other road-load/clutch parameters remain estimates.
|
||||||
|
|
||||||
|
The dyno now records brake shaft torque/power before external load reaction;
|
||||||
|
previously it displayed acceleration torque/power, which becomes zero at loaded
|
||||||
|
steady speed. An analytic load-balance test checks 10 Nm and 1 kW at 100 rad/s.
|
||||||
|
|
||||||
|
## Measurements and limits
|
||||||
|
|
||||||
|
The fixed-speed benchmark imposes speed as an ideal motoring/absorbing dyno and
|
||||||
|
records shaft output. It runs one simulated second per point and averages the
|
||||||
|
last half. All points fired approximately once per revolution, had zero pipe
|
||||||
|
positivity repairs, and cost about .53-.58 wall seconds per simulated second.
|
||||||
|
|
||||||
|
| RPM | Straight kW | Racing kW |
|
||||||
|
| ---: | ---: | ---: |
|
||||||
|
| 6000 | 4.02 | 6.27 |
|
||||||
|
| 8000 | .63 | 5.53 |
|
||||||
|
| 10000 | 2.43 | 10.53 |
|
||||||
|
| 12000 | 3.96 | 14.32 |
|
||||||
|
| 14000 | 3.60 | 12.38 |
|
||||||
|
| 17000 | -.80 | -3.94 |
|
||||||
|
|
||||||
|
This coarse sweep peaks near 12000 RPM; it is not a validated Yamaha dyno curve.
|
||||||
|
Negative power at 17000 means the virtual dyno must motor the engine. Free-rev
|
||||||
|
speed differs from peak-power speed: full pedal with the straight pipe still
|
||||||
|
approaches 16950 RPM after eight seconds. This remains an unresolved calibration
|
||||||
|
limit. The standard transient reached 16128 RPM, recovered idle at 3159 RPM,
|
||||||
|
and launched the motorcycle to 61.3 km/h without repairs or nonfinite output.
|
||||||
|
|
||||||
|
Six-second .6-pedal field captures had no clipping: racing rendered peak .201,
|
||||||
|
straight .554. Firing cadence, port timing and geometry are improved, but exact
|
||||||
|
YZ125 sound still requires listening and further combustion/scavenging work.
|
||||||
|
The first-order pipe flux damps high-frequency content; the compact source,
|
||||||
|
constant-gamma gas, ideal fueling, fixed power valve, single-zone mixing and
|
||||||
|
empirical monitoring/environment filters limit fidelity. No engine sound sample
|
||||||
|
or synthetic two-stroke firing track has been added.
|
||||||
|
|
||||||
|
## Run and compare
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build-desktop --target fluidsim_desktop -j 4
|
||||||
|
./build-desktop/fluidsim_desktop --scenario two-stroke --exhaust racing --scene field
|
||||||
|
./build-desktop/fluidsim_desktop --scenario two-stroke --exhaust straight --scene field
|
||||||
|
cmake --build build-audit --target fluidsim_two_stroke_benchmark -j 4
|
||||||
|
./build-audit/fluidsim_two_stroke_benchmark
|
||||||
|
```
|
||||||
|
|
||||||
|
Hold C, shift to first with D, apply W, then release C. A/D shift, Z selects
|
||||||
|
neutral. Up/down adjusts persistent pedal request; R changes environment.
|
||||||
|
The capture tool accepts the same exhaust option. Current captures are under
|
||||||
|
`build/audio/yz125-{racing,straight}-{rendered,dry,unfiltered}.wav`.
|
||||||
Reference in New Issue
Block a user