Add turbocharged twin scenario and cross-platform test harness

This commit is contained in:
2026-10-23 02:27:03 +02:00
parent a859740a95
commit 589d22aa5e
63 changed files with 4903 additions and 771 deletions

75
tests/b205_regression.cpp Normal file
View 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
View 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;
}

View 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';
}

View 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";
}

View File

@@ -1,4 +1,6 @@
#include "FluidSim/Scenario.hpp"
#include "FluidSim/SimulationRingBuffer.hpp"
#include <array>
#include "FluidSim/Atmosphere.hpp"
#include "FluidSim/Crankcase.hpp"
#include "FluidSim/DynoCurve.hpp"
@@ -26,6 +28,60 @@ void require(bool condition, const char* message) {
int main() {
using namespace fluidsim::port;
Ecu controller;
constexpr float ecu_dt = 1.0f / 44100.0f;
controller.step(ecu_dt, 1.0f, 2000.0f);
require(controller.throttle_opening() > 0.0f && controller.throttle_opening() <= 2.0f * ecu_dt,
"ECU throttle must obey its per-second travel limit");
for (int i = 0; i < 44100; ++i) controller.step(ecu_dt, 1.0f, 2000.0f);
require(controller.throttle_opening() > 0.99f && controller.target_afr() < 13.0f,
"ECU must reach full opening and enrich the high-load mixture");
const float before_close = controller.throttle_opening();
controller.step(ecu_dt, 0.0f, 2000.0f);
require(controller.throttle_opening() >= before_close - 2.01f * ecu_dt,
"ECU must also rate-limit throttle closing");
controller.step(ecu_dt, 1.0f, 10001.0f);
require(controller.fuel_cut() && controller.fuel_for_air(0.001f) == 0.0f,
"ECU rev limit must inhibit injection");
controller.step(ecu_dt, 1.0f, 9900.0f);
require(controller.rev_limited(), "ECU rev limiter must latch through hysteresis");
controller.rev_limit_enabled = false;
controller.step(ecu_dt, 1.0f, 15000.0f);
require(!controller.rev_limited() && !controller.fuel_cut() && controller.fuel_for_air(.001f) > 0,
"disabling a latched limiter must restore fuel above the old limit");
controller.rev_limit_enabled = true;
controller.step(ecu_dt, 1.0f, 9700.0f);
require(!controller.fuel_cut() && controller.fuel_for_air(0.001f) > 0.0f,
"ECU must resume injection below the rev recovery threshold");
controller.step(ecu_dt, 0.0f, 4000.0f);
require(controller.fuel_cut(), "closed-pedal overrun must cut injection");
controller.step(ecu_dt, 0.0f, 1300.0f);
require(!controller.fuel_cut() && controller.idle_active(), "idle catch must restore injection");
controller.step(ecu_dt, 0.0f, std::nanf(""));
require(controller.sensor_fault() && controller.fuel_cut(), "invalid sensor data must inhibit fuel");
// Unit-rate playback must preserve every sample across callback boundaries.
SimulationRingBuffer audio_ring(8);
for (int i = 0; i < 8; ++i) audio_ring.write(float(i), -float(i));
std::array<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");
@@ -42,6 +98,15 @@ int main() {
"dyno bins must retain their peak power and torque");
dyno_curve.reset();
require(dyno_curve.bins().empty(), "dyno reset must clear accumulated history");
RadiationSource propagation;
int first_arrival = -1;
for (int i = 0; i < 240; ++i) {
const auto frame = propagation.process(i == 0 ? 0.001f : 0.0f);
if (first_arrival < 0 && (frame.left != 0.0f || frame.right != 0.0f)) first_arrival = i;
}
require(first_arrival >= 190 && first_arrival <= 195,
"1.5 m propagation must delay sound about 193 samples at 44.1 kHz");
ThreadLoadTracker thread_load;
thread_load.record(1.0, 2.0);
require(std::abs(thread_load.load_percent() - 2.5) < 1e-9 &&
@@ -159,8 +224,8 @@ int main() {
const float before_volume_work = configurable_volume.pressure();
configurable_volume.update_state(1e-3f);
require(configurable_volume.pressure() < before_volume_work &&
std::abs(configurable_volume.pressure() - before_volume_work * 0.997f) < 1.0f,
"moving-volume work must remove p*dV energy using the configured volume rate");
std::abs(configurable_volume.pressure() - before_volume_work * std::pow(1.0f / 1.01f, 1.3f)) < 1.0f,
"moving-volume work must conserve the closed adiabatic p V^gamma invariant");
Pipe1D profiled_pipe{{0.001f, 0.002f, 0.003f}, {0.1f, 0.2f, 0.3f}};
require(std::abs(profiled_pipe.cell_mass(1) - 1.225f * 0.002f * 0.2f) < 1e-8f &&
@@ -191,6 +256,14 @@ int main() {
require(scenario.cylinder1.pressure > 0.0f && scenario.cylinder2.pressure > 0.0f,
"both cylinders must retain positive pressure");
FourStrokeTwinScenario idling;
for (int i = 0; i < 6 * 44100; ++i) idling.process();
const auto idle_state = idling.snapshot();
require(idle_state.idle_control && idle_state.rpm > 1000.0f && idle_state.rpm < 3000.0f,
"ECU idle control must sustain the unloaded twin without artificial torque");
require(idle_state.throttle > 0.0f && idle_state.pedal == 0.0f,
"idle control must open the butterfly independently of the pedal");
scenario.toggle_mechanical_sound();
for (int i = 0; i < 1000; ++i) {
const AudioFrame frame = scenario.process();

115
tests/turbo_physics.cpp Normal file
View 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";
}

View 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";
}

View 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';
}

View 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";
}