#include "FluidSim/Scenario.hpp" #include "FluidSim/Constants.hpp" #include "FluidSim/ScenarioGeometry.hpp" #include "FluidSim/ScenarioCatalog.hpp" #include #include #include 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(engine.geometry); active_tuning_=std::make_shared(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(crankshaft)=engine.crankshaft; static_cast(ecu)=engine.ecu; static_cast(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(engine.throttle_diameter); static_cast(*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(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 branch_used(junctions.branches.size(),false); int junction_index=0; auto junction=[&](std::span 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(box->volume(),101325,700); fitting->connect(boundaries_,pipes,ends); solver_.add_component(*fitting);exhaust_junctions_.push_back(std::move(fitting)); } else { std::vector 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 direction{b.x-a.x,b.y-a.y,b.z-a.z}; for(std::size_t j=0;j 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 ends; for(int i=0;i ends{{exit,true}}; for(int group=0;group ends{{exit,true}}; for(int i=0;istep(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(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 retired; { const std::lock_guard guard(mutex_); retired=std::move(active_tuning_);active_tuning_=std::move(next); std::swap(static_cast(ecu),calibration); if(carb_) { static_cast(*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;iset_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 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 guard(mutex_); audio_.set_monitor_filter(enabled); } AudioFrame FourStrokeScenario::dry_audio() const { const std::lock_guard guard(mutex_); return audio_.dry_frame(); } AudioFrame FourStrokeScenario::unfiltered_audio() const { const std::lock_guard 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 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 guard(mutex_); vehicle.shift_up(); } void FourStrokeScenario::shift_down() { const std::lock_guard guard(mutex_); vehicle.shift_down(); } void FourStrokeScenario::set_neutral() { const std::lock_guard guard(mutex_); vehicle.set_neutral(); } void FourStrokeScenario::set_first_gear() { const std::lock_guard guard(mutex_); vehicle.set_first_gear(); } float FourStrokeScenario::area_for_diameter(float diameter) { return pi * diameter * diameter * 0.25f; } std::vector FourStrokeScenario::create_pipes(const EngineConfig& engine,const ExhaustConfig& exhaust) { validate_engine_config(engine); std::vector 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;i4096) 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