Files
FluidSimCpp/src/scenario.cpp

359 lines
19 KiB
C++

#include "FluidSim/Scenario.hpp"
#include "FluidSim/Constants.hpp"
#include "FluidSim/ScenarioGeometry.hpp"
#include "FluidSim/ScenarioCatalog.hpp"
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset preset)
: FourStrokeScenario(sample_rate,preset_engine_definition(preset)) {}
FourStrokeScenario::FourStrokeScenario(int sample_rate,const ScenarioExample& definition)
: 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(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;
static_cast<EcuConfig&>(ecu)=engine.ecu;
static_cast<VehicleConfig&>(vehicle)=engine.vehicle;
crankshaft.omega=engine.initial_rpm*2*pi/60;
starter_torque=engine.starter_torque;
solver_.minimum_substeps=engine.minimum_substeps;
if(engine.carbureted) {
carb_=std::make_unique<Carburetor>(engine.throttle_diameter);
static_cast<CarburetorConfig&>(*carb_)=engine.fuel;
// Carburetors keep metering fuel during both limiter and overrun cuts.
ecu.rev_limit_fuel_cut_enabled=false;
ecu.overrun_fuel_cut_enabled=false;
ecu.overrun_ignition_cut_enabled=true;
intake_plenum.add_fuel_mass(intake_plenum.mass()/14.7f);
}
vehicle.set_neutral();
vehicle.clutch_input = clutch;
const auto engines = cylinders();
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;
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 (engine.initialize_phased_charge) {
// 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);
if(engine.carbureted) {c.fuel_mass=c.air_mass/15.7f;c.air_mass-=c.fuel_mass;}
c.update_state(0);
}
}
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 = engine.pipe_damping;
pipe.energy_relaxation_rate = 0;
}
auto& throttle_port = intake_plenum.create_port();
throttle_port_=&throttle_port;
solver_.add_component(intake_plenum);
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();
// 1--4 and 5--8 are separate physical banks for this firing order.
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]);
if (!v8 && !staged && junctions.model=="volume") boundaries_.add_orifice(exhaust_collector.create_port(), 1+count+i, false, header_area_);
}
// 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.
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) {
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);
boundaries_.add_closed_end(25,true);boundaries_.add_closed_end(25,false);
}
boundaries_.add_radiating_open_end(23,false,101325,pipes[23].area);
boundaries_.add_radiating_open_end(24,false,101325,pipes[24].area);
} else if(staged) {
const int exit=1+2*count;
const bool dual=engine.dual_outlets();
const int groups=engine.header_groups(), secondary=exit+(dual?2:1);
for(int group=0;group<groups;++group) {
std::vector<JunctionEnd> ends;
for(int i=0;i<count;++i)
if(groups==1 || (i<(count+1)/2?0:1)==group) ends.push_back({1+count+i,false});
ends.push_back({secondary+group,true});junction(ends);
}
if(dual) {
// Preserve independent bank pulse trains all the way to their mouths.
for(int bank=0;bank<2;++bank) {
join({{secondary+bank,false},{exit+bank,true}});
boundaries_.add_radiating_open_end(exit+bank,false,101325,pipes[exit+bank].area);
}
} else {
std::vector<JunctionEnd> ends{{exit,true}};
for(int group=0;group<groups;++group) ends.push_back({secondary+group,false});
junction(ends);
boundaries_.add_radiating_open_end(exit,false,101325,pipes[exit].area);
}
} else {
const int exit=1+2*count;
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);
}
const float opening = ecu.throttle_opening();
const float angle = opening * pi * .5f;
throttle_area_ = area_for_diameter(engine_config.throttle_diameter) * (1-std::cos(angle));
if(carb_) {
const auto inlet=pipes[0].state(int(pipes[0].cells.size())-1);
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_,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;
// Compact mouth radiation responds to volume velocity. Use ambient-equivalent
// mass flow so hot/cold donor-density changes do not enter the derivative.
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& 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;
}
void FourStrokeScenario::set_audio_scene(OutdoorScene scene) {
const std::lock_guard<std::mutex> guard(mutex_); audio_.set_scene(scene);
}
void FourStrokeScenario::set_listener(AudioListener listener) {
const std::lock_guard guard(mutex_); audio_.set_listener(listener);
}
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_starter(bool engaged) {
const std::lock_guard guard(mutex_);
starter_engaged=engaged;
}
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 FourStrokeScenario::shift_up() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.shift_up();
}
void FourStrokeScenario::shift_down() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.shift_down();
}
void FourStrokeScenario::set_neutral() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.set_neutral();
}
void FourStrokeScenario::set_first_gear() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.set_first_gear();
}
float FourStrokeScenario::area_for_diameter(float diameter) {
return pi * diameter * diameter * 0.25f;
}
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) {
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;
}
} // namespace fluidsim::port