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

View File

@@ -5,168 +5,184 @@
namespace fluidsim::port {
FourStrokeTwinScenario::FourStrokeTwinScenario(int sample_rate)
: pipes(create_pipes()),
dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))),
boundaries_(pipes), solver_(pipes, boundaries_),
intake_radiation_(sample_rate), exhaust_radiation_(sample_rate),
reverb_(sample_rate), mechanical_sound_(sample_rate) {
crankshaft.omega = 2000.0f * 2.0f * pi / 60.0f;
crankshaft.inertia = 0.04f;
crankshaft.friction_constant = 5.0f;
crankshaft.friction_viscous = 0.003f;
FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selected_preset, bool turbo_enabled)
: preset(selected_preset),
intake_plenum(selected_preset == FourStrokePreset::SaabB205 ? 3e-3f : 1e-3f, 101325, 300),
exhaust_collector(selected_preset == FourStrokePreset::SaabB205 ? .6e-3f : .15e-3f, 101325, 700),
pipes(create_pipes(selected_preset, turbo_enabled)), dt_(1.f / std::max(sample_rate, 1)),
boundaries_(pipes), solver_(pipes, boundaries_), audio_(sample_rate), mechanical_sound_(sample_rate) {
const bool saab = preset == FourStrokePreset::SaabB205;
const int count = cylinder_count();
crankshaft.omega = (saab ? 1200 : 2000) * 2 * pi / 60;
crankshaft.inertia = saab ? .18f : .04f;
crankshaft.friction_constant = saab ? 12.f : 5.f;
crankshaft.friction_viscous = saab ? .012f : .003f;
if (saab) {
// Rough 9-5 wagon with driver; FM55 B05 overall reductions from WIS.
vehicle.mass = 1600 + 75;
vehicle.primary_reduction = 1;
vehicle.final_drive_ratio = 4.05f;
vehicle.gear_ratios = {13.70f/4.05f, 7.12f/4.05f, 4.77f/4.05f, 3.62f/4.05f, 2.67f/4.05f};
vehicle.wheel_radius = .317f;
vehicle.rotational_inertia = 3.6f;
vehicle.drag_coefficient = .32f;
vehicle.frontal_area = 2.2f;
vehicle.clutch_capacity = 300;
ecu.idle_rpm = 850;
ecu.idle_spark_gain = .025f;
ecu.idle_minimum_advance = -10;
ecu.rev_limit_rpm = 6500;
ecu.rev_resume_rpm = 6250;
ecu.overrun_cut_rpm = 2000;
ecu.overrun_resume_rpm = 1300;
}
vehicle.set_neutral();
vehicle.clutch_input = clutch;
cylinder1.intake_valve_diameter = 0.030f;
cylinder1.intake_valve_lift = 0.0080f;
cylinder1.exhaust_valve_diameter = 0.026f;
cylinder1.exhaust_valve_lift = 0.0075f;
cylinder2.phase = pi;
cylinder2.intake_valve_diameter = 0.030f;
cylinder2.intake_valve_lift = 0.0080f;
cylinder2.exhaust_valve_diameter = 0.026f;
cylinder2.exhaust_valve_lift = 0.0075f;
cylinder1.rev_limit_enabled = cylinder2.rev_limit_enabled = true;
cylinder1.rev_limit_rpm = cylinder2.rev_limit_rpm = 10000.0f;
cylinder1.rev_limit_cut_time = cylinder2.rev_limit_cut_time = 0.020f;
cylinder1.rev_limit_hysteresis = cylinder2.rev_limit_hysteresis = 250.0f;
cylinder1.initialize(crankshaft);
cylinder2.initialize(crankshaft);
const float intake_area = area_for_diameter(0.0275f);
const float exhaust_area = area_for_diameter(0.028f);
runner_area_ = intake_area;
header_area_ = tail_area_ = exhaust_area;
for (auto& pipe : pipes) {
pipe.damping_multiplier = 1.0f;
pipe.energy_relaxation_rate = 0.5f;
pipe.ambient_pressure = 101325.0f;
const auto engines = cylinders();
const std::array<float, 4> phases{0, pi, 3*pi, 2*pi}; // I4 firing: 1-3-4-2
for (int i = 0; i < count; ++i) {
auto& c = *engines[i];
c.phase = phases[i];
if (saab) {
c.bore = .090f;
c.stroke = .078f;
c.rod = .153f; // estimated, not a verified B205 rod measurement
c.compression = 8.8f;
c.intake_valve_count = c.exhaust_valve_count = 2;
}
c.intake_valve_diameter = saab ? .032f : .030f;
c.intake_valve_lift = saab ? .0085f : .008f;
c.exhaust_valve_diameter = saab ? .028f : .026f;
c.exhaust_valve_lift = saab ? .008f : .0075f;
c.rev_limit_enabled = true;
c.rev_limit_rpm = ecu.rev_limit_rpm;
c.rev_limit_hysteresis = 250;
c.rev_limit_cut_time = .020f;
c.ecu = &ecu;
c.initialize(crankshaft);
if (saab) {
// Initialize each phased cylinder at its actual piston position.
c.volume = c.compute_volume(c.phase);
c.air_mass = ambient_density * c.volume;
c.energy = 101325 * c.volume / (gamma - 1);
c.update_state(0);
}
}
throttle_port_ = &intake_plenum.create_port();
runner_feed1_ = &intake_plenum.create_port();
runner_feed2_ = &intake_plenum.create_port();
collector_in1_ = &exhaust_collector.create_port();
collector_in2_ = &exhaust_collector.create_port();
collector_out_ = &exhaust_collector.create_port();
intake_radiation_.pipe_end = {-0.20f, 0.0f, 0.0f};
intake_radiation_.pipe_axis = {-1.0f, 0.0f, 0.0f};
intake_radiation_.gain = 30.0f;
intake_radiation_.directivity_balance = 0.30f;
intake_radiation_.flow_tau = 0.005f;
intake_radiation_.smooth_tau = 0.020f;
exhaust_radiation_.pipe_end = {0.55f, 0.0f, 0.0f};
exhaust_radiation_.pipe_axis = {1.0f, 0.0f, 0.0f};
exhaust_radiation_.gain = 25.0f;
exhaust_radiation_.directivity_balance = 0.30f;
exhaust_radiation_.flow_tau = 0.005f;
exhaust_radiation_.smooth_tau = 0.020f;
reverb_.dry_mix = 0.0f;
reverb_.early_mix = 0.30f;
reverb_.tail_mix = 0.22f;
reverb_.transient_feedback_boost = 0.40f;
reverb_.transient_hf_boost = 3.5f;
reverb_.saturation_drive = 1.5f;
solver_.add_component(cylinder1);
solver_.add_component(cylinder2);
intake_boundary_area_ = pipes.front().area;
runner_area_ = pipes[1].area;
header_area_ = pipes[1+count].area;
tail_area_ = pipes[1+2*count].area;
for (auto& pipe : pipes) {
pipe.damping_multiplier = saab ? 0.f : 1.f;
pipe.energy_relaxation_rate = 0;
}
auto& throttle_port = intake_plenum.create_port();
solver_.add_component(intake_atmosphere);
solver_.add_component(intake_plenum);
solver_.add_component(exhaust_collector);
boundaries_.add_open_end(0, true, 101325.0f, intake_area);
boundaries_.add_open_end(5, false, 101325.0f, exhaust_area);
boundaries_.add_orifice(*throttle_port_, 0, false, throttle_area_, 0.7f);
boundaries_.add_orifice(*runner_feed1_, 1, true, runner_area_);
boundaries_.add_orifice(*runner_feed2_, 2, true, runner_area_);
boundaries_.add_orifice(cylinder1.intake, 1, false, valve1_in_);
boundaries_.add_orifice(cylinder2.intake, 2, false, valve2_in_);
boundaries_.add_orifice(cylinder1.exhaust, 3, true, valve1_out_);
boundaries_.add_orifice(cylinder2.exhaust, 4, true, valve2_out_);
boundaries_.add_orifice(*collector_in1_, 3, false, header_area_);
boundaries_.add_orifice(*collector_in2_, 4, false, header_area_);
boundaries_.add_orifice(*collector_out_, 5, true, tail_area_);
if (turbo_enabled) {
turbo = std::make_unique<Turbocharger>();
const int fresh = 2+2*count, exhaust_exit = fresh+1;
boundaries_.add_orifice(intake_atmosphere.port, fresh, true, intake_boundary_area_);
turbo->connect(boundaries_, pipes, fresh, false, 0, true, 1+2*count, false, exhaust_exit, true);
solver_.add_component(*turbo);
} else {
boundaries_.add_orifice(intake_atmosphere.port, 0, true, intake_boundary_area_);
}
boundaries_.add_orifice(throttle_port, 0, false, throttle_area_, .7f);
for (int i = 0; i < count; ++i) {
auto& feed = intake_plenum.create_port();
auto& collector_in = exhaust_collector.create_port();
solver_.add_component(*engines[i]);
boundaries_.add_orifice(feed, 1+i, true, runner_area_);
boundaries_.add_orifice(engines[i]->intake, 1+i, false, intake_valve_areas_[i]);
boundaries_.add_orifice(engines[i]->exhaust, 1+count+i, true, exhaust_valve_areas_[i]);
boundaries_.add_orifice(collector_in, 1+count+i, false, header_area_);
}
auto& collector_out = exhaust_collector.create_port();
boundaries_.add_orifice(collector_out, 1+2*count, true, tail_area_);
const int exhaust_exit = turbo_enabled ? 3+2*count : 1+2*count;
boundaries_.add_open_end(exhaust_exit, false, 101325, pipes[exhaust_exit].area);
}
AudioFrame FourStrokeTwinScenario::process() {
const std::lock_guard<std::mutex> guard(mutex_);
crankshaft.load_torque = std::clamp(load, 0.0f, 1.0f) * 30.0f;
vehicle.clutch_input = std::clamp(clutch, 0.0f, 1.0f);
crankshaft.step(dt_);
crankshaft.drivetrain_torque = vehicle.update(crankshaft.omega, crankshaft.inertia, dt_);
cylinder1.pre_step(crankshaft, dt_, throttle);
cylinder2.pre_step(crankshaft, dt_, throttle);
const float throttle_fraction = std::clamp(throttle, 0.0f, 1.0f);
throttle_area_ = throttle_fraction <= 0.001f ? 0.0f : area_for_diameter(0.026f) * throttle_fraction;
const auto cycle_degrees = [this](float phase) {
float value = std::fmod((crankshaft.angle + phase) * 180.0f / pi, 720.0f);
return value < 0.0f ? value + 720.0f : value;
};
valve1_in_ = cylinder1.valve_area(cycle_degrees(cylinder1.phase), true);
valve2_in_ = cylinder2.valve_area(cycle_degrees(cylinder2.phase), true);
valve1_out_ = cylinder1.valve_area(cycle_degrees(cylinder1.phase), false);
valve2_out_ = cylinder2.valve_area(cycle_degrees(cylinder2.phase), false);
AudioFrame FourStrokeScenario::process() {
const std::lock_guard guard(mutex_);
step_motorcycle(crankshaft, dt_, preset == FourStrokePreset::SaabB205 ? 200.f : 30.f);
ecu.step(dt_, throttle, crankshaft.omega * 60 / (2*pi));
const auto engines = cylinders();
for (int i = 0; i < cylinder_count(); ++i) {
auto& c = *engines[i];
c.pre_step(crankshaft, dt_, throttle);
const float degrees = std::fmod((crankshaft.angle+c.phase)*180/pi, 720.f);
intake_valve_areas_[i] = c.valve_area(degrees, true);
exhaust_valve_areas_[i] = c.valve_area(degrees, false);
}
const float angle = ecu.throttle_opening() * pi * .5f;
throttle_area_ = area_for_diameter(preset == FourStrokePreset::SaabB205 ? .052f : .026f) * (1-std::cos(angle));
solver_.step(dt_);
intake_flow = boundaries_.open_end_mass_flow(0);
exhaust_flow = boundaries_.open_end_mass_flow(1);
const AudioFrame intake_audio = intake_radiation_.process(intake_flow);
const AudioFrame exhaust_audio = exhaust_radiation_.process(exhaust_flow);
intake_flow = intake_atmosphere.port.mass_flow;
exhaust_flow = boundaries_.open_end_mass_flow(0);
const auto rendered = audio_.process(intake_flow, exhaust_flow);
const float mechanical = mechanical_sound_enabled
? mechanical_sound_.process(crankshaft, cylinder1, load) * mechanical_mix : 0.0f;
const float reverb_input = intake_radiation_.last_mono_pressure() * intake_send +
exhaust_radiation_.last_mono_pressure() * exhaust_send +
mechanical * mechanical_send;
const AudioFrame wet = reverb_.process_stereo(reverb_input);
const float flow_left = std::tanh(intake_audio.left + exhaust_audio.left + wet.left * reverb_send_gain);
const float flow_right = std::tanh(intake_audio.right + exhaust_audio.right + wet.right * reverb_send_gain);
last_mix_l = std::isfinite(flow_left + mechanical)
? std::clamp(flow_left + mechanical, -1.0f, 1.0f) : 0.0f;
last_mix_r = std::isfinite(flow_right + mechanical)
? std::clamp(flow_right + mechanical, -1.0f, 1.0f) : 0.0f;
? mechanical_sound_.process(crankshaft, cylinder1, load) * mechanical_mix : 0;
last_mix_l = rendered.left + mechanical;
last_mix_r = rendered.right + mechanical;
++sample_count;
return {last_mix_l, last_mix_r};
}
void FourStrokeTwinScenario::set_controls(float throttle_value, float load_value, float clutch_value) {
void FourStrokeScenario::set_audio_scene(OutdoorScene scene) {
const std::lock_guard<std::mutex> guard(mutex_); audio_.set_scene(scene);
}
void FourStrokeScenario::set_monitor_filter(bool enabled) {
const std::lock_guard<std::mutex> guard(mutex_); audio_.set_monitor_filter(enabled);
}
AudioFrame FourStrokeScenario::dry_audio() const {
const std::lock_guard<std::mutex> guard(mutex_); return audio_.dry_frame();
}
AudioFrame FourStrokeScenario::unfiltered_audio() const {
const std::lock_guard<std::mutex> guard(mutex_); return audio_.unfiltered_frame();
}
void FourStrokeScenario::set_controls(float throttle_value, float load_value, float clutch_value) {
const std::lock_guard<std::mutex> guard(mutex_);
throttle = std::clamp(throttle_value, 0.0f, 1.0f);
load = std::clamp(load_value, 0.0f, 1.0f);
clutch = std::clamp(clutch_value, 0.0f, 1.0f);
}
void FourStrokeTwinScenario::toggle_mechanical_sound() {
void FourStrokeScenario::toggle_mechanical_sound() {
const std::lock_guard<std::mutex> guard(mutex_);
mechanical_sound_enabled = !mechanical_sound_enabled;
}
void FourStrokeTwinScenario::shift_up() {
void FourStrokeScenario::shift_up() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.shift_up();
}
void FourStrokeTwinScenario::shift_down() {
void FourStrokeScenario::shift_down() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.shift_down();
}
void FourStrokeTwinScenario::set_neutral() {
void FourStrokeScenario::set_neutral() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.set_neutral();
}
void FourStrokeTwinScenario::set_first_gear() {
void FourStrokeScenario::set_first_gear() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.set_first_gear();
}
ScenarioSnapshot FourStrokeTwinScenario::snapshot() const {
ScenarioSnapshot FourStrokeScenario::snapshot() const {
const std::lock_guard<std::mutex> guard(mutex_);
ScenarioSnapshot result;
result.turbo_enabled = bool(turbo);
if (turbo) result.turbo = turbo->snapshot();
result.solver_substeps = solver_.substep_count();
result.boundary_iterations = boundaries_.nonlinear_iterations;
result.rpm = crankshaft.omega * 60.0f / (2.0f * pi);
result.speed_kmh = vehicle.speed * 3.6f;
result.kinetic_energy = vehicle.kinetic_energy();
@@ -178,13 +194,18 @@ ScenarioSnapshot FourStrokeTwinScenario::snapshot() const {
result.cylinder_temperature2 = cylinder2.temperature();
result.cylinder_piston_fraction1 = (cylinder1.volume - cylinder1.clearance_volume()) / cylinder1.swept_volume();
result.cylinder_piston_fraction2 = (cylinder2.volume - cylinder2.clearance_volume()) / cylinder2.swept_volume();
result.valve1_intake_area = valve1_in_;
result.valve1_exhaust_area = valve1_out_;
result.valve2_intake_area = valve2_in_;
result.valve2_exhaust_area = valve2_out_;
result.valve1_intake_area = intake_valve_areas_[0];
result.valve1_exhaust_area = exhaust_valve_areas_[0];
result.valve2_intake_area = intake_valve_areas_[1];
result.valve2_exhaust_area = exhaust_valve_areas_[1];
result.intake_pressure = intake_plenum.pressure();
result.exhaust_pressure = exhaust_collector.pressure();
result.throttle = throttle;
result.throttle = ecu.throttle_opening();
result.pedal = throttle;
result.target_afr = ecu.target_afr();
result.idle_control = ecu.idle_active();
result.fuel_cut = ecu.fuel_cut();
result.audio_scene = audio_.scene();
result.clutch = clutch;
result.intake_flow = intake_flow;
result.exhaust_flow = exhaust_flow;
@@ -192,31 +213,39 @@ ScenarioSnapshot FourStrokeTwinScenario::snapshot() const {
result.rev_limit1 = cylinder1.rev_limit_active;
result.rev_limit2 = cylinder2.rev_limit_active;
result.mechanical_sound_enabled = mechanical_sound_enabled;
for (std::size_t pipe_index = 0; pipe_index < pipes.size(); ++pipe_index) {
auto& states = result.pipe_states[pipe_index];
states.reserve(pipes[pipe_index].cells.size());
for (std::size_t cell = 0; cell < pipes[pipe_index].cells.size(); ++cell)
states.push_back(pipes[pipe_index].state(static_cast<int>(cell)));
result.cylinder_count = cylinder_count();
result.rev_limit_rpm = ecu.rev_limit_rpm;
const std::array<const FourStrokeCylinder*, 4> engines{&cylinder1, &cylinder2, &cylinder3, &cylinder4};
for (int i = 0; i < cylinder_count(); ++i) {
result.cylinder_pressures[i] = engines[i]->pressure;
result.piston_fractions[i] = (engines[i]->volume-engines[i]->clearance_volume())/engines[i]->swept_volume();
result.intake_areas[i] = intake_valve_areas_[i];
result.exhaust_areas[i] = exhaust_valve_areas_[i];
}
result.capture_pipes(pipes);
return result;
}
float FourStrokeScenario::area_for_diameter(float diameter) {
return pi * diameter * diameter * 0.25f;
}
std::vector<Pipe1D> FourStrokeScenario::create_pipes(FourStrokePreset preset, bool turbo_enabled) {
const bool saab = preset == FourStrokePreset::SaabB205;
const int count = saab ? 4 : 2;
std::vector<Pipe1D> result;
result.reserve(4+2*count);
result.emplace_back(area_for_diameter(saab ? .060f : .0275f), saab ? .15f : .06f, saab ? 8 : 10);
for (int i = 0; i < count; ++i)
result.emplace_back(area_for_diameter(saab ? .032f : .0275f), saab ? .25f : .12f, 10);
for (int i = 0; i < count; ++i)
result.emplace_back(area_for_diameter(saab ? .034f : .028f), saab ? .28f : .20f, saab ? 10 : 6);
result.emplace_back(area_for_diameter(saab ? .060f : .028f), turbo_enabled ? .12f : (saab ? 1.4f : .40f), saab ? 40 : 10);
if (turbo_enabled) {
result.emplace_back(area_for_diameter(saab ? .060f : .0275f), .10f, 8);
result.emplace_back(area_for_diameter(saab ? .060f : .032f), .60f, 24);
}
return result;
}
float FourStrokeTwinScenario::area_for_diameter(float diameter) {
return pi * diameter * diameter * 0.25f;
}
std::vector<Pipe1D> FourStrokeTwinScenario::create_pipes() {
const float intake_area = area_for_diameter(0.0275f);
const float exhaust_area = area_for_diameter(0.028f);
std::vector<Pipe1D> result;
result.reserve(6);
result.emplace_back(intake_area, 0.06f, 10);
result.emplace_back(intake_area, 0.12f, 10);
result.emplace_back(intake_area, 0.12f, 10);
result.emplace_back(exhaust_area, 0.20f, 6);
result.emplace_back(exhaust_area, 0.20f, 6);
result.emplace_back(exhaust_area, 0.40f, 10);
return result;
}
} // namespace fluidsim::port