Files
FluidSimCpp/tests/physics_tests.cpp
2026-10-23 18:29:51 +02:00

320 lines
17 KiB
C++

#include "FluidSim/Scenario.hpp"
#include "FluidSim/SimulationRingBuffer.hpp"
#include <array>
#include "FluidSim/Atmosphere.hpp"
#include "FluidSim/Crankcase.hpp"
#include "FluidSim/DynoCurve.hpp"
#include "FluidSim/TwoStrokeCylinder.hpp"
#include "FluidSim/TwoStrokeScenario.hpp"
#include "FluidSim/OutdoorExhaustReverb.hpp"
#include "FluidSim/HelmholtzScenario.hpp"
#include "FluidSim/PulseTestScenario.hpp"
#include "FluidSim/Units.hpp"
#include "FluidSim/Desktop/PedalControl.hpp"
#include "FluidSim/Desktop/PipePath.hpp"
#include "FluidSim/Desktop/PressurePalette.hpp"
#include <cmath>
#include <iostream>
#include <stdexcept>
namespace {
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << "FAIL: " << message << '\n';
throw std::runtime_error(message);
}
}
}
int main() {
using namespace fluidsim::port;
Ecu fixed_idle;
fixed_idle.idle_control=Ecu::IdleControl::FixedOpening;
fixed_idle.idle_base_opening=.049f;
fixed_idle.overrun_fuel_cut_enabled=false;
for(float rpm : {0.f,500.f,3000.f,9000.f}) {
for(int i=0;i<4410;++i) fixed_idle.step(1.f/44100,0,rpm);
require(std::abs(fixed_idle.throttle_opening()-.049f)<1e-5f,
"fixed carb idle must not vary with RPM");
}
for(int i=0;i<4410;++i) fixed_idle.step(1.f/44100,.2f,3000);
require(std::abs(fixed_idle.throttle_opening()-.2f)<1e-5f,"pedal must open above carb stop");
for(int i=0;i<4410;++i) fixed_idle.step(1.f/44100,.01f,3000);
require(std::abs(fixed_idle.throttle_opening()-.049f)<1e-5f,"release must return to carb stop");
for(auto preset : {FourStrokePreset::Twin,FourStrokePreset::SaabB205,FourStrokePreset::AmericanV8}) {
FourStrokeScenario starter(44100,preset);
starter.crankshaft.omega=0;
starter.set_starter(true);
starter.process();
require(starter.crankshaft.omega>0,"starter must turn every four-stroke crank from rest");
starter.set_starter(false);
require(!starter.starter_engaged,"starter release must disengage");
}
{
TwoStrokeConfig config; config.idle_slide_opening=.071f;
TwoStrokeScenario starter(44100,config);
require(starter.ecu.idle_base_opening==.071f,"scenario must configure carb idle stop");
starter.crankshaft.omega=0; starter.set_starter(true); starter.process();
require(starter.crankshaft.omega>0,"starter must turn two-stroke crank from rest");
starter.set_starter(false);
}
desktop::PedalControl pedal;
for(int i=0;i<100;++i)pedal.advance(.0025f,true,false);
require(std::abs(pedal.value()-.5f)<1e-5f,"W pedal travel must be halfway after 0.25 seconds");
for(int i=0;i<100;++i)pedal.advance(.0025f,true,false);
require(std::abs(pedal.value()-1)<1e-5f,"W must reach full pedal in 0.5 seconds");
for(int i=0;i<200;++i)pedal.advance(.0025f,false,false);
require(pedal.value()<1e-5f,"keyboard release must return pedal in 0.5 seconds");
pedal.set(.8f);
for(int i=0;i<200;++i)pedal.advance(.0025f,false,true);
require(pedal.value()==0,"S must release pedal");
desktop::PipePath path({{0,{0,0,0}},{.25f,{.2f,.1f,0}},{1,{1,0,.1f}}});
auto knot=path.position(.25f),direction=path.tangent(.25f);
require(std::abs(knot.x-.2f)<1e-6f && std::abs(knot.y-.1f)<1e-6f,"pipe path must interpolate its metre-referenced control point");
require(std::abs(direction.x*direction.x+direction.y*direction.y+direction.z*direction.z-1)<1e-4f,"pipe path tangent must be normalized");
auto atm=desktop::pressure_tint(101325),vac=desktop::pressure_tint(0),peak=desktop::pressure_tint(1000000);
require(atm.y>.8f && vac.z>.99f && peak.x>.99f,"pressure palette must show atmosphere green, vacuum blue, spikes red");
Ecu fast_actuator;
for(int i=0;i<230;++i)fast_actuator.step(1.f/44100,1,5000);
require(fast_actuator.throttle_opening()>.99f,"throttle actuator must settle within about 5 ms");
for(int i=0;i<230;++i)fast_actuator.step(1.f/44100,0,5000);
require(fast_actuator.throttle_opening()<.01f,"throttle actuator must close within about 5 ms");
Ecu idle_actuator;
for(int i=0;i<441;++i)idle_actuator.step(1.f/44100,0,1000);
require(idle_actuator.idle_active() && idle_actuator.throttle_opening()>0,"zero UI pedal must retain ECU idle authority");
Ecu controller;
controller.throttle_time_constant=.08f;controller.throttle_rate=2;
constexpr float ecu_dt = 1.0f / 44100.0f;
controller.step(ecu_dt, 1.0f, 2000.0f);
require(controller.throttle_opening() > 0.0f && controller.throttle_opening() <= 2.0f * ecu_dt,
"ECU throttle must obey its per-second travel limit");
for (int i = 0; i < 44100; ++i) controller.step(ecu_dt, 1.0f, 2000.0f);
require(controller.throttle_opening() > 0.99f && controller.target_afr() < 13.0f,
"ECU must reach full opening and enrich the high-load mixture");
const float before_close = controller.throttle_opening();
controller.step(ecu_dt, 0.0f, 2000.0f);
require(controller.throttle_opening() >= before_close - 2.01f * ecu_dt,
"ECU must also rate-limit throttle closing");
controller.step(ecu_dt, 1.0f, 10001.0f);
require(controller.fuel_cut() && controller.fuel_for_air(0.001f) == 0.0f,
"ECU rev limit must inhibit injection");
controller.step(ecu_dt, 1.0f, 9900.0f);
require(controller.rev_limited(), "ECU rev limiter must latch through hysteresis");
controller.rev_limit_enabled = false;
controller.step(ecu_dt, 1.0f, 15000.0f);
require(!controller.rev_limited() && !controller.fuel_cut() && controller.fuel_for_air(.001f) > 0,
"disabling a latched limiter must restore fuel above the old limit");
controller.rev_limit_enabled = true;
controller.step(ecu_dt, 1.0f, 9700.0f);
require(!controller.fuel_cut() && controller.fuel_for_air(0.001f) > 0.0f,
"ECU must resume injection below the rev recovery threshold");
controller.step(ecu_dt, 0.0f, 4000.0f);
require(controller.fuel_cut(), "closed-pedal overrun must cut injection");
controller.step(ecu_dt, 0.0f, 1300.0f);
require(!controller.fuel_cut() && controller.idle_active(), "idle catch must restore injection");
controller.step(ecu_dt, 0.0f, std::nanf(""));
require(controller.sensor_fault() && controller.fuel_cut(), "invalid sensor data must inhibit fuel");
// Unit-rate playback must preserve every sample across callback boundaries.
SimulationRingBuffer audio_ring(8);
for (int i = 0; i < 8; ++i) audio_ring.write(float(i), -float(i));
std::array<float, 4> left{}, right{};
require(audio_ring.read_interpolated(left, right, 1.0) == 4,
"unit-rate ring read must fill a block");
for (int i = 0; i < 4; ++i)
require(left[i] == float(i) && right[i] == -float(i), "ring must preserve stereo samples");
require(audio_ring.read_interpolated(left, right, 1.0) == 3,
"ring underrun must stop before unavailable interpolation lookahead");
for (int i = 8; i < 12; ++i) audio_ring.write(float(i), -float(i));
require(audio_ring.read_interpolated(left, right, 1.0) == 4 && left[0] == 7.0f && left[3] == 10.0f,
"ring must resume continuously after underrun and wraparound");
SoundProcessor linear_source(44100, 1.0f);
linear_source.process(0.0f);
const float response = linear_source.process(0.01f);
SoundProcessor doubled_source(44100, 1.0f);
doubled_source.process(0.0f);
require(std::abs(doubled_source.process(0.02f) - 2.0f * response) < 1e-5f,
"radiation source must remain linear instead of saturating");
require(std::abs(units::celsius(25.0) - 298.15) < 1e-9 &&
std::abs(units::area_from_diameter(0.02) - static_cast<double>(pi) * 1e-4) < 1e-12,
"unit helpers must preserve the C# conversions");
DynoCurve dyno_curve;
dyno_curve.update(1000.0f, 10.0f, 50.0f);
dyno_curve.update(1150.0f, 22.0f, 80.0f);
require(dyno_curve.bins().size() == 24 &&
std::abs(dyno_curve.bins()[21].power_kw - 14.0f) < 1e-5f &&
std::abs(dyno_curve.bins()[22].torque_nm - 70.0f) < 1e-5f,
"dyno history must interpolate skipped 50-RPM bins like the C# scenario base");
dyno_curve.update(1150.0f, 5.0f, 40.0f);
require(std::abs(dyno_curve.bins()[23].power_kw - 22.0f) < 1e-5f &&
std::abs(dyno_curve.bins()[23].torque_nm - 80.0f) < 1e-5f,
"dyno bins must retain their peak power and torque");
dyno_curve.reset();
require(dyno_curve.bins().empty(), "dyno reset must clear accumulated history");
Crankshaft crank;
FourStrokeCylinder cylinder;
cylinder.initialize(crank);
require(std::abs(cylinder.compute_volume(0.0f) - cylinder.clearance_volume()) < 1e-8f,
"cylinder volume at TDC must equal clearance volume");
require(std::abs(cylinder.compute_volume(pi) - cylinder.clearance_volume() - cylinder.swept_volume()) < 1e-8f,
"cylinder volume at BDC must include the swept volume");
TwoStrokeCylinder two_stroke{0.054f, 0.0545f, 0.110f, 7.2f, 140.0f, 195.0f};
two_stroke.initialize(crank);
two_stroke.intake_valve_diameter = 0.030f;
two_stroke.intake_valve_lift = 0.010f;
two_stroke.exhaust_valve_diameter = 0.040f;
two_stroke.exhaust_valve_lift = 0.010f;
require(two_stroke.valve_area(180.0f, true) > 0.0f &&
two_stroke.valve_area(0.0f, true) == 0.0f,
"two-stroke transfer port must open around BDC only");
require(two_stroke.valve_area(180.0f, false) > 0.0f &&
two_stroke.valve_area(0.0f, false) == 0.0f,
"two-stroke exhaust port must open around BDC only");
TwoStrokeScenario two_stroke_scenario;
two_stroke_scenario.set_throttle(0.25f);
for (int i = 0; i < 2000; ++i) two_stroke_scenario.process();
const auto two_stroke_state = two_stroke_scenario.snapshot();
require(std::isfinite(two_stroke_state.rpm) && std::isfinite(two_stroke_state.cylinder_pressure) &&
std::isfinite(two_stroke_state.crankcase_pressure) && std::isfinite(two_stroke_state.intake_pressure),
"two-stroke scenario must remain finite while running");
Crankcase crankcase{crank, 0.02725f, 0.110f, 0.054f, 150e-6f};
two_stroke.set_crankcase(crankcase);
crankcase.pre_step(1.0f / 44100.0f);
require(std::isfinite(crankcase.pressure()) && crankcase.pressure() > 0.0f &&
crankcase.volume() > 0.0f,
"crankcase must retain a finite thermodynamic state");
Atmosphere atmosphere;
require(std::abs(atmosphere.port.pressure - 101325.0f) < 1e-3f &&
std::abs(atmosphere.port.air_fraction - 1.0f) < 1e-6f,
"atmosphere must expose ambient air through its port");
OutdoorExhaustReverb reverb{44100};
bool reverb_tail = false;
for (int i = 0; i < 12000; ++i) {
const AudioFrame output = reverb.process_stereo(i == 0 ? 0.8f : 0.0f);
require(std::isfinite(output.left) && std::isfinite(output.right),
"outdoor reverb must remain finite after a transient");
if (i > 300 && (std::abs(output.left) > 1e-5f || std::abs(output.right) > 1e-5f)) reverb_tail = true;
}
require(reverb_tail, "outdoor reverb must produce a delayed reflection tail");
SoundProcessor flow_sound{44100, 1.0f};
for (int i = 0; i < 2000; ++i)
require(std::isfinite(flow_sound.process(i < 1000 ? 0.0f : 0.01f)),
"flow sound processor must remain finite for a flow step");
HelmholtzScenario helmholtz;
float resonator_peak = 0.0f;
for (int i = 0; i < 3000; ++i) {
const float sample = helmholtz.process();
require(std::isfinite(sample) && std::isfinite(helmholtz.cavity_pressure()),
"Helmholtz resonator must remain finite while discharging");
resonator_peak = std::max(resonator_peak, std::abs(sample));
}
require(resonator_peak > 0.0f, "overpressurized Helmholtz cavity must excite the neck");
PulseTestScenario pulse_test;
for (int i = 0; i < 3000; ++i) {
const float sample = pulse_test.process();
require(std::isfinite(sample) && std::isfinite(pulse_test.pipe().state(0).pressure),
"pulse test pipe must remain finite while the pressure pulse travels");
}
Volume0D chamber{1e-3f};
Port& chamber_port = chamber.create_port();
std::vector<Pipe1D> pipes;
pipes.emplace_back(1e-3f, 0.1f, 4);
BoundarySystem boundary{pipes};
float closed_area = 0.0f;
boundary.add_orifice(chamber_port, 0, false, closed_area);
const float initial_pressure = chamber.pressure();
boundary.resolve(1e-5f);
require(chamber_port.mass_flow == 0.0f, "closed orifice must have zero mass flow");
require(std::abs(chamber.pressure() - initial_pressure) < 1e-2f,
"resolving a closed orifice must not alter the chamber state");
Volume0D equal_pressure_volume{1e-3f};
Port& equal_pressure_port = equal_pressure_volume.create_port();
equal_pressure_port.density = 1.0f;
std::vector<Pipe1D> equal_pressure_pipes;
equal_pressure_pipes.emplace_back(1e-3f, 0.1f, 4);
BoundarySystem equal_pressure_boundary{equal_pressure_pipes};
float equal_pressure_area = 1e-4f;
equal_pressure_boundary.add_orifice(equal_pressure_port, 0, false, equal_pressure_area);
equal_pressure_boundary.resolve(1e-6f);
require(std::abs(equal_pressure_pipes.front().right_ghost.rho - equal_pressure_port.density) < 1e-6f,
"equal-pressure orifices must retain the volume-side density in the ghost state");
Volume0D configurable_volume{1e-3f, 150000.0f, 350.0f, 300.0f, 1.3f};
auto& configurable_port = configurable_volume.create_port();
require(std::abs(configurable_volume.temperature() - 350.0f) < 0.01f &&
std::abs(configurable_port.enthalpy - 1.3f / 0.3f * 300.0f * 350.0f) < 1.0f,
"0-D volumes must honor configurable gamma and gas constant");
configurable_volume.set_pressure(200000.0f, 400.0f);
require(std::abs(configurable_volume.pressure() - 200000.0f) < 0.1f &&
std::abs(configurable_volume.temperature() - 400.0f) < 1e-3f,
"setting volume pressure and temperature must update conserved mass and energy");
configurable_volume.energy_relaxation_rate = 0.0f;
configurable_volume.volume_change_rate = 0.01f;
const float before_volume_work = configurable_volume.pressure();
configurable_volume.update_state(1e-3f);
require(configurable_volume.pressure() < before_volume_work &&
std::abs(configurable_volume.pressure() - before_volume_work * std::pow(1.0f / 1.01f, 1.3f)) < 1.0f,
"moving-volume work must conserve the closed adiabatic p V^gamma invariant");
Pipe1D profiled_pipe{{0.001f, 0.002f, 0.003f}, {0.1f, 0.2f, 0.3f}};
require(std::abs(profiled_pipe.cell_mass(1) - 1.225f * 0.002f * 0.2f) < 1e-8f &&
std::abs(profiled_pipe.minimum_cell_length() - 0.1f) < 1e-8f,
"profiled pipes must retain each cell's area and length for mass/CFL calculations");
Vehicle bike;
bike.set_first_gear();
const float rpm = 10000.0f;
const float first_gear_speed = (rpm * 2.0f * pi / 60.0f) / bike.total_gear_ratio() * bike.wheel_radius * 3.6f;
require(first_gear_speed > 55.0f && first_gear_speed < 65.0f,
"first gear should be geared for about 60 km/h at the rev limit");
Vehicle neutral;
neutral.update(500.0f, 0.04f, 1e-3f);
require(neutral.speed == 0.0f, "neutral must isolate the driveline");
bike.clutch_input = 0.0f;
for (int i = 0; i < 2000; ++i) bike.update(500.0f, 0.04f, 1e-3f);
require(bike.speed > 0.0f && bike.kinetic_energy() > 0.0f,
"clutch impulse must transfer engine angular momentum into the vehicle");
FourStrokeTwinScenario scenario;
scenario.throttle = 0.35f;
for (int i = 0; i < 10000; ++i) scenario.process();
require(std::isfinite(scenario.crankshaft.omega) && scenario.crankshaft.omega > 0.0f,
"twin scenario must retain a finite running crank speed");
require(std::isfinite(scenario.intake_plenum.pressure()) && std::isfinite(scenario.exhaust_collector.pressure()),
"shared intake and exhaust volumes must remain finite");
require(scenario.cylinder1.pressure > 0.0f && scenario.cylinder2.pressure > 0.0f,
"both cylinders must retain positive pressure");
FourStrokeTwinScenario idling;
for (int i = 0; i < 6 * 44100; ++i) idling.process();
const auto idle_state = idling.snapshot();
require(idle_state.idle_control && idle_state.rpm > 1000.0f && idle_state.rpm < 3000.0f,
"ECU idle control must sustain the unloaded twin without artificial torque");
require(idle_state.throttle > 0.0f && idle_state.pedal == 0.0f,
"idle control must open the butterfly independently of the pedal");
for (int i = 0; i < 1000; ++i) {
const AudioFrame frame = scenario.process();
require(std::isfinite(frame.left) && std::isfinite(frame.right),
"the integrated fluid-derived audio path must remain finite");
}
std::cout << "FluidSim C++ physics tests passed\n";
return 0;
}