328 lines
18 KiB
C++
328 lines
18 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");
|
|
const auto vacuum_before=desktop::pressure_tint(40000),vacuum_after=desktop::pressure_tint(40000,true);
|
|
const auto small_intake_pulse=desktop::pressure_tint(104325),small_exhaust_pulse=desktop::pressure_tint(104325,true);
|
|
require(vacuum_before.x==vacuum_after.x&&vacuum_before.y==vacuum_after.y&&vacuum_before.z==vacuum_after.z,
|
|
"exhaust highlighting must not change the established underpressure palette");
|
|
require(small_intake_pulse.x>.9f&&small_intake_pulse.y>.8f&&small_intake_pulse.z<.1f,
|
|
"a small positive intake pulse must be visible as yellow");
|
|
require(small_exhaust_pulse.x>.95f&&small_exhaust_pulse.y<.4f,
|
|
"a small positive exhaust pulse must be visible as orange-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;
|
|
}
|