Add American V8 preset, carb fuel transport, and adaptive desktop audio

This commit is contained in:
2026-10-23 12:43:57 +02:00
parent 7a5c77c1c8
commit 85d4fb828a
36 changed files with 805 additions and 144 deletions

View File

@@ -7,6 +7,11 @@ namespace fluidsim::port {
AudioOutputStream::AudioOutputStream(SimulationRingBuffer& ring, unsigned int sample_rate)
: ring_(ring) {
const float rate = static_cast<float>(sample_rate);
level_alpha_ = -std::expm1(-1.f / (rate * .15f));
peak_decay_ = std::exp(-1.f / (rate * .15f));
gain_rise_alpha_ = -std::expm1(-1.f / (rate * 1.5f));
gain_fall_alpha_ = -std::expm1(-1.f / (rate * .03f));
initialize(2, sample_rate, {sf::SoundChannel::FrontLeft, sf::SoundChannel::FrontRight});
}
@@ -14,18 +19,38 @@ bool AudioOutputStream::onGetData(Chunk& data) {
const std::size_t read = ring_.read_interpolated(temp_left_, temp_right_, speed());
if (read < block_size) underrun_blocks_.fetch_add(1, std::memory_order_relaxed);
constexpr float dc_coefficient = 0.9995f;
constexpr float playback_gain = 4.0f; // +12 dB desktop playback boost
const bool automatic = auto_gain();
// Keep callback work bounded: no allocation, waits or simulation here.
for (std::size_t i = 0; i < block_size; ++i) {
float left{}, right{};
if (i < read) {
const float input_left = std::isfinite(temp_left_[i]) ? temp_left_[i] : 0.0f;
const float input_right = std::isfinite(temp_right_[i]) ? temp_right_[i] : 0.0f;
const float input_left = std::isfinite(temp_left_[i]) ? temp_left_[i] * playback_gain : 0.0f;
const float input_right = std::isfinite(temp_right_[i]) ? temp_right_[i] * playback_gain : 0.0f;
left = input_left - previous_input_left_ + dc_coefficient * previous_output_left_;
right = input_right - previous_input_right_ + dc_coefficient * previous_output_right_;
previous_input_left_ = input_left;
previous_input_right_ = input_right;
previous_output_left_ = left;
previous_output_right_ = right;
// Stereo-linked level detection after DC removal. Freeze on
// underrun; silence returns to unity rather than chasing noise.
const float peak = std::max(std::abs(left), std::abs(right));
rms_power_ += (.5f * (left*left + right*right) - rms_power_) * level_alpha_;
peak_envelope_ = std::max(peak, peak_envelope_ * peak_decay_);
float target = 1.f;
if (automatic && rms_power_ > 1e-8f) {
target = std::clamp(.08f / std::sqrt(rms_power_), .125f, 12.f);
target = std::min(target, .85f / std::max(peak_envelope_, 1e-6f));
}
const float alpha = target < automatic_gain_ ? gain_fall_alpha_ : gain_rise_alpha_;
automatic_gain_ += (target - automatic_gain_) * alpha;
// Catch a sudden rev while the slow idle boost is still elevated.
// One linked multiplier preserves the stereo image.
const float gain = (automatic || automatic_gain_ > 1.001f) ?
std::min(automatic_gain_, .98f / std::max(peak, 1e-6f)) : automatic_gain_;
left *= gain;
right *= gain;
if (starving_) {
transition_left_ = last_left_ - left;
transition_right_ = last_right_ - right;

102
src/carburetor.cpp Normal file
View File

@@ -0,0 +1,102 @@
#include "FluidSim/Carburetor.hpp"
#include <algorithm>
#include <cmath>
namespace fluidsim::port {
namespace {
constexpr float pi = 3.14159265358979323846f;
float circle_area(float diameter) { return .25f * pi * diameter * diameter; }
float smoothstep(float value) {
value = std::clamp(value, 0.0f, 1.0f);
return value * value * (3.0f - 2.0f * value);
}
}
Carburetor::Carburetor(float throat_diameter)
: bowl_mass_(bowl_capacity), throat_area_(circle_area(std::max(throat_diameter, 1e-4f))) {}
void Carburetor::step(float dt, float pedal, float slide, float rpm, float air_mass_flow,
float air_density, float upstream_pressure, float downstream_pressure) {
fault_ = !std::isfinite(dt) || dt <= 0.0f || !std::isfinite(pedal) ||
!std::isfinite(slide) || !std::isfinite(rpm) || rpm < 0.0f ||
!std::isfinite(air_mass_flow) || !std::isfinite(air_density) || air_density <= 0.0f ||
!std::isfinite(upstream_pressure) ||
!std::isfinite(downstream_pressure) || upstream_pressure <= 0.0f ||
downstream_pressure <= 0.0f;
if (fault_) {
delivered_fuel_flow_ = 0.0f;
overrun_ = false;
return;
}
pedal = std::clamp(pedal, 0.0f, 1.0f);
slide = std::clamp(slide, 0.0f, 1.0f);
air_mass_flow = std::max(air_mass_flow, 0.0f);
if (!initialized_) {
previous_pedal_ = pedal;
initialized_ = true;
}
const bool throttle_lift = previous_pedal_ > 0.08f && pedal <= 0.005f && rpm > overrun_rpm;
if (throttle_lift)
lift_enrichment_ = 1.0f;
previous_pedal_ = pedal;
lift_enrichment_ *= std::exp(-dt / 0.32f);
if (pedal > 0.005f || rpm < overrun_exit_rpm)
overrun_ = false;
else if (throttle_lift)
overrun_ = true;
const float air_alpha = -std::expm1(-dt / 0.012f);
filtered_air_flow_ += (air_mass_flow - filtered_air_flow_) * air_alpha;
// The open slide controls the effective venturi area. Its local dynamic
// pressure and the measured static-pressure loss provide the jet signal.
const float venturi_area = throat_area_ * 0.55f * (0.10f + 0.90f * slide);
const float velocity = filtered_air_flow_ / std::max(air_density * venturi_area, 1e-9f);
const float dynamic_pressure = 0.5f * air_density * velocity * velocity;
const float static_drop = std::max(upstream_pressure - downstream_pressure, 0.0f);
venturi_depression_ = std::clamp(dynamic_pressure + 0.18f * static_drop, 0.0f, 60000.0f);
const float pilot_area = circle_area(pilot_jet_diameter);
const float main_area = circle_area(main_jet_diameter);
const float needle_jet_area = circle_area(needle_jet_diameter);
const float needle_lift = smoothstep((slide - 0.04f) / 0.86f);
const float needle_area = needle_jet_area * needle_lift;
const float main_path_area = needle_area > 1e-12f ?
1.0f / std::sqrt(1.0f / (main_area * main_area) + 1.0f / (needle_area * needle_area)) : 0.0f;
const float pilot_depression = bowl_head_pressure + (0.62f * static_drop +
0.25f * dynamic_pressure) * (1.0f + 0.25f * lift_enrichment_);
const float main_depression = bowl_head_pressure + venturi_depression_;
const float pilot_flow = pilot_discharge_coefficient * pilot_area *
std::sqrt(2.0f * fuel_density * std::max(pilot_depression, 0.0f));
const float main_flow = main_discharge_coefficient * main_path_area *
std::sqrt(2.0f * fuel_density * std::max(main_depression, 0.0f));
float demanded_fuel_flow = pilot_flow + main_flow;
const float deposit_fraction = std::clamp(film_fraction * (0.55f + 0.45f * slide), 0.0f, 0.8f);
const float deposited = demanded_fuel_flow * deposit_fraction;
const float evaporation_time = film_time_constant * (overrun_ ? 0.58f : 1.0f);
const float evaporated = std::min(film_mass_ / dt,
film_mass_ / std::max(evaporation_time, 1e-4f));
float jet_flow = demanded_fuel_flow;
const float available_flow = bowl_mass_ / dt;
if (jet_flow > available_flow) {
const float scale = available_flow / std::max(jet_flow, 1e-12f);
jet_flow *= scale;
}
bowl_mass_ -= jet_flow * dt;
bowl_mass_ += std::min(bowl_refill_rate * dt, bowl_capacity - bowl_mass_);
bowl_mass_ = std::clamp(bowl_mass_, 0.0f, bowl_capacity);
// Deposition cannot exceed the fuel that actually left the bowl.
const float actual_deposit = std::min(deposited, jet_flow);
film_mass_ = std::max(film_mass_ + (actual_deposit - evaporated) * dt, 0.0f);
delivered_fuel_flow_ = std::max(jet_flow - actual_deposit + evaporated, 0.0f);
const float fuel_alpha = -std::expm1(-dt / 0.025f);
filtered_fuel_flow_ += (delivered_fuel_flow_ - filtered_fuel_flow_) * fuel_alpha;
if (filtered_air_flow_ > 1e-7f && filtered_fuel_flow_ > 1e-9f)
indicated_afr_ = std::clamp(filtered_air_flow_ / filtered_fuel_flow_, 4.0f, 40.0f);
}
} // namespace fluidsim::port

View File

@@ -51,18 +51,25 @@ void Crankcase::pre_step(float /*dt*/) {
}
void Crankcase::update_state(float dt) {
float delta_mass = 0.0f, delta_air_mass = 0.0f, delta_energy = 0.0f;
float delta_mass = 0.0f, delta_air_mass = 0.0f, delta_fuel_mass = 0.0f, delta_energy = 0.0f;
for (Port* port : {&intake_port, &transfer_port}) {
const float flow = port->mass_flow;
const float fraction = flow >= 0.0f ? port->air_fraction : air_fraction_;
const float fuel = flow >= 0.0f ? port->fuel_fraction : fuel_fraction_;
delta_mass += flow * dt;
delta_air_mass += flow * fraction * dt;
delta_fuel_mass += flow * fuel * dt;
delta_energy += flow * port->enthalpy * dt;
}
const float old_air_mass = mass_ * air_fraction_;
const float old_fuel_mass = mass_ * fuel_fraction_;
mass_ += delta_mass;
energy_ += delta_energy;
if (mass_ > 1e-12f) air_fraction_ = std::clamp((old_air_mass + delta_air_mass) / mass_, 0.0f, 1.0f);
if (mass_ > 1e-12f) {
air_fraction_ = std::clamp((old_air_mass + delta_air_mass) / mass_, 0.0f, 1.0f);
fuel_fraction_ = std::clamp((old_fuel_mass + delta_fuel_mass) / mass_,
0.0f, 1.0f - air_fraction_);
}
if (!(mass_ > 0.0f) || !(energy_ > 0.0f) || !std::isfinite(mass_) || !std::isfinite(energy_))
throw std::runtime_error("nonphysical crankcase state");
@@ -80,6 +87,7 @@ void Crankcase::sync_ports() {
port->temperature = temperature_;
port->enthalpy = enthalpy;
port->air_fraction = air_fraction_;
port->fuel_fraction = fuel_fraction_;
}
}

View File

@@ -57,14 +57,15 @@ void draw_label(sf::RenderWindow& window, const sf::Font* font, const std::strin
}
std::optional<ScenarioKind> choose_scenario(sf::RenderWindow& window, const sf::Font* font) {
const std::array<sf::FloatRect, 4> cards{{{{32, 110}, {656, 84}}, {{32, 206}, {656, 84}}, {{32, 302}, {656, 84}}, {{32, 398}, {656, 84}}}};
const sf::FloatRect start_button{{32, 506}, {656, 52}};
const std::array<const char*, 4> titles{"600 cc parallel twin", "YZ125 two-stroke", "Saab B205 / 9-5 wagon", "600 cc twin + VZ21-size turbo"};
const std::array<const char*, 4> descriptions{
const std::array<sf::FloatRect, 5> cards{{{{32, 110}, {656, 72}}, {{32, 190}, {656, 72}}, {{32, 270}, {656, 72}}, {{32, 350}, {656, 72}}, {{32, 430}, {656, 72}}}};
const sf::FloatRect start_button{{32, 522}, {656, 52}};
const std::array<const char*, 5> titles{"600 cc parallel twin", "YZ125 two-stroke", "Saab B205 / 9-5 wagon", "600 cc twin + VZ21-size turbo", "1980s American 5.0 V8"};
const std::array<const char*, 5> descriptions{
"Four-stroke twin, motorcycle test and dyno",
"125 cc single, expansion chamber, motorcycle test and dyno",
"2.0 litre inline-four, GT17 turbo, ECU boost control and five-speed wagon",
"Coupled turbine/compressor, shaft inertia and pressure diagnostics"};
"Coupled turbine/compressor, shaft inertia and pressure diagnostics",
"EFI cross-plane V8, heavy flywheel, independent exhaust banks"};
int selected = 0;
while (window.isOpen()) {
while (const auto event = window.pollEvent()) {
@@ -72,26 +73,27 @@ std::optional<ScenarioKind> choose_scenario(sf::RenderWindow& window, const sf::
if (const auto* key = event->getIf<sf::Event::KeyPressed>()) {
switch (key->code) {
case sf::Keyboard::Key::Escape: return std::nullopt;
case sf::Keyboard::Key::Up: selected = (selected + 3) % 4; break;
case sf::Keyboard::Key::Up: selected = (selected + 4) % 5; break;
case sf::Keyboard::Key::Down:
case sf::Keyboard::Key::Tab: selected = (selected + 1) % 4; break;
case sf::Keyboard::Key::Tab: selected = (selected + 1) % 5; break;
case sf::Keyboard::Key::Num1: selected = 0; break;
case sf::Keyboard::Key::Num2: selected = 1; break;
case sf::Keyboard::Key::Num3: selected = 2; break;
case sf::Keyboard::Key::Num4: selected = 3; break;
case sf::Keyboard::Key::Num5: selected = 4; break;
case sf::Keyboard::Key::Enter: return static_cast<ScenarioKind>(selected);
default: break;
}
}
if (const auto* move = event->getIf<sf::Event::MouseMoved>()) {
const auto point = window.mapPixelToCoords(move->position);
for (int i = 0; i < 4; ++i)
for (int i = 0; i < 5; ++i)
if (cards[i].contains(point)) selected = i;
}
if (const auto* click = event->getIf<sf::Event::MouseButtonReleased>();
click && click->button == sf::Mouse::Button::Left) {
const auto point = window.mapPixelToCoords(click->position);
for (int i = 0; i < 4; ++i)
for (int i = 0; i < 5; ++i)
if (cards[i].contains(point)) return static_cast<ScenarioKind>(i);
if (start_button.contains(point)) return static_cast<ScenarioKind>(selected);
}
@@ -99,7 +101,7 @@ std::optional<ScenarioKind> choose_scenario(sf::RenderWindow& window, const sf::
window.clear({9, 12, 17});
draw_label(window, font, "FluidSim", {32, 24}, sf::Color::White, 28);
draw_label(window, font, "Choose a scenario to start", {32, 66}, {180, 190, 205}, 16);
for (int i = 0; i < 4; ++i) {
for (int i = 0; i < 5; ++i) {
sf::RectangleShape card(cards[i].size);
card.setPosition(cards[i].position);
card.setFillColor(i == selected ? sf::Color(24, 46, 60) : sf::Color(18, 24, 32));
@@ -115,7 +117,7 @@ std::optional<ScenarioKind> choose_scenario(sf::RenderWindow& window, const sf::
start.setPosition(start_button.position);
start.setFillColor({35, 105, 115});
window.draw(start);
draw_label(window, font, "Start selected scenario [Enter]", {52, 519}, sf::Color::White, 17);
draw_label(window, font, "Start selected scenario [Enter]", {52, 537}, sf::Color::White, 17);
draw_label(window, font, "Click a scenario or use Up/Down. Esc: quit", {32, 574}, {150, 165, 180}, 12);
window.display();
}
@@ -296,13 +298,13 @@ void draw_dyno_curve(sf::RenderWindow& window, const sf::Font* font, const DynoC
void draw_dashboard(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state,
float throttle_target, float brake, double playback_speed, std::size_t buffer_fill,
std::size_t buffer_capacity, bool time_warp, std::uint64_t underrun_blocks) {
std::size_t buffer_capacity, bool time_warp, std::uint64_t underrun_blocks, bool auto_gain) {
std::ostringstream status;
status << (time_warp ? "[Space] normal speed" : "[Space] real time")
<< " Sim " << std::fixed << std::setprecision(3) << playback_speed << "x"
<< " Audio buffer " << buffer_fill << "/" << buffer_capacity
<< " Underruns " << underrun_blocks
<< (state.cylinder_count == 4 ? "\nWagon " : "\nBike ") << std::setprecision(1) << state.speed_kmh << " km/h Gear "
<< (state.cylinder_count == 8 ? "\nCoupe " : state.cylinder_count == 4 ? "\nWagon " : "\nBike ") << std::setprecision(1) << state.speed_kmh << " km/h Gear "
<< (state.gear == 0 ? "N" : std::to_string(state.gear))
<< " Throttle " << state.throttle * 100.0f << "% / target " << throttle_target * 100.0f << "% [W, up/down]"
<< " Clutch " << state.clutch * 100.0f << "% [C] Engine load " << brake * 100.0f << "% [left/right]"
@@ -310,6 +312,7 @@ void draw_dashboard(sf::RenderWindow& window, const sf::Font* font, const Scenar
<< "\nScene [R]: " << scene_name(state.audio_scene) << " " << (state.fuel_cut ? "FUEL CUT" : (state.idle_control ? "IDLE CONTROL" : "RUNNING"));
if (!state.two_stroke)
status << " Mechanical [M]: " << (state.mechanical_sound_enabled ? "ON" : "OFF");
status << " Auto gain [G]: " << (auto_gain ? "ON" : "OFF");
if (font) {
sf::RectangleShape panel({1260.0f, 92.0f});
panel.setPosition({10.0f, 10.0f});
@@ -350,7 +353,7 @@ void draw_two_stroke_scene(sf::RenderWindow& window,const sf::Font* font,const S
draw_label(window,font,"CRANKCASE",{455,385},sf::Color::White,14);
draw_pipe(window,s,0,460,30,170);
draw_volume(window,s.intake_pressure,{{180,425},{95,70}});
draw_label(window,font,"INTAKE",{180,400},sf::Color::White,14);
draw_label(window,font,"CARB / INTAKE",{180,400},sf::Color::White,14);
draw_pipe(window,s,1,460,285,445);
draw_label(window,font,s.reed_open?"REED OPEN":"REED CLOSED",{290,485},s.reed_open?sf::Color::Green:sf::Color::Red,12);
draw_pipe(window,s,2,350,315,450);
@@ -365,7 +368,8 @@ void draw_two_stroke_scene(sf::RenderWindow& window,const sf::Font* font,const S
draw_label(window,font,s.racing_exhaust ? "HEADER / DIFFUSERS / BELLY / BAFFLE / STINGER" : "STRAIGHT EXHAUST",{670,205},sf::Color::White,13);
std::ostringstream pressures;
pressures<<std::fixed<<std::setprecision(1)<<"Cylinder "<<s.cylinder_pressure1/1000<<" kPa Crankcase "
<<s.crankcase_pressure/1000<<" kPa Intake "<<s.intake_pressure/1000<<" kPa";
<<s.crankcase_pressure/1000<<" kPa Intake "<<s.intake_pressure/1000<<" kPa Carb AFR "
<<s.target_afr<<":1";
draw_label(window,font,pressures.str(),{22,580},sf::Color::White,17);
draw_label(window,font,"54 x 54.5 mm | 8.2:1 trapped compression | estimated pipe/ports/CDI | fixed-open power valve",
{22,625},{180,190,200},11);
@@ -459,14 +463,38 @@ void draw_b205_scene(sf::RenderWindow& window, const sf::Font* font, const Scena
void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state,
float throttle_target, float brake, double playback_speed, std::size_t buffer_fill,
std::size_t buffer_capacity, bool time_warp, DynoCurve& dyno_curve, std::uint64_t underrun_blocks) {
std::size_t buffer_capacity, bool time_warp, DynoCurve& dyno_curve, std::uint64_t underrun_blocks, bool auto_gain) {
const float power_kw = state.average_power / 1000.0f;
dyno_curve.update(state.rpm, power_kw, state.average_torque);
if (state.two_stroke) draw_two_stroke_scene(window, font, state);
if (state.cylinder_count == 8) {
window.clear({9,12,17});
draw_label(window, font, "American 5.0 V8 | EFI / ECU | 7000 RPM | heavy flywheel", {25,185}, sf::Color::White, 16);
draw_pipe(window, state, 0, 205, 25, 115);
draw_volume(window, state.intake_pressure, {{120,200},{60,25}});
draw_label(window, font, "ONE THROTTLE / SHARED PLENUM", {850,225}, sf::Color::White, 12);
for (int i = 0; i < 8; ++i) {
const float x = 70 + (i%4)*185;
const float top = i < 4 ? 230.f : 455.f;
const std::string name = "CYL " + std::to_string(i+1);
draw_cylinder(window, font, state.cylinder_pressures[i], state.piston_fractions[i], top,
name.c_str(), state.intake_areas[i]>1e-7f, state.exhaust_areas[i]>1e-7f, x);
draw_pipe(window, state, 9+i, top+150, x-10, x+90);
draw_pipe(window, state, 1+i, top+125, x-10, x+90);
draw_graph_line(window, {x+90,top+150}, {830,top+150}, {200,120,80});
}
draw_graph_line(window, {830,380}, {850,300}, {200,120,80});
draw_graph_line(window, {830,605}, {850,375}, {200,120,80});
draw_pipe(window, state, 17, 300, 850, 1250);
draw_pipe(window, state, 18, 375, 850, 1250);
draw_label(window, font, "SEPARATE BANK COLLECTORS / DUAL TAILS", {850,410}, sf::Color::White, 11);
draw_label(window, font, "EFI AFR " + std::to_string(state.target_afr), {850,430}, sf::Color::White, 13);
draw_label(window, font, "302-class | shared 4 L intake, 12 cm runners | 7:1 compression | 750 rpm idle", {25,690}, {180,190,205}, 12);
}
else if (state.two_stroke) draw_two_stroke_scene(window, font, state);
else if (state.cylinder_count == 4) draw_b205_scene(window, font, state);
else draw_twin_scene(window, font, state);
draw_dashboard(window, font, state, throttle_target, brake, playback_speed, buffer_fill,
buffer_capacity, time_warp, underrun_blocks);
buffer_capacity, time_warp, underrun_blocks, auto_gain);
draw_dyno_curve(window, font, dyno_curve, 860, 455, 400, 250, state.rpm, power_kw);
window.display();
}
@@ -486,8 +514,8 @@ int main(int argc, char** argv) {
if((option=="--scenario"||option=="--scene"||option=="--exhaust"||option=="--turbo")&&i+1<argc){
const std::string value=argv[++i];
if(option=="--scenario"){
if(value!="twin"&&value!="two-stroke"&&value!="b205"){std::cerr<<"Choose twin, two-stroke or b205\n";return 2;}
selected_kind=value=="b205" ? ScenarioKind::SaabB205 :
if(value!="twin"&&value!="two-stroke"&&value!="b205"&&value!="v8"){std::cerr<<"Choose twin, two-stroke, b205 or v8\n";return 2;}
selected_kind=value=="v8" ? ScenarioKind::AmericanV8 : value=="b205" ? ScenarioKind::SaabB205 :
(value=="two-stroke" ? ScenarioKind::TwoStroke : ScenarioKind::Twin);
scenario_from_args=true;
}else if(option=="--turbo"){
@@ -505,7 +533,7 @@ int main(int argc, char** argv) {
else if(value=="woodland")selected_scene=OutdoorScene::Woodland;
else {std::cerr<<"Choose dry, field, wall or woodland\n";return 2;}
}
}else{std::cerr<<"Usage: fluidsim_desktop [--scenario twin|two-stroke|b205] [--scene dry|field|wall|woodland] [--exhaust racing|straight] [--turbo none|vz21|gt17]\n";return 2;}
}else{std::cerr<<"Usage: fluidsim_desktop [--scenario twin|two-stroke|b205|v8] [--scene dry|field|wall|woodland] [--exhaust racing|straight] [--turbo none|vz21|gt17]\n";return 2;}
}
const sf::Vector2u initial_size = scenario_from_args ? sf::Vector2u{window_width, window_height}
: sf::Vector2u{720, 606};
@@ -542,7 +570,7 @@ int main(int argc, char** argv) {
std::cerr<<"Use gt17 with b205 or vz21 with twin\n";return 2;
}
}
window.setTitle(selected_kind == ScenarioKind::TurboTwin ? "FluidSim - twin + VZ21 turbo" : selected_kind == ScenarioKind::SaabB205 ? "FluidSim - Saab B205 / 9-5 wagon" :
window.setTitle(selected_kind == ScenarioKind::AmericanV8 ? "FluidSim - 1980s American 5.0 V8" : selected_kind == ScenarioKind::TurboTwin ? "FluidSim - twin + VZ21 turbo" : selected_kind == ScenarioKind::SaabB205 ? "FluidSim - Saab B205 / 9-5 wagon" :
(selected_kind == ScenarioKind::TwoStroke ? "FluidSim - YZ125 two-stroke" : "FluidSim - parallel twin"));
DesktopScenario scenario(selected_kind, two_stroke_config, turbo_name!="none");
scenario.set_audio_scene(selected_scene);
@@ -600,6 +628,7 @@ int main(int argc, char** argv) {
scenario.set_audio_scene(selected_scene);
break;
case sf::Keyboard::Key::M: scenario.toggle_mechanical_sound(); break;
case sf::Keyboard::Key::G: audio.set_auto_gain(!audio.auto_gain()); break;
default: break;
}
}
@@ -629,7 +658,7 @@ int main(int argc, char** argv) {
scenario.set_controls(throttle_current, brake_current, clutch_current);
draw_scene(window, font_ptr, scenario.snapshot(), throttle_target, brake_current,
current_speed, ring.available_samples(), ring_capacity, time_warp, dyno_curve, audio.underrun_blocks());
current_speed, ring.available_samples(), ring_capacity, time_warp, dyno_curve, audio.underrun_blocks(), audio.auto_gain());
}
simulation.request_stop();

View File

@@ -51,8 +51,12 @@ void Ecu::step(float dt, float pedal, float rpm) {
const float idle_command = idle_ ? std::clamp(idle_base_opening + idle_kp * error + integral_,
0.0f, idle_max_opening) : 0.0f;
target_ = std::max(pedal, idle_command);
const float change = (target_ - opening_) * (-std::expm1(-dt / std::max(throttle_time_constant, 1e-4f)));
const float travel = std::max(throttle_rate, 0.0f) * dt;
const bool closing = target_ < opening_;
const float response = closing && throttle_closing_time_constant > 0 ?
throttle_closing_time_constant : throttle_time_constant;
const float rate = closing && throttle_closing_rate > 0 ? throttle_closing_rate : throttle_rate;
const float change = (target_ - opening_) * (-std::expm1(-dt / std::max(response, 1e-4f)));
const float travel = std::max(rate, 0.0f) * dt;
opening_ = std::clamp(opening_ + std::clamp(change, -travel, travel), 0.0f, 1.0f);
const float enrichment = std::clamp((opening_ - 0.7f) / 0.3f, 0.0f, 1.0f);
afr_ = stoichiometric_afr + (power_afr - stoichiometric_afr) * enrichment;

View File

@@ -50,7 +50,7 @@ float EngineCylinder::clearance_volume() const {
}
float EngineCylinder::total_mass() const {
return air_mass + exhaust_mass;
return air_mass + exhaust_mass + fuel_mass;
}
float EngineCylinder::temperature() const {
@@ -79,11 +79,13 @@ void EngineCylinder::initialize(const Crankshaft& /*crank*/) {
volume = clearance_volume();
air_mass = 1.225f * volume;
exhaust_mass = 0.0f;
fuel_mass = 0.0f;
energy = 101325.0f * volume / (gamma - 1.0f);
pressure = 101325.0f;
intake.owner_energy = exhaust.owner_energy = &energy;
intake.owner_air_mass = exhaust.owner_air_mass = &air_mass;
intake.owner_exhaust_mass = exhaust.owner_exhaust_mass = &exhaust_mass;
intake.owner_fuel_mass = exhaust.owner_fuel_mass = &fuel_mass;
sync_ports();
}
@@ -140,8 +142,10 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
const float previous_degrees = wrap_degrees((crank.previous_angle + phase) * 180.0f / pi);
const float current_degrees = wrap_degrees(theta * 180.0f / pi);
if (crossed_window_close(previous_degrees, current_degrees, intake_open, intake_close)) {
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
fuel_injected = fuel_mass > 0.0f;
if (!transported_fuel)
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
const float charge_afr = air_mass / std::max(fuel_mass, 1e-12f);
fuel_injected = fuel_mass > 0.0f && (!transported_fuel || (charge_afr >= 7 && charge_afr <= 24));
}
const float spark_angle = wrap_degrees(720.0f - spark_advance);
@@ -149,6 +153,7 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
if (crossed_spark && fuel_injected && !combustion_active && !(ecu ? ecu->fuel_cut() : rev_limit_active) && random_unit() >= misfire_probability) {
combustion_active = true;
burn_fraction = 0.0f;
active_fuel_mass_ = transported_fuel ? std::min(fuel_mass, air_mass / 14.7f) : fuel_mass;
energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.0f);
}
@@ -161,14 +166,23 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
if (fraction >= 1.0f || angle_since_spark > burn_duration + burn_start + spark_advance) {
fraction = 1.0f;
combustion_active = false;
exhaust_mass += air_mass;
air_mass = 0.0f;
if (!transported_fuel) {
exhaust_mass += air_mass;
air_mass = 0.0f;
}
}
fuel_injected = false;
const float increment = fraction - burn_fraction;
if (increment > 0.0f) {
energy += fuel_mass * fuel_lhv * energy_factor_ * increment;
exhaust_mass += fuel_mass * increment;
const float burned_fuel = std::min(active_fuel_mass_ * increment, fuel_mass);
energy += burned_fuel * fuel_lhv * energy_factor_;
fuel_mass -= burned_fuel;
exhaust_mass += burned_fuel;
if (transported_fuel) {
const float burned_air = std::min(air_mass, burned_fuel * 14.7f);
air_mass -= burned_air;
exhaust_mass += burned_air;
}
burn_fraction = fraction;
}
}
@@ -184,16 +198,20 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
}
void EngineCylinder::update_state(float dt) {
float delta_air = 0.0f, delta_exhaust = 0.0f, delta_energy = 0.0f;
float delta_air = 0.0f, delta_exhaust = 0.0f, delta_fuel = 0.0f, delta_energy = 0.0f;
for (Port* port : {&intake, &exhaust}) {
const float mass_flow = port->mass_flow;
const float fraction = mass_flow >= 0.0f ? port->air_fraction : air_fraction();
const float fuel_fraction = mass_flow >= 0.0f ? port->fuel_fraction :
fuel_mass / std::max(total_mass(), 1e-12f);
delta_air += mass_flow * fraction * dt;
delta_exhaust += mass_flow * (1.0f - fraction) * dt;
delta_fuel += mass_flow * fuel_fraction * dt;
delta_exhaust += mass_flow * std::max(1.0f - fraction - fuel_fraction, 0.0f) * dt;
delta_energy += mass_flow * port->enthalpy * dt;
}
air_mass += delta_air;
exhaust_mass += delta_exhaust;
fuel_mass += delta_fuel;
energy += delta_energy;
const float current_volume = std::max(volume, 1e-12f);
@@ -211,12 +229,14 @@ void EngineCylinder::update_state(float dt) {
if (total_mass() < 1e-9f) {
air_mass = 1e-9f;
exhaust_mass = 0.0f;
fuel_mass = 0.0f;
energy = 101325.0f * current_volume / (gamma - 1.0f);
} else if (energy < 0.0f) {
energy = 101325.0f * current_volume / (gamma - 1.0f);
}
air_mass = std::max(air_mass, 0.0f);
exhaust_mass = std::max(exhaust_mass, 0.0f);
fuel_mass = std::max(fuel_mass, 0.0f);
pressure = (gamma - 1.0f) * energy / current_volume;
sync_ports();
}
@@ -257,12 +277,14 @@ void EngineCylinder::sync_ports() {
pressure = (gamma - 1.0f) * energy / std::max(volume, 1e-12f);
const float temp = pressure / std::max(density * gas_constant, 1e-12f);
const float fraction = air_fraction();
const float fuel_fraction = fuel_mass / std::max(total_mass(), 1e-12f);
const float enthalpy = gamma / (gamma - 1.0f) * pressure / std::max(density, 1e-12f);
for (Port* port : {&intake, &exhaust}) {
port->pressure = pressure;
port->density = density;
port->temperature = temp;
port->air_fraction = fraction;
port->fuel_fraction = fuel_fraction;
port->enthalpy = enthalpy;
}
}

View File

@@ -46,10 +46,10 @@ int main(int argc, char** argv) {
constexpr int sample_rate = 44100;
const auto total_samples = static_cast<std::uint64_t>(duration_seconds * sample_rate);
const auto report_interval = static_cast<std::uint64_t>(sample_rate / 10);
if (scenario_name == "twin" || scenario_name == "b205") {
FourStrokeScenario scenario(sample_rate, scenario_name == "b205" ? FourStrokePreset::SaabB205 : FourStrokePreset::Twin, turbo_enabled);
if (scenario_name == "twin" || scenario_name == "b205" || scenario_name == "v8") {
FourStrokeScenario scenario(sample_rate, scenario_name == "v8" ? FourStrokePreset::AmericanV8 : scenario_name == "b205" ? FourStrokePreset::SaabB205 : FourStrokePreset::Twin, turbo_enabled);
scenario.throttle = throttle;
std::cout << (scenario_name == "b205" ? "FluidSimCpp | Saab B205 / 9-5 wagon | throttle " : "FluidSimCpp | 600 cc parallel twin | throttle ") << std::fixed << std::setprecision(0)
std::cout << (scenario_name == "v8" ? "FluidSimCpp | American 5.0 V8 | throttle " : scenario_name == "b205" ? "FluidSimCpp | Saab B205 / 9-5 wagon | throttle " : "FluidSimCpp | 600 cc parallel twin | throttle ") << std::fixed << std::setprecision(0)
<< throttle * 100.0f << "%\n";
for (std::uint64_t i = 0; i < total_samples; ++i) {
scenario.process();
@@ -116,7 +116,7 @@ int main(int argc, char** argv) {
}
} else {
std::cerr << "Unknown scenario '" << scenario_name
<< "'. Choose twin, two-stroke, b205, helmholtz, or pulse.\n";
<< "'. Choose twin, two-stroke, b205, v8, helmholtz, or pulse.\n";
return 2;
}
return 0;

View File

@@ -47,6 +47,7 @@ OutdoorAudio::OutdoorAudio(int rate, OutdoorAudioConfig config) : rate_(rate), c
config_.listener_right.z / norm};
configure_emitter(emitters_[0], config_.intake_position);
configure_emitter(emitters_[1], config_.exhaust_position);
if (config_.dual_exhaust) configure_emitter(emitters_[2], config_.second_exhaust_position);
constexpr std::array<int, 4> lengths{1499, 2111, 2633, 3251};
for (std::size_t i = 0; i < tail_.size(); ++i) {
auto &line = tail_[i];
@@ -148,8 +149,8 @@ AudioFrame OutdoorAudio::diffuse(float input) {
}
return {(values[0] + values[2]) * .35f, (values[1] + values[3]) * .35f};
}
AudioFrame OutdoorAudio::process(float intake, float exhaust) {
if (!std::isfinite(intake) || !std::isfinite(exhaust))
AudioFrame OutdoorAudio::process(float intake, float exhaust, float second_exhaust) {
if (!std::isfinite(intake) || !std::isfinite(exhaust) || !std::isfinite(second_exhaust))
throw std::runtime_error("nonfinite acoustic source flow");
const bool wet = scene_ != OutdoorScene::Dry;
const std::array<float, 4> target{1, wet ? 1.f : 0.f, scene_ == OutdoorScene::Wall ? 1.f : 0.f,
@@ -165,7 +166,8 @@ AudioFrame OutdoorAudio::process(float intake, float exhaust) {
last_raw_ = {};
const auto a = render(emitters_[0], intake, last_dry_, last_raw_);
const auto b = render(emitters_[1], exhaust, last_dry_, last_raw_);
const auto c = config_.dual_exhaust ? render(emitters_[2], second_exhaust, last_dry_, last_raw_) : AudioFrame{};
const auto tail = diffuse((last_dry_.left + last_dry_.right) * .5f);
return {a.left + b.left + tail.left * tail_mix_, a.right + b.right + tail.right * tail_mix_};
return {a.left + b.left + c.left + tail.left * tail_mix_, a.right + b.right + c.right + tail.right * tail_mix_};
}
} // namespace fluidsim::port

View File

@@ -7,11 +7,17 @@ namespace fluidsim::port {
FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selected_preset, bool turbo_enabled)
: preset(selected_preset),
intake_plenum(selected_preset == FourStrokePreset::SaabB205 ? 3e-3f : 1e-3f, 101325, 300),
exhaust_collector(selected_preset == FourStrokePreset::SaabB205 ? .6e-3f : .15e-3f, 101325, 700),
intake_plenum(selected_preset == FourStrokePreset::AmericanV8 ? 4e-3f : selected_preset == FourStrokePreset::SaabB205 ? 3e-3f : 1e-3f, 101325, 300),
exhaust_collector(selected_preset == FourStrokePreset::AmericanV8 ? 1.5e-3f : selected_preset == FourStrokePreset::SaabB205 ? .6e-3f : .15e-3f, 101325, 700),
pipes(create_pipes(selected_preset, turbo_enabled)), dt_(1.f / std::max(sample_rate, 1)),
boundaries_(pipes), solver_(pipes, boundaries_), audio_(sample_rate), mechanical_sound_(sample_rate) {
boundaries_(pipes), solver_(pipes, boundaries_), audio_(sample_rate, [&] {
OutdoorAudioConfig config;
config.dual_exhaust = selected_preset == FourStrokePreset::AmericanV8;
return config;
}()), mechanical_sound_(sample_rate) {
const bool saab = preset == FourStrokePreset::SaabB205;
const bool v8 = preset == FourStrokePreset::AmericanV8;
if (v8 && turbo_enabled) throw std::invalid_argument("American V8 preset is naturally aspirated");
const int count = cylinder_count();
crankshaft.omega = (saab ? 1200 : 2000) * 2 * pi / 60;
crankshaft.inertia = saab ? .18f : .04f;
@@ -36,13 +42,54 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selecte
ecu.overrun_cut_rpm = 2000;
ecu.overrun_resume_rpm = 1300;
}
if (v8) {
// Estimated mild 1980s 302-class pushrod small block and manual coupe.
crankshaft.omega = 1000 * 2*pi/60;
crankshaft.inertia = 1.1f;
crankshaft.friction_constant = 22;
crankshaft.friction_viscous = .025f;
vehicle.mass = 3000;
vehicle.primary_reduction = 1;
vehicle.final_drive_ratio = 6.16f / 1.7f; // 1.7x taller gearing in every gear.
vehicle.gear_ratios = {3.35f, 1.93f, 1.29f, 1.f, .68f};
vehicle.wheel_radius = .32f;
vehicle.clutch_capacity = 1100; // Hold V8 torque with the heavy flywheel/car.
vehicle.drag_coefficient = .4f;
vehicle.frontal_area = 2.f;
ecu.idle_rpm = 750;
ecu.throttle_time_constant = .065f;
ecu.throttle_rate = 2.3f;
ecu.throttle_closing_time_constant = .16f;
ecu.throttle_closing_rate = 1.4f;
ecu.idle_base_opening = .08f;
ecu.idle_spark_gain = .02f;
ecu.ignition_map = {{0, 10}, {750, 12}, {2500, 28}, {4000, 32}, {6000, 30}};
ecu.rev_limit_rpm = 7000;
ecu.rev_resume_rpm = 6750;
ecu.overrun_cut_rpm = 2000;
ecu.overrun_resume_rpm = 1100;
// One minimum substep; acoustic CFL still subdivides when needed.
solver_.minimum_substeps = 1;
}
vehicle.set_neutral();
vehicle.clutch_input = clutch;
const auto engines = cylinders();
const std::array<float, 4> phases{0, pi, 3*pi, 2*pi}; // I4 firing: 1-3-4-2
const std::array<float, 8> phases{0, pi, 3*pi, 2*pi}; // I4 firing: 1-3-4-2
const std::array<int, 8> firing_order{1, 5, 4, 2, 6, 3, 7, 8};
for (int i = 0; i < count; ++i) {
auto& c = *engines[i];
c.phase = phases[i];
if (v8) {
const auto position = std::find(firing_order.begin(), firing_order.end(), i+1) - firing_order.begin();
c.phase = std::fmod(4*pi - float(position)*pi*.5f, 4*pi);
c.bore = .1016f;
c.stroke = .0762f;
c.rod = .129f;
c.compression = 7.0f;
c.intake_open = 350; c.intake_close = 570;
c.exhaust_open = 140; c.exhaust_close = 370;
c.burn_duration = 65;
}
if (saab) {
c.bore = .090f;
c.stroke = .078f;
@@ -54,13 +101,17 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selecte
c.intake_valve_lift = saab ? .0085f : .008f;
c.exhaust_valve_diameter = saab ? .028f : .026f;
c.exhaust_valve_lift = saab ? .008f : .0075f;
if (v8) {
c.intake_valve_diameter = .045f; c.exhaust_valve_diameter = .037f;
c.intake_valve_lift = .010f; c.exhaust_valve_lift = .010f;
}
c.rev_limit_enabled = true;
c.rev_limit_rpm = ecu.rev_limit_rpm;
c.rev_limit_hysteresis = 250;
c.rev_limit_cut_time = .020f;
c.ecu = &ecu;
c.initialize(crankshaft);
if (saab) {
if (saab || v8) {
// Initialize each phased cylinder at its actual piston position.
c.volume = c.compute_volume(c.phase);
c.air_mass = ambient_density * c.volume;
@@ -80,6 +131,7 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selecte
solver_.add_component(intake_atmosphere);
solver_.add_component(intake_plenum);
solver_.add_component(exhaust_collector);
if (v8) solver_.add_component(second_bank_collector);
if (turbo_enabled) {
turbo = std::make_unique<Turbocharger>(saab ? TurboConfig::gt17() : TurboConfig{});
ecu.boost_control_enabled = saab;
@@ -93,7 +145,8 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selecte
boundaries_.add_orifice(throttle_port, 0, false, throttle_area_, .7f);
for (int i = 0; i < count; ++i) {
auto& feed = intake_plenum.create_port();
auto& collector_in = exhaust_collector.create_port();
// 1--4 and 5--8 are separate physical banks for this firing order.
auto& collector_in = (v8 && i >= 4 ? second_bank_collector : exhaust_collector).create_port();
solver_.add_component(*engines[i]);
boundaries_.add_orifice(feed, 1+i, true, runner_area_);
boundaries_.add_orifice(engines[i]->intake, 1+i, false, intake_valve_areas_[i]);
@@ -104,11 +157,16 @@ FourStrokeScenario::FourStrokeScenario(int sample_rate, FourStrokePreset selecte
boundaries_.add_orifice(collector_out, 1+2*count, true, tail_area_);
const int exhaust_exit = turbo_enabled ? 3+2*count : 1+2*count;
boundaries_.add_open_end(exhaust_exit, false, 101325, pipes[exhaust_exit].area);
if (v8) {
auto& outlet = second_bank_collector.create_port();
boundaries_.add_orifice(outlet, 18, true, tail_area_);
boundaries_.add_open_end(18, false, 101325, pipes[18].area);
}
}
AudioFrame FourStrokeScenario::process() {
const std::lock_guard guard(mutex_);
step_motorcycle(crankshaft, dt_, preset == FourStrokePreset::SaabB205 ? 200.f : 30.f);
step_motorcycle(crankshaft, dt_, preset == FourStrokePreset::AmericanV8 ? 300.f : preset == FourStrokePreset::SaabB205 ? 200.f : 30.f);
ecu.step(dt_, throttle, crankshaft.omega * 60 / (2*pi));
if (turbo) {
ecu.control_boost(dt_, throttle, crankshaft.omega * 60/(2*pi), intake_plenum.pressure(), turbo->compressor_outlet.pressure());
@@ -122,12 +180,15 @@ AudioFrame FourStrokeScenario::process() {
intake_valve_areas_[i] = c.valve_area(degrees, true);
exhaust_valve_areas_[i] = c.valve_area(degrees, false);
}
const float angle = ecu.throttle_opening() * pi * .5f;
throttle_area_ = area_for_diameter(preset == FourStrokePreset::SaabB205 ? .052f : .026f) * (1-std::cos(angle));
const float opening = ecu.throttle_opening();
const float angle = opening * pi * .5f;
throttle_area_ = area_for_diameter(preset == FourStrokePreset::AmericanV8 ? .065f : preset == FourStrokePreset::SaabB205 ? .052f : .026f) * (1-std::cos(angle));
solver_.step(dt_);
intake_flow = intake_atmosphere.port.mass_flow;
exhaust_flow = boundaries_.open_end_mass_flow(0);
const auto rendered = audio_.process(intake_flow, exhaust_flow);
const float other_exhaust = preset == FourStrokePreset::AmericanV8 ? boundaries_.open_end_mass_flow(1) : 0.f;
const auto rendered = audio_.process(intake_flow, exhaust_flow, other_exhaust);
exhaust_flow += other_exhaust;
const float mechanical = mechanical_sound_enabled
? mechanical_sound_.process(crankshaft, cylinder1, load) * mechanical_mix : 0;
last_mix_l = rendered.left + mechanical;
@@ -222,7 +283,8 @@ ScenarioSnapshot FourStrokeScenario::snapshot() const {
result.mechanical_sound_enabled = mechanical_sound_enabled;
result.cylinder_count = cylinder_count();
result.rev_limit_rpm = ecu.rev_limit_rpm;
const std::array<const FourStrokeCylinder*, 4> engines{&cylinder1, &cylinder2, &cylinder3, &cylinder4};
const std::array<const FourStrokeCylinder*, 8> engines{&cylinder1, &cylinder2, &cylinder3, &cylinder4,
&cylinder5, &cylinder6, &cylinder7, &cylinder8};
for (int i = 0; i < cylinder_count(); ++i) {
result.cylinder_pressures[i] = engines[i]->pressure;
result.piston_fractions[i] = (engines[i]->volume-engines[i]->clearance_volume())/engines[i]->swept_volume();
@@ -239,6 +301,20 @@ float FourStrokeScenario::area_for_diameter(float diameter) {
std::vector<Pipe1D> FourStrokeScenario::create_pipes(FourStrokePreset preset, bool turbo_enabled) {
const bool saab = preset == FourStrokePreset::SaabB205;
if (preset == FourStrokePreset::AmericanV8) {
// One inlet/throttle -> shared plenum -> short individual runners.
// Independent exhaust banks. Cells stay long enough to avoid routine
// extra CFL substeps during high-vacuum coast-down.
std::vector<Pipe1D> result;
result.emplace_back(area_for_diameter(.070f), .18f, 3);
for (int i = 0; i < 8; ++i)
result.emplace_back(area_for_diameter(.038f), .12f, 2);
for (int i = 0; i < 8; ++i)
result.emplace_back(area_for_diameter(.040f), .60f + .06f*(i%4), 12);
result.emplace_back(area_for_diameter(.050f), 1.8f, 36);
result.emplace_back(area_for_diameter(.050f), 1.95f, 39);
return result;
}
const int count = saab ? 4 : 2;
std::vector<Pipe1D> result;
result.reserve(4+2*count);

View File

@@ -106,6 +106,7 @@ void Turbocharger::set_transfer(Volume0D& in, Volume0D& out, Port& ip, Port& op,
ip.mass_flow = float(-flow);
op.mass_flow = float(flow);
ip.air_fraction = op.air_fraction = donor.air_fraction();
ip.fuel_fraction = op.fuel_fraction = donor.fuel_fraction();
ip.enthalpy = op.enthalpy = float(h);
// Positive work means energy delivered to the shaft. Only the receiving
// fluid enthalpy changes; the donor loses precisely its stagnation enthalpy.

View File

@@ -1,3 +1,4 @@
#include "FluidSim/Carburetor.hpp"
#include "FluidSim/Ecu.hpp"
#include "FluidSim/TwoStrokeCylinder.hpp"
@@ -70,16 +71,22 @@ void TwoStrokeCylinder::pre_step(Crankshaft &crank, float dt, float /*throttle*/
void TwoStrokeCylinder::handle_cycle_events(float previous_degrees, float current_degrees) {
const float trapped_angle = std::max(transfer_close(), exhaust_close());
if (previous_degrees < trapped_angle && current_degrees >= trapped_angle) {
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
fuel_injected = fuel_mass > 0;
if (!carburetor_ && fuel_mass <= 0.0f)
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
const float charge_afr = air_mass / std::max(fuel_mass, 1e-12f);
fuel_injected = fuel_mass > 0.0f && charge_afr >= 7.0f && charge_afr <= 24.0f;
}
const float spark_angle = wrap_degrees(360.0f - spark_advance, 360.0f);
const bool crossed_spark = crossed_angle(previous_degrees,current_degrees,spark_angle);
if (crossed_spark && fuel_injected && !combustion_active && !(ecu ? ecu->fuel_cut() : rev_limit_active) &&
if (crossed_spark && fuel_injected && !combustion_active &&
!(ecu ? ecu->fuel_cut() : rev_limit_active) &&
random_unit() >= misfire_probability) {
combustion_active = true;
burn_fraction = 0.0f;
// Fuel is a transported species. Only the portion with enough oxygen
// can release heat; rich excess remains unburned and can reach the pipe.
active_fuel_mass_ = std::min(fuel_mass, air_mass / 14.7f);
++combustion_count_;
active_spark_angle_ = spark_angle;
// Freeze the duration and timing for this charge. Changing the ECU map
@@ -99,14 +106,16 @@ void TwoStrokeCylinder::handle_cycle_events(float previous_degrees, float curren
if (fraction >= 1.0f || angle_since_spark > active_burn_duration_ + burn_start) {
fraction = 1.0f;
combustion_active = false;
exhaust_mass += air_mass;
air_mass = 0.0f;
}
fuel_injected = false;
const float increment = fraction - burn_fraction;
if (increment > 0.0f) {
energy += fuel_mass * fuel_lhv * energy_factor_ * increment;
exhaust_mass += fuel_mass * increment;
const float burned_fuel = std::min(active_fuel_mass_ * increment, fuel_mass);
energy += burned_fuel * fuel_lhv * energy_factor_;
fuel_mass -= burned_fuel;
const float burned_air = std::min(air_mass, burned_fuel * 14.7f);
air_mass -= burned_air;
exhaust_mass += burned_fuel + burned_air;
burn_fraction = fraction;
}
}

View File

@@ -7,6 +7,7 @@ namespace fluidsim::port {
TwoStrokeScenario::TwoStrokeScenario(int sample_rate, TwoStrokeConfig config)
: configuration(config),
carburetor(config.carburetor_diameter),
cylinder(config.bore, config.stroke, config.rod, config.geometric_compression(),
config.transfer_duration, config.exhaust_duration),
crankcase(crankshaft, config.stroke * .5f, config.rod, config.bore, 150e-6f), pipes(create_pipes(config)),
@@ -43,9 +44,22 @@ TwoStrokeScenario::TwoStrokeScenario(int sample_rate, TwoStrokeConfig config)
ecu.idle_spark_gain=.04f;
ecu.overrun_cut_rpm = 5000;
ecu.overrun_resume_rpm = 3500;
ecu.overrun_fuel_cut_enabled = false;
cylinder.ecu = &ecu;
cylinder.initialize(crankshaft);
cylinder.set_crankcase(crankcase);
cylinder.set_carburetor(carburetor);
// This scenario starts as an already-running warm engine at 3000 RPM.
// Seed its upstream charge accordingly; a dry system needs a starter.
crankcase.initialize_mixture(14.7f);
cylinder.fuel_mass = cylinder.air_mass / 15.7f;
cylinder.air_mass -= cylinder.fuel_mass;
intake_plenum.add_fuel_mass(intake_plenum.mass() / 14.7f);
for (int index : {1, 2})
for (auto& cell : pipes[index].cells) {
cell.fuel = 1.0f / 15.7f;
cell.air = 1.0f - cell.fuel;
}
for (auto &pipe : pipes) {
pipe.damping_multiplier = 0.8f;
@@ -77,8 +91,12 @@ AudioFrame TwoStrokeScenario::process() {
reed_out_area_ = std::min(reed.step(dt_, upstream - crankcase.pressure()), reed_in_area_);
reed_open_ = reed.lift() > 1e-6f;
ecu.step(dt_, throttle, crankshaft.omega * 60 / (2 * pi));
const float angle = ecu.throttle_opening() * pi * .5f;
max_throttle_area_ = area_for_diameter(configuration.carburetor_diameter) * (1 - std::cos(angle));
const auto carb_inlet = pipes[0].state(static_cast<int>(pipes[0].cells.size()) - 1);
carburetor.step(dt_, throttle, ecu.throttle_opening(), crankshaft.omega * 60 / (2 * pi),
std::max(plenum_inlet_->mass_flow, 0.0f), carb_inlet.rho,
carb_inlet.pressure, intake_plenum.pressure());
intake_plenum.add_fuel_mass(carburetor.fuel_flow() * dt_);
max_throttle_area_ = slide_area(configuration.carburetor_diameter, ecu.throttle_opening());
step_motorcycle(crankshaft, dt_, 10.0f);
crankcase.pre_step(dt_);
cylinder.pre_step(crankshaft, dt_, ecu.throttle_opening());
@@ -116,6 +134,16 @@ TwoStrokeSnapshot TwoStrokeScenario::snapshot() const {
float TwoStrokeScenario::area_for_diameter(float diameter) { return pi * diameter * diameter * 0.25f; }
float TwoStrokeScenario::slide_area(float diameter, float opening) {
const float radius = diameter * 0.5f;
const float height = std::clamp(opening, 0.0f, 1.0f) * diameter;
const float offset = radius - height;
const float root = std::sqrt(std::max(radius * radius - offset * offset, 0.0f));
// Cancellation near the seat can produce a tiny negative area in float.
return std::clamp(radius * radius * std::acos(std::clamp(offset / radius, -1.0f, 1.0f)) - offset * root,
0.0f, area_for_diameter(diameter));
}
std::vector<Pipe1D> TwoStrokeScenario::create_pipes(const TwoStrokeConfig& config) {
std::vector<Pipe1D> result;
result.reserve(4);