Load engines from TOML and improve scenario audio and carb tuning

Migrate 32 engine definitions into validated startup-loaded TOML files and separate configuration and snapshots from scenario assembly. Add camera-based stereo propagation from shared pipe geometry, preserve exhaust character, tune carburetor mixtures and idle, use ignition cuts for carb engines, and support returning to the scenario menu.

Validation: full Windows build and all 14 CTest checks passed.
This commit is contained in:
max
2026-10-24 03:24:35 +02:00
parent 5607dcf2b5
commit bf8621ad39
82 changed files with 24084 additions and 648 deletions

View File

@@ -4,6 +4,7 @@ namespace fluidsim::port {
struct AudioFrame { float left{}, right{}; };
struct Vec3 { float x{}, y{}, z{}; };
struct AudioListener { Vec3 position{}, right{1,0,0}; };
// Simple flow-derivative source used by the Helmholtz and pulse scenarios.
class SoundProcessor {

View File

@@ -5,21 +5,30 @@
namespace fluidsim::port {
// Lumped slide-carburetor model for the crankcase-scavenged two-stroke. The
// Lumped carburetor shared by two-stroke and four-stroke scenarios. The
// jets use liquid-orifice flow from a float bowl into the measured venturi
// depression. Delivered fuel is injected into the downstream fluid volume and
// is then transported as a conserved species; this class never doses a cylinder.
class Carburetor {
public:
struct CarburetorConfig {
float fuel_density{740.0f};
float pilot_jet_diameter{0.00040f}, main_jet_diameter{0.00150f};
float needle_jet_diameter{0.00190f};
// Needle annulus area corrections at configured slide positions; jets meter
// liquid fuel from measured pressure, without AFR feedback.
float needle_low_scale{1.f}, needle_mid_scale{1.f};
float needle_off_idle_scale{1.f}, needle_transition_scale{1.f}, needle_tip_scale{1.f};
float needle_quarter_scale{1.f}, needle_upper_scale{1.f};
float pilot_transition_scale{1.f}, pilot_tip_scale{1.f};
float pilot_discharge_coefficient{0.62f}, main_discharge_coefficient{0.72f};
float bowl_head_pressure{};
float film_fraction{0.28f}, film_time_constant{0.11f};
float bowl_capacity{0.008f}, bowl_refill_rate{0.0025f}; // kg, kg/s
float overrun_rpm{4200.0f}, overrun_exit_rpm{3300.0f};
};
class Carburetor : public CarburetorConfig {
public:
explicit Carburetor(float throat_diameter = 0.0381f);
// air_mass_flow is positive from atmosphere toward the engine.

View File

@@ -1,5 +1,5 @@
#pragma once
#include "FluidSim/Scenario.hpp"
#include "FluidSim/ScenarioSnapshot.hpp"
#include "FluidSim/DynoCurve.hpp"
#include "FluidSim/Desktop/PedalControl.hpp"
#include "FluidSim/Desktop/EngineRenderer3D.hpp"
@@ -22,6 +22,7 @@ public:
void nudge_pedal(float amount) {pedal_.set(pedal_.value()+amount);}
void update_pedal(float dt,bool raise,bool lower) {if(!pedal_drag_)pedal_.advance(dt,raise,lower);}
bool handle_event(const sf::Event&,sf::Vector2u);
AudioListener listener(const ScenarioSnapshot&,sf::Vector2u);
void draw(sf::RenderWindow&,const sf::Font*,const ScenarioSnapshot&,const PlaybackInfo&);
private:
DynoCurve dyno_;

View File

@@ -1,5 +1,5 @@
#pragma once
#include "FluidSim/Scenario.hpp"
#include "FluidSim/ScenarioSnapshot.hpp"
#include "FluidSim/Desktop/OrbitCamera.hpp"
#include <SFML/Graphics.hpp>
#include <memory>

View File

@@ -7,6 +7,13 @@ namespace fluidsim::port::desktop {
struct OrbitCamera {
Vec3 target{.12f,.25f,0};
float yaw{-28},pitch{24},distance{2.2f};
AudioListener listener(float distance_scale=1, float target_offset_x=0) const {
const float y=yaw*.017453293f,p=pitch*.017453293f,d=distance*distance_scale;
// Inverse of the renderer's pitch/yaw world-to-view rotation.
return {{target.x+target_offset_x-std::sin(y)*std::cos(p)*d,
target.y+std::sin(p)*d,target.z+std::cos(y)*std::cos(p)*d},
{std::cos(y),0,std::sin(y)}};
}
void reset(float aspect) {target={.12f,.25f,0};yaw=-28;pitch=24;distance=std::max(.65f,1.6f/aspect)/.414214f;}
void orbit(sf::Vector2i delta) {yaw+=delta.x*.35f;pitch=std::clamp(pitch+delta.y*.3f,-75.f,80.f);}
void pan(sf::Vector2i delta,float height) {

View File

@@ -1,24 +1,28 @@
#pragma once
#include "FluidSim/Scenario.hpp"
#include "FluidSim/ScenarioCatalog.hpp"
#include "FluidSim/TwoStrokeScenario.hpp"
#include <memory>
#include <mutex>
namespace fluidsim::port {
// Shared desktop controls/snapshot contract. Two-stroke access is serialized;
// the twin already protects its own simulation state.
// Shared desktop controls and snapshots. Each simulation owns its state lock.
class DesktopScenario {
public:
explicit DesktopScenario(ScenarioKind kind, TwoStrokeConfig config = {}, ExhaustConfig exhaust = {}, V8ExhaustConfig v8 = {}, EngineConfig engine = {}, std::string name = {}) : name_(std::move(name)) {
explicit DesktopScenario(const ScenarioExample& e)
: DesktopScenario(e.kind,e.two,e.exhaust,e.junctions,e.engine,e.title) {}
explicit DesktopScenario(ScenarioKind kind, TwoStrokeConfig config = {}, ExhaustConfig exhaust = {}, JunctionConfig junctions = {}, EngineConfig engine = {}, std::string name = {}) : name_(std::move(name)) {
if (kind == ScenarioKind::TwoStroke)
two_ = std::make_unique<TwoStrokeScenario>(44100, config);
else
twin_ = std::make_unique<FourStrokeScenario>(44100, kind == ScenarioKind::SaabB205 ?
FourStrokePreset::SaabB205 : kind == ScenarioKind::AmericanV8 ? FourStrokePreset::AmericanV8 : FourStrokePreset::Twin, exhaust, v8,engine);
else {
if(!engine.cylinders)engine=preset_engine_definition(kind==ScenarioKind::SaabB205?
FourStrokePreset::SaabB205:kind==ScenarioKind::AmericanV8?FourStrokePreset::AmericanV8:FourStrokePreset::Twin).engine;
four_ = std::make_unique<FourStrokeScenario>(44100,std::move(engine),exhaust,junctions);
}
}
AudioFrame process() {
if (twin_)
return twin_->process();
if (four_)
return four_->process();
const std::lock_guard lock(mutex_);
return two_->process();
}
@@ -33,8 +37,11 @@ public:
void set_audio_scene(OutdoorScene scene) {
visit([&](auto& scenario) { scenario.set_audio_scene(scene); });
}
void set_listener(AudioListener listener) {
visit([&](auto& scenario) { scenario.set_listener(listener); });
}
ScenarioSnapshot snapshot() const {
if (twin_) { auto s=twin_->snapshot();s.example_name=name_;return s; }
if (four_) { auto s=four_->snapshot();s.example_name=name_;return s; }
const std::lock_guard lock(mutex_);
const auto source = two_->snapshot();
ScenarioSnapshot s;
@@ -65,6 +72,7 @@ public:
s.valve1_exhaust_area = source.exhaust_area;
s.reed_open = source.reed_open;
s.fuel_cut = source.fuel_cut;
s.ignition_cut = source.ignition_cut;
s.idle_control = two_->ecu.idle_active();
s.rev_limit1 = two_->ecu.rev_limited();
s.audio_scene = source.audio_scene;
@@ -77,11 +85,11 @@ public:
private:
template<class Function> void visit(Function&& function) {
const std::lock_guard lock(mutex_);
if (twin_) function(*twin_);
if (four_) function(*four_);
else function(*two_);
}
std::string name_;
std::unique_ptr<FourStrokeTwinScenario> twin_;
std::unique_ptr<FourStrokeScenario> four_;
std::unique_ptr<TwoStrokeScenario> two_;
mutable std::mutex mutex_;
};

View File

@@ -4,8 +4,7 @@
namespace fluidsim::port {
// Ideal sensors and injectors; calibration parameters are not engine measurements.
class Ecu {
public:
struct EcuConfig {
enum class IdleControl { Feedback, FixedOpening };
IdleControl idle_control{IdleControl::Feedback};
struct IgnitionPoint { float rpm, degrees_btdc; };
@@ -18,18 +17,25 @@ public:
float throttle_closing_time_constant{}, throttle_closing_rate{};
float rev_limit_rpm{10000.0f}, rev_resume_rpm{9750.0f};
bool rev_limit_enabled{true};
bool rev_limit_fuel_cut_enabled{true};
float overrun_cut_rpm{3500.0f}, overrun_resume_rpm{2400.0f};
bool overrun_fuel_cut_enabled{true};
bool overrun_ignition_cut_enabled{false};
float idle_spark_gain{0.0f}, idle_minimum_advance{-5.0f}; // degrees/RPM, degrees BTDC
float stoichiometric_afr{14.7f}, power_afr{12.8f};
};
class Ecu : public EcuConfig {
public:
void step(float dt, float pedal, float rpm);
float fuel_for_air(float trapped_air_mass) const;
float throttle_opening() const { return opening_; }
float throttle_target() const { return target_; }
float target_afr() const { return afr_; }
float spark_advance() const { return spark_; }
bool fuel_cut() const { return limiter_ || (overrun_ && overrun_fuel_cut_enabled) || fault_; }
bool fuel_cut() const { return (limiter_ && rev_limit_fuel_cut_enabled) || (overrun_ && overrun_fuel_cut_enabled) || fault_; }
bool ignition_cut() const { return limiter_ || (overrun_ && overrun_ignition_cut_enabled) || fault_; }
bool rev_limited() const { return limiter_; }
bool idle_active() const { return idle_; }
bool sensor_fault() const { return fault_; }

View File

@@ -10,12 +10,16 @@ namespace fluidsim::port {
class Ecu;
class Crankshaft {
public:
float omega{400.0f * 2.0f * pi / 60.0f};
float angle{}, previous_angle{}, inertia{0.2f};
struct CrankshaftConfig {
float inertia{0.2f};
float friction_constant{}, friction_viscous{};
float friction_quadratic{}; // Nm / (rad/s)^2, dissipative windage term
};
class Crankshaft : public CrankshaftConfig {
public:
float omega{400.0f * 2.0f * pi / 60.0f};
float angle{}, previous_angle{};
float load_torque{}, drivetrain_torque{};
float average_power{}, average_torque{};
@@ -28,18 +32,22 @@ private:
float power_sum_{}, torque_sum_{};
};
class EngineCylinder : public Component {
public:
Ecu* ecu{}; // Optional ideal electronic fuel/ignition controller.
bool transported_fuel{}; // Upstream carb meters the fuel species.
struct CylinderConfig {
float intake_open{340}, intake_close{590}, exhaust_open{130}, exhaust_close{390};
float bore{0.075f}, stroke{0.068f}, rod{0.112f}, compression{11.5f};
float intake_valve_diameter{0.030f}, exhaust_valve_diameter{0.028f};
int intake_valve_count{1}, exhaust_valve_count{1};
float intake_valve_lift{0.005f}, exhaust_valve_lift{0.005f};
float phase{};
float spark_advance{20.0f}, wiebe_a{5.0f}, wiebe_m{2.0f}, burn_duration{60.0f}, burn_start{5.0f};
float afr{14.7f}, fuel_lhv{44.0e6f}, energy_variation{0.05f}, misfire_probability{};
float wall_area{0.02f}, heat_transfer{100.0f}, ambient_temperature{300.0f};
};
class EngineCylinder : public Component, public CylinderConfig {
public:
Ecu* ecu{};
bool transported_fuel{};
float phase{};
bool rev_limit_enabled{};
float rev_limit_rpm{7500.0f}, rev_limit_cut_time{0.05f}, rev_limit_hysteresis{200.0f};
float air_mass{}, exhaust_mass{}, energy{}, volume{}, pressure{101325.0f};
@@ -78,7 +86,6 @@ protected:
class FourStrokeCylinder final : public EngineCylinder {
public:
float intake_open{340.0f}, intake_close{590.0f}, exhaust_open{130.0f}, exhaust_close{390.0f};
float valve_area(float cycle_degrees, bool inlet) const override;
void pre_step(Crankshaft& crank, float dt, float throttle) override;
};

View File

@@ -0,0 +1,53 @@
#pragma once
#include "FluidSim/Engine.hpp"
#include "FluidSim/Ecu.hpp"
#include "FluidSim/Vehicle.hpp"
#include "FluidSim/Carburetor.hpp"
#include <string>
#include <vector>
namespace fluidsim::port {
enum class ScenarioKind { Twin, TwoStroke, SaabB205, AmericanV8 };
enum class FourStrokePreset { Twin, SaabB205, AmericanV8 };
enum class EngineLayout { Inline, V, Boxer };
enum class ExhaustTopology { Collector, Staged, AmericanV8 };
struct PipeConfig {
std::string id;
float diameter{}, length{}; // metres
int cells{}, count{1};
bool silenced{};
};
// Parsed calibration only. Runtime state belongs to the scenario components.
// Dimensional fields use SI units after loading.
struct EngineConfig {
int cylinders{};
EngineLayout layout{EngineLayout::Inline};
ExhaustTopology topology{ExhaustTopology::Collector};
float bank_angle{60};
CylinderConfig cylinder;
CrankshaftConfig crankshaft;
EcuConfig ecu;
VehicleConfig vehicle;
CarburetorConfig fuel;
bool carbureted{}, initialize_phased_charge{true};
float initial_rpm{}, starter_torque{}, maximum_load_torque{};
float plenum_volume{}, collector_volume{}, throttle_diameter{}, pipe_damping{};
int minimum_substeps{1};
std::vector<float> phase_degrees;
std::vector<PipeConfig> pipes;
bool has_silenced_exhaust() const { for(const auto& p:pipes)if(p.silenced)return true;return false; }
bool american_headers() const { return topology==ExhaustTopology::AmericanV8; }
bool staged_headers() const { return topology==ExhaustTopology::Staged; }
bool dual_outlets() const { return american_headers() || (staged_headers() && layout==EngineLayout::V); }
int header_groups() const { return cylinders==3?1:2; }
};
struct JunctionConfig {
bool h_pipe{true};
float junction_volume{50e-6f};
};
void validate_engine_config(const EngineConfig& config,const std::string& context="engine config");
}

View File

@@ -25,7 +25,7 @@ struct OutdoorAudioConfig {
float monitor_cutoff_hz{6000.0f}; // Second-order acquisition/rendering filter.
};
// Static geometric direct/first-order reflection paths plus an explicitly
// Moving geometric direct/first-order reflection paths plus an explicitly
// approximate diffuse tail. No feedback into the fluid/combustion simulation.
class OutdoorAudio {
public:
@@ -35,6 +35,9 @@ public:
AudioFrame process(float intake_mass_flow, float exhaust_mass_flow, float second_exhaust_mass_flow = 0);
void set_scene(OutdoorScene scene) { requested_scene_.store(scene, std::memory_order_relaxed); }
OutdoorScene scene() const { return requested_scene_.load(std::memory_order_relaxed); }
// Call on the simulation thread or under the scenario's simulation lock.
void set_listener(AudioListener listener);
void set_source_positions(const std::array<Vec3,3>& positions);
void set_monitor_filter(bool enabled) { monitor_filter_ = enabled; } // Set before playback/capture.
AudioFrame dry_frame() const { return last_dry_; }
AudioFrame unfiltered_frame() const { return last_raw_; }
@@ -45,8 +48,8 @@ private:
float process(float x);
};
struct Path {
std::size_t delay_left{}, delay_right{};
float fraction_left{}, fraction_right{}, gain_left{}, gain_right{}, alpha{1};
float delay_left{}, delay_right{}, gain_left{}, gain_right{}, alpha{1};
float target_delay_left{}, target_delay_right{}, target_gain_left{}, target_gain_right{};
float state_left{}, state_right{};
int group{}; // direct, ground, wall, trees, tunnel
};
@@ -55,6 +58,7 @@ private:
std::vector<Path> paths;
std::size_t position{};
float previous_flow{};
float center_delay{}, target_center_delay{}, center_gain{}, target_center_gain{};
LowPass filter;
};
int rate_{};
@@ -66,8 +70,10 @@ private:
OutdoorExhaustReverb late_reverb_;
std::array<float, 5> weights_{1, 0, 0, 0, 0};
float scene_alpha_{}, tail_mix_{};
float geometry_alpha_{}, reverb_input_{};
AudioFrame last_dry_{}, last_raw_{};
void configure_emitter(Emitter &emitter, Vec3 position);
void update_geometry();
AudioFrame render(Emitter &emitter, float flow, AudioFrame &dry, AudioFrame &raw);
};
} // namespace fluidsim::port

View File

@@ -1,5 +1,7 @@
#pragma once
#include "FluidSim/Silencer.hpp"
#include "FluidSim/EngineConfig.hpp"
#include "FluidSim/ScenarioSnapshot.hpp"
#include "FluidSim/Carburetor.hpp"
#include <string>
#include "FluidSim/Junction.hpp"
@@ -19,85 +21,24 @@
#include <vector>
namespace fluidsim::port {
struct ScenarioExample;
enum class ScenarioKind { Twin, TwoStroke, SaabB205, AmericanV8 };
enum class FourStrokePreset { Twin, SaabB205, AmericanV8 };
enum class EngineLayout { Inline, V, Boxer };
struct EngineConfig {
int cylinders{}; // zero selects the existing preset
EngineLayout layout{EngineLayout::Inline};
float bank_angle{60}, bore{.086f}, stroke{.086f}, rod{.145f}, compression{10};
float idle_rpm{1000}, rev_limit{7000}, idle_opening{.045f};
bool carbureted{};
float carb_pilot_scale{1.f};
float carb_jet_scale{1.f}; // multiplier on displacement-scaled effective jet diameters
std::array<float,12> phase_degrees{}; // used when custom_phases is true
bool custom_phases{};
};
struct V8ExhaustConfig {
float primary_length{.75f}, primary_diameter{.038f};
bool h_pipe{true};
float junction_volume{50e-6f}; // m3, 50 cc per fitting
};
struct ScenarioSnapshot {
std::string example_name;
EngineLayout layout{EngineLayout::Inline};
float bank_angle{};
bool v8_headers{};
PipeSolver pipe_solver{PipeSolver::Rusanov};
int cylinder_count{2};
float rev_limit_rpm{10000};
std::array<float, 12> cylinder_angles{};
std::array<float, 12> cylinder_pressures{}, piston_fractions{}, intake_areas{}, exhaust_areas{};
std::uint64_t solver_substeps{}, boundary_iterations{}, pipe_positivity_repairs{};
float rpm{}, speed_kmh{}, kinetic_energy{}, average_power{}, average_torque{};
float cylinder_pressure1{}, cylinder_pressure2{};
float cylinder_temperature1{}, cylinder_temperature2{};
float cylinder_piston_fraction1{}, cylinder_piston_fraction2{};
float valve1_intake_area{}, valve1_exhaust_area{}, valve2_intake_area{}, valve2_exhaust_area{};
float intake_pressure{}, exhaust_pressure{}, second_bank_pressure{};
std::array<float,2> crossover_pressures{};
float throttle{}, pedal{}, target_afr{}, clutch{}, intake_flow{}, exhaust_flow{};
bool idle_control{}, fuel_cut{}, two_stroke{}, reed_open{};
bool racing_exhaust{};
bool silencers{}, h_pipe{};
ExhaustConfig exhaust_config{};
float crankcase_pressure{};
OutdoorScene audio_scene{OutdoorScene::Wall};
int gear{};
bool rev_limit1{}, rev_limit2{};
std::array<std::vector<GasState>, 32> pipe_states;
std::array<std::vector<float>, 32> pipe_areas, pipe_lengths;
void capture_pipes(const std::vector<Pipe1D>& pipes) {
for (std::size_t k = 0; k < pipes.size(); ++k) {
pipe_positivity_repairs += pipes[k].positivity_repairs;
pipe_areas[k] = pipes[k].cell_areas;
pipe_lengths[k] = pipes[k].cell_lengths;
pipe_states[k].reserve(pipes[k].cells.size());
for (std::size_t i = 0; i < pipes[k].cells.size(); ++i)
pipe_states[k].push_back(pipes[k].state(static_cast<int>(i)));
}
}
};
// Shared four-stroke topology: independent runners/headers and shared plenums.
// Presets include a 600 cc twin, B205-sized inline-four and American 5.0 V8.
// Calibrated four-stroke assembly with independent runners and shared plenums.
class FourStrokeScenario : public MotorcycleTest {
public:
explicit FourStrokeScenario(int sample_rate = 44100, FourStrokePreset preset = FourStrokePreset::Twin, ExhaustConfig exhaust = {}, V8ExhaustConfig v8_exhaust = {}, EngineConfig engine = {});
const FourStrokePreset preset;
explicit FourStrokeScenario(int sample_rate = 44100, FourStrokePreset preset = FourStrokePreset::Twin);
FourStrokeScenario(int sample_rate, const ScenarioExample& definition);
FourStrokeScenario(int sample_rate, EngineConfig engine, ExhaustConfig exhaust = {}, JunctionConfig junctions = {});
const ExhaustConfig exhaust_config;
const V8ExhaustConfig v8_exhaust_config;
const JunctionConfig junction_config;
const EngineConfig engine_config;
int cylinder_count() const { return engine_config.cylinders ? engine_config.cylinders : preset == FourStrokePreset::AmericanV8 ? 8 : preset == FourStrokePreset::SaabB205 ? 4 : 2; }
int cylinder_count() const { return engine_config.cylinders; }
std::array<FourStrokeCylinder*, 12> cylinders() { return {&cylinder1, &cylinder2, &cylinder3, &cylinder4,
&cylinder5, &cylinder6, &cylinder7, &cylinder8,&cylinder9,&cylinder10,&cylinder11,&cylinder12}; }
AudioFrame process();
void set_controls(float throttle_value, float load_value, float clutch_value);
void set_audio_scene(OutdoorScene scene);
void set_listener(AudioListener listener);
void set_pipe_solver(PipeSolver solver);
void set_monitor_filter(bool enabled);
void set_starter(bool engaged);
@@ -115,8 +56,8 @@ public:
FourStrokeCylinder cylinder1{}, cylinder2{}, cylinder3{}, cylinder4{};
FourStrokeCylinder cylinder5{}, cylinder6{}, cylinder7{}, cylinder8{};
FourStrokeCylinder cylinder9{}, cylinder10{}, cylinder11{}, cylinder12{};
Volume0D intake_plenum{1.0e-3f, 101325.0f, 300.0f};
Junction exhaust_collector{0.15e-3f, 101325.0f, 700.0f};
Volume0D intake_plenum;
Junction exhaust_collector;
std::vector<Pipe1D> pipes;
float intake_flow{}, exhaust_flow{};
std::uint64_t sample_count{};
@@ -134,7 +75,7 @@ private:
Port* throttle_port_{};
static float area_for_diameter(float diameter);
static std::vector<Pipe1D> create_pipes(FourStrokePreset preset, const ExhaustConfig& exhaust, const V8ExhaustConfig& v8_exhaust, const EngineConfig& engine);
static std::vector<Pipe1D> create_pipes(const EngineConfig& engine, const ExhaustConfig& exhaust);
};
using FourStrokeTwinScenario = FourStrokeScenario;

View File

@@ -1,83 +1,28 @@
#pragma once
#include "FluidSim/Scenario.hpp"
#include "FluidSim/EngineConfig.hpp"
#include "FluidSim/Silencer.hpp"
#include "FluidSim/TwoStrokeConfig.hpp"
#include <filesystem>
#include <string>
#include <vector>
namespace fluidsim::port {
struct ScenarioExample {
std::string id,title,description;
ScenarioKind kind;
ScenarioKind kind{ScenarioKind::Twin};
TwoStrokeConfig two{};
ExhaustConfig exhaust{};
V8ExhaustConfig v8{};
JunctionConfig junctions{};
EngineConfig engine{};
std::string category;
int order{};
};
inline std::vector<ScenarioExample> scenario_examples() {
std::vector<ScenarioExample> examples;
auto add=[&](const char* id,const char* title,const char* description,ScenarioKind kind)->ScenarioExample& {
examples.push_back({id,title,description,kind,{},{},{},{},{}});return examples.back();
};
add("twin","600 cc parallel twin","EFI motorcycle baseline",ScenarioKind::Twin);
add("yz-racing","YZ125 / expansion chamber","Racing pipe, fixed 4.5% carb idle",ScenarioKind::TwoStroke);
add("yz-straight","YZ125 / straight exhaust","Same engine and carb, straight exhaust comparison",ScenarioKind::TwoStroke).two.exhaust=TwoStrokeExhaust::Straight;
add("saab-straight","Saab B205 / straight-through","6 L resonator and 18 L straight-through rear muffler",ScenarioKind::SaabB205);
auto& chambered=add("saab-chambered","Saab B205 / chambered","Expansion resonator and two-chamber rear muffler",ScenarioKind::SaabB205);
chambered.exhaust.resonator.type=SilencerType::ExpansionResonator;
chambered.exhaust.muffler.type=SilencerType::Chambered;chambered.exhaust.muffler.chambers=2;
auto& resonator=add("saab-expansion","Saab B205 / expansion silencers","Two single expansion chambers, same body volumes",ScenarioKind::SaabB205);
resonator.exhaust.resonator.type=SilencerType::ExpansionResonator;resonator.exhaust.muffler.type=SilencerType::ExpansionResonator;
add("saab-open","Saab B205 / open exhaust","Silencers bypassed; naturally aspirated baseline",ScenarioKind::SaabB205).exhaust.enabled=false;
add("v8-h","American 5.0 / 4-2-1 + H","750 mm primaries, 50 cc junctions, H crossover",ScenarioKind::AmericanV8);
add("v8-dual","American 5.0 / independent banks","Same 4-2-1 headers with H crossover disconnected",ScenarioKind::AmericanV8).v8.h_pipe=false;
add("v8-short","American 5.0 / short headers","600 mm primaries with H crossover",ScenarioKind::AmericanV8).v8.primary_length=.60f;
add("v8-long","American 5.0 / long headers","900 mm primaries with H crossover",ScenarioKind::AmericanV8).v8.primary_length=.90f;
add("v8-large","American 5.0 / larger primaries","42 mm x 750 mm primaries with H crossover",ScenarioKind::AmericanV8).v8.primary_diameter=.042f;
for(auto& e:examples) e.category=e.kind==ScenarioKind::TwoStroke?"Two-stroke / 125 cc":e.kind==ScenarioKind::SaabB205?"Inline four / Saab":e.kind==ScenarioKind::AmericanV8?"V8 / American 5.0":"Twins / parallel";
auto chamber=[&](const char* id,const char* title,float length,float width) {
auto& e=add(id,title,"Estimated chamber geometry / same 125 cc engine",ScenarioKind::TwoStroke);
e.category="Two-stroke / 125 cc";
for(auto& section:e.two.racing_pipe) {section.length*=length;section.inlet_diameter*=width;section.outlet_diameter*=width;}
};
chamber("yz-torque","125 / long torque chamber",1.18f,1.f);
chamber("yz-short","125 / short high-RPM chamber",.82f,1.f);
chamber("yz-fat","125 / wide belly chamber",1.f,1.18f);
auto two=[&](const char* id,const char* title,const char* category,float bore,float stroke,float carb) {
auto& e=add(id,title,"Concept single / transported carb fuel",ScenarioKind::TwoStroke);e.category=category;
const float scale=std::cbrt((bore*bore*stroke)/(.054f*.054f*.0545f));
e.two.jet_scale=bore<.05f?.5f:1.f;
e.two.bore=bore;e.two.stroke=stroke;e.two.rod*=scale;e.two.carburetor_diameter=carb;
e.two.transfer_width*=scale;e.two.exhaust_width*=scale;e.two.crankcase_clearance*=scale*scale*scale;
e.two.minimum_burn_seconds*=scale;
for(auto& section:e.two.racing_pipe) {section.length*=scale;section.inlet_diameter*=scale;section.outlet_diameter*=scale;}
e.two.exhaust_cell_length=.015f;
};
two("fifty","50 cc / little screamer","Two-stroke / 50 cc",.040f,.0398f,.019f);
two("fivehundred","500 cc / big single","Two-stroke / 500 cc",.086f,.086f,.041f);
auto custom=[&](const char* id,const char* title,const char* category,int count,EngineLayout layout,float bore,float stroke)->ScenarioExample& {
auto& e=add(id,title,"Estimated concept calibration / EFI",ScenarioKind::Twin);e.category=category;
e.engine.cylinders=count;e.engine.layout=layout;e.engine.bore=bore;e.engine.stroke=stroke;
e.engine.rod=std::max(.11f,stroke*1.8f);return e;
};
custom("single-450","450 cc / four-stroke thumper","Singles / four-stroke",1,EngineLayout::Inline,.096f,.062f);
custom("single-650","650 cc / big thumper","Singles / four-stroke",1,EngineLayout::Inline,.100f,.083f).engine.rev_limit=6500;
auto& twin270=custom("twin-270","700 cc / 270-degree parallel twin","Twins / parallel",2,EngineLayout::Inline,.080f,.068f);
twin270.engine.custom_phases=true;twin270.engine.phase_degrees[1]=450;
auto& ducati=custom("ltwin","916-inspired / 90-degree V twin","Twins / V",2,EngineLayout::V,.094f,.066f);
ducati.engine.bank_angle=90;ducati.engine.custom_phases=true;ducati.engine.phase_degrees[1]=450;
custom("triple","1.0 litre / inline triple","Inline three",3,EngineLayout::Inline,.079f,.068f);
auto& four=custom("f20","F20C-inspired / 2.0 high-rev four","Inline four / specials",4,EngineLayout::Inline,.087f,.084f);four.engine.rev_limit=9000;
custom("five","Audi-inspired / 2.2 inline five","Inline five",5,EngineLayout::Inline,.081f,.0864f);
custom("rb26","RB26-inspired / 2.6 inline six NA","Inline six",6,EngineLayout::Inline,.086f,.0737f);
custom("six-long","3.0 litre / long-stroke inline six","Inline six",6,EngineLayout::Inline,.086f,.086f);
custom("busso","Busso-inspired / 3.0 V6","V6",6,EngineLayout::V,.093f,.0726f);
auto& flat4=custom("ej20","EJ20-inspired / 2.0 flat four NA","Boxer / flat four",4,EngineLayout::Boxer,.092f,.075f);
flat4.engine.bank_angle=180;
custom("flat6","911-inspired / 3.0 flat six","Boxer / flat six",6,EngineLayout::Boxer,.095f,.0704f).engine.bank_angle=180;
auto& big=custom("big-carb","8.0 litre / carbureted big-block","V8 / big-block",8,EngineLayout::V,.1143f,.098f);
big.engine.carb_pilot_scale=.4f;big.engine.carb_jet_scale=.47f;big.engine.bank_angle=90;big.engine.compression=9;big.engine.carbureted=true;big.engine.idle_rpm=800;big.engine.idle_opening=.08f;
big.description="Carb fuel transport / fixed configurable idle stop";
custom("v10","5.0 litre / even-fire V10","V10",10,EngineLayout::V,.092f,.0752f).engine.bank_angle=72;
custom("v12","250-inspired / 3.0 V12","V12",12,EngineLayout::V,.073f,.0588f).engine.bank_angle=60;
return examples;
}
ScenarioExample load_engine_file(const std::filesystem::path& path);
std::vector<ScenarioExample> load_engine_directory(const std::filesystem::path& directory);
// FLUIDSIM_ENGINE_DIR overrides discovery. Otherwise search the working directory,
// beside the executable, then the source tree for developer tools.
std::filesystem::path engine_directory();
const std::vector<ScenarioExample>& scenario_examples();
const ScenarioExample& scenario_example(const std::string& id);
const ScenarioExample& preset_engine_definition(FourStrokePreset preset);
}

View File

@@ -0,0 +1,16 @@
#pragma once
#include "FluidSim/AudioProcessing.hpp"
#include <array>
namespace fluidsim::port {
struct ScenarioSnapshot;
// Fractions refer to simulated pipe length; positions are world metres.
// Both the pipe mesh and its acoustic mouths use these same routes.
struct PipeRoute {
std::array<Vec3, 5> points{};
std::array<float, 5> fractions{};
unsigned count{};
};
std::array<PipeRoute, 32> scenario_pipe_routes(const ScenarioSnapshot& state);
std::array<Vec3, 3> scenario_sound_sources(const ScenarioSnapshot& state);
}

View File

@@ -0,0 +1,54 @@
#pragma once
#include "FluidSim/EngineConfig.hpp"
#include "FluidSim/Fluid.hpp"
#include "FluidSim/OutdoorAudio.hpp"
#include "FluidSim/Silencer.hpp"
#include <array>
#include <cstdint>
#include <string>
#include <vector>
namespace fluidsim::port {
struct ScenarioSnapshot {
std::string example_name;
EngineLayout layout{EngineLayout::Inline};
float bank_angle{};
bool v8_headers{};
bool staged_headers{}, dual_exhaust{};
PipeSolver pipe_solver{PipeSolver::Rusanov};
int cylinder_count{2};
float rev_limit_rpm{10000};
std::array<float, 12> cylinder_angles{};
std::array<float, 12> cylinder_pressures{}, piston_fractions{}, intake_areas{}, exhaust_areas{};
std::uint64_t solver_substeps{}, boundary_iterations{}, pipe_positivity_repairs{};
float rpm{}, speed_kmh{}, kinetic_energy{}, average_power{}, average_torque{};
float cylinder_pressure1{}, cylinder_pressure2{};
float cylinder_temperature1{}, cylinder_temperature2{};
float cylinder_piston_fraction1{}, cylinder_piston_fraction2{};
float valve1_intake_area{}, valve1_exhaust_area{}, valve2_intake_area{}, valve2_exhaust_area{};
float intake_pressure{}, exhaust_pressure{}, second_bank_pressure{};
std::array<float,2> crossover_pressures{};
float throttle{}, pedal{}, target_afr{}, clutch{}, intake_flow{}, exhaust_flow{};
bool idle_control{}, fuel_cut{}, ignition_cut{}, two_stroke{}, reed_open{};
bool racing_exhaust{};
bool silencers{}, h_pipe{};
ExhaustConfig exhaust_config{};
float crankcase_pressure{};
OutdoorScene audio_scene{OutdoorScene::Wall};
int gear{};
bool rev_limit1{}, rev_limit2{};
std::array<std::vector<GasState>, 32> pipe_states;
std::array<std::vector<float>, 32> pipe_areas, pipe_lengths;
void capture_pipes(const std::vector<Pipe1D>& pipes) {
for (std::size_t k = 0; k < pipes.size(); ++k) {
pipe_positivity_repairs += pipes[k].positivity_repairs;
pipe_areas[k] = pipes[k].cell_areas;
pipe_lengths[k] = pipes[k].cell_lengths;
pipe_states[k].reserve(pipes[k].cells.size());
for (std::size_t i = 0; i < pipes[k].cells.size(); ++i)
pipe_states[k].push_back(pipes[k].state(static_cast<int>(i)));
}
}
};
}

View File

@@ -1,5 +1,7 @@
#pragma once
#include "FluidSim/PipeProfile.hpp"
#include "FluidSim/Ecu.hpp"
#include "FluidSim/Vehicle.hpp"
#include <array>
namespace fluidsim::port {
@@ -8,14 +10,40 @@ enum class TwoStrokeExhaust { Racing, Straight };
// YZ125-sized baseline. Port/chamber/combustion dimensions are explicit design
// estimates, not a measured Yamaha cylinder or factory exhaust/ignition map.
struct TwoStrokeConfig {
EcuConfig ecu=[] {
EcuConfig e;
e.idle_control=EcuConfig::IdleControl::FixedOpening;
e.throttle_time_constant=.0002f;e.throttle_rate=5000;
e.ignition_map={{0,10},{3000,18},{6000,24},{9000,18},{11000,12},{13000,6},{15000,2},{18000,0}};
e.idle_rpm=3000;e.idle_max_opening=.3f;e.rev_limit_enabled=false;
e.overrun_cut_rpm=5000;e.overrun_resume_rpm=3500;
e.rev_limit_fuel_cut_enabled=false;e.overrun_fuel_cut_enabled=false;e.overrun_ignition_cut_enabled=true;
return e;
}();
VehicleConfig vehicle=[] {
VehicleConfig v;
v.primary_reduction=64.f/19;v.final_drive_ratio=49.f/13;
v.gear_ratios={31.f/13,29.f/15,27.f/17,23.f/17,20.f/17,19.f/18};
v.mass=170;v.wheel_radius=.3313f;
return v;
}();
float initial_rpm{3000}, starter_torque{8}, maximum_load_torque{10};
float crankshaft_inertia{.01f};
TwoStrokeExhaust exhaust{TwoStrokeExhaust::Racing};
float bore{.054f}, stroke{.0545f}, rod{.110f};
float trapped_compression{8.2f}; // exhaust-closure volume / clearance volume
float transfer_duration{130}, exhaust_duration{190};
float transfer_width{.050f}, exhaust_width{.036f}; // summed effective window widths
float pilot_jet_scale{.75f}, main_jet_scale{1.6f}, needle_jet_scale{1.f};
float needle_low_scale{2.72f}, needle_mid_scale{.8f};
float needle_off_idle_scale{12.f}, needle_transition_scale{9.f}, needle_tip_scale{5.f};
float needle_quarter_scale{1.4f}, needle_upper_scale{.76f};
float pilot_transition_scale{1.f}, pilot_tip_scale{1.f};
float jet_scale{1.f}; // multiplier on effective jet diameters
float crankcase_clearance{150e-6f};
float carburetor_diameter{.0381f};
float idle_spark_gain{.05f}; // spark degrees per RPM error at the fixed carb stop
float idle_minimum_advance{-10.f};
float idle_slide_opening{.045f}; // fixed mechanical stop, normalized 0..1 (4.5%)
float minimum_burn_seconds{.0008f};
// Empirical mechanical FMEP = A + B*mean_piston_speed + C*speed^2.

View File

@@ -19,7 +19,7 @@ namespace fluidsim::port {
struct TwoStrokeSnapshot {
float rpm{}, cylinder_pressure{}, crankcase_pressure{}, intake_pressure{};
float throttle{}, transfer_area{}, exhaust_area{};
bool reed_open{}, fuel_cut{};
bool reed_open{}, fuel_cut{}, ignition_cut{};
float pedal{};
OutdoorScene audio_scene{OutdoorScene::Wall};
};
@@ -28,6 +28,7 @@ struct TwoStrokeSnapshot {
// the plenum / reed / crankcase / transfer / cylinder / exhaust topology.
class TwoStrokeScenario : public MotorcycleTest {
public:
void set_listener(AudioListener listener) { audio_.set_listener(listener); }
explicit TwoStrokeScenario(int sample_rate = 44100, TwoStrokeConfig config = {});
AudioFrame process();
void set_throttle(float value);

View File

@@ -5,16 +5,19 @@
namespace fluidsim::port {
// Longitudinal vehicle model and the friction clutch coupling it to the crank.
class Vehicle {
public:
int current_gear{};
struct VehicleConfig {
std::vector<float> gear_ratios{4.1f, 2.7f, 2.0f, 1.55f, 1.25f, 0.98f};
float final_drive_ratio{2.7f}, primary_reduction{1.8f};
float clutch_input{1.0f}, clutch_capacity{80.0f}, clutch_engagement_rate{8.0f};
float clutch_capacity{80.0f}, clutch_engagement_rate{8.0f};
float mass{160.0f}, wheel_radius{0.32f}, rotational_inertia{1.2f};
float rolling_resistance{0.015f}, drag_coefficient{0.7f}, frontal_area{0.65f};
float air_density{1.225f}, gravity{9.80665f};
float speed{}, engagement{};
};
class Vehicle : public VehicleConfig {
public:
int current_gear{};
float clutch_input{1.0f}, speed{}, engagement{};
float total_gear_ratio() const;
float kinetic_energy() const;