Files
FluidSimCpp/tests/physics_tests.cpp
2026-10-22 23:52:20 +02:00

206 lines
10 KiB
C++

#include "FluidSim/Scenario.hpp"
#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/ThreadLoadTracker.hpp"
#include "FluidSim/Units.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;
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");
ThreadLoadTracker thread_load;
thread_load.record(1.0, 2.0);
require(std::abs(thread_load.load_percent() - 2.5) < 1e-9 &&
std::abs(thread_load.last_step_microseconds() - 1000.0) < 1e-9,
"thread load tracker must report EMA budget use and last-step time");
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 * 0.997f) < 1.0f,
"moving-volume work must remove p*dV energy using the configured volume rate");
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");
scenario.toggle_mechanical_sound();
for (int i = 0; i < 1000; ++i) {
const AudioFrame frame = scenario.process();
require(std::isfinite(frame.left) && std::isfinite(frame.right),
"the integrated mechanical/reverb audio path must remain finite");
}
require(scenario.snapshot().mechanical_sound_enabled,
"mechanical-noise toggle must be reflected in the scenario snapshot");
std::cout << "FluidSim C++ physics tests passed\n";
return 0;
}