Improve physical geometry, fluid solvers, audio, and profiling
This commit is contained in:
@@ -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_{};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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&);
|
||||
};
|
||||
}
|
||||
|
||||
47
include/FluidSim/Desktop/EngineEditor.hpp
Normal file
47
include/FluidSim/Desktop/EngineEditor.hpp
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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_;
|
||||
};
|
||||
}
|
||||
|
||||
24
include/FluidSim/Desktop/PlaybackControl.hpp
Normal file
24
include/FluidSim/Desktop/PlaybackControl.hpp
Normal 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);}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
13
include/FluidSim/MeshResolution.hpp
Normal file
13
include/FluidSim/MeshResolution.hpp
Normal 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))));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
42
include/FluidSim/PhysicalGeometry.hpp
Normal file
42
include/FluidSim/PhysicalGeometry.hpp
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
59
include/FluidSim/RuntimeProfiler.hpp
Normal file
59
include/FluidSim/RuntimeProfiler.hpp
Normal 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_{};
|
||||
};
|
||||
}
|
||||
@@ -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_;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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{};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%)
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user