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

526
src/engine_config.cpp Normal file
View File

@@ -0,0 +1,526 @@
#include "FluidSim/ScenarioCatalog.hpp"
#include <toml.hpp>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <limits>
#include <set>
#include <sstream>
#include <stdexcept>
#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#endif
namespace fluidsim::port {
namespace {
class Reader {
const toml::table& table_;
std::set<std::string> used_;
std::string path_;
public:
Reader(const toml::table& table,std::string path):table_(table),path_(std::move(path)) {}
[[noreturn]] void fail(const std::string& message) const { throw std::runtime_error(path_+": "+message); }
void required(std::initializer_list<const char*> keys) const {
for(const auto* key:keys)if(!table_.contains(key))fail(std::string("missing required field '")+key+"'");
}
const toml::node* node(const std::string& key) { used_.insert(key);return table_.get(key); }
void field(const char* key,float& value,double scale=1) {
if(const auto* n=node(key)) {
auto number=n->value<double>();
if(!number || !std::isfinite(*number) || std::abs(*number*scale)>std::numeric_limits<float>::max())
fail(std::string(key)+" must be a finite number");
value=static_cast<float>(*number*scale);
}
}
void field(const char* key,int& value) {
if(const auto* n=node(key)) {
auto number=n->value<int64_t>();
if(!n->is_integer() || !number || *number<std::numeric_limits<int>::min() || *number>std::numeric_limits<int>::max())
fail(std::string(key)+" must be an integer");
value=static_cast<int>(*number);
}
}
void field(const char* key,bool& value) {
if(const auto* n=node(key)) {
auto b=n->value<bool>();if(!b)fail(std::string(key)+" must be true or false");value=*b;
}
}
void field(const char* key,std::string& value) {
if(const auto* n=node(key)) {
auto s=n->value<std::string>();if(!s)fail(std::string(key)+" must be a string");value=*s;
}
}
std::string text(const char* key,std::string fallback={}) {field(key,fallback);return fallback;}
Reader section(const char* key,bool required=false) {
static const toml::table empty;
const auto* n=node(key);
if(n && !n->is_table())fail(std::string(key)+" must be a table");
if(!n && required)fail(std::string("missing [")+key+"]");
return Reader(n?*n->as_table():empty,path_+"."+key);
}
const toml::array* array(const char* key) {
const auto* n=node(key);
if(n && !n->is_array())fail(std::string(key)+" must be an array");
if(n && n->as_array()->size()>4096)fail(std::string(key)+" has too many entries");
return n?n->as_array():nullptr;
}
float number(const toml::node& n) const {
auto value=n.value<double>();
if(!value || !std::isfinite(*value) || std::abs(*value)>std::numeric_limits<float>::max())fail("array entries must be finite numbers");
return static_cast<float>(*value);
}
void field(const char* key,std::vector<float>& values) {
if(const auto* a=array(key)) {values.clear();for(const auto& n:*a)values.push_back(number(n));}
}
void finish() const {
for(const auto& [key,value]:table_)if(!used_.contains(std::string(key.str())))fail("unknown field '"+std::string(key.str())+"'");
}
};
template<class T> void require(bool condition,const T& where,const char* message) {
if(!condition)throw std::runtime_error(std::string(where)+": "+message);
}
// Scalar readers below keep the file units explicit at the conversion boundary.
void read_cylinder(Reader r,CylinderConfig& c) {
r.required({"bore_mm","stroke_mm","rod_mm","compression"});
r.field("afr",c.afr);
r.field("ambient_temperature_k",c.ambient_temperature);
r.field("bore_mm",c.bore,0.001);
r.field("burn_duration_deg",c.burn_duration);
r.field("burn_start_deg",c.burn_start);
r.field("compression",c.compression);
r.field("energy_variation",c.energy_variation);
r.field("exhaust_close_deg",c.exhaust_close);
r.field("exhaust_open_deg",c.exhaust_open);
r.field("exhaust_valve_count",c.exhaust_valve_count);
r.field("exhaust_valve_diameter_mm",c.exhaust_valve_diameter,0.001);
r.field("exhaust_valve_lift_mm",c.exhaust_valve_lift,0.001);
r.field("fuel_lhv_j_per_kg",c.fuel_lhv);
r.field("heat_transfer",c.heat_transfer);
r.field("intake_close_deg",c.intake_close);
r.field("intake_open_deg",c.intake_open);
r.field("intake_valve_count",c.intake_valve_count);
r.field("intake_valve_diameter_mm",c.intake_valve_diameter,0.001);
r.field("intake_valve_lift_mm",c.intake_valve_lift,0.001);
r.field("misfire_probability",c.misfire_probability);
r.field("rod_mm",c.rod,0.001);
r.field("stroke_mm",c.stroke,0.001);
r.field("wall_area",c.wall_area);
r.field("wiebe_a",c.wiebe_a);
r.field("wiebe_m",c.wiebe_m);
r.finish();
}
void read_crankshaft(Reader r,CrankshaftConfig& c) {
r.field("friction_constant_nm",c.friction_constant);
r.field("friction_quadratic_nm_s2",c.friction_quadratic);
r.field("friction_viscous_nm_s",c.friction_viscous);
r.field("inertia_kg_m2",c.inertia);
r.finish();
}
void read_ecu(Reader r,EcuConfig& c) {
r.field("idle_base_opening",c.idle_base_opening);
r.field("idle_ki",c.idle_ki);
r.field("idle_kp",c.idle_kp);
r.field("idle_max_opening",c.idle_max_opening);
r.field("idle_minimum_advance_deg",c.idle_minimum_advance);
r.field("idle_rpm",c.idle_rpm);
r.field("idle_spark_gain",c.idle_spark_gain);
r.field("overrun_cut_rpm",c.overrun_cut_rpm);
r.field("overrun_fuel_cut_enabled",c.overrun_fuel_cut_enabled);
r.field("overrun_ignition_cut_enabled",c.overrun_ignition_cut_enabled);
r.field("overrun_resume_rpm",c.overrun_resume_rpm);
r.field("power_afr",c.power_afr);
r.field("rev_limit_enabled",c.rev_limit_enabled);
r.field("rev_limit_fuel_cut_enabled",c.rev_limit_fuel_cut_enabled);
r.field("rev_limit_rpm",c.rev_limit_rpm);
r.field("rev_resume_rpm",c.rev_resume_rpm);
r.field("stoichiometric_afr",c.stoichiometric_afr);
r.field("throttle_closing_rate",c.throttle_closing_rate);
r.field("throttle_closing_time_constant_s",c.throttle_closing_time_constant);
r.field("throttle_rate",c.throttle_rate);
r.field("throttle_time_constant_s",c.throttle_time_constant);
const auto mode=r.text("idle_control","feedback");
if(mode!="fixed" && mode!="feedback")r.fail("idle_control must be fixed or feedback");
c.idle_control=mode=="fixed"?EcuConfig::IdleControl::FixedOpening:EcuConfig::IdleControl::Feedback;
if(const auto* curve=r.array("ignition_map")) {
c.ignition_map.clear();float previous=-1;
for(const auto& n:*curve) {
const auto* point=n.as_array();
if(!point || point->size()!=2)r.fail("ignition_map entries must be [RPM, degrees BTDC]");
const float rpm=r.number(*point->get(0)),advance=r.number(*point->get(1));
if(rpm<0 || rpm<=previous || advance < -90 || advance>90)r.fail("ignition_map needs increasing nonnegative RPM and advances within +/-90 degrees");
c.ignition_map.push_back({rpm,advance});previous=rpm;
}
if(c.ignition_map.empty())r.fail("ignition_map must not be empty");
}
r.finish();
}
void read_vehicle(Reader r,VehicleConfig& c) {
r.field("air_density",c.air_density);
r.field("clutch_capacity",c.clutch_capacity);
r.field("clutch_engagement_rate",c.clutch_engagement_rate);
r.field("drag_coefficient",c.drag_coefficient);
r.field("final_drive_ratio",c.final_drive_ratio);
r.field("frontal_area",c.frontal_area);
r.field("gear_ratios",c.gear_ratios);
r.field("gravity",c.gravity);
r.field("mass_kg",c.mass);
r.field("primary_reduction",c.primary_reduction);
r.field("rolling_resistance",c.rolling_resistance);
r.field("rotational_inertia_kg_m2",c.rotational_inertia);
r.field("wheel_radius_mm",c.wheel_radius,0.001);
r.finish();
}
void read_silencer(Reader r,SilencerConfig& c) {
r.field("chambers",c.chambers);
r.field("core_diameter_mm",c.core_diameter,0.001);
r.field("length_mm",c.length,0.001);
r.field("loss_coefficient",c.loss_coefficient);
r.field("passage_diameter_mm",c.passage_diameter,0.001);
r.field("resistance",c.resistance);
r.field("volume_litres",c.volume,0.001);
const auto type=r.text("type","straight_through");
if(type=="straight_through")c.type=SilencerType::StraightThrough;
else if(type=="chambered")c.type=SilencerType::Chambered;
else if(type=="expansion")c.type=SilencerType::ExpansionResonator;
else r.fail("unknown silencer type '"+type+"'");
r.finish();
}
void read_silencers(Reader r,ExhaustConfig& c) {
r.field("cell_length_mm",c.cell_length,0.001);
r.field("connecting_length_mm",c.connecting_length,0.001);
r.field("diameter_mm",c.diameter,0.001);
r.field("enabled",c.enabled);
r.field("inlet_length_mm",c.inlet_length,0.001);
r.field("outlet_length_mm",c.outlet_length,0.001);
read_silencer(r.section("resonator"),c.resonator);
read_silencer(r.section("muffler"),c.muffler);
r.finish();
}
void read_two_stroke(Reader r,TwoStrokeConfig& c) {
r.required({"bore_mm","stroke_mm","rod_mm","trapped_compression","carburetor_diameter_mm"});
r.field("initial_rpm",c.initial_rpm);
r.field("starter_torque_nm",c.starter_torque);
r.field("maximum_load_torque_nm",c.maximum_load_torque);
r.field("crankshaft_inertia_kg_m2",c.crankshaft_inertia);
r.field("bore_mm",c.bore,0.001);
r.field("carburetor_diameter_mm",c.carburetor_diameter,0.001);
r.field("crankcase_clearance_cc",c.crankcase_clearance,1e-06);
r.field("exhaust_cell_length_mm",c.exhaust_cell_length,0.001);
r.field("exhaust_duration_deg",c.exhaust_duration);
r.field("exhaust_width_mm",c.exhaust_width,0.001);
r.field("friction_bar",c.friction_bar);
r.field("friction_bar_per_mps",c.friction_bar_per_mps);
r.field("friction_bar_per_mps2",c.friction_bar_per_mps2);
r.field("idle_minimum_advance_deg",c.idle_minimum_advance);
r.field("idle_slide_opening",c.idle_slide_opening);
r.field("idle_spark_gain",c.idle_spark_gain);
r.field("jet_scale",c.jet_scale);
r.field("main_jet_scale",c.main_jet_scale);
r.field("minimum_burn_s",c.minimum_burn_seconds);
r.field("needle_jet_scale",c.needle_jet_scale);
r.field("needle_low_scale",c.needle_low_scale);
r.field("needle_mid_scale",c.needle_mid_scale);
r.field("needle_off_idle_scale",c.needle_off_idle_scale);
r.field("needle_quarter_scale",c.needle_quarter_scale);
r.field("needle_tip_scale",c.needle_tip_scale);
r.field("needle_transition_scale",c.needle_transition_scale);
r.field("needle_upper_scale",c.needle_upper_scale);
r.field("pilot_jet_scale",c.pilot_jet_scale);
r.field("pilot_tip_scale",c.pilot_tip_scale);
r.field("pilot_transition_scale",c.pilot_transition_scale);
r.field("rod_mm",c.rod,0.001);
r.field("stroke_mm",c.stroke,0.001);
r.field("transfer_duration_deg",c.transfer_duration);
r.field("transfer_width_mm",c.transfer_width,0.001);
r.field("trapped_compression",c.trapped_compression);
const auto exhaust=r.text("exhaust","racing");
if(exhaust!="racing" && exhaust!="straight")r.fail("exhaust must be racing or straight");
c.exhaust=exhaust=="racing"?TwoStrokeExhaust::Racing:TwoStrokeExhaust::Straight;
if(const auto* sections=r.array("chamber_sections_mm")) {
if(sections->size()!=c.racing_pipe.size())r.fail("chamber_sections_mm must have six sections");
for(std::size_t i=0;i<sections->size();++i) {
const auto* section=sections->get(i)->as_array();
if(!section || section->size()!=3)r.fail("chamber sections must be [length, inlet diameter, outlet diameter] in mm");
auto& p=c.racing_pipe[i];
p.length=r.number(*section->get(0))*.001f;
p.inlet_diameter=r.number(*section->get(1))*.001f;
p.outlet_diameter=r.number(*section->get(2))*.001f;
}
}
r.finish();
}
void read_engine(Reader r,EngineConfig& c) {
r.required({"cylinders","layout","topology","phase_degrees"});
r.field("bank_angle_deg",c.bank_angle);
r.field("collector_volume_litres",c.collector_volume,0.001);
r.field("cylinders",c.cylinders);
r.field("initial_rpm",c.initial_rpm);
r.field("initialize_phased_charge",c.initialize_phased_charge);
r.field("maximum_load_torque_nm",c.maximum_load_torque);
r.field("minimum_substeps",c.minimum_substeps);
r.field("phase_degrees",c.phase_degrees);
r.field("pipe_damping",c.pipe_damping);
r.field("plenum_volume_litres",c.plenum_volume,0.001);
r.field("starter_torque_nm",c.starter_torque);
r.field("throttle_diameter_mm",c.throttle_diameter,0.001);
const auto layout=r.text("layout","inline"),topology=r.text("topology","collector");
if(layout=="inline")c.layout=EngineLayout::Inline;
else if(layout=="v")c.layout=EngineLayout::V;
else if(layout=="boxer")c.layout=EngineLayout::Boxer;
else r.fail("layout must be inline, v or boxer");
if(topology=="collector")c.topology=ExhaustTopology::Collector;
else if(topology=="staged")c.topology=ExhaustTopology::Staged;
else if(topology=="american_v8")c.topology=ExhaustTopology::AmericanV8;
else r.fail("unknown exhaust topology '"+topology+"'");
r.finish();
}
void read_fuel(Reader r,EngineConfig& c) {
const auto system=r.text("system","injection");
if(system!="injection" && system!="carburetor")r.fail("system must be injection or carburetor");
c.carbureted=system=="carburetor";
r.field("pilot_jet_diameter_mm",c.fuel.pilot_jet_diameter,0.001);
r.field("main_jet_diameter_mm",c.fuel.main_jet_diameter,0.001);
r.field("needle_jet_diameter_mm",c.fuel.needle_jet_diameter,0.001);
r.field("needle_low_scale",c.fuel.needle_low_scale);
r.field("needle_mid_scale",c.fuel.needle_mid_scale);
r.field("needle_off_idle_scale",c.fuel.needle_off_idle_scale);
r.field("needle_transition_scale",c.fuel.needle_transition_scale);
r.field("needle_tip_scale",c.fuel.needle_tip_scale);
r.field("needle_quarter_scale",c.fuel.needle_quarter_scale);
r.field("needle_upper_scale",c.fuel.needle_upper_scale);
r.field("pilot_transition_scale",c.fuel.pilot_transition_scale);
r.field("pilot_tip_scale",c.fuel.pilot_tip_scale);
r.field("fuel_density",c.fuel.fuel_density);
r.field("pilot_discharge_coefficient",c.fuel.pilot_discharge_coefficient);
r.field("main_discharge_coefficient",c.fuel.main_discharge_coefficient);
r.field("bowl_head_pressure",c.fuel.bowl_head_pressure);
r.field("film_fraction",c.fuel.film_fraction);
r.field("film_time_constant",c.fuel.film_time_constant);
r.field("overrun_rpm",c.fuel.overrun_rpm);
r.field("overrun_exit_rpm",c.fuel.overrun_exit_rpm);
r.field("bowl_capacity_kg",c.fuel.bowl_capacity);
r.field("bowl_refill_kg_per_s",c.fuel.bowl_refill_rate);
r.finish();
}
void validate_ecu(const EcuConfig& c,const std::string& path) {
require(c.idle_rpm>0 && c.idle_base_opening>=0 && c.idle_base_opening<=1 &&
c.idle_max_opening>=c.idle_base_opening && c.idle_max_opening<=1,path,"ecu: invalid idle calibration");
require(c.rev_limit_rpm>c.rev_resume_rpm && c.rev_resume_rpm>0 &&
c.overrun_cut_rpm>c.overrun_resume_rpm && c.overrun_resume_rpm>0,path,"ecu: invalid cut/resume RPM");
require(c.throttle_time_constant>0 && c.throttle_rate>0 && c.idle_spark_gain>=0 &&
c.throttle_closing_time_constant>=0 && c.throttle_closing_rate>=0,path,"ecu: invalid actuator calibration");
require(c.stoichiometric_afr>0 && c.power_afr>0,path,"ecu: AFR targets must be positive");
}
void validate_vehicle(const VehicleConfig& c,const std::string& path) {
require(c.mass>0 && c.wheel_radius>0 && c.rotational_inertia>=0 &&
c.primary_reduction>0 && c.final_drive_ratio>0 && c.clutch_capacity>0 &&
c.clutch_engagement_rate>0 && c.rolling_resistance>=0 &&
c.drag_coefficient>=0 && c.frontal_area>0 && c.air_density>0 && c.gravity>0,
path,"vehicle: invalid dimensions, ratios or resistance");
require(!c.gear_ratios.empty() && c.gear_ratios.size()<=16,path,"vehicle: expected 1..16 gear ratios");
for(float ratio:c.gear_ratios)require(ratio>0,path,"vehicle: gear ratios must be positive");
}
void validate_two(const TwoStrokeConfig& c,const std::string& path) {
require(c.bore>0 && c.bore<=1 && c.stroke>0 && c.stroke<=1 && c.rod>c.stroke*.5f &&
c.trapped_compression>1 && c.trapped_compression<=50,path,"two_stroke: invalid cylinder geometry");
require(c.transfer_duration>0 && c.transfer_duration<c.exhaust_duration && c.exhaust_duration<360 &&
c.transfer_width>0 && c.exhaust_width>0 && c.crankcase_clearance>0,
path,"two_stroke: invalid port timing or crankcase geometry");
require(c.idle_slide_opening>=0 && c.idle_slide_opening<=1 && c.initial_rpm>0 && c.initial_rpm<=30000 &&
c.crankshaft_inertia>0 && c.starter_torque>=0 && c.maximum_load_torque>=0 && c.idle_spark_gain>=0,
path,"two_stroke: invalid idle or crankshaft calibration");
require(c.carburetor_diameter>0 && c.minimum_burn_seconds>0 &&
c.friction_bar>=0 && c.friction_bar_per_mps>=0 && c.friction_bar_per_mps2>=0,
path,"two_stroke: invalid carburetor, burn time or friction");
for(float value:{c.jet_scale,c.pilot_jet_scale,c.main_jet_scale,c.needle_jet_scale,
c.needle_low_scale,c.needle_mid_scale,c.needle_off_idle_scale,c.needle_transition_scale,
c.needle_tip_scale,c.needle_quarter_scale,c.needle_upper_scale,c.pilot_transition_scale,c.pilot_tip_scale})
require(value>0,path,"two_stroke: jet and needle scales must be positive");
require(c.exhaust_cell_length>=.002f && c.exhaust_cell_length<=1,path,"two_stroke: exhaust cell length outside 2..1000 mm");
float total=0;
for(auto p:c.racing_pipe) {
require(p.length>0 && p.inlet_diameter>0 && p.outlet_diameter>0,path,"two_stroke: invalid chamber section");
total+=p.length;
}
require(total/c.exhaust_cell_length<=4096,path,"two_stroke: chamber exceeds 4096 cells");
validate_ecu(c.ecu,path);validate_vehicle(c.vehicle,path);
}
} // namespace
void validate_engine_config(const EngineConfig& c,const std::string& path) {
require(c.cylinders>=1 && c.cylinders<=12 && (c.layout==EngineLayout::Inline || c.cylinders%2==0),
path,"engine: expected 1..12 cylinders and even banked cylinder counts");
const auto& cylinder=c.cylinder;
require(cylinder.bore>0 && cylinder.bore<=1 && cylinder.stroke>0 && cylinder.stroke<=1 &&
cylinder.rod>cylinder.stroke*.5f && cylinder.compression>1 && cylinder.compression<=50,
path,"cylinder: invalid bore, stroke, rod or compression");
require(cylinder.intake_valve_count>=1 && cylinder.intake_valve_count<=8 &&
cylinder.exhaust_valve_count>=1 && cylinder.exhaust_valve_count<=8 &&
cylinder.intake_valve_diameter>0 && cylinder.exhaust_valve_diameter>0 &&
cylinder.intake_valve_lift>0 && cylinder.exhaust_valve_lift>0,path,"cylinder: invalid valve geometry");
for(float angle:{cylinder.intake_open,cylinder.intake_close,cylinder.exhaust_open,cylinder.exhaust_close})
require(angle>=0 && angle<720,path,"cylinder: valve events must be in [0,720) degrees");
require(cylinder.intake_open!=cylinder.intake_close && cylinder.exhaust_open!=cylinder.exhaust_close &&
cylinder.burn_duration>0 && cylinder.wiebe_a>0 && cylinder.wiebe_m>=0 &&
cylinder.energy_variation>=0 && cylinder.energy_variation<=1 &&
cylinder.misfire_probability>=0 && cylinder.misfire_probability<=1 &&
cylinder.fuel_lhv>0 && cylinder.wall_area>0 && cylinder.heat_transfer>=0 && cylinder.ambient_temperature>0,
path,"cylinder: invalid timing or combustion calibration");
require(c.crankshaft.inertia>0 && c.crankshaft.friction_constant>=0 && c.crankshaft.friction_viscous>=0 &&
c.crankshaft.friction_quadratic>=0,path,"crankshaft: invalid inertia or friction");
require(c.initial_rpm>0 && c.initial_rpm<=30000 && c.starter_torque>=0 && c.maximum_load_torque>=0 &&
c.plenum_volume>0 && c.collector_volume>0 && c.throttle_diameter>0 &&
c.pipe_damping>=0 && c.minimum_substeps>=1 && c.minimum_substeps<=64 &&
c.bank_angle>0 && c.bank_angle<=180,path,"engine: invalid startup, intake or solver calibration");
require(c.phase_degrees.size()==static_cast<std::size_t>(c.cylinders),path,"engine: phase_degrees needs one value per cylinder");
for(float phase:c.phase_degrees)require(std::isfinite(phase) && phase>=0 && phase<720,path,"engine: invalid cylinder phase");
require(!c.american_headers() || (c.cylinders==8 && c.layout==EngineLayout::V),path,"engine: american_v8 topology requires a V8");
require(!c.staged_headers() || c.cylinders>=3,path,"engine: staged topology requires at least three cylinders");
const int expected=c.american_headers()?26:1+2*c.cylinders+(c.dual_outlets()?2:1)+(c.staged_headers()?c.header_groups():0);
int count=0,cells=0,silenced=0;std::set<std::string> ids;
for(const auto& p:c.pipes) {
require(!p.id.empty() && ids.insert(p.id).second,path,"pipes: missing or duplicate pipe group id");
require(p.count>=1 && p.count<=12,path,"pipes: count must be in 1..12");
if(p.silenced) {
require(c.has_silenced_exhaust() && c.topology==ExhaustTopology::Collector &&
count==1+2*c.cylinders && p.count==1,path,"pipes: silenced profile must be the collector's final outlet");
++silenced;
} else {
require(p.diameter>0 && p.diameter<=2 && p.length>0 && p.length<=100 && p.cells>=1 && p.cells<=4096,
path,"pipes: invalid diameter, length or cell count");
cells+=p.cells*p.count;
}
count+=p.count;
}
require(count==expected && count<=32 && cells<=32768,path,"pipes: groups do not match the selected topology or exceed mesh limits");
require(silenced<=1,path,"pipes: only one silenced outlet is supported");
if(c.carbureted) {
const auto& f=c.fuel;
for(float value:{f.pilot_jet_diameter,f.main_jet_diameter,f.needle_jet_diameter,f.needle_low_scale,
f.needle_mid_scale,f.needle_off_idle_scale,f.needle_transition_scale,f.needle_tip_scale,
f.needle_quarter_scale,f.needle_upper_scale,f.pilot_transition_scale,f.pilot_tip_scale,
f.fuel_density,f.pilot_discharge_coefficient,f.main_discharge_coefficient,f.bowl_capacity,f.bowl_refill_rate,f.film_time_constant})
require(value>0,path,"fuel: jets, scales, density and time constants must be positive");
require(f.film_fraction>=0 && f.film_fraction<=.8f && f.bowl_head_pressure>=0 &&
f.overrun_rpm>f.overrun_exit_rpm && f.overrun_exit_rpm>0,path,"fuel: invalid film or overrun calibration");
}
validate_ecu(c.ecu,path);validate_vehicle(c.vehicle,path);
}
ScenarioExample load_engine_file(const std::filesystem::path& path) {
toml::table table;
try { table=toml::parse_file(path.string()); }
catch(const toml::parse_error& error) {std::ostringstream message;message<<error;throw std::runtime_error(message.str());}
Reader root(table,path.string());
int version=0;root.field("schema_version",version);
if(version!=1)root.fail("schema_version must be 1");
ScenarioExample e;
root.field("id",e.id);root.field("name",e.title);root.field("category",e.category);
root.field("description",e.description);root.field("order",e.order);
if(e.id.empty() || e.title.empty() || e.category.empty())root.fail("id, name and category are required");
if(e.id.find_first_not_of("abcdefghijklmnopqrstuvwxyz0123456789-_")!=std::string::npos)root.fail("id must use lowercase letters, digits, - or _");
const auto cycle=root.text("cycle");
if(cycle=="two_stroke") {
e.kind=ScenarioKind::TwoStroke;
read_two_stroke(root.section("two_stroke",true),e.two);
read_ecu(root.section("ecu"),e.two.ecu);
read_vehicle(root.section("vehicle"),e.two.vehicle);
validate_two(e.two,path.string());
} else if(cycle=="four_stroke") {
read_engine(root.section("engine",true),e.engine);
read_cylinder(root.section("cylinder",true),e.engine.cylinder);
read_crankshaft(root.section("crankshaft",true),e.engine.crankshaft);
read_ecu(root.section("ecu",true),e.engine.ecu);
read_vehicle(root.section("vehicle",true),e.engine.vehicle);
read_fuel(root.section("fuel",true),e.engine);
read_silencers(root.section("silencers"),e.exhaust);
auto junctions=root.section("junctions");
junctions.field("h_pipe",e.junctions.h_pipe);junctions.field("junction_volume_cc",e.junctions.junction_volume,1e-6);
junctions.finish();
if(const auto* pipes=root.array("pipes"))for(const auto& value:*pipes) {
if(!value.is_table())root.fail("pipes entries must be tables");
Reader r(*value.as_table(),path.string()+".pipes");PipeConfig p;
r.field("id",p.id);r.field("count",p.count);
const auto profile=r.text("profile");
if(!profile.empty() && profile!="silenced")r.fail("unknown pipe profile '"+profile+"'");
p.silenced=profile=="silenced";
r.field("diameter_mm",p.diameter,.001);r.field("length_mm",p.length,.001);r.field("cells",p.cells);
r.finish();e.engine.pipes.push_back(std::move(p));
}
validate_engine_config(e.engine,path.string());
require(e.junctions.junction_volume>0 && e.junctions.junction_volume<=1,path.string(),"junction_volume_cc outside (0,1000000]");
if(e.engine.has_silenced_exhaust()) {
require(e.exhaust.cell_length>=.002f && e.exhaust.cell_length<=1,path.string(),"silencers: cell length outside 2..1000 mm");
require(e.exhaust.diameter>0 && e.exhaust.diameter<=2 && e.exhaust.inlet_length>0 &&
e.exhaust.outlet_length>0 && e.exhaust.connecting_length>0,
path.string(),"silencers: invalid connecting pipe geometry");
float length=e.exhaust.inlet_length+e.exhaust.outlet_length+e.exhaust.connecting_length;
for(const auto& s:{e.exhaust.resonator,e.exhaust.muffler}) {
require(s.volume>0 && s.volume<=1 && s.length>0 && s.core_diameter>0 && s.core_diameter<=2 &&
s.passage_diameter>0 && s.passage_diameter<=2 && s.chambers>=1 && s.chambers<=16 &&
s.resistance>=0 && s.loss_coefficient>=0,path.string(),"silencers: invalid chamber geometry or resistance");
length+=s.length;
}
require(length/e.exhaust.cell_length+6*(e.exhaust.resonator.chambers+e.exhaust.muffler.chambers)+6<=4096,
path.string(),"silencers: mesh exceeds 4096 cells");
try {const auto pipe=make_silenced_exhaust(e.exhaust);require(pipe.cells.size()<=4096,path.string(),"silencers: mesh exceeds 4096 cells");}
catch(const std::exception& error) {root.fail(std::string("silencers: ")+error.what());}
}
} else root.fail("cycle must be two_stroke or four_stroke");
root.finish();return e;
}
std::vector<ScenarioExample> load_engine_directory(const std::filesystem::path& directory) {
require(std::filesystem::is_directory(directory),directory.string(),"engine directory does not exist");
std::vector<std::filesystem::path> files;
for(const auto& entry:std::filesystem::directory_iterator(directory))
if(entry.is_regular_file() && entry.path().extension()==".toml")files.push_back(entry.path());
std::sort(files.begin(),files.end());
std::vector<ScenarioExample> engines;std::set<std::string> ids;
for(const auto& file:files) {
auto engine=load_engine_file(file);
require(ids.insert(engine.id).second,file.string(),"duplicate engine id");
engines.push_back(std::move(engine));
}
require(!engines.empty(),directory.string(),"no .toml engine definitions found");
std::sort(engines.begin(),engines.end(),[](const auto& a,const auto& b) {
return a.order==b.order?a.id<b.id:a.order<b.order;
});
return engines;
}
std::filesystem::path engine_directory() {
if(const char* override_path=std::getenv("FLUIDSIM_ENGINE_DIR");override_path && *override_path)return override_path;
std::vector<std::filesystem::path> candidates{std::filesystem::current_path()/"engines"};
#ifdef _WIN32
std::wstring executable(32768,L'\0');
const auto size=GetModuleFileNameW(nullptr,executable.data(),static_cast<DWORD>(executable.size()));
if(size>0 && size<executable.size()) {executable.resize(size);candidates.push_back(std::filesystem::path(executable).parent_path()/"engines");}
#elif defined(__linux__)
std::error_code error;
const auto executable=std::filesystem::read_symlink("/proc/self/exe",error);
if(!error)candidates.push_back(executable.parent_path()/"engines");
#endif
candidates.emplace_back(FLUIDSIM_ENGINE_SOURCE_DIR);
for(const auto& candidate:candidates)if(std::filesystem::is_directory(candidate))return candidate;
throw std::runtime_error("Cannot find engines/. Set FLUIDSIM_ENGINE_DIR to the engine definitions directory.");
}
const std::vector<ScenarioExample>& scenario_examples() {
static const auto engines=load_engine_directory(engine_directory());return engines;
}
const ScenarioExample& scenario_example(const std::string& id) {
const auto& engines=scenario_examples();
const auto found=std::find_if(engines.begin(),engines.end(),[&](const auto& e) {return e.id==id;});
if(found==engines.end())throw std::runtime_error("Unknown engine id '"+id+"' in "+engine_directory().string());
return *found;
}
const ScenarioExample& preset_engine_definition(FourStrokePreset preset) {
return scenario_example(preset==FourStrokePreset::AmericanV8?"v8-h":preset==FourStrokePreset::SaabB205?"saab-straight":"twin");
}
} // namespace fluidsim::port