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

@@ -24,7 +24,7 @@ private:
std::array<std::int16_t, block_size * 2> output_{};
std::atomic<std::uint64_t> underrun_blocks_{};
std::atomic<double> speed_{1.0};
std::atomic<bool> auto_gain_enabled_{true};
std::atomic<bool> auto_gain_enabled_{false};
float rms_power_{}, peak_envelope_{}, automatic_gain_{1.f};
float level_alpha_{}, peak_decay_{}, gain_rise_alpha_{}, gain_fall_alpha_{};
float previous_input_left_{}, previous_output_left_{};

View File

@@ -3,7 +3,7 @@
namespace fluidsim::port {
struct AudioFrame { float left{}, right{}; };
struct Vec3 { float x{}, y{}, z{}; };
struct Vec3 { float x{}, y{}, z{}; bool operator==(const Vec3&) const = default; };
struct AudioListener { Vec3 position{}, right{1,0,0}; };
// Simple flow-derivative source used by the Helmholtz and pulse scenarios.

View File

@@ -3,6 +3,8 @@
#include "FluidSim/DynoCurve.hpp"
#include "FluidSim/Desktop/PedalControl.hpp"
#include "FluidSim/Desktop/EngineRenderer3D.hpp"
#include <optional>
#include <string>
#include <SFML/Graphics.hpp>
namespace fluidsim::port::desktop {
struct PlaybackInfo {
@@ -24,8 +26,17 @@ public:
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&);
void configure_resolution(float density) { resolution_=density; }
std::optional<float> take_resolution_request() {auto value=resolution_request_;resolution_request_.reset();return value;}
void resolution_started() {resolution_busy_=true;resolution_status_="Building mesh...";}
void resolution_finished(float density,const std::string& message) {resolution_=density;resolution_busy_=false;resolution_status_=message;}
static std::array<sf::FloatRect,2> resolution_buttons(sf::Vector2u size);
private:
DynoCurve dyno_;
float resolution_{20};
bool resolution_busy_{};
std::optional<float> resolution_request_;
std::string resolution_status_;
EngineRenderer3D renderer_;
OrbitCamera camera_;
bool three_d_{true},camera_fitted_{},pan_drag_{},pedal_drag_{};
@@ -34,5 +45,8 @@ private:
sf::Vector2i previous_mouse_{};
bool dragging_{};
float zoom_{1};
float camera_aspect_{};
WorkspaceLayout workspace_layout(sf::Vector2u size) const;
void fit_camera(float aspect,const ScenarioSnapshot&);
};
}

View File

@@ -0,0 +1,47 @@
#pragma once
#include "FluidSim/ScenarioCatalog.hpp"
#include "FluidSim/ScenarioSnapshot.hpp"
#include <SFML/Graphics.hpp>
#include <optional>
namespace fluidsim::port::desktop {
// Owns an editable design only. Simulation state is exchanged through snapshots
// and explicit actions; mouse handlers never touch running components.
class EngineEditor {
public:
void configure(const ScenarioExample& definition);
bool handle_event(const sf::Event& event,sf::Vector2u size);
void draw(sf::RenderWindow& window,const sf::Font* font,const ScenarioSnapshot& state) const;
bool close();
bool visible() const { return visible_; }
const EngineConfig& draft() const { return draft_; }
std::optional<EngineConfig> take_live_update();
std::optional<EngineConfig> take_rebuild_request();
bool take_save_request();
void finish_rebuild(bool success,const std::string& message);
void set_status(std::string message) { status_=std::move(message); }
static sf::FloatRect bounds(sf::Vector2u size);
static sf::FloatRect toggle_bounds(sf::Vector2u size);
private:
enum class Tab { Valves, Fuel, Pipes };
enum class Key { Mass,Spring,Damping,Preload,SoundArea,SoundDamping,Ignition,Idle,Pilot,Main,Needle,Stoich,Power,Length,Diameter };
struct Field { Key key;std::string label,unit;float value,minimum,maximum;int precision; };
bool supported_{},visible_{},exhaust_{},live_changed_{},rebuild_requested_{},save_requested_{},busy_{};
Tab tab_{Tab::Valves};
EngineConfig original_,draft_,rebuilding_;
std::vector<PipeConfig> applied_pipes_;
std::vector<EngineConfig> undo_,redo_;
std::size_t pipe_{};
int dragging_field_{-1},dragging_point_{-1};
std::string status_{"Drag a cam point or slider. W/S controls the throttle."};
std::vector<Field> fields() const;
void set_field(const Field& field,float value);
void begin_edit();
void undo();
void redo();
void reset();
void drag(sf::Vector2f position,sf::Vector2u size);
sf::FloatRect graph_bounds(sf::Vector2u size) const;
sf::FloatRect field_bounds(sf::Vector2u size,std::size_t index) const;
};
}

View File

@@ -7,8 +7,7 @@
#include <utility>
namespace fluidsim::port::desktop {
struct PipeAnchor {float distance;Vec3 position;};
// Knots are distances along the simulated pipe, in metres. Display positions
// define its schematic route; changing them never changes fluid cell lengths.
// Piecewise straight centerline; knot distances are measured from its geometry.
class PipePath {
public:
explicit PipePath(std::vector<PipeAnchor> anchors):anchors_(std::move(anchors)) {
@@ -24,10 +23,8 @@ public:
auto it=std::upper_bound(anchors_.begin(),anchors_.end(),distance,[](float s,const PipeAnchor& a){return s<a.distance;});
std::size_t i=std::clamp<std::size_t>(it-anchors_.begin(),1,anchors_.size()-1)-1;
const auto& a=anchors_[i];const auto& b=anchors_[i+1];
const float span=b.distance-a.distance,t=(distance-a.distance)/span,t2=t*t,t3=t2*t;
const auto ma=tangent_at(i),mb=tangent_at(i+1);
auto coordinate=[&](float pa,float pb,float ta,float tb){return (2*t3-3*t2+1)*pa+(t3-2*t2+t)*span*ta+(-2*t3+3*t2)*pb+(t3-t2)*span*tb;};
return {coordinate(a.position.x,b.position.x,ma.x,mb.x),coordinate(a.position.y,b.position.y,ma.y,mb.y),coordinate(a.position.z,b.position.z,ma.z,mb.z)};
const float t=(distance-a.distance)/(b.distance-a.distance);
return {std::lerp(a.position.x,b.position.x,t),std::lerp(a.position.y,b.position.y,t),std::lerp(a.position.z,b.position.z,t)};
}
Vec3 tangent(float distance) const {
const float epsilon=anchors_.back().distance*.0001f;
@@ -35,12 +32,21 @@ public:
const float x=b.x-a.x,y=b.y-a.y,z=b.z-a.z,n=std::sqrt(x*x+y*y+z*z);
return n>1e-8f?Vec3{x/n,y/n,z/n}:Vec3{1,0,0};
}
private:
Vec3 tangent_at(std::size_t i) const {
const auto& a=anchors_[i?i-1:0];const auto& b=anchors_[std::min(i+1,anchors_.size()-1)];
const float d=b.distance-a.distance;
return {(b.position.x-a.position.x)/d,(b.position.y-a.position.y)/d,(b.position.z-a.position.z)/d};
Vec3 miter_offset(float distance,Vec3 radial) const {
const auto unit=[](Vec3 v){const float n=std::sqrt(v.x*v.x+v.y*v.y+v.z*v.z);return Vec3{v.x/n,v.y/n,v.z/n};};
for(std::size_t i=1;i+1<anchors_.size();++i)if(std::abs(distance-anchors_[i].distance)<1e-6f) {
const auto a=anchors_[i-1].position,b=anchors_[i].position,c=anchors_[i+1].position;
const auto u=unit({b.x-a.x,b.y-a.y,b.z-a.z}),v=unit({c.x-b.x,c.y-b.y,c.z-b.z});
const float cosine=std::clamp(u.x*v.x+u.y*v.y+u.z*v.z,-1.f,1.f);
if(cosine>.99999f)return radial;
const auto bend=unit({u.x-v.x,u.y-v.y,u.z-v.z});
const float scale=std::min(3.f,1/std::sqrt(std::max(.0001f,(1+cosine)*.5f)));
const float extension=(radial.x*bend.x+radial.y*bend.y+radial.z*bend.z)*(scale-1);
return {radial.x+bend.x*extension,radial.y+bend.y*extension,radial.z+bend.z*extension};
}
return radial;
}
private:
std::vector<PipeAnchor> anchors_;
};
}

View File

@@ -0,0 +1,24 @@
#pragma once
#include <SFML/Window/Event.hpp>
#include <algorithm>
#include <cmath>
namespace fluidsim::port::desktop {
class PlaybackControl {
public:
bool realtime() const {return realtime_;}
double target_speed() const {return realtime_?1.0:manual_speed_;}
bool handle_event(const sf::Event& event,bool shift=false) {
if(const auto* key=event.getIf<sf::Event::KeyPressed>()) {
if(key->code==sf::Keyboard::Key::Space){realtime_=!realtime_;return true;}
if(!realtime_&&key->code==sf::Keyboard::Key::PageUp){adjust(1);return true;}
if(!realtime_&&key->code==sf::Keyboard::Key::PageDown){adjust(-1);return true;}
}
if(const auto* wheel=event.getIf<sf::Event::MouseWheelScrolled>();wheel&&!realtime_&&shift) {adjust(wheel->delta);return true;}
return false;
}
private:
bool realtime_{true};
double manual_speed_{1};
void adjust(double steps){manual_speed_=std::clamp(manual_speed_*std::exp(steps*.12),.0001,1.0);}
};
}

View File

@@ -3,10 +3,29 @@
#include <algorithm>
#include <cmath>
namespace fluidsim::port::desktop {
// Fixed absolute scale: vacuum blue, atmosphere green, >=1 MPa red.
inline Vec3 pressure_tint(float pressure) {
// Keep the established absolute vacuum palette. Above atmosphere, use a
// narrower positive range so modest pulses stand out from the green baseline.
inline Vec3 pressure_tint(float pressure, bool exhaust=false, float temperature=300.f) {
if(pressure<=101325) {float t=std::clamp(pressure/101325,0.f,1.f);return {.05f,.15f+.70f*t,1-.95f*t};}
float t=std::sqrt(std::clamp((pressure-101325)/(1000000-101325),0.f,1.f));
return {.05f+.95f*t,.85f-.80f*t,.05f-.03f*t};
const float temperature_boost=std::sqrt(std::clamp(temperature/300.f,.25f,9.f));
const float pulse=(pressure-101325.f)*temperature_boost;
// Exhaust reaches the warm colors sooner, while using the same continuous
// ramp so the atmosphere boundary and pulse edges stay smooth.
const float scale=exhaust?45000.f:150000.f;
const float t=pulse/(pulse+scale);
const auto mix=[](Vec3 a,Vec3 b,float u) {
u=u*u*(3.f-2.f*u);
return Vec3{a.x+(b.x-a.x)*u,a.y+(b.y-a.y)*u,a.z+(b.z-a.z)*u};
};
constexpr Vec3 green{.05f,.85f,.05f},yellow{1.f,1.f,.03f},orange{1.f,.52f,.02f},red{1.f,0.f,0.f};
if(t<.35f)return mix(green,yellow,t/.35f);
if(t<.68f)return mix(yellow,orange,(t-.35f)/.33f);
return mix(orange,red,(t-.68f)/.32f);
}
inline float pressure_expansion(float pressure,float temperature=300.f) {
if(pressure<=101325.f)return 0.f;
const float temperature_boost=std::sqrt(std::clamp(temperature/300.f,.25f,9.f));
const float pulse=(pressure-101325.f)*temperature_boost;
return .10f*pulse/(pulse+80000.f);
}
}

View File

@@ -11,7 +11,7 @@ class DesktopScenario {
public:
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)) {
explicit DesktopScenario(ScenarioKind kind, TwoStrokeConfig config = {}, ExhaustConfig exhaust = {}, JunctionConfig junctions = {}, EngineConfig engine = {}, std::string name = {}) : name_(std::move(name)),exhaust_(exhaust),junctions_(junctions) {
if (kind == ScenarioKind::TwoStroke)
two_ = std::make_unique<TwoStrokeScenario>(44100, config);
else {
@@ -21,33 +21,74 @@ public:
}
}
AudioFrame process() {
const std::lock_guard lock(mutex_);
if (four_)
return four_->process();
const std::lock_guard lock(mutex_);
return two_->process();
}
void set_controls(float pedal, float load, float clutch) {
visit([&](auto& scenario) { scenario.set_controls(pedal, load, clutch); });
visit([&](auto& scenario) { pedal_=pedal;load_=load;clutch_=clutch;scenario.set_controls(pedal, load, clutch); });
}
void set_starter(bool engaged) { visit([&](auto& scenario) { scenario.set_starter(engaged); }); }
void set_starter(bool engaged) { visit([&](auto& scenario) { starter_=engaged;scenario.set_starter(engaged); }); }
void shift_up() { visit([](auto& scenario) { scenario.shift_up(); }); }
void shift_down() { visit([](auto& scenario) { scenario.shift_down(); }); }
void set_neutral() { visit([](auto& scenario) { scenario.set_neutral(); }); }
void set_pipe_solver(PipeSolver solver) { visit([&](auto& scenario) { scenario.set_pipe_solver(solver); }); }
void set_pipe_solver(PipeSolver solver) { visit([&](auto& scenario) { solver_=solver;scenario.set_pipe_solver(solver); }); }
void set_audio_scene(OutdoorScene scene) {
visit([&](auto& scenario) { scenario.set_audio_scene(scene); });
visit([&](auto& scenario) { scene_=scene;scenario.set_audio_scene(scene); });
}
void set_listener(AudioListener listener) {
visit([&](auto& scenario) { scenario.set_listener(listener); });
visit([&](auto& scenario) { listener_=listener;scenario.set_listener(listener); });
}
void set_tuning(const EngineConfig& tuning) {
const std::lock_guard lock(mutex_);
if(four_)four_->set_tuning(tuning);
}
// Build on a background thread. Publishing takes the same lock as process(),
// so no solver/port pointers can be observed during the swap.
void rebuild_engine(const EngineConfig& engine) {
validate_engine_config(engine,"pipe rebuild");
auto replacement=std::make_unique<FourStrokeScenario>(44100,engine,exhaust_,junctions_);
{
const std::lock_guard lock(mutex_);
if(!four_)throw std::invalid_argument("Live editor requires a four-stroke engine");
replacement->set_controls(pedal_,load_,clutch_);
replacement->set_starter(starter_);replacement->set_pipe_solver(solver_);
replacement->set_audio_scene(scene_);replacement->set_listener(listener_);
four_.swap(replacement);
}
}
void set_cells_per_meter(float density) {
mesh_cell_count(.1f,density);
EngineConfig engine;TwoStrokeConfig two_config;bool is_two;
{
const std::lock_guard lock(mutex_);
is_two=bool(two_);
if(is_two) two_config=two_->configuration; else engine=four_->engine_config;
}
std::unique_ptr<FourStrokeScenario> next_four;
std::unique_ptr<TwoStrokeScenario> next_two;
if(is_two) { two_config.cells_per_meter=density;two_config.exhaust_cell_length=1/density;next_two=std::make_unique<TwoStrokeScenario>(44100,two_config); }
else { engine.cells_per_meter=density;next_four=std::make_unique<FourStrokeScenario>(44100,engine,exhaust_,junctions_); }
{
const std::lock_guard lock(mutex_);
const auto restore=[&](auto& scenario) {
scenario.set_controls(pedal_,load_,clutch_);scenario.set_starter(starter_);
scenario.set_pipe_solver(solver_);scenario.set_audio_scene(scene_);scenario.set_listener(listener_);
};
if(is_two) {restore(*next_two);two_.swap(next_two);}
else {restore(*next_four);four_.swap(next_four);}
}
}
ScenarioSnapshot snapshot() const {
if (four_) { auto s=four_->snapshot();s.example_name=name_;return s; }
const std::lock_guard lock(mutex_);
if (four_) { auto s=four_->snapshot();s.example_name=name_;s.cells_per_meter=four_->engine_config.cells_per_meter;return s; }
const auto source = two_->snapshot();
ScenarioSnapshot s;
s.example_name=name_;
s.pipe_solver=two_->pipes.front().solver;
s.two_stroke = true;
s.cells_per_meter=two_->configuration.cells_per_meter;
s.cylinder_angles[0]=two_->crankshaft.angle;
s.cylinder_count = 1;
s.racing_exhaust = two_->configuration.exhaust == TwoStrokeExhaust::Racing;
@@ -79,6 +120,7 @@ public:
s.intake_flow = two_->intake_flow;
s.exhaust_flow = two_->exhaust_flow;
s.capture_pipes(two_->pipes);
two_->capture_geometry(s);
return s;
}
@@ -89,6 +131,13 @@ private:
else function(*two_);
}
std::string name_;
ExhaustConfig exhaust_;
JunctionConfig junctions_;
float pedal_{},load_{},clutch_{};
bool starter_{};
PipeSolver solver_{PipeSolver::Rusanov};
OutdoorScene scene_{OutdoorScene::Wall};
AudioListener listener_{};
std::unique_ptr<FourStrokeScenario> four_;
std::unique_ptr<TwoStrokeScenario> two_;
mutable std::mutex mutex_;

View File

@@ -5,6 +5,7 @@
#include <array>
#include <cstddef>
#include <random>
#include <vector>
namespace fluidsim::port {
@@ -33,11 +34,9 @@ private:
};
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 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};
@@ -79,14 +78,44 @@ protected:
static bool crossed_angle(float previous, float current, float event) {
return current >= previous ? previous < event && current >= event : event > previous || event <= current;
}
static bool crossed_window_close(float previous, float current, float open, float close);
void update_rev_limiter(float rpm, float dt);
void sync_ports();
};
class FourStrokeCylinder final : public EngineCylinder {
public:
struct CamPoint { float angle_degrees{}, lift_metres{}; };
struct ValveDynamics {
float mass_kg{0.035f};
float spring_rate_n_per_m{30000.f};
float damping_n_s_per_m{24.f};
float seat_force_n{100.f};
float maximum_lift_m{0.015f};
float seat_restitution{0.005f}, cam_restitution{0.01f}, stop_restitution{0.01f};
};
struct CamProfile { std::vector<CamPoint> points; };
void set_valve_profiles(const CamProfile& intake_cam, const ValveDynamics& intake_valve,
const CamProfile& exhaust_cam, const ValveDynamics& exhaust_valve);
float valve_area(float cycle_degrees, bool inlet) const override;
void pre_step(Crankshaft& crank, float dt, float throttle) override;
float take_contact_impulse() { const float value=contact_impulse_;contact_impulse_=0;return value; }
bool valve_floating() const { return intake_floating_ || exhaust_floating_; }
float intake_lift() const { return intake_lift_; }
float exhaust_lift() const { return exhaust_lift_; }
static float cam_lift_at(const CamProfile& profile,float degrees) { return sample_cam(profile,degrees).lift_metres; }
private:
struct ValveState { float lift{},velocity{};bool floating{}; };
const CamProfile* intake_cam_{};
const CamProfile* exhaust_cam_{};
ValveDynamics intake_dynamics_{},exhaust_dynamics_{};
ValveState intake_state_{},exhaust_state_{};
float intake_lift_{},exhaust_lift_{},contact_impulse_{};
bool intake_floating_{},exhaust_floating_{};
static CamPoint sample_cam(const CamProfile& profile,float degrees);
float advance_valve(ValveState& state,const CamProfile& cam,const ValveDynamics& dynamics,
bool inlet,const Crankshaft& crank,float dt);
};
} // namespace fluidsim::port

View File

@@ -1,9 +1,12 @@
#pragma once
#include "FluidSim/MeshResolution.hpp"
#include "FluidSim/Engine.hpp"
#include "FluidSim/Ecu.hpp"
#include "FluidSim/Vehicle.hpp"
#include "FluidSim/Carburetor.hpp"
#include "FluidSim/PhysicalGeometry.hpp"
#include <string>
#include <array>
#include <vector>
namespace fluidsim::port {
@@ -17,11 +20,14 @@ struct PipeConfig {
float diameter{}, length{}; // metres
int cells{}, count{1};
bool silenced{};
bool operator==(const PipeConfig&) const = default;
};
// Parsed calibration only. Runtime state belongs to the scenario components.
// Dimensional fields use SI units after loading.
struct EngineConfig {
PhysicalGeometry geometry;
float cells_per_meter{}; // Zero retains legacy programmatic meshes. TOML scenarios specify a density.
int cylinders{};
EngineLayout layout{EngineLayout::Inline};
ExhaustTopology topology{ExhaustTopology::Collector};
@@ -31,6 +37,9 @@ struct EngineConfig {
EcuConfig ecu;
VehicleConfig vehicle;
CarburetorConfig fuel;
FourStrokeCylinder::CamProfile intake_cam,exhaust_cam;
FourStrokeCylinder::ValveDynamics intake_valve,exhaust_valve;
struct ValveSound { float mode_hz{3600}, effective_mass_kg{0.18f}, radiation_area{0.001f}, damping_ratio{0.9f}; } valve_sound;
bool carbureted{}, initialize_phased_charge{true};
float initial_rpm{}, starter_torque{}, maximum_load_torque{};
float plenum_volume{}, collector_volume{}, throttle_diameter{}, pipe_damping{};
@@ -45,7 +54,14 @@ struct EngineConfig {
int header_groups() const { return cylinders==3?1:2; }
};
struct JunctionBranchConfig {
int pipe{}; bool left{};
std::array<float,3> direction{};
};
struct JunctionConfig {
std::string model{"volume"}; // volume, characteristic, or legacy
float turning_loss{0};
std::vector<JunctionBranchConfig> branches;
bool h_pipe{true};
float junction_volume{50e-6f};
};

View File

@@ -4,6 +4,8 @@
#include "FluidSim/OpenEndRadiation.hpp"
#include "FluidSim/PipeSolver.hpp"
#include <algorithm>
#include <array>
#include <span>
#include <cmath>
#include <deque>
#include <limits>
@@ -181,12 +183,17 @@ public:
std::vector<GasCell> cells;
GasState left_ghost{}, right_ghost{};
std::uint64_t positivity_repairs{};
std::size_t scratch_bytes() const {
return next_cells_.capacity()*sizeof(GasCell)+states_.capacity()*sizeof(GasState)+
reconstructed_left_.capacity()*sizeof(GasState)+reconstructed_right_.capacity()*sizeof(GasState)+
waves_.capacity()*sizeof(float)+faces_.capacity()*sizeof(Flux)+roe_states_.capacity()*sizeof(RoeState);
}
Pipe1D(float a, float length, int count)
: Pipe1D(uniform(a, count), uniform(length / std::max(count, 1), count)) {}
Pipe1D(std::vector<float> areas, std::vector<float> lengths)
: area(areas.empty() ? 0 : areas.front()), dx(lengths.empty() ? 0 : lengths.front()),
cell_areas(std::move(areas)), cell_lengths(std::move(lengths)), cells(cell_areas.size()),
next_cells_(cells.size()), states_(cells.size()), waves_(cells.size()), faces_(cells.size() + 1), roe_states_(cells.size()) {
next_cells_(cells.size()), states_(cells.size()), waves_(cells.size()), faces_(cells.size() + 1) {
if (cells.empty() || cell_lengths.size() != cells.size())
throw std::invalid_argument("pipe profiles must be nonempty and have equal length");
for (std::size_t i = 0; i < cells.size(); ++i)
@@ -212,6 +219,16 @@ public:
// Solver preparation is valid until advance; boundary resolution changes
// fluxes/ghosts, not cells. Standalone advance still prepares its own cache.
float prepare_step(float cfl = 0.8f) {
if(!scratch_solver_initialized_ || scratch_solver_!=solver) {
if(solver==PipeSolver::Muscl) {
if(reconstructed_left_.size()!=cells.size()) reconstructed_left_.resize(cells.size());
if(reconstructed_right_.size()!=cells.size()) reconstructed_right_.resize(cells.size());
} else if(solver==PipeSolver::Hlle || solver==PipeSolver::Hllc) {
if(roe_states_.size()!=cells.size()) roe_states_.resize(cells.size());
}
scratch_solver_=solver;
scratch_solver_initialized_=true;
}
float dt = std::numeric_limits<float>::max();
for (std::size_t i = 0; i < cells.size(); ++i) {
states_[i] = primitive(cells[i]);
@@ -225,7 +242,7 @@ public:
throw std::runtime_error("nonphysical pipe state in preparation");
dt = std::min(dt, cfl * cell_lengths[i] / waves_[i]);
}
if (solver != PipeSolver::Rusanov)
if (solver == PipeSolver::Hlle || solver == PipeSolver::Hllc)
for (std::size_t i=0; i<cells.size(); ++i) {
const auto& s=states_[i];
const double energy=s.pressure*pipe_gm1_inverse+.5*double(s.rho)*s.velocity*s.velocity;
@@ -327,6 +344,18 @@ public:
static Flux numerical_flux(const GasState& a,const GasState& b,PipeSolver solver) {
return numerical_flux(a,b,solver,std::sqrt(gamma*a.pressure/a.rho),std::sqrt(gamma*b.pressure/b.rho));
}
void set_acoustic_neighbor(bool left,GasState state) {
if(left) { left_acoustic_neighbor_=state; has_left_acoustic_neighbor_=true; }
else { right_acoustic_neighbor_=state; has_right_acoustic_neighbor_=true; }
}
void prepare_acoustic_reconstruction(float dt) {
if(solver!=PipeSolver::Muscl) return;
if(!prepared_) prepare_step();
reconstruct(dt); reconstructed_prepared_=true;
}
GasState acoustic_edge(bool left) const {
return left?reconstructed_left_.front():reconstructed_right_.back();
}
void advance(float dt) {
if (dt <= 0)
return;
@@ -338,7 +367,18 @@ public:
faces_[0] = left_override_ ? left_flux_ : scaled(numerical_flux(left_ghost, states_[0], solver), cell_areas[0]);
faces_[n] =
right_override_ ? right_flux_ : scaled(numerical_flux(states_[n - 1], right_ghost, solver), cell_areas[n - 1]);
if (solver == PipeSolver::Rusanov) {
if(solver==PipeSolver::Muscl) {
if(!reconstructed_prepared_) reconstruct(dt);
for(std::size_t i=1;i<n;++i) {
bool fallback=false;
const auto& a=reconstructed_right_[i-1];
const auto& b=reconstructed_left_[i];
faces_[i]=scaled(numerical_flux(a,b,PipeSolver::Hllc,
std::sqrt(gamma*a.pressure/a.rho),std::sqrt(gamma*b.pressure/b.rho),&fallback),
.5f*(cell_areas[i-1]+cell_areas[i]));
hllc_fallbacks+=fallback;
}
} else if (solver == PipeSolver::Rusanov) {
for (std::size_t i = 1; i < n; ++i)
faces_[i] = scaled(riemann(states_[i-1], states_[i], std::max(waves_[i-1], waves_[i])),
.5f*(cell_areas[i-1]+cell_areas[i]));
@@ -400,6 +440,7 @@ public:
}
cells.swap(next_cells_);
prepared_ = false;
reconstructed_prepared_=has_left_acoustic_neighbor_=has_right_acoustic_neighbor_=false;
left_override_ = right_override_ = false;
}
static Flux scaled(Flux f, float a) {
@@ -409,11 +450,53 @@ public:
private:
std::vector<GasCell> next_cells_;
std::vector<GasState> states_;
std::vector<GasState> reconstructed_left_, reconstructed_right_;
std::vector<float> waves_;
std::vector<Flux> faces_;
std::vector<RoeState> roe_states_;
PipeSolver scratch_solver_{PipeSolver::Rusanov};
bool scratch_solver_initialized_{};
Flux left_flux_{}, right_flux_{};
bool left_override_{}, right_override_{}, prepared_{};
GasState left_acoustic_neighbor_{}, right_acoustic_neighbor_{};
bool has_left_acoustic_neighbor_{}, has_right_acoustic_neighbor_{}, reconstructed_prepared_{};
// Primitive minmod MUSCL-Hancock on uniform sections. Component interfaces
// and area/profile transitions retain the existing first-order coupling.
void reconstruct(float dt) {
const auto minmod=[](float a,float b) {
return a*b<=0 ? 0.f : std::copysign(std::min(std::abs(a),std::abs(b)),a);
};
for(std::size_t i=0;i<states_.size();++i) {
const auto q=states_[i];
reconstructed_left_[i]=reconstructed_right_[i]=q;
if((i==0 && !has_left_acoustic_neighbor_) ||
(i+1==states_.size() && !has_right_acoustic_neighbor_) ||
(i>0 && (cell_areas[i-1]!=cell_areas[i] || cell_lengths[i-1]!=cell_lengths[i])) ||
(i+1<states_.size() && (cell_areas[i+1]!=cell_areas[i] || cell_lengths[i+1]!=cell_lengths[i]))) continue;
const auto a=i==0?left_acoustic_neighbor_:states_[i-1];
const auto b=i+1==states_.size()?right_acoustic_neighbor_:states_[i+1];
const float dr=minmod(q.rho-a.rho,b.rho-q.rho);
const float du=minmod(q.velocity-a.velocity,b.velocity-q.velocity);
const float dp=minmod(q.pressure-a.pressure,b.pressure-q.pressure);
const float da=minmod(q.air-a.air,b.air-q.air);
const float df=minmod(q.fuel-a.fuel,b.fuel-q.fuel);
const float h=.5f*dt/cell_lengths[i];
const GasState half{q.rho-h*(q.velocity*dr+q.rho*du),
q.velocity-h*(q.velocity*du+dp/q.rho),
q.pressure-h*(q.velocity*dp+gamma*q.pressure*du),
q.air-h*q.velocity*da,q.fuel-h*q.velocity*df};
const auto edge=[&](float sign) { return GasState{half.rho+sign*dr,
half.velocity+sign*du,half.pressure+sign*dp,half.air+sign*da,half.fuel+sign*df}; };
const auto left=edge(-.5f), right=edge(.5f);
const auto physical=[](const GasState& v) {
return v.rho>1e-12f && v.pressure>1e-6f && std::isfinite(v.pressure) &&
std::isfinite(v.rho) && std::isfinite(v.velocity) && v.air>=0 && v.fuel>=0 && v.air+v.fuel<=1.000001f;
};
if(physical(left) && physical(right)) {
reconstructed_left_[i]=left; reconstructed_right_[i]=right;
}
}
}
static std::vector<float> uniform(float v, int n) {
if (n <= 0)
throw std::invalid_argument("pipe count must be positive");
@@ -514,25 +597,39 @@ public:
claim(b, left_b);
connections_.push_back({a, b, left_a, left_b});
}
struct JunctionFace { int pipe; bool left; };
void add_junction(std::initializer_list<JunctionFace> ends) {
if(ends.size()<2) throw std::invalid_argument("junction needs at least two faces");
JunctionNode node;
struct JunctionFace {
int pipe; bool left;
// Unit vector pointing OUT of the node into this branch, in world axes.
std::array<float,3> direction{};
};
enum class JunctionMethod { Characteristic, LegacyRusanov };
void add_junction(std::initializer_list<JunctionFace> ends,
JunctionMethod method=JunctionMethod::Characteristic,float turning_loss=0) {
add_junction(std::span(ends.begin(),ends.size()),method,turning_loss);
}
void add_junction(std::span<const JunctionFace> ends,
JunctionMethod method=JunctionMethod::Characteristic,float turning_loss=0) {
if(ends.size()<2 || !std::isfinite(turning_loss) || turning_loss<0 || turning_loss>10)
throw std::invalid_argument("invalid junction size or turning loss");
JunctionNode node; node.method=method; node.turning_loss=turning_loss;
for(auto e:ends) {
check_face(e.pipe,e.left);
for(auto previous:node.ends) {
for(auto previous:node.ends)
if(previous.pipe==e.pipe && previous.left==e.left) throw std::invalid_argument("duplicate junction face");
}
double norm=0;
for(float v:e.direction) { if(!std::isfinite(v)) throw std::invalid_argument("invalid junction direction"); norm+=double(v)*v; }
if(norm>0) for(float& v:e.direction) v/=std::sqrt(norm);
else if(turning_loss>0) throw std::invalid_argument("direction required for turning loss");
node.ends.push_back(e);
}
for(auto e:ends) claim(e.pipe,e.left);
node.states.resize(ends.size());
node.states.resize(ends.size()); node.faces.resize(ends.size());
nodes_.push_back(std::move(node));
}
float open_end_mass_flow(std::size_t i) const { return opens_.at(i).last_mass_flow; }
float open_end_volume_flow(std::size_t i) const { return opens_.at(i).last_volume_flow; }
float open_end_pressure(std::size_t i) const { return opens_.at(i).last_face_pressure; }
std::uint64_t limited_fluxes{}, nonlinear_iterations{};
std::uint64_t limited_fluxes{}, nonlinear_iterations{}, junction_fallbacks{}, junction_iterations{}, junction_limited_fluxes{};
void validate_topology() const {
if (claims_.size() != 2 * pipes_.size())
throw std::runtime_error("every pipe endpoint needs an explicit boundary or connection");
@@ -544,6 +641,20 @@ public:
b.mass = .25f * (*b.air + (b.exhaust ? *b.exhaust : 0) + (b.fuel ? *b.fuel : 0));
b.heat = b.energy ? .25f * (*b.energy) : std::numeric_limits<float>::max();
}
// Supply matching direct-pipe neighbors before reconstruction, preserving
// the same stencil across artificial pipe splits and all orientations.
for(const auto& o:connections_) {
auto& a=pipes_[o.a]; auto& b=pipes_[o.b];
const int ia=end(a,o.left_a), ib=end(b,o.left_b);
if(a.solver==PipeSolver::Muscl && b.solver==PipeSolver::Muscl &&
a.cell_area(ia)==b.cell_area(ib) && a.cell_length(ia)==b.cell_length(ib)) {
auto sa=a.state(ia), sb=b.state(ib);
const float sign=o.left_a==o.left_b?-1.f:1.f;
sa.velocity*=sign; sb.velocity*=sign;
a.set_acoustic_neighbor(o.left_a,sb); b.set_acoustic_neighbor(o.left_b,sa);
}
}
for(auto& pipe:pipes_) pipe.prepare_acoustic_reconstruction(dt);
for (auto &o : orifices_)
resolve_orifice(o, dt);
for (auto &o : opens_)
@@ -557,12 +668,21 @@ private:
struct JunctionNode {
std::vector<JunctionFace> ends;
std::vector<GasState> states;
struct Face { double pressure{},rho{},velocity{},mass{},enthalpy{},air{},fuel{},loss{}; };
std::vector<Face> faces;
JunctionMethod method{JunctionMethod::Characteristic};
double turning_loss{};
};
std::vector<JunctionNode> nodes_;
// Zero-storage Rusanov star state. Integrated mass, total energy and
// species fluxes sum to zero algebraically across every connected face.
// The node has no conserved state, timestep or storage volume of its own.
void resolve_junction(JunctionNode& node,float) {
void resolve_junction(JunctionNode& node,float dt) {
if(node.method==JunctionMethod::Characteristic && characteristic_junction(node,dt)) return;
if(node.method==JunctionMethod::Characteristic) ++junction_fallbacks;
legacy_junction(node);
}
void legacy_junction(JunctionNode& node) {
double speed=0;
for(std::size_t k=0;k<node.ends.size();++k) {
auto e=node.ends[k];auto& p=pipes_[e.pipe];auto s=p.state(end(p,e.left));
@@ -593,6 +713,135 @@ private:
p.set_boundary_flux(e.left,{float(orientation*m),float(momentum),float(orientation*heat),float(orientation*y),float(orientation*f)});
}
}
// Nonlinear outgoing acoustic-wave curves (shock or rarefaction), with
// common node pressure, conservative stagnation-enthalpy/species mixing,
// and outgoing contact density set from the mixed enthalpy. This is a
// compact subsonic engineering closure, not a resolved 3D junction.
bool characteristic_junction(JunctionNode& node,float dt) {
double low=std::numeric_limits<double>::max(), high=0;
for(std::size_t k=0;k<node.ends.size();++k) {
const auto e=node.ends[k]; const auto& pipe=pipes_[e.pipe];
auto state=pipe.state(end(pipe,e.left));
state.velocity*=e.left?-1:1; // positive INTO the node
const double c=std::sqrt(gamma*state.pressure/state.rho);
if(std::abs(state.velocity)>=.95*c) return false;
node.states[k]=state;
low=std::min(low,double(state.pressure)); high=std::max(high,double(state.pressure));
node.faces[k].loss=0;
}
const auto wave_curve=[](const GasState& q,double p) {
if(p>q.pressure) {
const double a=2/((gamma+1)*q.rho), b=(gamma-1)/(gamma+1)*q.pressure;
return (p-q.pressure)*std::sqrt(a/(p+b));
}
const double c=std::sqrt(gamma*q.pressure/q.rho);
return 2*c/(gamma-1)*(std::pow(p/q.pressure,(gamma-1)/(2*gamma))-1);
};
const auto density=[](const GasState& q,double p) {
const double ratio=p/q.pressure, b=(gamma-1)/(gamma+1);
return p>q.pressure ? q.rho*(ratio+b)/(b*ratio+1) : q.rho*std::pow(ratio,1/gamma);
};
// Each pressure evaluation writes scratch face states only, never pipes.
const auto residual=[&](double pressure) {
double incoming=0, heat=0, air=0, fuel=0;
for(std::size_t k=0;k<node.ends.size();++k) {
const auto q=node.states[k]; auto& f=node.faces[k];
double p=pressure;
for(int iteration=0;iteration<(f.loss>0?8:1);++iteration) {
const double u=q.velocity-wave_curve(q,p), rho=density(q,p);
const double target=std::clamp(pressure+std::copysign(.5*f.loss*rho*u*u,u),pressure*.05,pressure*20);
p=.5*(p+target);
}
f.pressure=p; f.rho=density(q,p); f.velocity=q.velocity-wave_curve(q,p);
const auto e=node.ends[k]; const auto& pipe=pipes_[e.pipe];
f.mass=pipe.cell_area(end(pipe,e.left))*f.rho*f.velocity;
f.enthalpy=gamma/(gamma-1)*p/f.rho+.5*f.velocity*f.velocity;
f.air=q.air; f.fuel=q.fuel;
if(f.mass>0) { incoming+=f.mass; heat+=f.mass*f.enthalpy; air+=f.mass*q.air; fuel+=f.mass*q.fuel; }
}
double balance=0;
for(std::size_t k=0;k<node.ends.size();++k) {
auto& f=node.faces[k];
if(f.mass<0 && incoming>1e-20) {
const double h=heat/incoming, thermal=h-.5*f.velocity*f.velocity;
if(!(thermal>0)) return std::numeric_limits<double>::quiet_NaN();
f.rho=gamma/(gamma-1)*f.pressure/thermal;
const auto e=node.ends[k]; const auto& pipe=pipes_[e.pipe];
f.mass=pipe.cell_area(end(pipe,e.left))*f.rho*f.velocity;
f.enthalpy=h; f.air=air/incoming; f.fuel=fuel/incoming;
}
balance+=f.mass;
}
return balance;
};
// Iterate the donor-weighted direction coefficients separately from the
// scalar pressure root; K=0 remains the ideal acoustic scattering case.
double node_pressure=.5*(low+high);
for(int geometry=0;geometry<(node.turning_loss>0?3:1);++geometry) {
double lo=low*.01, hi=high*100;
double fl=residual(lo), fh=residual(hi);
if(!std::isfinite(fl) || !std::isfinite(fh) || fl<0 || fh>0) return false;
node_pressure=std::clamp(node_pressure,lo,hi);
bool converged=false;
for(int iteration=0;iteration<40;++iteration) {
const double value=residual(node_pressure); ++junction_iterations;
if(!std::isfinite(value)) return false;
double magnitude=0; for(const auto& face:node.faces) magnitude+=std::abs(face.mass);
if(std::abs(value)<1e-14+1e-8*magnitude) { converged=true; break; }
if(value>0) lo=node_pressure; else hi=node_pressure;
const double delta=std::max(1e-3,node_pressure*1e-5);
const double derivative=(residual(node_pressure+delta)-value)/delta;
const double next=node_pressure-value/derivative;
node_pressure=std::isfinite(next) && next>lo && next<hi ? next : .5*(lo+hi);
}
if(!converged) return false;
residual(node_pressure);
if(geometry+1<(node.turning_loss>0?3:1)) {
for(std::size_t k=0;k<node.ends.size();++k) {
auto& f=node.faces[k]; double weight=0, loss=0;
if(f.mass<0) for(std::size_t j=0;j<node.ends.size();++j) {
const double m=std::max(0.,node.faces[j].mass); double dot=0;
for(int axis=0;axis<3;++axis) dot+=node.ends[k].direction[axis]*node.ends[j].direction[axis];
weight+=m; loss+=m*.5*(1+std::clamp(dot,-1.,1.));
}
f.loss=weight>0?node.turning_loss*loss/weight:0;
}
}
}
double incoming=0, outgoing=0, heat=0, air=0, fuel=0, scale=1;
for(std::size_t k=0;k<node.ends.size();++k) {
const auto& f=node.faces[k];
if(!std::isfinite(f.mass) || !std::isfinite(f.enthalpy)) return false;
if(std::abs(f.velocity)>=.98*std::sqrt(gamma*f.pressure/f.rho)) return false;
if(f.mass>0) { incoming+=f.mass;heat+=f.mass*f.enthalpy;air+=f.mass*f.air;fuel+=f.mass*f.fuel; }
else outgoing-=f.mass;
}
if(incoming>1e-16 && outgoing<=1e-16) {
if(incoming>1e-11) return false;
// Residual tolerance at rest must not create a one-sided mass source.
for(auto& face:node.faces) face.mass=0;
incoming=heat=air=fuel=0;
}
// Remove root roundoff by scaling all outflows together. A shared donor
// budget scales advective fluxes together, preserving node conservation.
for(std::size_t k=0;k<node.ends.size();++k) {
auto& f=node.faces[k];
if(f.mass<0) { f.mass*=outgoing>0?incoming/outgoing:0; f.enthalpy=incoming>0?heat/incoming:0; f.air=incoming>0?air/incoming:0; f.fuel=incoming>0?fuel/incoming:0; }
if(f.mass>0) {
const auto e=node.ends[k]; const auto& pipe=pipes_[e.pipe];const int i=end(pipe,e.left);
scale=std::min(scale,.25*pipe.cell_mass(i)/(f.mass*dt));
scale=std::min(scale,.25*double(pipe.cells[i].energy)*pipe.cell_area(i)*pipe.cell_length(i)/(f.mass*f.enthalpy*dt));
}
}
if(scale<1) { ++limited_fluxes; ++junction_limited_fluxes; }
for(std::size_t k=0;k<node.ends.size();++k) {
const auto e=node.ends[k]; auto& pipe=pipes_[e.pipe]; const auto& f=node.faces[k];
const double orientation=e.left?-1:1, m=f.mass*scale;
const double momentum=m*f.velocity+f.pressure*pipe.cell_area(end(pipe,e.left));
pipe.set_boundary_flux(e.left,{float(orientation*m),float(momentum),float(orientation*m*f.enthalpy),float(orientation*m*f.air),float(orientation*m*f.fuel)});
}
return true;
}
struct Budget {
float *air{}, *exhaust{}, *fuel{}, *energy{};
float mass{}, heat{};
@@ -864,6 +1113,9 @@ private:
auto &b = pipes_[o.b];
int ia = end(a, o.left_a), ib = end(b, o.left_b);
auto sa = a.state(ia), sb = b.state(ib);
if(a.solver==PipeSolver::Muscl && b.solver==PipeSolver::Muscl) {
sa=a.acoustic_edge(o.left_a); sb=b.acoustic_edge(o.left_b);
}
const float da = o.left_a ? -1.f : 1.f, db = o.left_b ? 1.f : -1.f;
sa.velocity *= da;
sb.velocity *= db;

View File

@@ -0,0 +1,13 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
inline int mesh_cell_count(float length,float cells_per_meter) {
if(!std::isfinite(cells_per_meter) || cells_per_meter<1 || cells_per_meter>500 || !std::isfinite(length) || length<=0)
throw std::invalid_argument("cells_per_meter must be in 1..500 with positive pipe length");
const double count=double(length)*cells_per_meter;
if(count>4096.001) throw std::invalid_argument("pipe mesh exceeds 4096 cells");
return std::max(1,int(std::ceil(count-1e-6*std::max(1.,count))));
}
}

View File

@@ -17,7 +17,9 @@ struct OutdoorAudioConfig {
Vec3 intake_position{-0.2f, 0.7f, 0.0f};
Vec3 exhaust_position{0.55f, 0.5f, 0.0f};
Vec3 second_exhaust_position{-0.55f, 0.5f, 0.0f};
Vec3 valve_position{0.08f,0.42f,0.0f};
bool dual_exhaust{};
bool valve_contact_enabled{};
float ear_spacing{0.18f}, sound_speed{343.0f};
float ground_y{0.0f}, wall_x{-1.5f};
float ground_reflection{0.35f}, wall_reflection{0.65f};
@@ -29,10 +31,15 @@ struct OutdoorAudioConfig {
// approximate diffuse tail. No feedback into the fluid/combustion simulation.
class OutdoorAudio {
public:
static constexpr std::array<const char*,4> source_names{"intake", "exhaust", "exhaust-2", "valve-contact"};
struct SourceFrames { AudioFrame rendered{}, dry{}, unfiltered{}; };
const auto& source_frames() const { return sources_; }
AudioFrame tail_frame() const { return last_tail_; }
void set_source_enabled(std::size_t index,bool enabled) { enabled_.at(index)=enabled; }
explicit OutdoorAudio(int sample_rate = 44100, OutdoorAudioConfig config = {});
// Acoustic source inputs are ambient-density times mouth volume flow, kg/s.
// Fluid transport still uses actual donor-density mass flux independently.
AudioFrame process(float intake_mass_flow, float exhaust_mass_flow, float second_exhaust_mass_flow = 0);
AudioFrame process(float intake_mass_flow, float exhaust_mass_flow, float second_exhaust_mass_flow = 0, float valve_contact_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.
@@ -66,12 +73,14 @@ private:
std::atomic<OutdoorScene> requested_scene_{OutdoorScene::Wall};
OutdoorScene active_scene_{OutdoorScene::Wall};
bool monitor_filter_{true};
std::array<Emitter, 3> emitters_;
std::array<Emitter, 4> emitters_;
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_{};
AudioFrame last_dry_{}, last_raw_{}, last_tail_{};
std::array<SourceFrames,4> sources_{};
std::array<bool,4> enabled_{true,true,true,true};
void configure_emitter(Emitter &emitter, Vec3 position);
void update_geometry();
AudioFrame render(Emitter &emitter, float flow, AudioFrame &dry, AudioFrame &raw);

View File

@@ -0,0 +1,42 @@
#pragma once
#include "FluidSim/AudioProcessing.hpp"
#include <string>
#include <vector>
namespace fluidsim::port {
struct EngineConfig;
struct ExhaustConfig;
struct JunctionConfig;
struct TwoStrokeConfig;
struct GeometryPath {
bool operator==(const GeometryPath&) const = default;
std::vector<Vec3> points;
float length() const;
Vec3 position(float distance) const;
};
struct GeometryBox {
bool operator==(const GeometryBox&) const = default;
Vec3 center{},size{};
float volume() const {return size.x*size.y*size.z;}
Vec3 minimum() const {return {center.x-size.x*.5f,center.y-size.y*.5f,center.z-size.z*.5f};}
Vec3 maximum() const {return {center.x+size.x*.5f,center.y+size.y*.5f,center.z+size.z*.5f};}
};
struct GeometryVolume {std::string id;GeometryBox box;bool operator==(const GeometryVolume&) const = default;};
struct CylinderPlacement {Vec3 origin{};float tilt_degrees{};bool operator==(const CylinderPlacement&) const = default;};
struct PhysicalGeometry {
bool operator==(const PhysicalGeometry&) const = default;
std::vector<GeometryPath> pipes;
std::vector<GeometryVolume> volumes;
std::vector<CylinderPlacement> cylinders;
const GeometryBox* volume(const std::string& id) const;
GeometryBox* volume(const std::string& id);
bool empty() const {return pipes.empty();}
};
float geometry_distance(Vec3 a,Vec3 b);
Vec3 cylinder_point(const CylinderPlacement& cylinder,Vec3 local);
PhysicalGeometry default_geometry(const EngineConfig&,const ExhaustConfig&,const JunctionConfig&);
PhysicalGeometry default_geometry(const TwoStrokeConfig&);
EngineConfig resolve_engine_geometry(EngineConfig,const ExhaustConfig&,const JunctionConfig&);
TwoStrokeConfig resolve_two_stroke_geometry(TwoStrokeConfig);
void validate_geometry(const PhysicalGeometry&,std::size_t pipes,std::size_t cylinders);
}

View File

@@ -6,6 +6,10 @@ namespace fluidsim::port {
struct PipeSection {
float length, inlet_diameter, outlet_diameter;
};
inline int profile_section_cells(float length,float maximum_cell_length) {
const double count=double(length)/maximum_cell_length;
return std::max(1,static_cast<int>(std::ceil(count-1e-6*std::max(1.,count))));
}
// Linear diameter within each section. Cell area is the exact frustum-volume
// average, so refining the mesh preserves the specified chamber volume.
@@ -20,7 +24,7 @@ inline Pipe1D make_profiled_pipe(std::span<const PipeSection> sections, float ma
section.inlet_diameter <= 0 || section.outlet_diameter <= 0 ||
std::abs(section.inlet_diameter - previous_diameter) > 1e-6f)
throw std::invalid_argument("invalid/discontinuous pipe profile");
const int count = static_cast<int>(std::ceil(section.length / maximum_cell_length));
const int count = profile_section_cells(section.length,maximum_cell_length);
for (int i = 0; i < count; ++i) {
const float d0 = std::lerp(section.inlet_diameter, section.outlet_diameter, float(i) / count);
const float d1 = std::lerp(section.inlet_diameter, section.outlet_diameter, float(i + 1) / count);

View File

@@ -2,12 +2,13 @@
#include <stdexcept>
#include <string_view>
namespace fluidsim::port {
enum class PipeSolver { Rusanov, Hlle, Hllc };
enum class PipeSolver { Rusanov, Hlle, Hllc, Muscl };
inline const char* pipe_solver_name(PipeSolver solver) {
switch(solver) {
case PipeSolver::Rusanov: return "rusanov";
case PipeSolver::Hlle: return "hlle";
case PipeSolver::Hllc: return "hllc";
case PipeSolver::Muscl: return "muscl";
}
return "unknown";
}
@@ -15,6 +16,7 @@ inline PipeSolver parse_pipe_solver(std::string_view name) {
if(name=="rusanov") return PipeSolver::Rusanov;
if(name=="hlle") return PipeSolver::Hlle;
if(name=="hllc") return PipeSolver::Hllc;
throw std::invalid_argument("choose rusanov, hlle or hllc pipe solver");
if(name=="muscl") return PipeSolver::Muscl;
throw std::invalid_argument("choose rusanov, hlle, hllc or muscl pipe solver");
}
} // namespace fluidsim::port

View File

@@ -0,0 +1,59 @@
#pragma once
#include <array>
#include <chrono>
#include <cstddef>
#include <algorithm>
#include <cstdint>
#include <string_view>
namespace fluidsim::port {
enum class ProfileStage : std::size_t { Total, Controls, Solver, Boundary, Pipe, Components, Audio, Count };
inline constexpr std::array<std::string_view,7> profile_stage_names{
"total", "controls", "solver", "boundary", "pipe", "components", "audio"};
struct ProfileTotals {
std::array<std::uint64_t,static_cast<std::size_t>(ProfileStage::Count)> nanoseconds{};
std::uint64_t sampled_steps{};
std::array<std::uint64_t,512> frame_histogram{};
};
inline double profile_percentile_ms(const ProfileTotals& totals,double quantile) {
if(!totals.sampled_steps) return 0;
const auto target=static_cast<std::uint64_t>(quantile*(totals.sampled_steps-1));
std::uint64_t count=0;
for(std::size_t i=0;i<totals.frame_histogram.size();++i) {
count+=totals.frame_histogram[i];
if(count>target) return (i+.5)*.001;
}
return .512;
}
class RuntimeProfiler {
public:
void enable(bool value) { enabled_=value; frame_=0; totals_={}; }
bool begin_step() { return enabled_ && (frame_++%2==0); }
void add(ProfileStage stage,std::uint64_t ns) { totals_.nanoseconds[static_cast<std::size_t>(stage)]+=ns; }
void finish_step(std::uint64_t elapsed_ns) {
++totals_.sampled_steps;
const auto bin=std::min<std::uint64_t>(elapsed_ns/1000,totals_.frame_histogram.size()-1);
++totals_.frame_histogram[bin];
}
double frame_percentile_ms(double quantile) const {
return profile_percentile_ms(totals_,quantile);
}
const ProfileTotals& totals() const { return totals_; }
bool enabled() const { return enabled_; }
private:
bool enabled_{};
std::uint64_t frame_{};
ProfileTotals totals_{};
};
class ProfileScope {
public:
ProfileScope(RuntimeProfiler* profiler,bool active,ProfileStage stage)
: profiler_(active?profiler:nullptr),stage_(stage),start_(profiler_?Clock::now():Clock::time_point{}) {}
~ProfileScope() { if(profiler_) profiler_->add(stage_,std::chrono::duration_cast<std::chrono::nanoseconds>(Clock::now()-start_).count()); }
std::uint64_t elapsed_ns() const {
return profiler_?std::chrono::duration_cast<std::chrono::nanoseconds>(Clock::now()-start_).count():0;
}
private:
using Clock=std::chrono::steady_clock;
RuntimeProfiler* profiler_{}; ProfileStage stage_{}; Clock::time_point start_{};
};
}

View File

@@ -12,6 +12,7 @@
#include "FluidSim/Fluid.hpp"
#include "FluidSim/OutdoorAudio.hpp"
#include "FluidSim/Solver.hpp"
#include "FluidSim/RuntimeProfiler.hpp"
#include "FluidSim/Vehicle.hpp"
#include "FluidSim/MotorcycleTest.hpp"
#include <memory>
@@ -35,13 +36,22 @@ public:
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}; }
std::array<const FourStrokeCylinder*, 12> cylinders() const { return {&cylinder1, &cylinder2, &cylinder3, &cylinder4,
&cylinder5, &cylinder6, &cylinder7, &cylinder8,&cylinder9,&cylinder10,&cylinder11,&cylinder12}; }
AudioFrame process();
void enable_profiling(bool enabled) { profiler_.enable(enabled); }
const ProfileTotals& profile_totals() const { return profiler_.totals(); }
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);
// Replaces calibration at a simulation step boundary, preserving all state.
void set_tuning(const EngineConfig& tuning);
const auto& source_audio() const { return audio_.source_frames(); }
AudioFrame tail_audio() const { return audio_.tail_frame(); }
void set_audio_source_enabled(std::size_t index,bool enabled) { audio_.set_source_enabled(index,enabled); }
AudioFrame dry_audio() const;
AudioFrame unfiltered_audio() const;
void shift_up();
@@ -69,6 +79,12 @@ private:
BoundarySystem boundaries_;
Solver solver_;
OutdoorAudio audio_;
RuntimeProfiler profiler_;
std::shared_ptr<const PhysicalGeometry> geometry_;
std::shared_ptr<const EngineConfig> active_tuning_;
std::array<float,181> intake_lift_trace_{},exhaust_lift_trace_{};
float last_contact_impulse_{};
float valve_sound_displacement_{},valve_sound_velocity_{};
mutable std::mutex mutex_;
std::vector<std::unique_ptr<Junction>> exhaust_junctions_;
std::unique_ptr<Carburetor> carb_;

View File

@@ -16,8 +16,13 @@ struct ScenarioExample {
EngineConfig engine{};
std::string category;
int order{};
std::filesystem::path source_path;
};
ScenarioExample load_engine_file(const std::filesystem::path& path);
void migrate_engine_geometry(const std::filesystem::path& path,bool relayout=false);
// Writes a distinct, validated engine file; never overwrites a preset.
std::filesystem::path save_tuned_engine(const ScenarioExample& source,const EngineConfig& tuning,
const std::filesystem::path& directory);
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.

View File

@@ -7,10 +7,11 @@ 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{};
std::array<Vec3, 64> points{};
std::array<float, 64> fractions{};
unsigned count{};
};
std::array<PipeRoute, 32> scenario_pipe_routes(const ScenarioSnapshot& state);
bool scenario_pipe_is_exhaust(const ScenarioSnapshot& state, std::size_t pipe_index);
std::array<Vec3, 3> scenario_sound_sources(const ScenarioSnapshot& state);
}

View File

@@ -7,10 +7,19 @@
#include <cstdint>
#include <string>
#include <vector>
#include <memory>
namespace fluidsim::port {
struct ScenarioSnapshot {
struct StorageVolume {std::string id;GeometryBox box;float pressure{};};
struct PipeHousing {std::size_t pipe{};float start{},length{},diameter{};};
std::shared_ptr<const PhysicalGeometry> geometry;
std::vector<StorageVolume> storage_volumes;
std::vector<PipeHousing> pipe_housings;
float cylinder_bore{},cylinder_stroke{},cylinder_rod{},cylinder_clearance{};
float intake_valve_diameter{},exhaust_valve_diameter{};
std::string example_name;
float cells_per_meter{};
EngineLayout layout{EngineLayout::Inline};
float bank_angle{};
bool v8_headers{};
@@ -20,6 +29,8 @@ struct ScenarioSnapshot {
float rev_limit_rpm{10000};
std::array<float, 12> cylinder_angles{};
std::array<float, 12> cylinder_pressures{}, piston_fractions{}, intake_areas{}, exhaust_areas{};
std::array<float,12> intake_lifts{},exhaust_lifts{};
std::uint64_t junction_fallbacks{}, junction_iterations{}, boundary_limited_fluxes{}, junction_limited_fluxes{};
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{};
@@ -30,6 +41,10 @@ struct ScenarioSnapshot {
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 valve_float{};
// Cylinder 1 lift over the most recently sampled 720 degree cycle (4 degree bins, metres).
std::array<float,181> intake_cam_lifts{},exhaust_cam_lifts{},intake_lift_trace{},exhaust_lift_trace{};
float intake_lift{},exhaust_lift{},valve_contact_impulse{};
bool racing_exhaust{};
bool silencers{}, h_pipe{};
ExhaustConfig exhaust_config{};

View File

@@ -46,7 +46,7 @@ inline Pipe1D make_silenced_exhaust(const ExhaustConfig& c) {
add(c.outlet_length,c.diameter,c.diameter);
auto pipe=make_profiled_pipe(sections,c.cell_length);
for(std::size_t i=0;i<sections.size();++i) {
const int n=int(std::ceil(sections[i].length/c.cell_length));
const int n=profile_section_cells(sections[i].length,c.cell_length);
for(int j=0;j<n;++j) { pipe.linear_resistance.push_back(linear[i]);pipe.quadratic_resistance.push_back(quadratic[i]); }
}
return pipe;

View File

@@ -2,6 +2,7 @@
#include "FluidSim/Component.hpp"
#include "FluidSim/Fluid.hpp"
#include "FluidSim/RuntimeProfiler.hpp"
#include <cstdint>
#include <vector>
@@ -13,13 +14,15 @@ public:
std::uint64_t substep_count() const { return substep_count_; }
int minimum_substeps{4};
void add_component(Component& component);
void step(float dt);
void set_profiler(RuntimeProfiler* profiler) { profiler_=profiler; }
void step(float dt,bool profile_sample=false);
std::uint64_t step_count() const { return step_count_; }
private:
std::vector<Pipe1D>& pipes_;
BoundarySystem& boundaries_;
std::vector<Component*> components_;
std::uint64_t step_count_{}, substep_count_{};
RuntimeProfiler* profiler_{};
};
} // namespace fluidsim::port

View File

@@ -1,7 +1,9 @@
#pragma once
#include "FluidSim/MeshResolution.hpp"
#include "FluidSim/PipeProfile.hpp"
#include "FluidSim/Ecu.hpp"
#include "FluidSim/Vehicle.hpp"
#include "FluidSim/PhysicalGeometry.hpp"
#include <array>
namespace fluidsim::port {
@@ -10,6 +12,8 @@ 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 {
PhysicalGeometry geometry;
float cells_per_meter{}; // Zero retains legacy programmatic meshes. TOML scenarios specify a density.
EcuConfig ecu=[] {
EcuConfig e;
e.idle_control=EcuConfig::IdleControl::FixedOpening;
@@ -42,6 +46,7 @@ struct TwoStrokeConfig {
float jet_scale{1.f}; // multiplier on effective jet diameters
float crankcase_clearance{150e-6f};
float carburetor_diameter{.0381f};
float reed_pipe_diameter{.040f},transfer_pipe_diameter{.032f};
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%)

View File

@@ -23,6 +23,9 @@ public:
void set_crankcase(Crankcase& crankcase) { crankcase_ = &crankcase; }
void set_carburetor(Carburetor& carburetor) { carburetor_ = &carburetor; }
float transfer_width{.050f}, exhaust_width{.036f};
// Retained as two-stroke compatibility metadata; port areas come from the
// transfer and exhaust geometry above rather than poppet valve motion.
float intake_valve_lift{}, exhaust_valve_lift{};
float minimum_burn_seconds{.0008f};
std::uint64_t combustion_count() const { return combustion_count_; }

View File

@@ -5,6 +5,7 @@
#include "FluidSim/Carburetor.hpp"
#include "FluidSim/Fluid.hpp"
#include "FluidSim/OutdoorAudio.hpp"
#include "FluidSim/RuntimeProfiler.hpp"
#include "FluidSim/Ecu.hpp"
#include "FluidSim/ReedValve.hpp"
#include "FluidSim/MotorcycleTest.hpp"
@@ -15,6 +16,7 @@
#include <vector>
namespace fluidsim::port {
struct ScenarioSnapshot;
struct TwoStrokeSnapshot {
float rpm{}, cylinder_pressure{}, crankcase_pressure{}, intake_pressure{};
@@ -31,6 +33,8 @@ public:
void set_listener(AudioListener listener) { audio_.set_listener(listener); }
explicit TwoStrokeScenario(int sample_rate = 44100, TwoStrokeConfig config = {});
AudioFrame process();
void enable_profiling(bool enabled) { profiler_.enable(enabled); }
const ProfileTotals& profile_totals() const { return profiler_.totals(); }
void set_throttle(float value);
void set_controls(float pedal, float load_value, float clutch_value);
void shift_up() { vehicle.shift_up(); }
@@ -39,9 +43,13 @@ public:
void set_pipe_solver(PipeSolver solver) { for(auto& pipe : pipes) pipe.solver=solver; }
void set_audio_scene(OutdoorScene scene) { audio_.set_scene(scene); }
void set_monitor_filter(bool enabled) { audio_.set_monitor_filter(enabled); }
const auto& source_audio() const { return audio_.source_frames(); }
AudioFrame tail_audio() const { return audio_.tail_frame(); }
void set_audio_source_enabled(std::size_t index,bool enabled) { audio_.set_source_enabled(index,enabled); }
AudioFrame dry_audio() const { return audio_.dry_frame(); }
AudioFrame unfiltered_audio() const { return audio_.unfiltered_frame(); }
TwoStrokeSnapshot snapshot() const;
void capture_geometry(ScenarioSnapshot&) const;
float throttle{};
const TwoStrokeConfig configuration;
@@ -65,6 +73,8 @@ private:
BoundarySystem boundaries_;
Solver solver_;
OutdoorAudio audio_;
RuntimeProfiler profiler_;
std::shared_ptr<const PhysicalGeometry> geometry_;
static float area_for_diameter(float diameter);
static float slide_area(float diameter, float opening);