all of the changes

This commit is contained in:
2026-10-23 18:29:51 +02:00
parent 4c435fdd76
commit 5607dcf2b5
73 changed files with 4365 additions and 2059 deletions

View File

@@ -1,13 +1,11 @@
#pragma once
#include <vector>
namespace fluidsim::port {
struct AudioFrame { float left{}, right{}; };
struct Vec3 { float x{}, y{}, z{}; };
// Flow-derivative sound source used by the C# two-stroke and pulse scenarios.
// Simple flow-derivative source used by the Helmholtz and pulse scenarios.
class SoundProcessor {
public:
float gain{0.005f};
@@ -17,34 +15,4 @@ private:
float dt_{}, scale_factor_{}, previous_flow_{};
};
class RadiationSource {
public:
// Optional diagnostic filters; disabled for the linear radiation path.
float flow_tau{0.0f}, smooth_tau{0.0f}, gain{0.005f};
Vec3 pipe_end{}, pipe_axis{1.0f, 0.0f, 0.0f};
Vec3 listener{0.0f, 0.0f, 1.5f}, listener_right{1.0f, 0.0f, 0.0f};
float ear_spacing{0.18f}, head_shadow_cutoff{0.0f}, speed_of_sound{343.0f};
float directivity_balance{0.0f}, air_absorption_exponent{0.0f}, min_air_cutoff{1500.0f};
explicit RadiationSource(int sample_rate = 44100);
AudioFrame process(float mass_flow);
float last_mono_pressure() const { return last_mono_pressure_; }
private:
class DelayLine;
class OnePole;
int sample_rate_{};
float dt_{}, flow_lp_{}, previous_flow_lp_{}, smooth_derivative_{};
float last_mono_pressure_{};
std::vector<float> left_delay_, right_delay_;
std::size_t write_position_{}, left_offset_{}, right_offset_{};
float air_filter_{}, shadow_left_{}, shadow_right_{};
static float dot(Vec3 a, Vec3 b);
static float length(Vec3 value);
static Vec3 subtract(Vec3 a, Vec3 b);
float one_pole(float input, float cutoff, float& state) const;
float delayed(float input, std::vector<float>& buffer, std::size_t offset);
};
} // namespace fluidsim::port

View File

@@ -0,0 +1,37 @@
#pragma once
#include "FluidSim/Scenario.hpp"
#include "FluidSim/DynoCurve.hpp"
#include "FluidSim/Desktop/PedalControl.hpp"
#include "FluidSim/Desktop/EngineRenderer3D.hpp"
#include <SFML/Graphics.hpp>
namespace fluidsim::port::desktop {
struct PlaybackInfo {
float pedal_target{},load{};
double speed{1};
std::size_t buffered{},capacity{};
std::uint64_t underruns{};
bool auto_gain{},time_warp{};
};
struct WorkspaceLayout {
sf::FloatRect sidebar,viewport,chart,inspector,pedal;
static WorkspaceLayout calculate(sf::Vector2u size);
};
class DesktopUi {
public:
float pedal() const {return pedal_.value();}
void nudge_pedal(float amount) {pedal_.set(pedal_.value()+amount);}
void update_pedal(float dt,bool raise,bool lower) {if(!pedal_drag_)pedal_.advance(dt,raise,lower);}
bool handle_event(const sf::Event&,sf::Vector2u);
void draw(sf::RenderWindow&,const sf::Font*,const ScenarioSnapshot&,const PlaybackInfo&);
private:
DynoCurve dyno_;
EngineRenderer3D renderer_;
OrbitCamera camera_;
bool three_d_{true},camera_fitted_{},pan_drag_{},pedal_drag_{};
PedalControl pedal_;
sf::Vector2f pan_{};
sf::Vector2i previous_mouse_{};
bool dragging_{};
float zoom_{1};
};
}

View File

@@ -0,0 +1,6 @@
#pragma once
#include "FluidSim/DynoCurve.hpp"
#include <SFML/Graphics.hpp>
namespace fluidsim::port::desktop {
void draw_dyno_curve(sf::RenderWindow&,const sf::Font*,const DynoCurve&,float,float,float,float,float,float);
}

View File

@@ -0,0 +1,19 @@
#pragma once
#include "FluidSim/Scenario.hpp"
#include "FluidSim/Desktop/OrbitCamera.hpp"
#include <SFML/Graphics.hpp>
#include <memory>
namespace fluidsim::port::desktop {
// Visualization consumes snapshots only; it never accesses mutable solver state.
class EngineRenderer3D {
public:
EngineRenderer3D();
~EngineRenderer3D();
EngineRenderer3D(const EngineRenderer3D&)=delete;
EngineRenderer3D& operator=(const EngineRenderer3D&)=delete;
void draw(sf::RenderWindow&,const ScenarioSnapshot&,sf::FloatRect,const OrbitCamera&);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}

View File

@@ -0,0 +1,6 @@
#pragma once
#include "FluidSim/Scenario.hpp"
#include <SFML/Graphics.hpp>
namespace fluidsim::port::desktop {
void draw_schematic(sf::RenderWindow&, const sf::Font*, const ScenarioSnapshot&);
}

View File

@@ -0,0 +1,20 @@
#pragma once
#include "FluidSim/AudioProcessing.hpp"
#include <SFML/System/Vector2.hpp>
#include <algorithm>
#include <cmath>
namespace fluidsim::port::desktop {
struct OrbitCamera {
Vec3 target{.12f,.25f,0};
float yaw{-28},pitch{24},distance{2.2f};
void reset(float aspect) {target={.12f,.25f,0};yaw=-28;pitch=24;distance=std::max(.65f,1.6f/aspect)/.414214f;}
void orbit(sf::Vector2i delta) {yaw+=delta.x*.35f;pitch=std::clamp(pitch+delta.y*.3f,-75.f,80.f);}
void pan(sf::Vector2i delta,float height) {
const float y=yaw*.017453293f,p=pitch*.017453293f,k=distance*.828427f/std::max(height,1.f);
target.x+=k*(-delta.x*std::cos(y)+delta.y*std::sin(p)*std::sin(y));
target.y+=k*delta.y*std::cos(p);
target.z+=k*(-delta.x*std::sin(y)-delta.y*std::sin(p)*std::cos(y));
}
void zoom(float delta) {distance=std::clamp(distance*std::exp(-delta*.12f),.35f,12.f);}
};
}

View File

@@ -0,0 +1,15 @@
#pragma once
#include <algorithm>
namespace fluidsim::port::desktop {
// Keyboard input travel is separate from the ECU actuator response.
class PedalControl {
public:
float value() const {return value_;}
void set(float value) {value_=std::clamp(value,0.f,1.f);}
void advance(float dt,bool raise,bool lower) {
if(dt>0) set(value_+(raise && !lower?2.f:-2.f)*dt);
}
private:
float value_{};
};
}

View File

@@ -0,0 +1,46 @@
#pragma once
#include "FluidSim/AudioProcessing.hpp"
#include <algorithm>
#include <cmath>
#include <stdexcept>
#include <vector>
#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.
class PipePath {
public:
explicit PipePath(std::vector<PipeAnchor> anchors):anchors_(std::move(anchors)) {
if(anchors_.size()<2 || anchors_.front().distance!=0)throw std::invalid_argument("pipe path needs origin and endpoint");
for(std::size_t i=0;i<anchors_.size();++i) {
const auto& p=anchors_[i];
if(!std::isfinite(p.distance) || !std::isfinite(p.position.x) || !std::isfinite(p.position.y) || !std::isfinite(p.position.z) ||
(i && !(p.distance>anchors_[i-1].distance))) throw std::invalid_argument("invalid distance-referenced pipe path");
}
}
Vec3 position(float distance) const {
distance=std::clamp(distance,0.f,anchors_.back().distance);
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)};
}
Vec3 tangent(float distance) const {
const float epsilon=anchors_.back().distance*.0001f;
const auto a=position(distance-epsilon),b=position(distance+epsilon);
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};
}
std::vector<PipeAnchor> anchors_;
};
}

View File

@@ -0,0 +1,12 @@
#pragma once
#include "FluidSim/AudioProcessing.hpp"
#include <algorithm>
#include <cmath>
namespace fluidsim::port::desktop {
// Fixed absolute scale: vacuum blue, atmosphere green, >=1 MPa red.
inline Vec3 pressure_tint(float pressure) {
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};
}
}

View File

@@ -0,0 +1,7 @@
#pragma once
#include "FluidSim/ScenarioCatalog.hpp"
#include <SFML/Graphics.hpp>
#include <optional>
namespace fluidsim::port::desktop {
std::optional<std::size_t> choose_scenario(sf::RenderWindow&,const sf::Font*,const std::vector<ScenarioExample>&);
}

View File

@@ -9,13 +9,12 @@ namespace fluidsim::port {
// the twin already protects its own simulation state.
class DesktopScenario {
public:
explicit DesktopScenario(ScenarioKind kind, TwoStrokeConfig config = {}, bool saab_turbo = true) {
explicit DesktopScenario(ScenarioKind kind, TwoStrokeConfig config = {}, ExhaustConfig exhaust = {}, V8ExhaustConfig v8 = {}, EngineConfig engine = {}, std::string name = {}) : name_(std::move(name)) {
if (kind == ScenarioKind::TwoStroke)
two_ = std::make_unique<TwoStrokeScenario>(44100, config);
else
twin_ = std::make_unique<FourStrokeScenario>(44100, kind == ScenarioKind::SaabB205 ?
FourStrokePreset::SaabB205 : kind == ScenarioKind::AmericanV8 ? FourStrokePreset::AmericanV8 : FourStrokePreset::Twin,
kind == ScenarioKind::TurboTwin || (kind == ScenarioKind::SaabB205 && saab_turbo));
FourStrokePreset::SaabB205 : kind == ScenarioKind::AmericanV8 ? FourStrokePreset::AmericanV8 : FourStrokePreset::Twin, exhaust, v8,engine);
}
AudioFrame process() {
if (twin_)
@@ -26,23 +25,23 @@ public:
void set_controls(float pedal, float load, float clutch) {
visit([&](auto& scenario) { scenario.set_controls(pedal, load, clutch); });
}
void set_starter(bool engaged) { visit([&](auto& scenario) { 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 toggle_mechanical_sound() {
if (twin_)
twin_->toggle_mechanical_sound();
}
void set_pipe_solver(PipeSolver solver) { visit([&](auto& scenario) { scenario.set_pipe_solver(solver); }); }
void set_audio_scene(OutdoorScene scene) {
visit([&](auto& scenario) { scenario.set_audio_scene(scene); });
}
ScenarioSnapshot snapshot() const {
if (twin_)
return twin_->snapshot();
if (twin_) { auto s=twin_->snapshot();s.example_name=name_;return s; }
const std::lock_guard lock(mutex_);
const auto source = two_->snapshot();
ScenarioSnapshot s;
s.example_name=name_;
s.pipe_solver=two_->pipes.front().solver;
s.two_stroke = true;
s.cylinder_angles[0]=two_->crankshaft.angle;
s.cylinder_count = 1;
s.racing_exhaust = two_->configuration.exhaust == TwoStrokeExhaust::Racing;
s.rpm = source.rpm;
@@ -81,6 +80,7 @@ private:
if (twin_) function(*twin_);
else function(*two_);
}
std::string name_;
std::unique_ptr<FourStrokeTwinScenario> twin_;
std::unique_ptr<TwoStrokeScenario> two_;
mutable std::mutex mutex_;

View File

@@ -6,12 +6,14 @@ namespace fluidsim::port {
// Ideal sensors and injectors; calibration parameters are not engine measurements.
class Ecu {
public:
enum class IdleControl { Feedback, FixedOpening };
IdleControl idle_control{IdleControl::Feedback};
struct IgnitionPoint { float rpm, degrees_btdc; };
std::vector<IgnitionPoint> ignition_map{{0, 10}, {15000, 40}};
float idle_rpm{1800.0f};
float idle_base_opening{0.08f}, idle_max_opening{0.25f};
float idle_kp{0.0001f}, idle_ki{0.00004f};
float throttle_time_constant{0.08f}, throttle_rate{2.0f}; // seconds, full travel/second
float throttle_time_constant{0.001f}, throttle_rate{1000.0f}; // seconds, full travel/second
// Zero uses the opening calibration, preserving symmetric response.
float throttle_closing_time_constant{}, throttle_closing_rate{};
float rev_limit_rpm{10000.0f}, rev_resume_rpm{9750.0f};
@@ -20,12 +22,6 @@ public:
bool overrun_fuel_cut_enabled{true};
float idle_spark_gain{0.0f}, idle_minimum_advance{-5.0f}; // degrees/RPM, degrees BTDC
float stoichiometric_afr{14.7f}, power_afr{12.8f};
bool boost_control_enabled{false};
float maximum_boost{45000}; // Pa gauge, estimated low-pressure B205 calibration
float boost_kp{1e-5f}, boost_ki{4e-6f};
void control_boost(float dt, float pedal, float rpm, float manifold, float charge, float ambient = 101325);
float boost_target() const { return boost_target_; }
float wastegate_duty() const { return boost_duty_; }
void step(float dt, float pedal, float rpm);
float fuel_for_air(float trapped_air_mass) const;
@@ -33,14 +29,12 @@ public:
float throttle_target() const { return target_; }
float target_afr() const { return afr_; }
float spark_advance() const { return spark_; }
bool fuel_cut() const { return limiter_ || (overrun_ && overrun_fuel_cut_enabled) || fault_ || boost_cut_; }
bool fuel_cut() const { return limiter_ || (overrun_ && overrun_fuel_cut_enabled) || fault_; }
bool rev_limited() const { return limiter_; }
bool idle_active() const { return idle_; }
bool sensor_fault() const { return fault_; }
private:
float boost_target_{}, boost_duty_{}, boost_integral_{}, filtered_boost_{};
bool boost_cut_{};
float filtered_rpm_{};
bool initialized_{};
float opening_{}, target_{}, integral_{}, afr_{14.7f}, spark_{10.0f};

View File

@@ -1,6 +1,8 @@
#pragma once
#include "FluidSim/Engine.hpp"
#include "FluidSim/OpenEndRadiation.hpp"
#include "FluidSim/PipeSolver.hpp"
#include <algorithm>
#include <cmath>
#include <deque>
@@ -165,10 +167,14 @@ struct GasState {
class Pipe1D {
public:
std::vector<float> linear_resistance, quadratic_resistance;
// Integrated face fluxes: kg/s, N, W, kg/s of fresh air and fuel, positive along +x.
struct Flux {
float m{}, p{}, e{}, y{}, fuel{};
};
struct RoeState { double root_density{}, enthalpy{}; };
PipeSolver solver{PipeSolver::Rusanov};
std::uint64_t hllc_fallbacks{};
float area{}, dx{};
float damping_multiplier{0.0f}, energy_relaxation_rate{0.0f}, ambient_pressure{101325.0f};
std::vector<float> cell_areas, cell_lengths;
@@ -180,7 +186,7 @@ public:
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) {
next_cells_(cells.size()), states_(cells.size()), waves_(cells.size()), faces_(cells.size() + 1), roe_states_(cells.size()) {
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)
@@ -219,6 +225,12 @@ public:
throw std::runtime_error("nonphysical pipe state in preparation");
dt = std::min(dt, cfl * cell_lengths[i] / waves_[i]);
}
if (solver != PipeSolver::Rusanov)
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;
roe_states_[i]={std::sqrt(s.rho),(energy+s.pressure)/s.rho};
}
prepared_ = true;
return dt;
}
@@ -251,6 +263,70 @@ public:
static Flux riemann(const GasState &a, const GasState &b) {
return riemann(a, b, std::max(wave(a), wave(b)));
}
// HLLE/HLLC use bounds enclosing endpoint and Roe acoustic speeds.
template<PipeSolver method>
static Flux flux_kernel(const GasState& a, const GasState& b,
float ca, float cb, bool* fallback = nullptr,
const RoeState* left_roe = nullptr, const RoeState* right_roe = nullptr) {
if constexpr (method == PipeSolver::Rusanov)
return riemann(a,b,std::max(std::abs(a.velocity)+ca,std::abs(b.velocity)+cb));
const double ra=left_roe?left_roe->root_density:std::sqrt(a.rho);
const double rb=right_roe?right_roe->root_density:std::sqrt(b.rho), sum=ra+rb;
const double ea=a.pressure*pipe_gm1_inverse+.5*double(a.rho)*a.velocity*a.velocity;
const double eb=b.pressure*pipe_gm1_inverse+.5*double(b.rho)*b.velocity*b.velocity;
const double ua=a.velocity, ub=b.velocity;
const double uroe=(ra*ua+rb*ub)/sum;
const double ha=left_roe?left_roe->enthalpy:(ea+a.pressure)/a.rho;
const double hb=right_roe?right_roe->enthalpy:(eb+b.pressure)/b.rho;
const double hroe=(ra*ha+rb*hb)/sum;
const double croe=std::sqrt(std::max(0.,double(gamma-1)*(hroe-.5*uroe*uroe)));
const double sl=std::min({ua-ca,ub-cb,uroe-croe});
const double sr=std::max({ua+ca,ub+cb,uroe+croe});
const auto physical=[](const GasState& q,double energy) {
const float m=q.rho*q.velocity;
return Flux{m,m*q.velocity+q.pressure,float((energy+q.pressure)*q.velocity),m*q.air,m*q.fuel};
};
const Flux fa=physical(a,ea), fb=physical(b,eb);
if(sl>=0) return fa;
if(sr<=0) return fb;
const auto hlle=[&] {
const double inv=1/(sr-sl), diff=sl*sr;
return Flux{float((sr*fa.m-sl*fb.m+diff*(b.rho-a.rho))*inv),
float((sr*fa.p-sl*fb.p+diff*(b.rho*ub-a.rho*ua))*inv),
float((sr*fa.e-sl*fb.e+diff*(eb-ea))*inv),
float((sr*fa.y-sl*fb.y+diff*(b.rho*b.air-a.rho*a.air))*inv),
float((sr*fa.fuel-sl*fb.fuel+diff*(b.rho*b.fuel-a.rho*a.fuel))*inv)};
};
if constexpr(method==PipeSolver::Hlle) return hlle();
const double dl=a.rho*(sl-ua), dr=b.rho*(sr-ub);
const double sm=(b.pressure-a.pressure+dl*ua-dr*ub)/(dl-dr);
const double ps=a.pressure+dl*(sm-ua);
// Reject nonphysical HLLC intermediate states without modifying conserved cells.
const bool left=sm>=0;
const auto& q=left?a:b;
const auto f=left?fa:fb;
const double speed=left?sl:sr;
const double inv=1/(speed-sm);
const double density=(left?dl:dr)*inv;
const double energy=((speed-q.velocity)*(left?ea:eb)-q.pressure*q.velocity+ps*sm)*inv;
if(!std::isfinite(sm) || !(sl<sm && sm<sr) || !(ps>0) || !(density>0) ||
!(energy>.5*density*sm*sm)) {
if(fallback) *fallback=true;
return hlle();
}
const double mass=f.m+speed*(density-q.rho);
return {float(mass),float(f.p+speed*(density*sm-q.rho*q.velocity)),
float(f.e+speed*(energy-(left?ea:eb))),float(mass*q.air),float(mass*q.fuel)};
}
static Flux numerical_flux(const GasState& a,const GasState& b,PipeSolver solver,
float ca,float cb,bool* fallback=nullptr) {
if(solver==PipeSolver::Rusanov) return flux_kernel<PipeSolver::Rusanov>(a,b,ca,cb,fallback);
if(solver==PipeSolver::Hlle) return flux_kernel<PipeSolver::Hlle>(a,b,ca,cb,fallback);
return flux_kernel<PipeSolver::Hllc>(a,b,ca,cb,fallback);
}
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 advance(float dt) {
if (dt <= 0)
return;
@@ -259,12 +335,27 @@ public:
const std::size_t n = cells.size();
if (!prepared_)
prepare_step();
faces_[0] = left_override_ ? left_flux_ : scaled(riemann(left_ghost, states_[0]), cell_areas[0]);
faces_[0] = left_override_ ? left_flux_ : scaled(numerical_flux(left_ghost, states_[0], solver), cell_areas[0]);
faces_[n] =
right_override_ ? right_flux_ : scaled(riemann(states_[n - 1], right_ghost), cell_areas[n - 1]);
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]));
right_override_ ? right_flux_ : scaled(numerical_flux(states_[n - 1], right_ghost, solver), cell_areas[n - 1]);
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]));
} else {
const auto fill_faces=[&]<PipeSolver method>() {
for (std::size_t i = 1; i < n; ++i) {
bool fallback=false;
faces_[i] = scaled(flux_kernel<method>(states_[i-1], states_[i],
waves_[i-1]-std::abs(states_[i-1].velocity), waves_[i]-std::abs(states_[i].velocity),
&fallback, &roe_states_[i-1], &roe_states_[i]),
.5f*(cell_areas[i-1]+cell_areas[i]));
hllc_fallbacks += fallback;
}
};
if(solver==PipeSolver::Hlle) fill_faces.template operator()<PipeSolver::Hlle>();
else fill_faces.template operator()<PipeSolver::Hllc>();
}
const float relaxation = energy_relaxation_rate > 0 ? std::exp(-energy_relaxation_rate * dt) : 1.0f;
for (std::size_t i = 0; i < n; ++i) {
const auto fl = faces_[i], fr = faces_[i + 1];
@@ -283,6 +374,11 @@ public:
rho = 1e-12f;
++positivity_repairs;
}
if (!linear_resistance.empty()) {
const float speed = std::abs(mom/rho);
mom /= 1 + dt*(linear_resistance[i] + .5f*quadratic_resistance[i]*speed);
// Retain total energy: dissipated kinetic energy becomes heat.
}
if (damping_multiplier > 0)
mom *= std::exp(-.1f * damping_multiplier * dt / cells[i].rho);
if (energy_relaxation_rate > 0)
@@ -315,6 +411,7 @@ private:
std::vector<GasState> states_;
std::vector<float> waves_;
std::vector<Flux> faces_;
std::vector<RoeState> roe_states_;
Flux left_flux_{}, right_flux_{};
bool left_override_{}, right_override_{}, prepared_{};
static std::vector<float> uniform(float v, int n) {
@@ -387,6 +484,19 @@ public:
claim(pipe, left);
opens_.push_back({pipe, left, p, a});
}
// Full circular mouth radiating into free space; no flange or mean-flow correction.
void add_radiating_open_end(int pipe, bool left, float pressure, float area, float temperature = 300) {
if (!std::isfinite(temperature) || temperature <= 0)
throw std::invalid_argument("invalid ambient temperature");
if (pipe < 0 || static_cast<std::size_t>(pipe) >= pipes_.size())
throw std::invalid_argument("invalid boundary pipe index");
const float face_area = pipes_[pipe].cell_area(end(pipes_[pipe], left));
if (std::abs(area-face_area) > 1e-6f*face_area)
throw std::invalid_argument("radiating mouth must match pipe face area");
add_open_end(pipe, left, pressure, area);
opens_.back().radiating = true;
opens_.back().temperature = temperature;
}
// An ideal, zero-volume junction between two duct faces. Orientation is explicit.
void add_connection(int a, bool left_a, int b, bool left_b) {
if (a < 0 || b < 0 || static_cast<std::size_t>(a) >= pipes_.size() ||
@@ -404,7 +514,23 @@ 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;
for(auto e:ends) {
check_face(e.pipe,e.left);
for(auto previous:node.ends) {
if(previous.pipe==e.pipe && previous.left==e.left) throw std::invalid_argument("duplicate junction face");
}
node.ends.push_back(e);
}
for(auto e:ends) claim(e.pipe,e.left);
node.states.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{};
void validate_topology() const {
@@ -421,12 +547,52 @@ public:
for (auto &o : orifices_)
resolve_orifice(o, dt);
for (auto &o : opens_)
resolve_open(o);
resolve_open(o, dt);
for (auto &o : connections_)
resolve_connection(o);
for(auto& node:nodes_) resolve_junction(node,dt);
}
private:
struct JunctionNode {
std::vector<JunctionFace> ends;
std::vector<GasState> states;
};
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) {
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));
s.velocity*=e.left?-1:1;node.states[k]=s;
speed=std::max(speed,2*(std::sqrt(gamma*s.pressure/s.rho)+std::abs(double(s.velocity))));
}
double area_sum=0,rho=0,energy=0,air=0,fuel=0;
for(std::size_t k=0;k<node.ends.size();++k) {
auto e=node.ends[k];auto& p=pipes_[e.pipe];const auto s=node.states[k];
const double a=p.cell_area(end(p,e.left)), u=s.velocity;
const double en=s.pressure/pipe_gm1+.5*s.rho*u*u;
area_sum+=a;rho+=a*s.rho*(1+u/speed);
energy+=a*(en+(en+s.pressure)*u/speed);
air+=a*s.rho*s.air*(1+u/speed);fuel+=a*s.rho*s.fuel*(1+u/speed);
}
rho/=area_sum;energy/=area_sum;air/=area_sum;fuel/=area_sum;
const double pressure=energy*pipe_gm1;
for(std::size_t k=0;k<node.ends.size();++k) {
auto e=node.ends[k];auto& p=pipes_[e.pipe];const auto s=node.states[k];
const double a=p.cell_area(end(p,e.left)), u=s.velocity;
const double en=s.pressure/pipe_gm1+.5*s.rho*u*u;
const double orientation=e.left?-1:1;
const double m=.5*a*(s.rho*u-speed*(rho-s.rho));
const double heat=.5*a*((en+s.pressure)*u-speed*(energy-en));
const double y=.5*a*(s.rho*s.air*u-speed*(air-s.rho*s.air));
const double f=.5*a*(s.rho*s.fuel*u-speed*(fuel-s.rho*s.fuel));
const double momentum=.5*a*(s.rho*u*u+s.pressure+pressure+speed*s.rho*u);
p.set_boundary_flux(e.left,{float(orientation*m),float(momentum),float(orientation*heat),float(orientation*y),float(orientation*f)});
}
}
struct Budget {
float *air{}, *exhaust{}, *fuel{}, *energy{};
float mass{}, heat{};
@@ -443,7 +609,10 @@ private:
struct Open {
int pipe;
bool left;
float pressure, area, last_mass_flow{}, last_face_pressure{};
float pressure, area, last_mass_flow{}, last_face_pressure{}, last_volume_flow{};
bool radiating{};
float temperature{300};
OpenEndRadiation reflection{};
};
struct Connection {
int a, b;
@@ -630,7 +799,7 @@ private:
if (!from)
v.fuel_fraction = s.fuel;
}
void resolve_open(Open &o) {
void resolve_open(Open &o, float dt) {
auto &p = pipes_[o.pipe];
int i = end(p, o.left);
auto s = p.state(i);
@@ -638,6 +807,40 @@ private:
if (o.left ? s.velocity >= c : s.velocity <= -c)
throw std::runtime_error(
"supersonic inlet needs full upstream state; pressure-only boundary is insufficient");
if (o.radiating) {
const float direction = o.left ? -1.f : 1.f;
const double outward = direction*s.velocity;
GasState face = s;
if (outward < c) {
const double reference_c = c*std::pow(double(o.pressure)/s.pressure, (gamma-1)/(2*gamma));
const double incident = outward + 2*(c-reference_c)/(gamma-1);
const double radius = std::sqrt(o.area/pi);
const double ambient_c = std::sqrt(gamma*gas_constant*o.temperature);
const double reflected = o.reflection.process(incident, dt*ambient_c/radius);
const double sound = reference_c + (gamma-1)*.25*(incident+reflected);
if (!(sound > 0)) throw std::runtime_error("radiation characteristic outside subsonic model");
face.pressure = o.pressure*std::pow(sound/reference_c, 2*gamma/(gamma-1));
face.velocity = direction*.5*(incident-reflected);
face.rho = s.rho*std::pow(face.pressure/s.pressure, 1/gamma);
if (direction*face.velocity < 0) {
// Fresh ambient gas enters with ambient stagnation enthalpy.
const float temperature = o.temperature - .5f*face.velocity*face.velocity /
(gamma/(gamma-1)*gas_constant);
if (!(temperature > 0)) throw std::runtime_error("ambient inflow exceeds stagnation enthalpy");
face.rho = face.pressure/(gas_constant*temperature);
face.air = 1; face.fuel = 0;
}
}
ghost(p, o.left, face);
const float mass_flux = face.rho*face.velocity*o.area;
const float enthalpy = gamma/(gamma-1)*face.pressure/face.rho + .5f*face.velocity*face.velocity;
p.set_boundary_flux(o.left, {mass_flux, mass_flux*face.velocity+face.pressure*o.area,
mass_flux*enthalpy, mass_flux*face.air, mass_flux*face.fuel});
o.last_volume_flow = direction*face.velocity*o.area;
o.last_mass_flow = direction*mass_flux;
o.last_face_pressure = face.pressure;
return;
}
const float entropy = s.pressure / std::pow(s.rho, gamma);
const float rho = std::pow(o.pressure / entropy, 1 / gamma);
const float cb = std::sqrt(gamma * o.pressure / rho);
@@ -653,6 +856,7 @@ private:
f.p += s.pressure * (p.cell_area(i) - a);
p.set_boundary_flux(o.left, f);
o.last_mass_flow = f.m * (o.left ? -1 : 1);
o.last_volume_flow = o.last_mass_flow / (ambient_inflow ? rho : s.rho);
o.last_face_pressure = f.p / p.cell_area(i);
}
void resolve_connection(const Connection &o) {
@@ -664,7 +868,7 @@ private:
sa.velocity *= da;
sb.velocity *= db;
const float area = std::min(a.cell_area(ia), b.cell_area(ib));
auto f = Pipe1D::scaled(Pipe1D::riemann(sa, sb), area);
auto f = Pipe1D::scaled(Pipe1D::numerical_flux(sa, sb, a.solver == b.solver ? a.solver : PipeSolver::Rusanov), area);
a.set_boundary_flux(o.left_a,
{da * f.m, f.p + sa.pressure * (a.cell_area(ia) - area), da * f.e,
da * f.y, da * f.fuel});

View File

@@ -0,0 +1,34 @@
#pragma once
#include "FluidSim/Fluid.hpp"
#include <span>
namespace fluidsim::port {
struct JunctionEnd { int pipe; bool left; float discharge{1.f}; };
// Finite-volume N-port fitting: its specified physical volume stores gas and
// energy. All ports share pressure and mixture, with bidirectional nozzle flow.
// T, Y, cross and collectors share this model; angles require calibrated losses.
class Junction : public Volume0D {
public:
using Volume0D::Volume0D;
void connect(BoundarySystem& boundaries, const std::vector<Pipe1D>& pipes,
std::span<const JunctionEnd> ends) {
if(connected_ || ends.size()<2) throw std::invalid_argument("junction needs at least two distinct ends and one connection call");
for(std::size_t i=0;i<ends.size();++i) {
const auto& e=ends[i];
if(e.pipe<0 || std::size_t(e.pipe)>=pipes.size() || !std::isfinite(e.discharge) || e.discharge<=0 || e.discharge>1)
throw std::invalid_argument("invalid junction endpoint");
for(std::size_t j=0;j<i;++j) if(e.pipe==ends[j].pipe && e.left==ends[j].left)
throw std::invalid_argument("duplicate junction endpoint");
}
for(const auto& e:ends) {
const auto& pipe=pipes[e.pipe];
areas_.push_back(pipe.cell_area(e.left?0:int(pipe.cells.size())-1));
boundaries.add_orifice(create_port(),e.pipe,e.left,areas_.back(),e.discharge);
}
connected_=true;
}
private:
std::deque<float> areas_; // stable addresses for boundary area references
bool connected_{};
};
}

View File

@@ -1,49 +0,0 @@
#pragma once
#include "FluidSim/Engine.hpp"
#include <random>
namespace fluidsim::port {
// Synthetic valve, piston, combustion, chain, and gear sounds driven by live
// engine state; direct port of Core/MechanicalSoundProcessor.cs.
class MechanicalSoundProcessor {
public:
float master_gain{0.6f}, valve_tick_gain{0.9f}, piston_slap_gain{1.2f};
float combustion_gain{0.5f}, gear_whine_gain{0.12f}, cam_chain_gain{0.25f};
explicit MechanicalSoundProcessor(int sample_rate = 44100);
float process(const Crankshaft& crank, const EngineCylinder& cylinder, float load);
private:
class Resonator {
public:
Resonator(float frequency_hz, float q, float sample_rate);
float process(float input);
private:
float b1_{}, b2_{}, gain_{}, y1_{}, y2_{};
};
class OnePoleLowPass {
public:
OnePoleLowPass(float cutoff_hz, float sample_rate);
float process(float input);
private:
float alpha_{}, state_{};
};
float dt_{};
Resonator intake_tick_, exhaust_tick_;
OnePoleLowPass slap_filter_, combustion_filter_, chain_filter_;
float slap_envelope_{}, combustion_envelope_{}, chain_envelope_{};
float slap_decay_{}, combustion_decay_{}, chain_decay_{};
float gear_phase1_{}, gear_phase2_{}, gear_phase3_{};
float previous_intake_area_{}, previous_exhaust_area_{}, previous_pressure_{};
float previous_cycle_position_{-1.0f}, previous_crank_fraction_{-1.0f};
std::mt19937 random_{0x5EEDu};
std::uniform_real_distribution<float> unit_{-1.0f, 1.0f};
float random_sample();
static bool crossed_fraction(float previous, float current, float target);
};
} // namespace fluidsim::port

View File

@@ -10,11 +10,15 @@ class MotorcycleTest {
public:
Vehicle vehicle{};
float load{}, clutch{1.0f};
float starter_torque{18.f}; // N m at the crankshaft while held
bool starter_engaged{};
void set_starter(bool engaged) { starter_engaged=engaged; }
protected:
void step_motorcycle(Crankshaft& crank, float dt, float maximum_load_torque) {
crank.load_torque = std::clamp(load, 0.0f, 1.0f) * maximum_load_torque;
vehicle.clutch_input = std::clamp(clutch, 0.0f, 1.0f);
if(starter_engaged) crank.add_torque(std::max(starter_torque,0.f));
crank.step(dt);
crank.drivetrain_torque = vehicle.update(crank.omega, crank.inertia, dt);
}

View File

@@ -0,0 +1,27 @@
#pragma once
#include <cmath>
namespace fluidsim::port {
// Causal unflanged circular-pipe reflection fit, Silva et al. (2009), Eq. 16.
// R(s)=-(1+n*a/c*s)/(1+d*a/c*s+e*(a/c*s)^2).
// Trapezoidal state integration supports the solver's variable CFL timestep.
class OpenEndRadiation {
public:
double process(double incident, double scaled_dt) {
if (!initialized_) { position_ = incident; previous_ = incident; initialized_ = true; }
constexpr double n = .167, d = n + 2*.6133;
constexpr double e = (d*d-n*n-1)/2;
const double h = scaled_dt*.5;
const double rhs = velocity_ + h/e*(previous_ + incident - position_ - d*velocity_);
const double next_velocity = (rhs - h/e*(position_ + h*velocity_)) /
(1 + h*d/e + h*h/e);
position_ += h*(velocity_ + next_velocity);
velocity_ = next_velocity;
previous_ = incident;
return -(position_ + n*velocity_);
}
private:
double position_{}, velocity_{}, previous_{};
bool initialized_{};
};
} // namespace fluidsim::port

View File

@@ -1,12 +1,14 @@
#pragma once
#include "FluidSim/AudioProcessing.hpp"
#include "FluidSim/OutdoorExhaustReverb.hpp"
#include <array>
#include <atomic>
#include <cstddef>
#include <vector>
namespace fluidsim::port {
enum class OutdoorScene { Dry, Field, Wall, Woodland };
enum class OutdoorScene { Dry, Field, Wall, Woodland, Tunnel };
const char *scene_name(OutdoorScene scene);
struct OutdoorAudioConfig {
@@ -28,9 +30,11 @@ struct OutdoorAudioConfig {
class OutdoorAudio {
public:
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);
void set_scene(OutdoorScene scene) { scene_ = scene; }
OutdoorScene scene() const { return scene_; }
void set_scene(OutdoorScene scene) { requested_scene_.store(scene, std::memory_order_relaxed); }
OutdoorScene scene() const { return requested_scene_.load(std::memory_order_relaxed); }
void set_monitor_filter(bool enabled) { monitor_filter_ = enabled; } // Set before playback/capture.
AudioFrame dry_frame() const { return last_dry_; }
AudioFrame unfiltered_frame() const { return last_raw_; }
@@ -44,7 +48,7 @@ private:
std::size_t delay_left{}, delay_right{};
float fraction_left{}, fraction_right{}, gain_left{}, gain_right{}, alpha{1};
float state_left{}, state_right{};
int group{}; // direct, ground, wall, trees
int group{}; // direct, ground, wall, trees, tunnel
};
struct Emitter {
std::vector<float> filtered, raw;
@@ -53,22 +57,17 @@ private:
float previous_flow{};
LowPass filter;
};
struct TailLine {
std::vector<float> buffer;
std::size_t position{};
float gain{}, state{};
};
int rate_{};
OutdoorAudioConfig config_;
OutdoorScene scene_{OutdoorScene::Wall};
std::atomic<OutdoorScene> requested_scene_{OutdoorScene::Wall};
OutdoorScene active_scene_{OutdoorScene::Wall};
bool monitor_filter_{true};
std::array<Emitter, 3> emitters_;
std::array<TailLine, 4> tail_;
std::array<float, 4> weights_{1, 0, 0, 0};
float scene_alpha_{}, tail_alpha_{}, tail_mix_{};
OutdoorExhaustReverb late_reverb_;
std::array<float, 5> weights_{1, 0, 0, 0, 0};
float scene_alpha_{}, tail_mix_{};
AudioFrame last_dry_{}, last_raw_{};
void configure_emitter(Emitter &emitter, Vec3 position);
AudioFrame render(Emitter &emitter, float flow, AudioFrame &dry, AudioFrame &raw);
AudioFrame diffuse(float input);
};
} // namespace fluidsim::port

View File

@@ -19,6 +19,7 @@ public:
explicit OutdoorExhaustReverb(int sample_rate = 44100);
AudioFrame process_stereo(float input);
float process(float input);
void set_decay_times(float low_seconds, float mid_seconds, float high_seconds);
private:
class DelayLine {
@@ -40,7 +41,7 @@ private:
};
int sample_rate_{};
float dt_{}, gain_low_{}, gain_mid_{}, gain_high_{};
float dt_{}, average_delay_seconds_{}, gain_low_{}, gain_mid_{}, gain_high_{};
std::vector<DelayLine> fdn_;
std::vector<OnePoleLowPass> low_filters_, mid_filters_;
std::vector<float> read_buffer_, mix_buffer_;
@@ -49,6 +50,7 @@ private:
float fast_envelope_{}, slow_envelope_{}, bloom_{};
float fast_attack_{}, fast_release_{}, slow_attack_{}, slow_release_{}, bloom_decay_{};
void update_feedback_gains();
static std::size_t delay_samples(int sample_rate, float milliseconds);
};

View File

@@ -0,0 +1,20 @@
#pragma once
#include <stdexcept>
#include <string_view>
namespace fluidsim::port {
enum class PipeSolver { Rusanov, Hlle, Hllc };
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";
}
return "unknown";
}
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");
}
} // namespace fluidsim::port

View File

@@ -1,16 +1,17 @@
#pragma once
#include "FluidSim/Silencer.hpp"
#include "FluidSim/Carburetor.hpp"
#include <string>
#include "FluidSim/Junction.hpp"
#include "FluidSim/AudioProcessing.hpp"
#include "FluidSim/Atmosphere.hpp"
#include "FluidSim/Engine.hpp"
#include "FluidSim/Ecu.hpp"
#include "FluidSim/Fluid.hpp"
#include "FluidSim/MechanicalSoundProcessor.hpp"
#include "FluidSim/OutdoorAudio.hpp"
#include "FluidSim/Solver.hpp"
#include "FluidSim/Vehicle.hpp"
#include "FluidSim/MotorcycleTest.hpp"
#include "FluidSim/Turbocharger.hpp"
#include <memory>
#include <array>
#include <cstdint>
@@ -19,34 +20,60 @@
namespace fluidsim::port {
enum class ScenarioKind { Twin, TwoStroke, SaabB205, TurboTwin, AmericanV8 };
enum class ScenarioKind { Twin, TwoStroke, SaabB205, AmericanV8 };
enum class FourStrokePreset { Twin, SaabB205, AmericanV8 };
enum class EngineLayout { Inline, V, Boxer };
struct EngineConfig {
int cylinders{}; // zero selects the existing preset
EngineLayout layout{EngineLayout::Inline};
float bank_angle{60}, bore{.086f}, stroke{.086f}, rod{.145f}, compression{10};
float idle_rpm{1000}, rev_limit{7000}, idle_opening{.045f};
bool carbureted{};
float carb_pilot_scale{1.f};
float carb_jet_scale{1.f}; // multiplier on displacement-scaled effective jet diameters
std::array<float,12> phase_degrees{}; // used when custom_phases is true
bool custom_phases{};
};
struct V8ExhaustConfig {
float primary_length{.75f}, primary_diameter{.038f};
bool h_pipe{true};
float junction_volume{50e-6f}; // m3, 50 cc per fitting
};
struct ScenarioSnapshot {
bool turbo_enabled{};
float boost_target{}, wastegate_duty{};
TurboSnapshot turbo{};
std::string example_name;
EngineLayout layout{EngineLayout::Inline};
float bank_angle{};
bool v8_headers{};
PipeSolver pipe_solver{PipeSolver::Rusanov};
int cylinder_count{2};
float rev_limit_rpm{10000};
std::array<float, 8> cylinder_pressures{}, piston_fractions{}, intake_areas{}, exhaust_areas{};
std::uint64_t solver_substeps{}, boundary_iterations{};
std::array<float, 12> cylinder_angles{};
std::array<float, 12> cylinder_pressures{}, piston_fractions{}, intake_areas{}, exhaust_areas{};
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{};
float cylinder_temperature1{}, cylinder_temperature2{};
float cylinder_piston_fraction1{}, cylinder_piston_fraction2{};
float valve1_intake_area{}, valve1_exhaust_area{}, valve2_intake_area{}, valve2_exhaust_area{};
float intake_pressure{}, exhaust_pressure{};
float intake_pressure{}, exhaust_pressure{}, second_bank_pressure{};
std::array<float,2> crossover_pressures{};
float throttle{}, pedal{}, target_afr{}, clutch{}, intake_flow{}, exhaust_flow{};
bool idle_control{}, fuel_cut{}, two_stroke{}, reed_open{};
bool racing_exhaust{};
bool silencers{}, h_pipe{};
ExhaustConfig exhaust_config{};
float crankcase_pressure{};
OutdoorScene audio_scene{OutdoorScene::Wall};
int gear{};
bool rev_limit1{}, rev_limit2{}, mechanical_sound_enabled{};
std::array<std::vector<GasState>, 22> pipe_states;
std::array<std::vector<float>, 22> pipe_areas, pipe_lengths;
bool rev_limit1{}, rev_limit2{};
std::array<std::vector<GasState>, 32> pipe_states;
std::array<std::vector<float>, 32> pipe_areas, pipe_lengths;
void capture_pipes(const std::vector<Pipe1D>& pipes) {
for (std::size_t k = 0; k < pipes.size(); ++k) {
pipe_positivity_repairs += pipes[k].positivity_repairs;
pipe_areas[k] = pipes[k].cell_areas;
pipe_lengths[k] = pipes[k].cell_lengths;
pipe_states[k].reserve(pipes[k].cells.size());
@@ -60,17 +87,20 @@ struct ScenarioSnapshot {
// Presets include a 600 cc twin, B205-sized inline-four and American 5.0 V8.
class FourStrokeScenario : public MotorcycleTest {
public:
explicit FourStrokeScenario(int sample_rate = 44100, FourStrokePreset preset = FourStrokePreset::Twin,
bool turbo_enabled = false);
explicit FourStrokeScenario(int sample_rate = 44100, FourStrokePreset preset = FourStrokePreset::Twin, ExhaustConfig exhaust = {}, V8ExhaustConfig v8_exhaust = {}, EngineConfig engine = {});
const FourStrokePreset preset;
int cylinder_count() const { return preset == FourStrokePreset::AmericanV8 ? 8 : preset == FourStrokePreset::SaabB205 ? 4 : 2; }
std::array<FourStrokeCylinder*, 8> cylinders() { return {&cylinder1, &cylinder2, &cylinder3, &cylinder4,
&cylinder5, &cylinder6, &cylinder7, &cylinder8}; }
const ExhaustConfig exhaust_config;
const V8ExhaustConfig v8_exhaust_config;
const EngineConfig engine_config;
int cylinder_count() const { return engine_config.cylinders ? engine_config.cylinders : preset == FourStrokePreset::AmericanV8 ? 8 : preset == FourStrokePreset::SaabB205 ? 4 : 2; }
std::array<FourStrokeCylinder*, 12> cylinders() { return {&cylinder1, &cylinder2, &cylinder3, &cylinder4,
&cylinder5, &cylinder6, &cylinder7, &cylinder8,&cylinder9,&cylinder10,&cylinder11,&cylinder12}; }
AudioFrame process();
void set_controls(float throttle_value, float load_value, float clutch_value);
void toggle_mechanical_sound();
void set_audio_scene(OutdoorScene scene);
void set_pipe_solver(PipeSolver solver);
void set_monitor_filter(bool enabled);
void set_starter(bool engaged);
AudioFrame dry_audio() const;
AudioFrame unfiltered_audio() const;
void shift_up();
@@ -80,34 +110,31 @@ public:
ScenarioSnapshot snapshot() const;
float throttle{};
bool mechanical_sound_enabled{};
float mechanical_mix{0.02f};
Ecu ecu{};
std::unique_ptr<Turbocharger> turbo;
Crankshaft crankshaft{};
FourStrokeCylinder cylinder1{}, cylinder2{}, cylinder3{}, cylinder4{};
FourStrokeCylinder cylinder5{}, cylinder6{}, cylinder7{}, cylinder8{};
Atmosphere intake_atmosphere{};
FourStrokeCylinder cylinder9{}, cylinder10{}, cylinder11{}, cylinder12{};
Volume0D intake_plenum{1.0e-3f, 101325.0f, 300.0f};
Volume0D exhaust_collector{0.15e-3f, 101325.0f, 700.0f};
Volume0D second_bank_collector{1.5e-3f, 101325.0f, 700.0f};
Junction exhaust_collector{0.15e-3f, 101325.0f, 700.0f};
std::vector<Pipe1D> pipes;
float intake_flow{}, exhaust_flow{};
float last_mix_l{}, last_mix_r{};
std::uint64_t sample_count{};
private:
float intake_boundary_area_{}, throttle_area_{}, runner_area_{}, header_area_{}, tail_area_{};
std::array<float, 8> intake_valve_areas_{}, exhaust_valve_areas_{};
float throttle_area_{}, runner_area_{}, header_area_{}, tail_area_{};
std::array<float, 12> intake_valve_areas_{}, exhaust_valve_areas_{};
float dt_{};
BoundarySystem boundaries_;
Solver solver_;
OutdoorAudio audio_;
MechanicalSoundProcessor mechanical_sound_;
mutable std::mutex mutex_;
std::vector<std::unique_ptr<Junction>> exhaust_junctions_;
std::unique_ptr<Carburetor> carb_;
Port* throttle_port_{};
static float area_for_diameter(float diameter);
static std::vector<Pipe1D> create_pipes(FourStrokePreset preset, bool turbo_enabled);
static std::vector<Pipe1D> create_pipes(FourStrokePreset preset, const ExhaustConfig& exhaust, const V8ExhaustConfig& v8_exhaust, const EngineConfig& engine);
};
using FourStrokeTwinScenario = FourStrokeScenario;
@@ -116,17 +143,10 @@ public:
explicit AmericanV8Scenario(int sample_rate = 44100)
: FourStrokeScenario(sample_rate, FourStrokePreset::AmericanV8) {}
};
class TurboTwinScenario : public FourStrokeScenario {
public:
explicit TurboTwinScenario(int sample_rate = 44100) : FourStrokeScenario(sample_rate, FourStrokePreset::Twin, true) {}
};
class SaabB205Scenario : public FourStrokeScenario {
public:
explicit SaabB205Scenario(int sample_rate = 44100) : FourStrokeScenario(sample_rate, FourStrokePreset::SaabB205) {}
};
class SaabTurboScenario : public FourStrokeScenario {
public:
explicit SaabTurboScenario(int sample_rate = 44100) : FourStrokeScenario(sample_rate, FourStrokePreset::SaabB205, true) {}
};
} // namespace fluidsim::port

View File

@@ -0,0 +1,83 @@
#pragma once
#include "FluidSim/Scenario.hpp"
#include "FluidSim/TwoStrokeConfig.hpp"
#include <string>
namespace fluidsim::port {
struct ScenarioExample {
std::string id,title,description;
ScenarioKind kind;
TwoStrokeConfig two{};
ExhaustConfig exhaust{};
V8ExhaustConfig v8{};
EngineConfig engine{};
std::string category;
};
inline std::vector<ScenarioExample> scenario_examples() {
std::vector<ScenarioExample> examples;
auto add=[&](const char* id,const char* title,const char* description,ScenarioKind kind)->ScenarioExample& {
examples.push_back({id,title,description,kind,{},{},{},{},{}});return examples.back();
};
add("twin","600 cc parallel twin","EFI motorcycle baseline",ScenarioKind::Twin);
add("yz-racing","YZ125 / expansion chamber","Racing pipe, fixed 4.5% carb idle",ScenarioKind::TwoStroke);
add("yz-straight","YZ125 / straight exhaust","Same engine and carb, straight exhaust comparison",ScenarioKind::TwoStroke).two.exhaust=TwoStrokeExhaust::Straight;
add("saab-straight","Saab B205 / straight-through","6 L resonator and 18 L straight-through rear muffler",ScenarioKind::SaabB205);
auto& chambered=add("saab-chambered","Saab B205 / chambered","Expansion resonator and two-chamber rear muffler",ScenarioKind::SaabB205);
chambered.exhaust.resonator.type=SilencerType::ExpansionResonator;
chambered.exhaust.muffler.type=SilencerType::Chambered;chambered.exhaust.muffler.chambers=2;
auto& resonator=add("saab-expansion","Saab B205 / expansion silencers","Two single expansion chambers, same body volumes",ScenarioKind::SaabB205);
resonator.exhaust.resonator.type=SilencerType::ExpansionResonator;resonator.exhaust.muffler.type=SilencerType::ExpansionResonator;
add("saab-open","Saab B205 / open exhaust","Silencers bypassed; naturally aspirated baseline",ScenarioKind::SaabB205).exhaust.enabled=false;
add("v8-h","American 5.0 / 4-2-1 + H","750 mm primaries, 50 cc junctions, H crossover",ScenarioKind::AmericanV8);
add("v8-dual","American 5.0 / independent banks","Same 4-2-1 headers with H crossover disconnected",ScenarioKind::AmericanV8).v8.h_pipe=false;
add("v8-short","American 5.0 / short headers","600 mm primaries with H crossover",ScenarioKind::AmericanV8).v8.primary_length=.60f;
add("v8-long","American 5.0 / long headers","900 mm primaries with H crossover",ScenarioKind::AmericanV8).v8.primary_length=.90f;
add("v8-large","American 5.0 / larger primaries","42 mm x 750 mm primaries with H crossover",ScenarioKind::AmericanV8).v8.primary_diameter=.042f;
for(auto& e:examples) e.category=e.kind==ScenarioKind::TwoStroke?"Two-stroke / 125 cc":e.kind==ScenarioKind::SaabB205?"Inline four / Saab":e.kind==ScenarioKind::AmericanV8?"V8 / American 5.0":"Twins / parallel";
auto chamber=[&](const char* id,const char* title,float length,float width) {
auto& e=add(id,title,"Estimated chamber geometry / same 125 cc engine",ScenarioKind::TwoStroke);
e.category="Two-stroke / 125 cc";
for(auto& section:e.two.racing_pipe) {section.length*=length;section.inlet_diameter*=width;section.outlet_diameter*=width;}
};
chamber("yz-torque","125 / long torque chamber",1.18f,1.f);
chamber("yz-short","125 / short high-RPM chamber",.82f,1.f);
chamber("yz-fat","125 / wide belly chamber",1.f,1.18f);
auto two=[&](const char* id,const char* title,const char* category,float bore,float stroke,float carb) {
auto& e=add(id,title,"Concept single / transported carb fuel",ScenarioKind::TwoStroke);e.category=category;
const float scale=std::cbrt((bore*bore*stroke)/(.054f*.054f*.0545f));
e.two.jet_scale=bore<.05f?.5f:1.f;
e.two.bore=bore;e.two.stroke=stroke;e.two.rod*=scale;e.two.carburetor_diameter=carb;
e.two.transfer_width*=scale;e.two.exhaust_width*=scale;e.two.crankcase_clearance*=scale*scale*scale;
e.two.minimum_burn_seconds*=scale;
for(auto& section:e.two.racing_pipe) {section.length*=scale;section.inlet_diameter*=scale;section.outlet_diameter*=scale;}
e.two.exhaust_cell_length=.015f;
};
two("fifty","50 cc / little screamer","Two-stroke / 50 cc",.040f,.0398f,.019f);
two("fivehundred","500 cc / big single","Two-stroke / 500 cc",.086f,.086f,.041f);
auto custom=[&](const char* id,const char* title,const char* category,int count,EngineLayout layout,float bore,float stroke)->ScenarioExample& {
auto& e=add(id,title,"Estimated concept calibration / EFI",ScenarioKind::Twin);e.category=category;
e.engine.cylinders=count;e.engine.layout=layout;e.engine.bore=bore;e.engine.stroke=stroke;
e.engine.rod=std::max(.11f,stroke*1.8f);return e;
};
custom("single-450","450 cc / four-stroke thumper","Singles / four-stroke",1,EngineLayout::Inline,.096f,.062f);
custom("single-650","650 cc / big thumper","Singles / four-stroke",1,EngineLayout::Inline,.100f,.083f).engine.rev_limit=6500;
auto& twin270=custom("twin-270","700 cc / 270-degree parallel twin","Twins / parallel",2,EngineLayout::Inline,.080f,.068f);
twin270.engine.custom_phases=true;twin270.engine.phase_degrees[1]=450;
auto& ducati=custom("ltwin","916-inspired / 90-degree V twin","Twins / V",2,EngineLayout::V,.094f,.066f);
ducati.engine.bank_angle=90;ducati.engine.custom_phases=true;ducati.engine.phase_degrees[1]=450;
custom("triple","1.0 litre / inline triple","Inline three",3,EngineLayout::Inline,.079f,.068f);
auto& four=custom("f20","F20C-inspired / 2.0 high-rev four","Inline four / specials",4,EngineLayout::Inline,.087f,.084f);four.engine.rev_limit=9000;
custom("five","Audi-inspired / 2.2 inline five","Inline five",5,EngineLayout::Inline,.081f,.0864f);
custom("rb26","RB26-inspired / 2.6 inline six NA","Inline six",6,EngineLayout::Inline,.086f,.0737f);
custom("six-long","3.0 litre / long-stroke inline six","Inline six",6,EngineLayout::Inline,.086f,.086f);
custom("busso","Busso-inspired / 3.0 V6","V6",6,EngineLayout::V,.093f,.0726f);
auto& flat4=custom("ej20","EJ20-inspired / 2.0 flat four NA","Boxer / flat four",4,EngineLayout::Boxer,.092f,.075f);
flat4.engine.bank_angle=180;
custom("flat6","911-inspired / 3.0 flat six","Boxer / flat six",6,EngineLayout::Boxer,.095f,.0704f).engine.bank_angle=180;
auto& big=custom("big-carb","8.0 litre / carbureted big-block","V8 / big-block",8,EngineLayout::V,.1143f,.098f);
big.engine.carb_pilot_scale=.4f;big.engine.carb_jet_scale=.47f;big.engine.bank_angle=90;big.engine.compression=9;big.engine.carbureted=true;big.engine.idle_rpm=800;big.engine.idle_opening=.08f;
big.description="Carb fuel transport / fixed configurable idle stop";
custom("v10","5.0 litre / even-fire V10","V10",10,EngineLayout::V,.092f,.0752f).engine.bank_angle=72;
custom("v12","250-inspired / 3.0 V12","V12",12,EngineLayout::V,.073f,.0588f).engine.bank_angle=60;
return examples;
}
}

View File

@@ -0,0 +1,54 @@
#pragma once
#include "FluidSim/PipeProfile.hpp"
namespace fluidsim::port {
enum class SilencerType { StraightThrough, ExpansionResonator, Chambered };
struct SilencerConfig {
SilencerType type{SilencerType::Chambered};
float volume{.018f}, length{.65f}, core_diameter{.055f};
int chambers{2};
float passage_diameter{.058f};
float resistance{2.f}; // linear momentum decay, 1/s (packing surrogate)
float loss_coefficient{.35f}; // total dimensionless quadratic flow resistance
float diameter() const { return std::sqrt(4*volume/(pi*length)); }
};
struct ExhaustConfig {
bool enabled{true};
float inlet_length{1.4f}, outlet_length{.35f}, connecting_length{.8f}, diameter{.060f}, cell_length{.035f};
SilencerConfig resonator{SilencerType::StraightThrough,.006f,.40f,.060f,1,.060f,2, .1f};
SilencerConfig muffler{SilencerType::StraightThrough,.018f,.65f,.060f,1,.060f,4, .35f};
};
inline Pipe1D make_silenced_exhaust(const ExhaustConfig& c) {
std::vector<PipeSection> sections;
std::vector<float> linear, quadratic;
auto add=[&](float length,float a,float b,float r=0,float k=0) {
if(!std::isfinite(length)||length<=0||!std::isfinite(r)||r<0||!std::isfinite(k)||k<0)
throw std::invalid_argument("invalid silencer resistance or length");
sections.push_back({length,a,b}); linear.push_back(r); quadratic.push_back(k/length);
};
add(c.inlet_length,c.diameter,c.diameter);
if(c.enabled) for(int index=0;index<2;++index) {
if(index) add(c.connecting_length,c.diameter,c.diameter);
const auto& s=index?c.muffler:c.resonator;
if(!std::isfinite(s.volume)||s.volume<=0||!std::isfinite(s.length)||s.length<=0||s.chambers<1||s.chambers>16 || s.core_diameter<=0 || s.passage_diameter<=0)
throw std::invalid_argument("invalid silencer geometry");
const float d=s.type==SilencerType::StraightThrough?s.core_diameter:s.diameter();
const int n=s.type==SilencerType::Chambered?s.chambers:1;
float previous=c.diameter;
for(int i=0;i<n;++i) {
add(s.length*.1f/n,previous,d,s.resistance,s.loss_coefficient*.5f/n);
add(s.length*.8f/n,d,d,s.resistance,0);
const float end=i+1==n?c.diameter:s.passage_diameter;
add(s.length*.1f/n,d,end,s.resistance,s.loss_coefficient*.5f/n);
previous=end;
}
}
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));
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

@@ -1,24 +0,0 @@
#pragma once
#include <atomic>
namespace fluidsim::port {
class ThreadLoadTracker {
public:
void record(double busy_milliseconds, double budget_milliseconds) {
if (budget_milliseconds <= 0.0) return;
constexpr double alpha = 0.05;
const double instant = busy_milliseconds / budget_milliseconds * 100.0;
const double previous = load_percent_.load(std::memory_order_relaxed);
load_percent_.store(previous + alpha * (instant - previous), std::memory_order_relaxed);
last_busy_ms_.store(busy_milliseconds, std::memory_order_relaxed);
}
double load_percent() const { return load_percent_.load(std::memory_order_relaxed); }
double last_step_microseconds() const { return last_busy_ms_.load(std::memory_order_relaxed) * 1000.0; }
private:
std::atomic<double> load_percent_{};
std::atomic<double> last_busy_ms_{};
};
} // namespace fluidsim::port

View File

@@ -1,81 +0,0 @@
#pragma once
#include "FluidSim/Fluid.hpp"
#include <array>
#include <cstdint>
namespace fluidsim::port {
// RHB31/VZ21-size mean-line approximation. Geometry, losses and coefficients
// are estimates, not a digitized or validated manufacturer performance map.
struct TurboConfig {
static TurboConfig gt17(); // estimated Saab GT1752-size calibration
double shaft_inertia{4e-6}; // kg m^2, compressor+turbine+shaft rigid rotor
double compressor_radius{.018}, turbine_radius{.0135};
double compressor_head_coefficient{.60}, compressor_efficiency{.65};
double turbine_peak_efficiency{.65}, turbine_optimum_speed_ratio{.70};
double compressor_effective_area{110e-6}, turbine_effective_area{100e-6};
double bearing_viscous{1e-7}, windage{2e-11}; // Nm/(rad/s), Nm/(rad/s)^2
// Estimated pressure-can/spring wastegate. Set maximum lift to zero for a
// pure ungated turbo bench test. This changes bypass AREA, never boost.
double gate_diaphragm_area{8e-4}, gate_spring_preload{40}, gate_spring_stiffness{6000};
double gate_damping{120}, gate_diameter{.014}, gate_maximum_lift{.004};
double reference_pressure{101325};
float compressor_inlet_volume{20e-6f}, compressor_outlet_volume{30e-6f};
float turbine_inlet_volume{30e-6f}, turbine_outlet_volume{20e-6f};
std::array<float, 4> connection_diameters{.024f, .024f, .025f, .026f};
};
struct TurboSnapshot {
double rpm{}, angle{}, shaft_energy{}, turbine_power{}, compressor_power{}, bearing_power{};
double compressor_flow{}, turbine_flow{}, compressor_pressure_ratio{}, turbine_pressure_ratio{};
double turbine_inlet_pressure{}, turbine_outlet_pressure{}, charge_pressure{}, charge_temperature{};
double bearing_heat{}, energy_residual{};
std::uint64_t limited_transfers{};
double wastegate_flow{}, wastegate_lift{};
};
// Four pressure-storage nodes joined by turbine/nozzle and centrifugal-pump
// characteristics. Existing pipe orifices exchange conserved mass/enthalpy.
// The whole turbo is ONE solver component; do not also register its volumes.
class Turbocharger final : public Component {
public:
explicit Turbocharger(TurboConfig config = {});
const TurboConfig config;
Volume0D compressor_inlet, compressor_outlet, turbine_inlet, turbine_outlet;
Port& compressor_inlet_port;
Port& compressor_outlet_port;
Port& turbine_inlet_port;
Port& turbine_outlet_port;
void connect(BoundarySystem& boundaries, const std::vector<Pipe1D>& pipes,
int compressor_in, bool compressor_in_left, int compressor_out, bool compressor_out_left,
int turbine_in, bool turbine_in_left, int turbine_out, bool turbine_out_left);
std::vector<Port*> ports() override;
void update_state(float dt) override;
TurboSnapshot snapshot() const;
double omega() const { return omega_; }
double shaft_energy() const { return .5*config.shaft_inertia*omega_*omega_; }
double fluid_energy() const;
// Intended for standalone initialization/tests, never an imposed running speed.
void set_initial_rpm(double rpm);
void set_wastegate_duty(double duty) { gate_duty_ = std::isfinite(duty) ? std::clamp(duty, 0., .85) : 0.; }
private:
std::array<Port*, 4> internal_;
std::array<float, 4> connection_areas_{};
double omega_{}, angle_{}, bearing_heat_{};
double turbine_power_{}, compressor_power_{}, bearing_power_{}, energy_residual_{};
double compressor_flow_{}, turbine_flow_{};
double wastegate_flow_{}, wastegate_lift_{};
std::uint64_t limited_transfers_{};
bool connected_{};
double gate_duty_{}; // pneumatic bleed fraction; zero restores spring boost
static double nozzle(double upstream_pressure, double upstream_temperature,
double downstream_pressure, double area);
double transfer_flow(Volume0D& inlet, Volume0D& outlet, Port& inlet_external,
Port& outlet_external, double requested, double dt);
static void set_transfer(Volume0D& inlet, Volume0D& outlet, Port& inlet_internal,
Port& outlet_internal, double flow, double shaft_work, double dt);
};
} // namespace fluidsim::port

View File

@@ -13,7 +13,10 @@ struct TwoStrokeConfig {
float trapped_compression{8.2f}; // exhaust-closure volume / clearance volume
float transfer_duration{130}, exhaust_duration{190};
float transfer_width{.050f}, exhaust_width{.036f}; // summed effective window widths
float jet_scale{1.f}; // multiplier on effective jet diameters
float crankcase_clearance{150e-6f};
float carburetor_diameter{.0381f};
float idle_slide_opening{.045f}; // fixed mechanical stop, normalized 0..1 (4.5%)
float minimum_burn_seconds{.0008f};
// Empirical mechanical FMEP = A + B*mean_piston_speed + C*speed^2.
// Generic correlation estimates, not a measured YZ125 coast-down fit.

View File

@@ -5,7 +5,6 @@
#include "FluidSim/Carburetor.hpp"
#include "FluidSim/Fluid.hpp"
#include "FluidSim/OutdoorAudio.hpp"
#include "FluidSim/Atmosphere.hpp"
#include "FluidSim/Ecu.hpp"
#include "FluidSim/ReedValve.hpp"
#include "FluidSim/MotorcycleTest.hpp"
@@ -36,6 +35,7 @@ public:
void shift_up() { vehicle.shift_up(); }
void shift_down() { vehicle.shift_down(); }
void set_neutral() { vehicle.set_neutral(); }
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); }
AudioFrame dry_audio() const { return audio_.dry_frame(); }
@@ -47,7 +47,6 @@ public:
Ecu ecu{};
Carburetor carburetor;
ReedValve reed{};
Atmosphere intake_atmosphere{};
float intake_flow{}, exhaust_flow{};
Crankshaft crankshaft{};
TwoStrokeCylinder cylinder;
@@ -56,7 +55,7 @@ public:
std::vector<Pipe1D> pipes;
private:
float dt_{}, max_throttle_area_{}, intake_boundary_area_{};
float dt_{}, max_throttle_area_{};
float reed_in_area_{}, reed_out_area_{}, transfer_pipe_area_{}, transfer_valve_area_{}, exhaust_valve_area_{};
bool reed_open_{};
std::uint64_t step_count_{};