Add machine workshop, typed logic, mechanics and sensor-driven ECUs

This commit is contained in:
max
2026-11-02 07:27:13 +01:00
parent eb2b6366c2
commit abfcce0f46
99 changed files with 28835 additions and 145 deletions

View File

@@ -29,7 +29,7 @@ struct CarburetorConfig {
class Carburetor : public CarburetorConfig {
public:
explicit Carburetor(float throat_diameter = 0.0381f);
explicit Carburetor(float throat_diameter = 0.0381f,const CarburetorConfig& config={});
// air_mass_flow is positive from atmosphere toward the engine.
void step(float dt, float pedal, float slide, float rpm, float air_mass_flow,

View File

@@ -13,6 +13,7 @@ struct Port {
float gamma_value{1.4f}, gas_constant_value{287.0f};
float air_fraction{1.0f};
float fuel_fraction{};
float fuel_equivalent_fraction{}; // fuel origin, including fuel already burned
float* owner_energy{};
float* owner_air_mass{};
float* owner_exhaust_mass{};
@@ -25,6 +26,8 @@ class Component {
public:
virtual ~Component() = default;
virtual std::vector<Port*> ports() = 0;
// Optional actuation at each CFL substep, before boundary fluxes are resolved.
virtual void prepare_step(float /*dt*/) {}
virtual void update_state(float dt) = 0;
};

View File

@@ -0,0 +1,45 @@
#pragma once
#include "FluidSim/Machine.hpp"
#include "FluidSim/Desktop/WorkshopLayout.hpp"
#include <SFML/Graphics.hpp>
#include <functional>
#include <memory>
namespace fluidsim::port::desktop {
struct LogicEditorHooks {
MachineDocument& document;
const MachineSnapshot* live{};
bool shift{};
std::function<bool(const std::function<void()>&)> edit;
std::function<void(std::uint32_t,bool)> boolean_control;
std::function<void(std::uint32_t,double)> number_control;
std::function<void()> release_buttons;
std::function<void(std::uint32_t)> select_physical,focus_physical;
std::function<void(const std::string&)> status;
std::function<void()> example;
};
class LogicEditor {
public:
LogicEditor();
~LogicEditor();
void reset_document();
void deactivate(const LogicEditorHooks&);
bool editing_text()const;
bool active_gesture()const;
std::uint32_t selected_node()const;
void select_node(std::uint32_t,const MachineDocument&);
void select_part_reference(std::uint32_t,const MachineDocument&);
void fit(const LogicGraph&,sf::FloatRect,bool selection_only=false);
void reveal(std::uint32_t,const MachineDocument&,sf::FloatRect);
sf::Vector2f project(LogicPosition,sf::FloatRect)const;
LogicPosition unproject(sf::Vector2f,sf::FloatRect)const;
sf::FloatRect node_rect(const LogicNode&,sf::FloatRect)const;
sf::Vector2f pin_position(const LogicNode&,const std::string&,sf::FloatRect)const;
// Panels can be owned independently in split view; the canvas is always live.
bool event(const sf::Event&,const LogicEditorHooks&,const WorkshopLayout&,sf::FloatRect canvas,bool panels);
void draw(sf::RenderWindow&,const sf::Font*,const LogicEditorHooks&,const WorkshopLayout&,sf::FloatRect canvas,bool panels);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}

View File

@@ -0,0 +1,57 @@
#pragma once
#include "FluidSim/Machine.hpp"
#include "FluidSim/Desktop/OrbitCamera.hpp"
#include <SFML/Graphics.hpp>
#include <memory>
#include <optional>
namespace fluidsim::port::desktop {
struct BuilderRay {Vec3 origin,direction;};
BuilderRay builder_ray(const OrbitCamera&,sf::FloatRect,sf::Vector2f);
std::optional<sf::Vector2f> builder_project(const OrbitCamera&,sf::FloatRect,Vec3);
// Returns metres along the ray; OBBs and duct centerlines use the rendered dimensions.
std::optional<float> builder_pick_part(const MachinePart&,BuilderRay,float tolerance=0);
Vec3 builder_rotate_world(Vec3 euler,unsigned axis,float degrees);
struct BuilderPipeLocation {std::size_t segment{};Vec3 local_point;float screen_distance{};};
std::optional<BuilderPipeLocation> builder_pipe_location(const OrbitCamera&,sf::FloatRect,const MachinePart&,sf::Vector2f);
struct MachinePlaybackStats {double simulation_seconds{};std::uint64_t underruns{};std::size_t buffered{};double speed{1};bool paused{};};
enum class WorkshopView {ThreeD,Logic,Split};
class MachineBuilder {
public:
MachineBuilder();
~MachineBuilder();
const MachineDocument& document()const;
std::vector<std::uint32_t> selected_parts()const;
void set_document(MachineDocument,std::filesystem::path source={});
bool load_file(const std::filesystem::path&);
void start_test();
void stop_test();
bool testing()const;
MachinePlaybackStats playback_stats()const;
void set_test_speed(double);
void pause_test();
void step_test();
std::vector<MachineMeasurement> measurements()const;
bool export_measurements(const std::filesystem::path&);
bool finished()const;
bool wants_close_window()const;
bool handle_event(const sf::Event&,sf::Vector2u);
void update();
void draw(sf::RenderWindow&,const sf::Font*);
static sf::FloatRect viewport(sf::Vector2u);
sf::FloatRect workspace_viewport(sf::Vector2u)const;
sf::FloatRect logic_viewport(sf::Vector2u)const;
WorkshopView workspace_view()const;
void set_workspace_view(WorkshopView);
std::uint32_t selected_logic_node()const;
std::vector<LogicNodeState> logic_values()const;
sf::FloatRect logic_node_rectangle(std::uint32_t,sf::Vector2u)const;
sf::Vector2f logic_pin_position(std::uint32_t,const std::string&,sf::Vector2u)const;
void restore_workspace_preferences();
const OrbitCamera& camera()const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
void run_machine_builder(sf::RenderWindow&,const sf::Font*,const std::filesystem::path& file={},bool play=false);
}

View File

@@ -0,0 +1,21 @@
#pragma once
#include "FluidSim/Machine.hpp"
#include "FluidSim/Desktop/OrbitCamera.hpp"
#include <SFML/Graphics.hpp>
#include <memory>
namespace fluidsim::port::desktop {
struct MachineRenderStats {std::uint64_t pipe_mesh_builds{},icon_atlas_builds{};std::size_t cached_pipes{};};
class MachineRenderer3D {
public:
MachineRenderer3D();
~MachineRenderer3D();
MachineRenderer3D(const MachineRenderer3D&)=delete;
MachineRenderer3D& operator=(const MachineRenderer3D&)=delete;
void draw(sf::RenderWindow&,const MachineDocument&,const MachineSnapshot*,sf::FloatRect,const OrbitCamera&,std::uint32_t selected,const MachinePart* ghost=nullptr,const std::vector<std::uint32_t>& selection={});
void draw_icon(sf::RenderWindow&,MachinePartKind,sf::FloatRect);
MachineRenderStats stats()const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}

View File

@@ -0,0 +1,10 @@
#pragma once
#include "FluidSim/MachinePorts.hpp"
#include <SFML/Graphics/Color.hpp>
namespace fluidsim::port::desktop {
inline sf::Color port_color(PortDomain domain){
switch(domain){case PortDomain::Boolean:return {235,92,102};case PortDomain::Number:return {87,201,128};case PortDomain::Gas:return {189,200,214};case PortDomain::Rotational:return {229,184,91};case PortDomain::Translational:return {177,139,220};case PortDomain::EcuAssignment:return {91,161,242};}
return sf::Color::White;
}
inline bool filled_port(const MachinePortDescriptor& descriptor){return descriptor.direction==PortDirection::Output;}
}

View File

@@ -2,6 +2,26 @@
#include "FluidSim/ScenarioCatalog.hpp"
#include <SFML/Graphics.hpp>
#include <optional>
#include <memory>
namespace fluidsim::port::desktop {
std::optional<std::size_t> choose_scenario(sf::RenderWindow&,const sf::Font*,const std::vector<ScenarioExample>&);
struct ScenarioMenuChoice {
std::optional<std::size_t> example;
std::filesystem::path machine;
bool play{};
};
enum class MenuScreen {Home,Workshop,Scenarios};
class ScenarioMenu {
public:
ScenarioMenu(std::vector<ScenarioExample>,std::vector<std::filesystem::path> machines={});
~ScenarioMenu();
bool handle_event(const sf::Event&,sf::Vector2u);
void draw(sf::RenderWindow&,const sf::Font*);
bool finished()const;
MenuScreen screen()const;
std::optional<ScenarioMenuChoice> take_choice();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
std::optional<ScenarioMenuChoice> choose_scenario(sf::RenderWindow&,const sf::Font*,const std::vector<ScenarioExample>&);
}

View File

@@ -0,0 +1,8 @@
#pragma once
#include <SFML/Graphics/RenderWindow.hpp>
namespace fluidsim::port::desktop {
// Maximize the decorated OS window; retain normal restore/minimize controls.
void maximize_window(sf::RenderWindow&);
bool window_is_maximized(const sf::RenderWindow&);
}

View File

@@ -0,0 +1,30 @@
#pragma once
#include <SFML/Graphics/Rect.hpp>
#include <SFML/System/Vector2.hpp>
#include <algorithm>
namespace fluidsim::port::desktop {
// One geometry source for drawing, picking and viewport projection.
struct WorkshopLayout {
sf::FloatRect left, right, viewport, library, search;
float inspector_x{}, inspector_width{}, height{}, width{};
static constexpr float field_top=302, field_height=52;
int field_rows()const{return std::max(1,int((height-164-field_top)/field_height));}
int library_rows()const{return std::max(1,int(library.size.y/36));}
sf::FloatRect field_row(int row)const{return {{inspector_x+12,field_top+row*field_height},{inspector_width-24,48}};}
sf::FloatRect field_value(int row)const{auto r=field_row(row);return {{r.position.x,r.position.y+21},{r.size.x-68,26}};}
static WorkshopLayout calculate(sf::Vector2u size,float left_width=260,float right_width=340,bool left_closed=false,bool right_closed=false){
WorkshopLayout l;l.width=float(size.x);l.height=float(size.y);
const float available=std::max(480.f,l.width-440);
float lw=left_closed?28.f:std::clamp(left_width,220.f,420.f),rw=right_closed?28.f:std::clamp(right_width,280.f,520.f);
if(lw+rw>available){const float excess=lw+rw-available;rw=std::max(right_closed?28.f:280.f,rw-excess);lw=std::max(left_closed?28.f:180.f,std::min(lw,available-rw));}
l.left={{8,96},{lw,std::max(100.f,l.height-154)}};
l.right={{l.width-rw-8,96},{rw,std::max(100.f,l.height-154)}};
l.inspector_x=l.right.position.x;l.inspector_width=rw;
l.viewport={{l.left.position.x+lw+8,96},{std::max(100.f,l.right.position.x-lw-24),std::max(100.f,l.height-154)}};
l.search={{20,152},{lw-24,30}};
l.library={{20,230},{lw-24,std::max(36.f,l.height-474)}};
return l;
}
};
}

View File

@@ -1,8 +1,29 @@
#pragma once
#include "FluidSim/MachinePorts.hpp"
#include <string_view>
#include <vector>
namespace fluidsim::port {
// Accepts only coherent, current channels from one physical crank pickup.
// Signed reverse measurements remain valid sensors, but cannot drive this
// forward-only ignition controller. A stationary crank is valid.
std::optional<SignalTiming> ecu_crank_sample(const SignalValue& speed,
const SignalValue& angle,double time);
class CrankIgnitionClock {
public:
struct Result {bool valid{};unsigned sparks{};};
// Crank-only wasted spark: one event per revolution at the configured
// pickup angle, without consulting a cylinder's hidden 720-degree phase.
Result sample(const SignalValue& speed,const SignalValue& angle,
double time,double advance_degrees,double tdc_degrees=0);
void reset(){previous_.reset();last_event_cycle_.reset();}
private:
std::optional<SignalTiming> previous_;
std::optional<double> last_event_cycle_;
};
// Ideal sensors and injectors; calibration parameters are not engine measurements.
struct EcuConfig {
enum class IdleControl { Feedback, FixedOpening };
@@ -18,6 +39,8 @@ struct EcuConfig {
float rev_limit_rpm{10000.0f}, rev_resume_rpm{9750.0f};
bool rev_limit_enabled{true};
bool rev_limit_fuel_cut_enabled{true};
bool rev_limit_ignition_cut_enabled{true};
float rev_limit_cut_time{}; // Minimum cut duration in seconds, followed by RPM hysteresis.
float overrun_cut_rpm{3500.0f}, overrun_resume_rpm{2400.0f};
bool overrun_fuel_cut_enabled{true};
bool overrun_ignition_cut_enabled{false};
@@ -35,7 +58,7 @@ public:
float target_afr() const { return afr_; }
float spark_advance() const { return spark_; }
bool fuel_cut() const { return (limiter_ && rev_limit_fuel_cut_enabled) || (overrun_ && overrun_fuel_cut_enabled) || fault_; }
bool ignition_cut() const { return limiter_ || (overrun_ && overrun_ignition_cut_enabled) || fault_; }
bool ignition_cut() const { return (limiter_ && rev_limit_ignition_cut_enabled) || (overrun_ && overrun_ignition_cut_enabled) || fault_; }
bool rev_limited() const { return limiter_; }
bool idle_active() const { return idle_; }
bool sensor_fault() const { return fault_; }
@@ -45,5 +68,51 @@ private:
bool initialized_{};
float opening_{}, target_{}, integral_{}, afr_{14.7f}, spark_{10.0f};
bool limiter_{}, overrun_{}, fault_{}, idle_{};
float limiter_time_{};
};
// Separate from the legacy ECU: measurements are supplied through typed wires.
// Alpha-N cells are calibrated kg per cylinder per four-stroke cycle, indexed
// by RPM rows and measured TPS columns. No trapped-air-mass input is available.
struct SensorEcuConfig {
enum class Strategy {Ignition,AlphaN,SpeedDensity};
Strategy strategy{Strategy::Ignition};
std::vector<EcuConfig::IgnitionPoint> ignition_map{{0,10},{6000,30}};
std::vector<double> fuel_rpm{0,6000},fuel_tps{0,1};
std::vector<double> fuel_per_cycle{0.000002,0.00002,0.000002,0.00002};
// Open-loop dry ideal-gas estimate per cylinder. Geometry, VE and fuel AFR
// are calibration; MAP and IAT must arrive as current wired measurements.
double displacement{.0003},air_gas_constant{287},target_afr{14.7};
std::vector<double> ve_rpm{0,6000},ve_pressure{20000,100000};
std::vector<double> volumetric_efficiency{.9,.9,.9,.9};
bool rev_limit_enabled{true};
double rev_limit_rpm{7000},rev_resume_rpm{6800};
};
inline constexpr int sensor_ecu_strategy_count=3;
const char* sensor_ecu_strategy_key(SensorEcuConfig::Strategy);
const char* sensor_ecu_strategy_name(SensorEcuConfig::Strategy);
SensorEcuConfig::Strategy sensor_ecu_strategy_from_key(std::string_view);
SensorEcuConfig::Strategy next_sensor_ecu_strategy(SensorEcuConfig::Strategy);
struct SensorEcuMeasurements {SignalValue speed,angle,tps,map,iat;};
struct SensorEcuState {
bool sensor_fault{true},rev_limited{};
double spark_advance{},fuel_per_cycle{},fuel_mass_flow{};
double estimated_air_per_cycle{},volumetric_efficiency{};
unsigned spark_events{};
};
class SensorEcu {
public:
explicit SensorEcu(SensorEcuConfig config);
void apply_calibration(SensorEcuConfig config);
const SensorEcuState& sample(const SignalValue& speed,const SignalValue& angle,
const SignalValue& tps,double time);
const SensorEcuState& sample(const SensorEcuMeasurements&,double time);
const SensorEcuState& state()const{return state_;}
void reset(){clock_.reset();state_={};limiter_=false;}
private:
SensorEcuConfig config_;
CrankIgnitionClock clock_;
SensorEcuState state_;
bool limiter_{};
};
} // namespace fluidsim::port

View File

@@ -4,6 +4,8 @@
#include "FluidSim/Constants.hpp"
#include <array>
#include <cstddef>
#include <limits>
#include <optional>
#include <random>
#include <vector>
@@ -25,6 +27,8 @@ public:
float average_power{}, average_torque{};
void add_torque(float torque);
float take_torque(); // Consume gas torque when an external shaft integrator owns motion.
void record_output(float torque,float power);
void step(float dt);
private:
float external_torque_{};
@@ -51,6 +55,7 @@ public:
float rev_limit_rpm{7500.0f}, rev_limit_cut_time{0.05f}, rev_limit_hysteresis{200.0f};
float air_mass{}, exhaust_mass{}, energy{}, volume{}, pressure{101325.0f};
float fuel_mass{}, burn_fraction{};
float fuel_equivalent_mass{}; // passive fuel-origin tracer, unaffected by combustion
bool combustion_active{}, fuel_injected{}, rev_limit_active{};
Port intake{}, exhaust{};
@@ -84,6 +89,14 @@ protected:
class FourStrokeCylinder final : public EngineCylinder {
public:
struct IgnitionCommand {bool fire{},cut{true};};
// Present selects external timing exclusively. A fire pulse is consumed
// once; missing subsequent commands cannot recreate internal timed sparks.
// Requires physically transported fuel, including carburetor operation.
std::optional<IgnitionCommand> ignition_command;
// Once per cycle, before ignition consumes fuel. Independent of spark cut.
float charge_afr{std::numeric_limits<float>::quiet_NaN()};
bool charge_sampled{};
struct CamPoint { float angle_degrees{}, lift_metres{}; };
struct ValveDynamics {
float mass_kg{0.035f};
@@ -106,6 +119,8 @@ public:
static float cam_lift_at(const CamProfile& profile,float degrees) { return sample_cam(profile,degrees).lift_metres; }
private:
float external_burn_angle_{};
bool external_charge_ready_{};
struct ValveState { float lift{},velocity{};bool floating{}; };
const CamProfile* intake_cam_{};
const CamProfile* exhaust_cam_{};

View File

@@ -54,10 +54,12 @@ public:
float air_fraction() const { return air_mass_ / std::max(mass(), 1e-12f); }
float fuel_fraction() const { return fuel_mass_ / std::max(mass(), 1e-12f); }
float fuel_mass() const { return fuel_mass_; }
float fuel_equivalent_fraction()const{return fuel_equivalent_mass_/std::max(mass(),1e-12f);}
void add_fuel_mass(float value, float temperature = 300.0f) {
if (!std::isfinite(value) || value < 0.0f || !std::isfinite(temperature) || temperature <= 0.0f)
throw std::invalid_argument("invalid fuel addition");
fuel_mass_ += value;
fuel_equivalent_mass_ += value;
energy_ += value * gas_constant / (gamma - 1.0f) * temperature;
sync_ports();
}
@@ -68,9 +70,11 @@ public:
const float total = value * volume_ / (gas_constant * target_t);
const float fraction = air_fraction();
const float fuel = fuel_fraction();
const float equivalent=fuel_equivalent_fraction();
air_mass_ = total * fraction;
fuel_mass_ = total * fuel;
exhaust_mass_ = total * std::max(1.0f - fraction - fuel, 0.0f);
fuel_equivalent_mass_=total*equivalent;equivalent_roundoff_=0;
energy_ = value * volume_ / (gamma - 1.0f);
air_roundoff_ = exhaust_roundoff_ = fuel_roundoff_ = energy_roundoff_ = 0;
sync_ports();
@@ -80,7 +84,7 @@ public:
throw std::invalid_argument("invalid volume timestep");
if (dt == 0)
return;
double d_air = 0, d_exhaust = 0, d_fuel = 0, d_energy = 0;
double d_air = 0, d_exhaust = 0, d_fuel = 0, d_energy = 0, d_equivalent=0;
const float fraction = air_fraction();
const float fuel = fuel_fraction();
for (const Port &p : ports_) {
@@ -90,11 +94,13 @@ public:
d_fuel += double(p.mass_flow) * flow_fuel;
d_exhaust += double(p.mass_flow) * std::max(1.0f - flow_fraction - flow_fuel, 0.0f);
d_energy += double(p.mass_flow) * p.enthalpy;
d_equivalent+=double(p.mass_flow)*(p.mass_flow>=0?std::max(p.fuel_equivalent_fraction,p.fuel_fraction):fuel_equivalent_fraction());
}
accumulate(air_mass_, air_roundoff_, d_air * dt);
accumulate(exhaust_mass_, exhaust_roundoff_, d_exhaust * dt);
accumulate(fuel_mass_, fuel_roundoff_, d_fuel * dt);
accumulate(energy_, energy_roundoff_, d_energy * dt);
accumulate(fuel_equivalent_mass_,equivalent_roundoff_,d_equivalent*dt);
const float new_volume = volume_ + volume_change_rate * dt;
if (!(new_volume > 0) || !std::isfinite(new_volume))
throw std::runtime_error("invalid moving volume");
@@ -118,6 +124,8 @@ public:
air_mass_ = std::max(air_mass_, 0.0f);
exhaust_mass_ = std::max(exhaust_mass_, 0.0f);
fuel_mass_ = std::max(fuel_mass_, 0.0f);
fuel_equivalent_mass_=std::clamp(fuel_equivalent_mass_,fuel_mass_,mass());
if(fuel_equivalent_mass_<std::numeric_limits<float>::min()){fuel_equivalent_mass_=0;equivalent_roundoff_=0;}
sync_ports();
}
void update_state(float dt) override { update(dt); }
@@ -132,6 +140,7 @@ public:
private:
float volume_, air_mass_{}, exhaust_mass_{}, fuel_mass_{}, energy_;
float fuel_equivalent_mass_{};double equivalent_roundoff_{};
// Preserve sub-ulp transfers without changing the float port/budget ABI.
double air_roundoff_{}, exhaust_roundoff_{}, fuel_roundoff_{}, energy_roundoff_{};
static void accumulate(float& value, double& roundoff, double increment) {
@@ -149,6 +158,7 @@ private:
p.enthalpy = gamma / (gamma - 1.0f) * p.pressure / std::max(p.density, 1e-12f);
p.air_fraction = air_fraction();
p.fuel_fraction = fuel_fraction();
p.fuel_equivalent_fraction=fuel_equivalent_fraction();
}
void sync_ports() {
for (Port &p : ports_)
@@ -161,10 +171,12 @@ struct GasCell {
float energy{1.225f * (101325.0f / (pipe_gm1 * 1.225f))};
float air{1.0f};
float fuel{};
float fuel_equivalent{};
};
struct GasState {
float rho{1.225f}, velocity{}, pressure{101325.0f}, air{1.0f};
float fuel{};
float fuel_equivalent{};
};
class Pipe1D {
@@ -172,7 +184,7 @@ 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{};
float m{}, p{}, e{}, y{}, fuel{}, fuel_equivalent{};
};
struct RoeState { double root_density{}, enthalpy{}; };
PipeSolver solver{PipeSolver::Rusanov};
@@ -200,6 +212,11 @@ public:
if (!std::isfinite(cell_areas[i]) || !std::isfinite(cell_lengths[i]) || cell_areas[i] <= 0 ||
cell_lengths[i] <= 0)
throw std::invalid_argument("pipe geometry must be finite and positive");
if(std::any_of(cell_areas.begin(),cell_areas.end(),[&](float a){return a!=cell_areas.front();})){
geometry_cfl_lengths_.resize(cells.size());
for(std::size_t i=0;i<cells.size();++i){const float a=cell_areas[i],left=i?.5f*(cell_areas[i-1]+a):a,right=i+1<cells.size()?.5f*(a+cell_areas[i+1]):a;
geometry_cfl_lengths_[i]=cell_lengths[i]*(a/std::max({a,left,right}));}
}
}
GasState state(int i) const { return primitive(cells.at(static_cast<std::size_t>(i))); }
float cell_mass(int i) const { return cells.at(i).rho * cell_areas.at(i) * cell_lengths.at(i); }
@@ -212,7 +229,9 @@ public:
auto s = primitive(cells[i]);
if (!(s.rho > 0) || !(s.pressure > 0) || !std::isfinite(s.velocity) || !std::isfinite(s.pressure))
throw std::runtime_error("nonphysical pipe state in CFL calculation");
dt = std::min(dt, cfl * cell_lengths[i] / wave(s));
float speed=wave(s),length=cell_lengths[i];
if(!geometry_cfl_lengths_.empty()){length=geometry_cfl_lengths_[i];if(i)speed=std::max(speed,wave(primitive(cells[i-1])));if(i+1<cells.size())speed=std::max(speed,wave(primitive(cells[i+1])));}
dt = std::min(dt, cfl * length / speed);
}
return dt;
}
@@ -242,6 +261,13 @@ public:
throw std::runtime_error("nonphysical pipe state in preparation");
dt = std::min(dt, cfl * cell_lengths[i] / waves_[i]);
}
// A narrow cell exchanges flux through faces larger than its own area.
// Limit that face/volume ratio and use each face's fastest adjacent wave.
// Uniform pipes retain the original fast preparation path.
if(!geometry_cfl_lengths_.empty())for(std::size_t i=0;i<cells.size();++i){
float speed=waves_[i];if(i)speed=std::max(speed,waves_[i-1]);if(i+1<cells.size())speed=std::max(speed,waves_[i+1]);
dt=std::min(dt,cfl*geometry_cfl_lengths_[i]/speed);
}
if (solver == PipeSolver::Hlle || solver == PipeSolver::Hllc)
for (std::size_t i=0; i<cells.size(); ++i) {
const auto& s=states_[i];
@@ -272,7 +298,7 @@ public:
const float u = c.momentum / std::max(c.rho, 1e-12f);
const float air = std::clamp(c.air, 0.0f, 1.0f);
return {c.rho, u, pipe_gm1 * (c.energy - .5f * c.momentum * u), air,
std::clamp(c.fuel, 0.0f, 1.0f - air)};
std::clamp(c.fuel, 0.0f, 1.0f - air),std::clamp(std::max(c.fuel,c.fuel_equivalent),0.f,1.f)};
}
static float wave(const GasState &s) {
return std::abs(s.velocity) + std::sqrt(gamma * s.pressure / s.rho);
@@ -301,7 +327,7 @@ public:
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};
return Flux{m,m*q.velocity+q.pressure,float((energy+q.pressure)*q.velocity),m*q.air,m*q.fuel,m*q.fuel_equivalent};
};
const Flux fa=physical(a,ea), fb=physical(b,eb);
if(sl>=0) return fa;
@@ -312,7 +338,7 @@ public:
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)};
float((sr*fa.fuel-sl*fb.fuel+diff*(b.rho*b.fuel-a.rho*a.fuel))*inv),float((sr*fa.fuel_equivalent-sl*fb.fuel_equivalent+diff*(b.rho*b.fuel_equivalent-a.rho*a.fuel_equivalent))*inv)};
};
if constexpr(method==PipeSolver::Hlle) return hlle();
const double dl=a.rho*(sl-ua), dr=b.rho*(sr-ub);
@@ -333,7 +359,7 @@ public:
}
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)};
float(f.e+speed*(energy-(left?ea:eb))),float(mass*q.air),float(mass*q.fuel),float(mass*q.fuel_equivalent)};
}
static Flux numerical_flux(const GasState& a,const GasState& b,PipeSolver solver,
float ca,float cb,bool* fallback=nullptr) {
@@ -407,6 +433,7 @@ public:
float en = cells[i].energy - factor * (fr.e - fl.e);
const float air = cells[i].rho * cells[i].air - factor * (fr.y - fl.y);
const float fuel = cells[i].rho * cells[i].fuel - factor * (fr.fuel - fl.fuel);
const float equivalent=cells[i].rho*std::max(cells[i].fuel_equivalent,cells[i].fuel)-factor*(fr.fuel_equivalent-fl.fuel_equivalent);
if (!std::isfinite(rho) || !std::isfinite(mom) || !std::isfinite(en) ||
!std::isfinite(air) || !std::isfinite(fuel))
throw std::runtime_error("nonfinite finite-volume update");
@@ -434,9 +461,10 @@ public:
// subnormal concentrations; total gas mass and energy are unchanged.
const float fraction = std::clamp(air / rho, 0.0f, 1.0f);
const float fuel_fraction = std::clamp(fuel / rho, 0.0f, 1.0f - fraction);
const float equivalent_fraction=std::clamp(equivalent/rho,fuel_fraction,1.f);
next_cells_[i] = {rho, mom, en,
fraction < std::numeric_limits<float>::min() ? 0.0f : fraction,
fuel_fraction < std::numeric_limits<float>::min() ? 0.0f : fuel_fraction};
fuel_fraction < std::numeric_limits<float>::min() ? 0.0f : fuel_fraction,equivalent_fraction<std::numeric_limits<float>::min()?0.f:equivalent_fraction};
}
cells.swap(next_cells_);
prepared_ = false;
@@ -444,11 +472,12 @@ public:
left_override_ = right_override_ = false;
}
static Flux scaled(Flux f, float a) {
return {f.m * a, f.p * a, f.e * a, f.y * a, f.fuel * a};
return {f.m * a, f.p * a, f.e * a, f.y * a, f.fuel * a,f.fuel_equivalent*a};
}
private:
std::vector<GasCell> next_cells_;
std::vector<float> geometry_cfl_lengths_;
std::vector<GasState> states_;
std::vector<GasState> reconstructed_left_, reconstructed_right_;
std::vector<float> waves_;
@@ -480,13 +509,14 @@ private:
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 de=minmod(q.fuel_equivalent-a.fuel_equivalent,b.fuel_equivalent-q.fuel_equivalent);
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};
q.air-h*q.velocity*da,q.fuel-h*q.velocity*df,q.fuel_equivalent-h*q.velocity*de};
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}; };
half.velocity+sign*du,half.pressure+sign*dp,half.air+sign*da,half.fuel+sign*df,half.fuel_equivalent+sign*de}; };
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) &&
@@ -510,7 +540,7 @@ private:
.5f * (ma * a.velocity + a.pressure + mb * b.velocity + b.pressure - c * (mb - ma)),
.5f * ((ea + a.pressure) * a.velocity + (eb + b.pressure) * b.velocity - c * (eb - ea)),
.5f * (ma * a.air + mb * b.air - c * (b.rho * b.air - a.rho * a.air)),
.5f * (ma * a.fuel + mb * b.fuel - c * (b.rho * b.fuel - a.rho * a.fuel))};
.5f * (ma * a.fuel + mb * b.fuel - c * (b.rho * b.fuel - a.rho * a.fuel)),.5f*(ma*a.fuel_equivalent+mb*b.fuel_equivalent-c*(b.rho*b.fuel_equivalent-a.rho*a.fuel_equivalent))};
}
};
@@ -668,7 +698,7 @@ private:
struct JunctionNode {
std::vector<JunctionFace> ends;
std::vector<GasState> states;
struct Face { double pressure{},rho{},velocity{},mass{},enthalpy{},air{},fuel{},loss{}; };
struct Face { double pressure{},rho{},velocity{},mass{},enthalpy{},air{},fuel{},loss{},equivalent{}; };
std::vector<Face> faces;
JunctionMethod method{JunctionMethod::Characteristic};
double turning_loss{};
@@ -689,7 +719,7 @@ private:
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;
double area_sum=0,rho=0,energy=0,air=0,fuel=0,equivalent=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;
@@ -697,8 +727,10 @@ private:
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);
equivalent+=a*s.rho*s.fuel_equivalent*(1+u/speed);
}
rho/=area_sum;energy/=area_sum;air/=area_sum;fuel/=area_sum;
equivalent/=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];
@@ -710,7 +742,8 @@ private:
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)});
const double marker=.5*a*(s.rho*s.fuel_equivalent*u-speed*(equivalent-s.rho*s.fuel_equivalent));
p.set_boundary_flux(e.left,{float(orientation*m),float(momentum),float(orientation*heat),float(orientation*y),float(orientation*f),float(orientation*marker)});
}
}
// Nonlinear outgoing acoustic-wave curves (shock or rarefaction), with
@@ -743,7 +776,7 @@ private:
};
// Each pressure evaluation writes scratch face states only, never pipes.
const auto residual=[&](double pressure) {
double incoming=0, heat=0, air=0, fuel=0;
double incoming=0, heat=0, air=0, fuel=0,equivalent=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;
@@ -757,6 +790,7 @@ private:
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;
f.equivalent=q.fuel_equivalent;if(f.mass>0)equivalent+=f.mass*f.equivalent;
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;
@@ -769,6 +803,7 @@ private:
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;
f.equivalent=equivalent/incoming;
}
balance+=f.mass;
}
@@ -808,12 +843,13 @@ private:
}
}
}
double incoming=0, outgoing=0, heat=0, air=0, fuel=0, scale=1;
double incoming=0, outgoing=0, heat=0, air=0, fuel=0, scale=1,equivalent=0;
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; }
if(f.mass>0)equivalent+=f.mass*f.equivalent;
else outgoing-=f.mass;
}
if(incoming>1e-16 && outgoing<=1e-16) {
@@ -821,12 +857,14 @@ private:
// 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;
equivalent=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)f.equivalent=incoming>0?equivalent/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));
@@ -838,7 +876,7 @@ private:
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)});
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),float(orientation*m*f.equivalent)});
}
return true;
}
@@ -1036,17 +1074,19 @@ private:
const float direction = o.left ? 1.f : -1.f;
const float species = from ? v.air_fraction : s.air;
const float fuel = from ? v.fuel_fraction : s.fuel;
const float equivalent=from?std::max(v.fuel_equivalent_fraction,v.fuel_fraction):s.fuel_equivalent;
const float signed_mass = direction * mdot;
p.set_boundary_flux(o.left, {signed_mass,
mdot * velocity + face_pressure * area + s.pressure * (face_area - area),
signed_mass * h, signed_mass * species, signed_mass * fuel});
ghost(p, o.left, {density, direction * velocity, face_pressure, species, fuel});
signed_mass * h, signed_mass * species, signed_mass * fuel,signed_mass*equivalent});
ghost(p, o.left, {density, direction * velocity, face_pressure, species, fuel,equivalent});
v.mass_flow = -mdot;
v.enthalpy = h;
if (!from)
v.air_fraction = s.air;
if (!from)
v.fuel_fraction = s.fuel;
if(!from)v.fuel_equivalent_fraction=s.fuel_equivalent;
}
void resolve_open(Open &o, float dt) {
auto &p = pipes_[o.pipe];
@@ -1078,13 +1118,14 @@ private:
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;
face.fuel_equivalent=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});
mass_flux*enthalpy, mass_flux*face.air, mass_flux*face.fuel,mass_flux*face.fuel_equivalent});
o.last_volume_flow = direction*face.velocity*o.area;
o.last_mass_flow = direction*mass_flux;
o.last_face_pressure = face.pressure;
@@ -1098,7 +1139,7 @@ private:
const bool out = o.left ? s.velocity <= -c : s.velocity >= c;
const bool ambient_inflow = o.left ? u >= 0 : u <= 0;
GasState g = out ? s : GasState{rho, u, o.pressure, ambient_inflow ? 1.f : s.air,
ambient_inflow ? 0.f : s.fuel};
ambient_inflow ? 0.f : s.fuel,ambient_inflow?0.f:s.fuel_equivalent};
ghost(p, o.left, g);
const float a = std::min(o.area, p.cell_area(i));
auto f = Pipe1D::scaled(o.left ? Pipe1D::riemann(g, s) : Pipe1D::riemann(s, g), a);
@@ -1123,10 +1164,10 @@ private:
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});
da * f.y, da * f.fuel,da*f.fuel_equivalent});
b.set_boundary_flux(o.left_b,
{db * f.m, f.p + sb.pressure * (b.cell_area(ib) - area), db * f.e,
db * f.y, db * f.fuel});
db * f.y, db * f.fuel,db*f.fuel_equivalent});
}
};

View File

@@ -0,0 +1,31 @@
#pragma once
#include "FluidSim/MachinePorts.hpp"
namespace fluidsim::port {
class Volume0D;
// Initial delivery model: calibrated regulated rail, with declared ideal or
// finite supply. Rail pressure dynamics and liquid plumbing are separate work.
struct FuelInjectorConfig {
enum class Supply {IdealRail,FiniteRail};
Supply supply{Supply::FiniteRail};
double maximum_mass_flow{.001}; // kg/s at the calibrated rail pressure
double initial_fuel_mass{.1}; // kg, used only by FiniteRail
float fuel_temperature{300}; // sensible energy of the vaporized fuel
};
struct FuelInjectorState {
bool command_fault{},saturated{},empty{};
double mass_flow{},delivered_mass{},remaining_mass{};
};
class FuelInjector {
public:
explicit FuelInjector(FuelInjectorConfig config={});
// Continuous/batch-average delivery, not a sequential pulse-width model.
// Adds conserved fuel species, tracer and sensible energy to its host.
const FuelInjectorState& deliver(Volume0D& host,const SignalValue& command,
double time,double dt);
const FuelInjectorState& state()const{return state_;}
private:
FuelInjectorConfig config_;
FuelInjectorState state_;
};
}

View File

@@ -0,0 +1,288 @@
#pragma once
#include "FluidSim/Engine.hpp"
#include "FluidSim/PhysicalGeometry.hpp"
#include "FluidSim/PipeSolver.hpp"
#include "FluidSim/OutdoorAudio.hpp"
#include "FluidSim/Ecu.hpp"
#include "FluidSim/Carburetor.hpp"
#include "FluidSim/FuelInjector.hpp"
#include "FluidSim/PipeProfile.hpp"
#include "FluidSim/MachinePorts.hpp"
#include "FluidSim/Mechanics.hpp"
#include "FluidSim/MachineSensors.hpp"
#include "FluidSim/MachineLogic.hpp"
#include <cstdint>
#include <array>
#include <filesystem>
#include <memory>
#include <optional>
#include <string>
#include <vector>
namespace fluidsim::port {
namespace machine_math {
Vec3 add(Vec3,Vec3);
Vec3 subtract(Vec3,Vec3);
Vec3 multiply(Vec3,float);
float dot(Vec3,Vec3);
Vec3 cross(Vec3,Vec3);
float length(Vec3);
Vec3 unit(Vec3);
// Intrinsic XYZ Euler angles, in degrees. Rendering and picking use this transform.
Vec3 rotate(Vec3,Vec3 degrees);
Vec3 inverse_rotate(Vec3,Vec3 degrees);
Vec3 rotate_euler_world(Vec3,unsigned axis,float degrees);
}
enum class MachinePartKind { Pipe, Chamber, Junction, Throttle, CheckValve, Cylinder, PistonPump, Reservoir, Cap, Carburetor, Ecu, Shaft, Flywheel, DriveMotor, Brake };
inline constexpr int machine_part_kind_count=15;
enum class MachineConnectionKind { Orifice, Inertance, Characteristic, Legacy, Control, Mechanical };
inline constexpr int machine_connection_kind_count=6;
const char* machine_part_name(MachinePartKind);
const char* machine_part_key(MachinePartKind);
const char* machine_connection_name(MachineConnectionKind);
const char* machine_connection_key(MachineConnectionKind);
struct MachineShaftConfig {
float inertia{.15f},initial_speed{},initial_angle{};
float friction_constant{},friction_viscous{},friction_quadratic{},starter_torque{};
};
struct MachineDriveConfig {float target_speed{100},torque{1};bool speed_control{true},enabled{true};};
struct MachineBrakeConfig {float constant{1},viscous{},quadratic{};bool enabled{true};};
struct MachineSensorEcuConfig {
bool enabled{}; // False preserves the explicitly labeled legacy ECU.
SensorEcuConfig::Strategy strategy{SensorEcuConfig::Strategy::Ignition};
std::vector<float> fuel_rpm{0,6000},fuel_tps{0,1};
std::vector<float> fuel_per_cycle{.000002f,.00002f,.000002f,.00002f};
float displacement{.0003f},air_gas_constant{287},target_afr{14.7f};
std::vector<float> ve_rpm{0,6000},ve_pressure{20000,100000},volumetric_efficiency{.9f,.9f,.9f,.9f};
bool operator==(const MachineSensorEcuConfig&)const=default;
};
struct MachineInjectorConfig {
std::uint32_t ecu{};
std::string ecu_label;
int cylinders{1}; // Number of calibrated cylinder doses delivered here.
float maximum_flow{.001f},initial_mass{.1f},temperature{300};
bool finite_supply{true};
bool operator==(const MachineInjectorConfig&)const=default;
};
struct MachinePart {
std::uint32_t id{};
MachinePartKind kind{MachinePartKind::Pipe};
std::string name;
Vec3 position{0,.25f,0}, rotation{}, size{.12f,.12f,.12f};
// Pipe centerline in part-local metres. Length is always derived from it.
std::vector<Vec3> points{{-.15f,0,0},{.15f,0,0}};
// One diameter per route point, linearly interpolated along each segment.
// Empty profiles are legacy/programmatic pipes using the two scalar values.
std::vector<float> point_diameters;
float diameter{.04f}, outlet_diameter{.04f};
float pressure{101325}, temperature{300}, opening{1}, discharge{.8f};
bool pedal_control{true};
float cracking_pressure{100}, phase_degrees{}, maximum_lift{.008f};
float intake_open{350}, intake_close{580}, exhaust_open{130}, exhaust_close{370};
CylinderConfig cylinder{};
FourStrokeCylinder::ValveDynamics valve{};
FourStrokeCylinder::ValveDynamics exhaust_valve{};
FourStrokeCylinder::CamProfile intake_profile,exhaust_profile;
// Normalized body coordinates preserve imported port locations when resized.
std::vector<Vec3> port_coordinates;
// Stable identities paired with custom chamber/junction port coordinates.
std::vector<std::string> port_keys;
EcuConfig ecu;
MachineSensorEcuConfig sensor_ecu;
std::optional<MachineInjectorConfig> injector; // Explicit chamber attachment.
float ignition_tdc_degrees{}; // Calibrated crank pickup angle, 0..360.
CarburetorConfig carburetor;
bool injection_enabled{true};
// Junction method 0 stores gas in its visible box; 1/2 are zero-volume nodes.
int junction_method{};
float turning_loss{};
MachineShaftConfig shaft;
MachineDriveConfig drive;
MachineBrakeConfig brake;
};
struct MachinePort {
std::uint32_t part{};
unsigned port{};
bool operator==(const MachinePort&) const = default;
};
SensorEcuConfig machine_sensor_ecu_config(const MachinePart&);
struct MachineLink {
MachinePort a,b;
MachineConnectionKind kind{MachineConnectionKind::Orifice};
float discharge{1}, inertance_length{.01f}, loss{};
};
enum class MachineQuantity {Pressure,Temperature,MassFlow,AirFuelRatio,Lambda,ChargeAfr,Rpm,Torque,Power};
inline constexpr std::size_t machine_probe_limit=8;
inline constexpr float machine_afr_reading_limit=1000.f;
enum class MachineReadingState {Valid,NoFuel,LeanLimit,FuelCut,AwaitingCharge};
const char* machine_reading_state_name(MachineReadingState);
const char* machine_quantity_name(MachineQuantity);
const char* machine_quantity_unit(MachineQuantity);
float machine_quantity_scale(MachineQuantity);
bool machine_quantity_supported(const MachinePart&,MachineQuantity);
struct MachineProbe {
std::uint32_t id{},part{};
std::string name;
MachineQuantity quantity{MachineQuantity::Pressure};
float position{.5f}; // fraction of pipe arclength; lumped parts ignore it
float reference_afr{14.7f}; // stoichiometric AFR for the selected fuel
bool generated_output{}; // Migrated legacy shaft torque/power excludes bench motor/load.
bool operator==(const MachineProbe&)const=default;
};
struct MachineMeasurement {
double time{};
std::array<float,machine_probe_limit> values{}; // SI; undefined readings are NaN
std::array<MachineReadingState,machine_probe_limit> states{};
std::size_t count{};
};
struct MachineDocument {
std::string name{"Untitled machine"};
float cells_per_meter{40}, drive_rpm{}, shaft_inertia{.15f}, load_torque{};
bool driven{};
// Deprecated calibration fields above are read only by legacy migration.
std::uint32_t primary_shaft{};
float friction_constant{.15f},friction_viscous{.002f},friction_quadratic{},starter_torque{15};
PipeSolver solver{PipeSolver::Rusanov};
OutdoorScene scene{OutdoorScene::Wall};
std::uint32_t next_id{1};
std::vector<MachinePart> parts;
std::vector<MachineLink> links;
std::vector<MachineProbe> probes;
std::vector<MachineSensor> sensors;
std::uint32_t next_sensor_id{1};
LogicGraph logic;
MachinePart* find(std::uint32_t);
const MachinePart* find(std::uint32_t) const;
MachinePart& add(MachinePartKind,Vec3 position={0,.25f,0});
MachineSensor& add_sensor(MachineSensorKind,std::uint32_t host,float position=.5f);
MachineSensor* find_sensor(std::uint32_t);
const MachineSensor* find_sensor(std::uint32_t)const;
void remove_sensor(std::uint32_t);
void remove(std::uint32_t);
void disconnect(MachinePort);
// At least one endpoint must be a pipe. Its centerline snaps to the other port.
void connect(MachinePort,MachinePort,MachineConnectionKind=MachineConnectionKind::Orifice);
void sync_connections(std::uint32_t moved_part=0);
void transform_selection(const std::vector<std::uint32_t>& ids,Vec3 pivot,Vec3 translation,unsigned rotation_axis=0,float degrees=0);
};
bool machine_sensor_supported(const MachinePart&,MachineSensorKind,bool reference=false);
MachineSensorStatus machine_sensor_binding(const MachineDocument&,const MachineSensor&);
std::optional<Vec3> machine_sensor_position(const MachineDocument&,const MachineSensorTap&);
void validate_machine_sensors(const MachineDocument&);
bool machine_control_port(const MachinePart&,unsigned);
MachinePortDescriptor machine_port_descriptor(const MachinePart&,unsigned);
unsigned machine_port_index(const MachinePart&,const std::string& key);
PortDomain machine_link_domain(const MachineDocument&,const MachineLink&);
bool machine_rotational_part(MachinePartKind);
std::uint32_t machine_shaft_for(const MachineDocument&,std::uint32_t part);
std::uint32_t machine_primary_shaft(const MachineDocument&);
bool machine_shaft_driven(const MachineDocument&,std::uint32_t shaft);
// Convert an old shared-shaft document to visible mechanical parts once.
void upgrade_machine_mechanics(MachineDocument&);
bool machine_port_available(const MachineDocument&,MachinePort);
unsigned machine_port_count(const MachinePart&);
std::string machine_port_name(const MachinePart&,unsigned);
Vec3 machine_local_port(const MachinePart&,unsigned);
Vec3 machine_world_point(const MachinePart&,Vec3);
Vec3 machine_local_point(const MachinePart&,Vec3);
Vec3 machine_port_position(const MachinePart&,unsigned);
std::vector<Vec3> machine_pipe_path(const MachinePart&);
float machine_pipe_length(const MachinePart&);
std::vector<float> machine_pipe_diameters(const MachinePart&);
std::vector<PipeSection> machine_pipe_sections(const MachinePart&);
float machine_pipe_diameter(const MachinePart&,float distance);
void machine_insert_pipe_point(MachinePart&,std::size_t segment,Vec3 local_point);
void machine_remove_pipe_point(MachinePart&,std::size_t point);
float machine_part_volume(const MachinePart&);
Vec3 machine_part_size(const MachinePart&);
// Drafts may contain unconnected ports. Unconnected duct ends are explicitly capped at test time.
void validate_machine(const MachineDocument&);
std::vector<std::string> machine_diagnostics(const MachineDocument&);
struct MachineConnectionPreview {
bool allowed{};
std::string message;
std::optional<MachinePart> pipe;
};
// Dry-run the actual connection and validation without changing the design.
MachineConnectionPreview machine_connection_preview(const MachineDocument&,MachinePort,MachinePort);
bool machine_has_fuel_source(const MachineDocument&,std::uint32_t cylinder);
struct MachineCheck {
std::uint32_t part{};
bool warning{};
std::string title,advice;
std::uint32_t sensor{};
std::uint32_t logic_node{};
};
std::vector<MachineCheck> machine_checks(const MachineDocument&);
std::filesystem::path machine_directory();
std::vector<std::filesystem::path> machine_files();
std::filesystem::path machine_assembly_directory();
std::vector<std::filesystem::path> machine_assembly_files();
// Center of the selected world geometry, including routed pipes.
Vec3 machine_selection_center(const MachineDocument&,const std::vector<std::uint32_t>&);
// Stores only selected parts, internal links and their probes, relative to the center.
MachineDocument machine_extract_assembly(const MachineDocument&,const std::vector<std::uint32_t>&,std::string name={});
// Inserts an independent copy atomically; returned IDs follow assembly.parts order.
std::vector<std::uint32_t> machine_insert_assembly(MachineDocument&,const MachineDocument&,Vec3 center);
MachineDocument load_machine(const std::filesystem::path&);
void save_machine(const MachineDocument&,const std::filesystem::path&);
MachineDocument machine_pump_example();
MachineDocument machine_engine_example();
MachineDocument machine_logic_example();
MachineDocument machine_sensor_engine_example(bool injection=false);
MachineDocument machine_speed_density_example();
struct ScenarioExample;
MachineDocument machine_from_engine(const ScenarioExample&);
struct MachinePartState {
std::uint32_t id{};
float pressure{101325}, temperature{300}, volume{}, flow{}, piston_fraction{}, intake_lift{}, exhaust_lift{}, opening{};
std::vector<float> pipe_pressures;
float fuel_mass{},fuel_flow{},afr{},burn_fraction{};
bool combustion{},fuel_cut{},ignition_cut{},idle_control{},valve_float{},fuel_source{};
bool actuator_enabled{},signal_controlled{};
double command_target{};
double injector_remaining{},injector_delivered{};
bool sensor_fault{},rev_limited{},injector_fault{},injector_empty{};
float spark_advance{};
std::uint32_t shaft{};
double shaft_angle{},shaft_speed{},rotational_inertia{},torque{},power{},kinetic_energy{},mechanical_work{},mechanical_loss{};
};
struct MachineSnapshot {
double time{};
float rpm{}, torque{}, power{};
std::uint64_t cells{},substeps{},positivity_repairs{};
float minimum_cell_length{};
std::uint32_t minimum_cell_part{};
std::vector<MachinePartState> parts;
std::uint32_t primary_shaft{};
std::vector<ShaftState> shafts;
std::vector<MachineSensorState> sensors;
std::vector<LogicNodeState> logic;
};
// Owns the compiled graph. Editing never mutates a running solver; Test compiles a fresh graph.
class MachineSimulation {
public:
explicit MachineSimulation(const MachineDocument&,int sample_rate=44100);
~MachineSimulation();
MachineSimulation(const MachineSimulation&)=delete;
MachineSimulation& operator=(const MachineSimulation&)=delete;
AudioFrame process();
void set_controls(float pedal,bool starter=false);
void set_listener(AudioListener);
MachineSnapshot snapshot() const;
MachineMeasurement measure() const;
// Latest sample-boundary values, independent of recorded instrument channels.
MachineSensorState sense(std::uint32_t sensor)const;
void set_logic_control(std::uint32_t node,bool);
void set_logic_control(std::uint32_t node,double);
void release_logic_buttons();
// Applies safe tuning and instrument changes between samples, preserving state.
// Geometry, topology, initial conditions and compiled boundary settings must match.
void apply_tuning(const MachineDocument&);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}

View File

@@ -0,0 +1,111 @@
#pragma once
#include "FluidSim/MachineSensors.hpp"
#include "FluidSim/Ecu.hpp"
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace fluidsim::port {
struct MachineDocument;
enum class LogicNodeKind {
BooleanConstant,Switch,Button,NumberConstant,Slider,NumberInput,
And,Or,Not,Xor,Nand,Nor,Greater,Less,Equal,Window,Hysteresis,
Add,Subtract,Multiply,Divide,Minimum,Maximum,Absolute,Clamp,GainOffset,MapRange,Select,BooleanToNumber,
Sensor,Command,Lamp,Readout,BooleanDelay,NumberDelay,Ecu
};
inline constexpr int logic_node_kind_count=36;
enum class LogicCommand {MotorEnable,MotorSpeed,MotorTorque,BrakeEnable,ThrottleOpening,Starter};
inline constexpr int logic_command_count=6;
struct LogicPosition {float x{},y{};bool operator==(const LogicPosition&)const=default;};
struct LogicNode {
std::uint32_t id{};
LogicNodeKind kind{LogicNodeKind::Switch};
std::string name;
LogicPosition position;
SignalQuantity quantity{SignalQuantity::Scalar},output_quantity{SignalQuantity::Scalar};
PressureReference pressure_reference{PressureReference::None},output_pressure_reference{PressureReference::None};
double value{},minimum{},maximum{1},low{.25},high{.75},tolerance{1e-6};
double gain{1},offset{},false_value{},true_value{1},input_low{},input_high{1},output_low{},output_high{1};
bool boolean{},initial{},below{},clamp{true};
unsigned input_count{2};
std::uint32_t sensor{},part{};
MachineSensorKind sensor_kind{MachineSensorKind::Pressure}; // Schema survives an unresolved reference.
SensorEcuConfig::Strategy ecu_strategy{SensorEcuConfig::Strategy::Ignition};
LogicCommand command{LogicCommand::MotorEnable};
std::string binding_label;
bool operator==(const LogicNode&)const=default;
};
struct LogicEndpoint {
std::uint32_t node{};
std::string port;
bool operator==(const LogicEndpoint&)const=default;
};
struct LogicLink {
LogicEndpoint from,to;
bool operator==(const LogicLink&)const=default;
};
struct LogicGraph {
std::uint32_t next_id{1};
std::vector<LogicNode> nodes;
std::vector<LogicLink> links;
LogicNode* find(std::uint32_t);
const LogicNode* find(std::uint32_t)const;
LogicNode& add(LogicNodeKind,LogicPosition={});
void remove(std::uint32_t);
void disconnect(const LogicEndpoint&);
void connect(LogicEndpoint,LogicEndpoint); // Canonical output -> input; validates without mutation on failure.
bool operator==(const LogicGraph&)const=default;
};
const char* logic_node_key(LogicNodeKind);
const char* logic_node_name(LogicNodeKind);
const char* logic_command_key(LogicCommand);
const char* logic_command_name(LogicCommand);
bool logic_memory_node(LogicNodeKind);
std::vector<MachinePortDescriptor> logic_node_ports(const LogicNode&,const MachineDocument* context=nullptr);
MachinePortDescriptor logic_port(const LogicGraph&,const LogicEndpoint&);
PortCompatibility logic_connection_preview(const LogicGraph&,LogicEndpoint,LogicEndpoint);
void validate_logic_graph(const LogicGraph&); // Drafts may have required unconnected inputs.
std::vector<std::uint32_t> logic_evaluation_order(const LogicGraph&,std::vector<std::uint32_t>* cycle_path=nullptr);
std::vector<std::string> logic_required_inputs(const LogicGraph&);
bool logic_topology_equal(const LogicGraph&,const LogicGraph&);
// Empty means a compatible physical binding. Missing targets remain unresolved.
std::string logic_binding_problem(const MachineDocument&,const LogicNode&);
const LogicNode* logic_command_owner(const MachineDocument&,std::uint32_t part,LogicCommand);
void validate_machine_logic(const MachineDocument&);
struct LogicNodeState {
std::uint32_t id{};
std::vector<SignalValue> values; // Same order as logic_node_ports, including inputs.
std::string problem;
};
struct LogicCommandState {
std::uint32_t node{},part{};
LogicCommand command{};
SignalValue value; // Invalid values require the declared inactive consumer fallback.
};
struct LogicEcuInputs {
std::uint32_t node{},part{};
SignalValue speed,angle,tps,map,iat;
};
class MachineLogicRuntime {
public:
using SensorReader=std::function<MachineSensorState(std::uint32_t)>;
explicit MachineLogicRuntime(const MachineDocument&,SensorReader={});
~MachineLogicRuntime();
MachineLogicRuntime(const MachineLogicRuntime&)=delete;
MachineLogicRuntime& operator=(const MachineLogicRuntime&)=delete;
void evaluate(double time,double dt);
SignalValue value(const LogicEndpoint&)const;
std::vector<LogicNodeState> snapshot()const;
const std::vector<LogicCommandState>& commands()const;
const std::vector<LogicEcuInputs>& ecu_inputs()const;
void set_manual(std::uint32_t,bool);
void set_manual(std::uint32_t,double);
void release_buttons();
void apply_tuning(const MachineDocument&);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}

View File

@@ -0,0 +1,51 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <variant>
namespace fluidsim::port {
enum class PortDomain {Gas,Rotational,Translational,Boolean,Number,EcuAssignment};
enum class PortDirection {Bidirectional,Input,Output};
enum class PortCardinality {Single,Multiple};
// Quantities carry meaning as well as dimensions. Opening and scalar, or crank
// angle and cycle phase, require an explicit conversion even when units match.
enum class SignalQuantity {None,Scalar,Fraction,Pressure,Temperature,MassFlow,FreshAirMassFlow,AngularSpeed,Angle,CyclePhase,Torque,Power,Position,Velocity,Force,Time,Inertia};
enum class PressureReference {None,Absolute,Gauge,Differential};
enum class SignalValidity {Valid,Missing,Fault};
// Runtime-only pickup evidence. Arithmetic creates new values without timing
// authority; routing retains the original acquisition time, including delays.
struct SignalTiming {
std::uint32_t sensor{},shaft{};
double sampled_at{},unwrapped_angle{},angular_speed{};
bool operator==(const SignalTiming&)const=default;
};
struct SignalValue {
std::variant<bool,double> value{false};
SignalValidity validity{SignalValidity::Missing};
double timestamp{};
std::optional<SignalTiming> timing;
static SignalValue boolean(bool,double timestamp=0);
static SignalValue number(double,double timestamp=0);
};
struct MachinePortDescriptor {
std::string key,name;
PortDomain domain{PortDomain::Gas};
PortDirection direction{PortDirection::Bidirectional};
PortCardinality cardinality{PortCardinality::Single};
SignalQuantity quantity{SignalQuantity::None};
PressureReference pressure_reference{PressureReference::None};
bool required{};
std::variant<std::monostate,bool,double> default_value;
std::optional<double> minimum,maximum;
bool operator==(const MachinePortDescriptor&)const=default;
};
struct PortCompatibility {bool allowed{};std::string message;};
const char* port_domain_key(PortDomain);
const char* signal_quantity_key(SignalQuantity);
const char* signal_quantity_unit(SignalQuantity); // Internal SI units.
bool signal_domain(PortDomain);
void validate_port_descriptor(const MachinePortDescriptor&);
PortCompatibility port_compatibility(const MachinePortDescriptor&,const MachinePortDescriptor&);
bool signal_value_valid_for(const SignalValue&,const MachinePortDescriptor&);
}

View File

@@ -0,0 +1,40 @@
#pragma once
#include "FluidSim/MachinePorts.hpp"
#include <array>
#include <cstdint>
#include <string>
namespace fluidsim::port {
enum class MachineSensorKind {Pressure,DifferentialPressure,GasTemperature,MassFlow,Crank,ThrottlePosition};
inline constexpr int machine_sensor_kind_count=6;
struct MachineSensorTap {
std::uint32_t part{};
float position{.5f}; // Normalized pipe arclength, independent of fluid cells.
std::string label; // Retains the intended host when a binding is unresolved.
bool operator==(const MachineSensorTap&)const=default;
};
struct MachineSensor {
std::uint32_t id{};
MachineSensorKind kind{MachineSensorKind::Pressure};
std::string name;
MachineSensorTap target,reference;
float angle_offset{}; // Radians, added to the physical shaft angle.
bool operator==(const MachineSensor&)const=default;
};
enum class MachineSensorStatus {Valid,MissingHost,IncompatibleHost,Fault};
struct MachineSensorState {
std::uint32_t id{};
std::array<SignalValue,3> outputs;
unsigned count{};
MachineSensorStatus status{MachineSensorStatus::MissingHost};
// An ideal crank pickup preserves provenance and unwrapped crossings. An
// arbitrary numeric RPM signal must never acquire timing authority from it.
std::uint32_t timing_shaft{};
double unwrapped_angle{};
};
const char* machine_sensor_key(MachineSensorKind);
const char* machine_sensor_name(MachineSensorKind);
const char* machine_sensor_status_name(MachineSensorStatus);
unsigned machine_sensor_output_count(MachineSensorKind);
MachinePortDescriptor machine_sensor_output(const MachineSensor&,unsigned);
}

View File

@@ -0,0 +1,54 @@
#pragma once
#include <cstdint>
#include <memory>
namespace fluidsim::port {
// Rotational quantities use SI: rad, rad/s, N m, kg m2, J and W.
enum class DriveMode {Torque,Speed};
struct ShaftParameters {
double inertia{.15},initial_speed{},initial_angle{};
double friction_constant{},friction_viscous{},friction_quadratic{};
bool forward_only{};
};
struct DriveParameters {DriveMode mode{DriveMode::Torque};double command{};bool enabled{true};};
struct BrakeParameters {double constant{},viscous{},quadratic{};bool enabled{true};};
struct ShaftState {
std::uint32_t id{};
double angle{},previous_angle{},speed{},inertia{},kinetic_energy{};
double net_torque{},source_torque{},external_torque{},friction_torque{},brake_torque{},constraint_torque{};
double external_work{},drive_work{},friction_loss{},brake_loss{},initial_energy{};
// initial_energy + external_work + drive_work = kinetic_energy + losses.
};
struct DriveState {
std::uint32_t id{},shaft{};
double torque{},power{},work{};
};
struct BrakeState {
std::uint32_t id{},shaft{};
double torque{},power{},loss{}; // Torque on shaft signed; absorbed power >= 0.
};
// Independent rigid shaft nodes. Gears and clutches will couple these nodes;
// direct attachment adds torque or inertia to one named node exactly once.
class MechanicalSystem {
public:
MechanicalSystem();
~MechanicalSystem();
MechanicalSystem(const MechanicalSystem&)=delete;
MechanicalSystem& operator=(const MechanicalSystem&)=delete;
void add_shaft(std::uint32_t,const ShaftParameters&);
void add_flywheel(std::uint32_t id,std::uint32_t shaft,double inertia);
void add_drive(std::uint32_t id,std::uint32_t shaft,const DriveParameters&);
void add_brake(std::uint32_t id,std::uint32_t shaft,const BrakeParameters&);
void set_drive(std::uint32_t,const DriveParameters&);
void set_brake(std::uint32_t,const BrakeParameters&);
void set_friction(std::uint32_t shaft,double constant,double viscous,double quadratic);
void add_torque(std::uint32_t shaft,double torque);
void step(double dt);
const ShaftState& shaft(std::uint32_t)const;
const DriveState& drive(std::uint32_t)const;
const BrakeState& brake(std::uint32_t)const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}

View File

@@ -35,4 +35,31 @@ inline Pipe1D make_profiled_pipe(std::span<const PipeSection> sections, float ma
}
return Pipe1D(std::move(areas), std::move(lengths));
}
// Uniform cells over the full arclength, integrating every profile section that
// a cell crosses. Route/profile knots do not force short cells; gas volume is
// still the exact sum of the piecewise frustums at any mesh density.
inline Pipe1D make_uniform_profiled_pipe(std::span<const PipeSection> sections,float maximum_cell_length=.01f){
if(!std::isfinite(maximum_cell_length)||maximum_cell_length<=0||sections.empty())throw std::invalid_argument("invalid pipe profile mesh");
double total=0;float previous=sections.front().inlet_diameter;
for(const auto& s:sections){
if(!std::isfinite(s.length)||!std::isfinite(s.inlet_diameter)||!std::isfinite(s.outlet_diameter)||s.length<=0||s.inlet_diameter<=0||s.outlet_diameter<=0||std::abs(s.inlet_diameter-previous)>1e-6f)throw std::invalid_argument("invalid/discontinuous pipe profile");
total+=s.length;previous=s.outlet_diameter;
}
const int count=profile_section_cells(float(total),maximum_cell_length);const double dx=total/count;
std::vector<float> areas;areas.reserve(count);std::vector<float> lengths(count,float(dx));
std::size_t section=0;double section_start=0,section_end=sections.front().length;
for(int i=0;i<count;++i){
double cursor=i*dx,volume=0;const double end=i+1==count?total:(i+1)*dx;
while(cursor<end){
while(section+1<sections.size()&&cursor>=section_end){section_start=section_end;section_end+=sections[++section].length;}
const auto& s=sections[section];const double stop=std::min(end,section_end);
const double d0=std::lerp(double(s.inlet_diameter),double(s.outlet_diameter),std::clamp((cursor-section_start)/s.length,0.,1.));
const double d1=std::lerp(double(s.inlet_diameter),double(s.outlet_diameter),std::clamp((stop-section_start)/s.length,0.,1.));
volume+=double(pi)/12*(stop-cursor)*(d0*d0+d0*d1+d1*d1);cursor=stop;
}
areas.push_back(float(volume/dx));
}
return Pipe1D(std::move(areas),std::move(lengths));
}
} // namespace fluidsim::port

View File

@@ -19,6 +19,8 @@ public:
bool write(float left, float right, std::stop_token stop = {});
bool wait_for_space(std::size_t target_fill, std::stop_token stop);
void reset_consumer();
// Only while both producer and audio consumer are stopped.
void discard();
std::size_t read_interpolated(std::span<float> left, std::span<float> right, double speed);
private: