Improve physical geometry, fluid solvers, audio, and profiling

This commit is contained in:
max
2026-10-30 18:57:56 +01:00
parent bf8621ad39
commit eb2b6366c2
130 changed files with 14649 additions and 862 deletions

View File

@@ -5,6 +5,7 @@
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset preset)
@@ -14,17 +15,22 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate,const ScenarioExample& de
: FourStrokeScenario(sample_rate,definition.engine,definition.exhaust,definition.junctions) {}
FourStrokeScenario::FourStrokeScenario(int sample_rate, EngineConfig configuration, ExhaustConfig exhaust, JunctionConfig fittings)
: exhaust_config(exhaust), junction_config(fittings), engine_config(std::move(configuration)),
: exhaust_config(exhaust), junction_config(fittings), engine_config(resolve_engine_geometry(std::move(configuration),exhaust,fittings)),
intake_plenum(engine_config.plenum_volume,101325,300),
exhaust_collector(engine_config.collector_volume,101325,700),
pipes(create_pipes(engine_config,exhaust)), dt_(1.f/std::max(sample_rate,1)),
boundaries_(pipes), solver_(pipes,boundaries_), audio_(sample_rate,[&] {
OutdoorAudioConfig config;
config.dual_exhaust=engine_config.dual_outlets();
config.valve_contact_enabled=true;
return config;
}()) {
const auto& engine=engine_config;
geometry_=std::make_shared<PhysicalGeometry>(engine.geometry);
active_tuning_=std::make_shared<EngineConfig>(engine_config);
const auto& junctions=junction_config;
if(junctions.model!="volume" && junctions.model!="characteristic" && junctions.model!="legacy")
throw std::invalid_argument("invalid junction model");
const bool v8=engine.american_headers(),staged=engine.staged_headers();
const int count=cylinder_count();
static_cast<CrankshaftConfig&>(crankshaft)=engine.crankshaft;
@@ -48,6 +54,7 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, EngineConfig configurati
for (int i=0;i<count;++i) {
auto& c=*engines[i];
static_cast<CylinderConfig&>(c)=engine.cylinder;
c.set_valve_profiles(active_tuning_->intake_cam,engine.intake_valve,active_tuning_->exhaust_cam,engine.exhaust_valve);
c.phase=engine.phase_degrees.at(i)*pi/180;
c.transported_fuel=engine.carbureted;
c.rev_limit_enabled = ecu.rev_limit_enabled;
@@ -75,7 +82,7 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, EngineConfig configurati
auto& throttle_port = intake_plenum.create_port();
throttle_port_=&throttle_port;
solver_.add_component(intake_plenum);
if(!v8 && !staged) solver_.add_component(exhaust_collector);
if(!v8 && !staged && junctions.model=="volume") solver_.add_component(exhaust_collector);
boundaries_.add_orifice(throttle_port, 0, false, throttle_area_, .7f);
for (int i = 0; i < count; ++i) {
auto& feed = intake_plenum.create_port();
@@ -85,24 +92,53 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, EngineConfig configurati
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]);
if (!v8 && !staged) boundaries_.add_orifice(exhaust_collector.create_port(), 1+count+i, false, header_area_);
if (!v8 && !staged && junctions.model=="volume") boundaries_.add_orifice(exhaust_collector.create_port(), 1+count+i, false, header_area_);
}
if(v8) {
auto junction=[&](std::initializer_list<JunctionEnd> ends) {
auto fitting=std::make_unique<Junction>(junctions.junction_volume,101325,700);
fitting->connect(boundaries_,pipes,std::span(ends.begin(),ends.size()));
// Node axes follow the last/first route segment, unless explicitly supplied.
ScenarioSnapshot geometry;
geometry.geometry=geometry_;
geometry.cylinder_count=count;geometry.layout=engine.layout;geometry.bank_angle=engine.bank_angle;
geometry.v8_headers=v8;geometry.staged_headers=staged;geometry.dual_exhaust=engine.dual_outlets();
geometry.h_pipe=junctions.h_pipe;geometry.silencers=engine.has_silenced_exhaust() && exhaust.enabled;geometry.exhaust_config=exhaust;
const auto routes=scenario_pipe_routes(geometry);
std::vector<bool> branch_used(junctions.branches.size(),false);
int junction_index=0;
auto junction=[&](std::span<const JunctionEnd> ends) {
if(junctions.model=="volume") {
const auto* box=engine.geometry.volume("junction_"+std::to_string(junction_index));
if(!box)throw std::invalid_argument("geometry missing junction_"+std::to_string(junction_index));
auto fitting=std::make_unique<Junction>(box->volume(),101325,700);
fitting->connect(boundaries_,pipes,ends);
solver_.add_component(*fitting);exhaust_junctions_.push_back(std::move(fitting));
};
} else {
std::vector<BoundarySystem::JunctionFace> faces;
for(const auto& e:ends) {
const auto& route=routes.at(e.pipe);
if(route.count<2) throw std::invalid_argument("junction branch has no route direction");
const auto a=route.points[e.left?0:route.count-1], b=route.points[e.left?1:route.count-2];
std::array<float,3> direction{b.x-a.x,b.y-a.y,b.z-a.z};
for(std::size_t j=0;j<junctions.branches.size();++j) {
const auto& override=junctions.branches[j];
if(override.pipe==e.pipe && override.left==e.left) { direction=override.direction;branch_used[j]=true; }
}
faces.push_back({e.pipe,e.left,direction});
}
boundaries_.add_junction(faces,junctions.model=="legacy"?BoundarySystem::JunctionMethod::LegacyRusanov:BoundarySystem::JunctionMethod::Characteristic,junctions.turning_loss);
}
++junction_index;
};
auto join=[&](std::initializer_list<JunctionEnd> ends) { junction(std::span(ends.begin(),ends.size())); };
if(v8) {
// Within each bank pair cylinders with the widest available firing gaps.
junction({{9,false},{10,false},{17,true}});
junction({{11,false},{12,false},{18,true}});
junction({{13,false},{15,false},{19,true}});
junction({{14,false},{16,false},{20,true}});
junction({{17,false},{18,false},{21,true}});
junction({{19,false},{20,false},{22,true}});
join({{9,false},{10,false},{17,true}});
join({{11,false},{12,false},{18,true}});
join({{13,false},{15,false},{19,true}});
join({{14,false},{16,false},{20,true}});
join({{17,false},{18,false},{21,true}});
join({{19,false},{20,false},{22,true}});
if(junctions.h_pipe) {
junction({{21,false},{23,true},{25,true}});
junction({{22,false},{24,true},{25,false}});
join({{21,false},{23,true},{25,true}});
join({{22,false},{24,true},{25,false}});
} else {
boundaries_.add_connection(21,false,23,true);
boundaries_.add_connection(22,false,24,true);
@@ -114,11 +150,6 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, EngineConfig configurati
const int exit=1+2*count;
const bool dual=engine.dual_outlets();
const int groups=engine.header_groups(), secondary=exit+(dual?2:1);
auto junction=[&](const std::vector<JunctionEnd>& ends) {
auto fitting=std::make_unique<Junction>(junctions.junction_volume,101325,700);
fitting->connect(boundaries_,pipes,ends);
solver_.add_component(*fitting);exhaust_junctions_.push_back(std::move(fitting));
};
for(int group=0;group<groups;++group) {
std::vector<JunctionEnd> ends;
for(int i=0;i<count;++i)
@@ -128,7 +159,7 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, EngineConfig configurati
if(dual) {
// Preserve independent bank pulse trains all the way to their mouths.
for(int bank=0;bank<2;++bank) {
junction({{secondary+bank,false},{exit+bank,true}});
join({{secondary+bank,false},{exit+bank,true}});
boundaries_.add_radiating_open_end(exit+bank,false,101325,pipes[exit+bank].area);
}
} else {
@@ -138,23 +169,37 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, EngineConfig configurati
boundaries_.add_radiating_open_end(exit,false,101325,pipes[exit].area);
}
} else {
auto& collector_out = exhaust_collector.create_port();
const int exit=1+2*count;
boundaries_.add_orifice(collector_out,exit,true,tail_area_);
if(junctions.model=="volume") {
auto& collector_out = exhaust_collector.create_port();
boundaries_.add_orifice(collector_out,exit,true,tail_area_);
} else {
std::vector<JunctionEnd> ends{{exit,true}};
for(int i=0;i<count;++i) ends.push_back({1+count+i,false});
junction(ends);
}
boundaries_.add_radiating_open_end(exit,false,101325,pipes[exit].area);
}
for(std::size_t i=0;i<branch_used.size();++i)
if(junctions.model!="volume" && !branch_used[i]) throw std::invalid_argument("junction direction override does not name an active characteristic/legacy junction branch");
boundaries_.add_radiating_open_end(0, true, 101325, pipes[0].area);
solver_.set_profiler(&profiler_);
audio_.set_source_positions(scenario_sound_sources(snapshot()));
}
AudioFrame FourStrokeScenario::process() {
const std::lock_guard guard(mutex_);
const bool profile=profiler_.begin_step();
ProfileScope total(&profiler_,profile,ProfileStage::Total);
float contact_impulse=0;
{ ProfileScope controls(&profiler_,profile,ProfileStage::Controls);
step_motorcycle(crankshaft, dt_, engine_config.maximum_load_torque);
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);
contact_impulse+=c.take_contact_impulse();
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);
@@ -167,7 +212,8 @@ AudioFrame FourStrokeScenario::process() {
carb_->step(dt_,throttle,opening,crankshaft.omega*60/(2*pi),std::max(throttle_port_->mass_flow,0.f),inlet.rho,inlet.pressure,intake_plenum.pressure());
intake_plenum.add_fuel_mass(carb_->fuel_flow()*dt_);
}
solver_.step(dt_);
}
solver_.step(dt_,profile);
intake_flow = boundaries_.open_end_mass_flow(engine_config.dual_outlets() ? 2 : 1);
exhaust_flow = boundaries_.open_end_mass_flow(0);
const float other_exhaust = engine_config.dual_outlets() ? boundaries_.open_end_mass_flow(1) : 0.f;
@@ -176,12 +222,57 @@ AudioFrame FourStrokeScenario::process() {
const float intake_source = ambient_density * boundaries_.open_end_volume_flow(engine_config.dual_outlets() ? 2 : 1);
const float exhaust_source = ambient_density * boundaries_.open_end_volume_flow(0);
const float other_source = engine_config.dual_outlets() ? ambient_density * boundaries_.open_end_volume_flow(1) : 0.f;
const auto rendered = audio_.process(intake_source, exhaust_source, other_source);
const auto& sound=active_tuning_->valve_sound;
const float mode_omega=2*pi*sound.mode_hz;
valve_sound_velocity_+=contact_impulse/sound.effective_mass_kg;
valve_sound_velocity_+=(-mode_omega*mode_omega*valve_sound_displacement_-2*sound.damping_ratio*mode_omega*valve_sound_velocity_)*dt_;
valve_sound_displacement_+=valve_sound_velocity_*dt_;
const float valve_contact_source=ambient_density*sound.radiation_area*valve_sound_velocity_;
ProfileScope audio_profile(&profiler_,profile,ProfileStage::Audio);
const auto rendered = audio_.process(intake_source, exhaust_source, other_source,valve_contact_source);
// Count sampled frames only after the complete process call has finished.
if(profile) profiler_.finish_step(total.elapsed_ns());
exhaust_flow += other_exhaust;
++sample_count;
const auto bin=std::clamp(int(std::fmod((crankshaft.angle+cylinder1.phase)*180/pi,720.f)/4),0,179);
intake_lift_trace_[bin]=cylinder1.intake_lift();exhaust_lift_trace_[bin]=cylinder1.exhaust_lift();
intake_lift_trace_[180]=intake_lift_trace_[0];exhaust_lift_trace_[180]=exhaust_lift_trace_[0];
last_contact_impulse_=contact_impulse;
return rendered;
}
void FourStrokeScenario::set_tuning(const EngineConfig& tuning) {
validate_engine_config(tuning,"live tuning");
// The live editor only changes calibration. Geometry is rebuilt separately.
const auto& a=tuning.cylinder;const auto& b=engine_config.cylinder;
if(tuning.geometry!=engine_config.geometry || tuning.cylinders!=engine_config.cylinders || tuning.layout!=engine_config.layout ||
tuning.topology!=engine_config.topology || tuning.phase_degrees!=engine_config.phase_degrees ||
tuning.carbureted!=engine_config.carbureted || tuning.throttle_diameter!=engine_config.throttle_diameter ||
a.bore!=b.bore || a.stroke!=b.stroke || a.rod!=b.rod || a.compression!=b.compression ||
tuning.cells_per_meter!=engine_config.cells_per_meter || tuning.pipes!=engine_config.pipes || tuning.plenum_volume!=engine_config.plenum_volume ||
tuning.collector_volume!=engine_config.collector_volume ||
a.intake_valve_count!=b.intake_valve_count || a.exhaust_valve_count!=b.exhaust_valve_count ||
a.intake_valve_diameter!=b.intake_valve_diameter || a.exhaust_valve_diameter!=b.exhaust_valve_diameter)
throw std::invalid_argument("Live tuning cannot change engine geometry; rebuild the engine instead");
auto next=std::make_shared<const EngineConfig>(tuning);
auto calibration=tuning.ecu;
// The scenario lock spans exactly one process() call. Copy/validation and
// retirement of the old profile happen on the editing thread outside it.
std::shared_ptr<const EngineConfig> retired;
{
const std::lock_guard guard(mutex_);
retired=std::move(active_tuning_);active_tuning_=std::move(next);
std::swap(static_cast<EcuConfig&>(ecu),calibration);
if(carb_) {
static_cast<CarburetorConfig&>(*carb_)=tuning.fuel;
ecu.rev_limit_fuel_cut_enabled=false;ecu.overrun_fuel_cut_enabled=false;ecu.overrun_ignition_cut_enabled=true;
}
const auto engines=cylinders();
for(int i=0;i<cylinder_count();++i)
engines[i]->set_valve_profiles(active_tuning_->intake_cam,tuning.intake_valve,active_tuning_->exhaust_cam,tuning.exhaust_valve);
}
}
void FourStrokeScenario::set_pipe_solver(PipeSolver solver) {
const std::lock_guard guard(mutex_);
for(auto& pipe : pipes) pipe.solver=solver;
@@ -242,10 +333,25 @@ float FourStrokeScenario::area_for_diameter(float diameter) {
std::vector<Pipe1D> FourStrokeScenario::create_pipes(const EngineConfig& engine,const ExhaustConfig& exhaust) {
validate_engine_config(engine);
std::vector<Pipe1D> pipes;
auto mesh_exhaust=exhaust;
if(engine.cells_per_meter>0) mesh_exhaust.cell_length=1/engine.cells_per_meter;
std::size_t index=0;
for(const auto& pipe:engine.pipes)for(int i=0;i<pipe.count;++i) {
if(pipe.silenced)pipes.push_back(make_silenced_exhaust(exhaust));
else pipes.emplace_back(area_for_diameter(pipe.diameter),pipe.length,pipe.cells);
const float length=engine.geometry.pipes.at(index++).length();
if(pipe.silenced) {
auto layout=mesh_exhaust;
const float original=layout.inlet_length+layout.outlet_length+(layout.enabled?layout.connecting_length+layout.resonator.length+layout.muffler.length:0);
const float scale=length/original;
layout.inlet_length*=scale;layout.outlet_length*=scale;layout.connecting_length*=scale;
layout.resonator.length*=scale;layout.resonator.volume*=scale;layout.muffler.length*=scale;layout.muffler.volume*=scale;
auto profile=make_silenced_exhaust(layout);
if(profile.cells.size()>4096) throw std::invalid_argument("silencer mesh exceeds 4096 cells");
pipes.push_back(std::move(profile));
}
else pipes.emplace_back(area_for_diameter(pipe.diameter),length,engine.cells_per_meter>0?mesh_cell_count(length,engine.cells_per_meter):pipe.cells);
}
std::size_t total=0;for(const auto& pipe:pipes)total+=pipe.cells.size();
if(total>32768) throw std::invalid_argument("network mesh exceeds 32768 cells");
return pipes;
}