Add turbocharged twin scenario and cross-platform test harness

This commit is contained in:
2026-10-23 02:27:03 +02:00
parent a859740a95
commit 589d22aa5e
63 changed files with 4903 additions and 771 deletions

View File

@@ -12,20 +12,47 @@ AudioOutputStream::AudioOutputStream(SimulationRingBuffer& ring, unsigned int sa
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;
for (std::size_t i = 0; i < read; ++i) {
const float left = temp_left_[i] - previous_input_left_ + dc_coefficient * previous_output_left_;
const float right = temp_right_[i] - previous_input_right_ + dc_coefficient * previous_output_right_;
previous_input_left_ = temp_left_[i];
previous_input_right_ = temp_right_[i];
previous_output_left_ = left;
previous_output_right_ = right;
const float finite_left = std::isfinite(left) ? std::clamp(left, -1.0f, 1.0f) : 0.0f;
const float finite_right = std::isfinite(right) ? std::clamp(right, -1.0f, 1.0f) : 0.0f;
output_[i * 2] = static_cast<std::int16_t>(finite_left * 32767.0f);
output_[i * 2 + 1] = static_cast<std::int16_t>(finite_right * 32767.0f);
// 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;
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;
if (starving_) {
transition_left_ = last_left_ - left;
transition_right_ = last_right_ - right;
transition_remaining_ = transition_samples;
starving_ = false;
}
const float weight = static_cast<float>(transition_remaining_) / transition_samples;
left += transition_left_ * weight;
right += transition_right_ * weight;
} else {
if (!starving_) {
transition_left_ = last_left_;
transition_right_ = last_right_;
transition_remaining_ = transition_samples;
starving_ = true;
}
const float weight = static_cast<float>(transition_remaining_) / transition_samples;
left = transition_left_ * weight;
right = transition_right_ * weight;
}
if (transition_remaining_ > 0) --transition_remaining_;
// Device protection only; simulated pressure stays linear upstream.
last_left_ = std::clamp(left, -1.0f, 1.0f);
last_right_ = std::clamp(right, -1.0f, 1.0f);
output_[i * 2] = static_cast<std::int16_t>(last_left_ * 32767.0f);
output_[i * 2 + 1] = static_cast<std::int16_t>(last_right_ * 32767.0f);
}
std::fill(output_.begin() + static_cast<std::ptrdiff_t>(read * 2), output_.end(), 0);
data.samples = output_.data();
data.sampleCount = output_.size();
return true;

View File

@@ -10,30 +10,27 @@ SoundProcessor::SoundProcessor(int sample_rate, float listener_distance) {
const int safe_rate = std::max(sample_rate, 1);
dt_ = 1.0f / static_cast<float>(safe_rate);
scale_factor_ = 1.0f / (4.0f * pi * std::max(listener_distance, 1e-3f));
derivative_alpha_ = std::exp(-dt_ / 0.020f);
lowpass_alpha_ = std::exp(-dt_ / 0.005f);
}
float SoundProcessor::process(float mass_flow_out) {
flow_lowpass_ = lowpass_alpha_ * flow_lowpass_ + (1.0f - lowpass_alpha_) * mass_flow_out;
const float raw_derivative = (flow_lowpass_ - previous_flow_) / dt_;
previous_flow_ = flow_lowpass_;
smooth_derivative_ = derivative_alpha_ * smooth_derivative_ +
(1.0f - derivative_alpha_) * raw_derivative;
return std::tanh(smooth_derivative_ * scale_factor_ * gain);
// Compact monopole approximation: p(r,t) = (d mass_flow/dt) / (4 pi r).
// gain maps pressure in pascals to device full scale (200 Pa by default).
const float derivative = (mass_flow_out - previous_flow_) / dt_;
previous_flow_ = mass_flow_out;
return derivative * scale_factor_ * gain;
}
RadiationSource::RadiationSource(int sample_rate)
: sample_rate_(std::max(sample_rate, 1)), dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))) {
const std::size_t max_delay = static_cast<std::size_t>(std::max(1.0f,
0.5f / std::max(speed_of_sound, 1.0f) * static_cast<float>(sample_rate_)));
100.0f / std::max(speed_of_sound, 1.0f) * static_cast<float>(sample_rate_)));
left_delay_.resize(max_delay);
right_delay_.resize(max_delay);
}
AudioFrame RadiationSource::process(float mass_flow) {
const float flow_alpha = 1.0f - std::exp(-dt_ / std::max(flow_tau, 1e-5f));
const float derivative_decay = std::exp(-dt_ / std::max(smooth_tau, 1e-5f));
const float flow_alpha = flow_tau > 0.0f ? 1.0f - std::exp(-dt_ / flow_tau) : 1.0f;
const float derivative_decay = smooth_tau > 0.0f ? std::exp(-dt_ / smooth_tau) : 0.0f;
flow_lp_ += flow_alpha * (mass_flow - flow_lp_);
const float raw_derivative = (flow_lp_ - previous_flow_lp_) / dt_;
previous_flow_lp_ = flow_lp_;
@@ -44,11 +41,12 @@ AudioFrame RadiationSource::process(float mass_flow) {
const Vec3 direction{to_listener.x / distance, to_listener.y / distance, to_listener.z / distance};
const float cosine = dot(pipe_axis, direction);
const float directivity = (1.0f - directivity_balance) + directivity_balance * cosine;
last_mono_pressure_ = std::tanh(smooth_derivative_ * gain * directivity / (4.0f * 3.14159265358979323846f * distance));
last_mono_pressure_ = smooth_derivative_ * gain * directivity / (4.0f * 3.14159265358979323846f * distance);
const float air_cutoff = std::max(min_air_cutoff,
20000.0f * std::exp(-distance * air_absorption_exponent));
const float filtered = one_pole(last_mono_pressure_, air_cutoff, air_filter_);
const float filtered = air_absorption_exponent > 0.0f
? one_pole(last_mono_pressure_, air_cutoff, air_filter_) : last_mono_pressure_;
const Vec3 ear_offset{listener_right.x * ear_spacing * 0.5f,
listener_right.y * ear_spacing * 0.5f,
@@ -62,15 +60,15 @@ AudioFrame RadiationSource::process(float mass_flow) {
const auto delay_sample = [this, filtered](std::vector<float>& buffer, std::size_t offset, float ear_distance) {
const std::size_t read = (write_position_ + buffer.size() - offset) % buffer.size();
const float value = buffer[read];
buffer[write_position_] = filtered * (length(subtract(listener, pipe_end)) / ear_distance);
const float value = buffer[read];
return value;
};
float left = delay_sample(left_delay_, left_offset_, left_distance);
float right = delay_sample(right_delay_, right_offset_, right_distance);
write_position_ = (write_position_ + 1) % left_delay_.size();
if (left_distance > right_distance) left = one_pole(left, head_shadow_cutoff, shadow_left_);
else if (right_distance > left_distance) right = one_pole(right, head_shadow_cutoff, shadow_right_);
if (head_shadow_cutoff > 0.0f && left_distance > right_distance) left = one_pole(left, head_shadow_cutoff, shadow_left_);
else if (head_shadow_cutoff > 0.0f && right_distance > left_distance) right = one_pole(right, head_shadow_cutoff, shadow_right_);
return {left, right};
}

View File

@@ -2,6 +2,7 @@
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
namespace {
@@ -26,6 +27,7 @@ Crankcase::Crankcase(Crankshaft& crankshaft, float crank_radius, float connectin
temperature_ = initial_temperature;
mass_ = initial_pressure * volume_ / (gas_constant * initial_temperature);
energy_ = mass_ * cv * initial_temperature;
intake_port.owner_energy = transfer_port.owner_energy = &energy_;
intake_port.owner_air_mass = transfer_port.owner_air_mass = &mass_;
sync_ports();
}
@@ -38,6 +40,9 @@ void Crankcase::pre_step(float /*dt*/) {
const float displacement = crank_radius_ * (1.0f - cosine) + connecting_rod_length_ * (1.0f - term);
const float maximum_volume = clearance_volume_ + piston_area_ * 2.0f * crank_radius_;
volume_ = std::max(maximum_volume - piston_area_ * displacement, 1e-12f);
const float old_energy=energy_;
energy_ *= std::pow(previous_volume_ / volume_, gamma - 1.0f);
geometry_work_=old_energy-energy_;
if (mass_ > 1e-12f) {
temperature_ = energy_ / (mass_ * cv);
pressure_ = mass_ * gas_constant * temperature_ / volume_;
@@ -59,31 +64,10 @@ void Crankcase::update_state(float dt) {
energy_ += delta_energy;
if (mass_ > 1e-12f) air_fraction_ = std::clamp((old_air_mass + delta_air_mass) / mass_, 0.0f, 1.0f);
const float volume_change = volume_ - previous_volume_;
energy_ -= pressure_ * volume_change;
if (mass_ < 1e-9f) {
mass_ = 1e-9f;
energy_ = mass_ * cv * 300.0f;
air_fraction_ = 1.0f;
}
if (energy_ < 0.0f) energy_ = mass_ * cv * 300.0f;
if (!(mass_ > 0.0f) || !(energy_ > 0.0f) || !std::isfinite(mass_) || !std::isfinite(energy_))
throw std::runtime_error("nonphysical crankcase state");
temperature_ = energy_ / (mass_ * cv);
pressure_ = mass_ * gas_constant * temperature_ / std::max(volume_, 1e-12f);
if (pressure_ < 1.0f) {
temperature_ = volume_ / (mass_ * gas_constant);
energy_ = mass_ * cv * temperature_;
pressure_ = 1.0f;
}
constexpr float max_pressure = 5.0e5f;
if (pressure_ > max_pressure) {
const float target_mass = max_pressure * volume_ / (gas_constant * temperature_);
if (mass_ > target_mass) {
mass_ = target_mass;
energy_ = mass_ * cv * temperature_;
pressure_ = max_pressure;
}
}
pressure_ = mass_ * gas_constant * temperature_ / volume_;
sync_ports();
}

View File

@@ -1,4 +1,4 @@
#include "FluidSim/Scenario.hpp"
#include "FluidSim/DesktopScenario.hpp"
#include "FluidSim/SoundEngine.hpp"
#include "FluidSim/DynoCurve.hpp"
@@ -6,6 +6,8 @@
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <cmath>
#include <filesystem>
#include <iomanip>
@@ -19,8 +21,8 @@ using namespace fluidsim::port;
constexpr unsigned int window_width = 1280;
constexpr unsigned int window_height = 720;
constexpr std::size_t ring_capacity = 8192;
constexpr std::size_t target_fill = 1024;
constexpr std::size_t preroll_samples = 1024;
constexpr std::size_t target_fill = 4096;
constexpr std::size_t preroll_samples = 4096;
constexpr float throttle_lerp_rate = 10.0f;
sf::Color pressure_color(float pressure) {
@@ -54,6 +56,72 @@ void draw_label(sf::RenderWindow& window, const sf::Font* font, const std::strin
window.draw(label);
}
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{
"Four-stroke twin, motorcycle test and dyno",
"125 cc single, expansion chamber, motorcycle test and dyno",
"2.0 litre inline-four, no turbo yet, five-speed wagon and dyno",
"Coupled turbine/compressor, shaft inertia and pressure diagnostics"};
int selected = 0;
while (window.isOpen()) {
while (const auto event = window.pollEvent()) {
if (event->is<sf::Event::Closed>()) return std::nullopt;
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::Down:
case sf::Keyboard::Key::Tab: selected = (selected + 1) % 4; 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::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)
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)
if (cards[i].contains(point)) return static_cast<ScenarioKind>(i);
if (start_button.contains(point)) return static_cast<ScenarioKind>(selected);
}
}
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) {
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));
card.setOutlineColor(i == selected ? sf::Color(90, 200, 210) : sf::Color(55, 65, 78));
card.setOutlineThickness(2);
window.draw(card);
draw_label(window, font, titles[i], cards[i].position + sf::Vector2f{20, 16},
sf::Color::White, 20);
draw_label(window, font, descriptions[i], cards[i].position + sf::Vector2f{20, 54},
{180, 190, 205}, 13);
}
sf::RectangleShape start(start_button.size);
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, "Click a scenario or use Up/Down. Esc: quit", {32, 574}, {150, 165, 180}, 12);
window.display();
}
return std::nullopt;
}
void draw_volume(sf::RenderWindow& window, float pressure, sf::FloatRect bounds) {
sf::RectangleShape body({bounds.size.x, bounds.size.y});
body.setPosition(bounds.position);
@@ -67,20 +135,27 @@ void draw_pipe(sf::RenderWindow& window, const ScenarioSnapshot& snapshot, std::
float center_y, float x0, float x1) {
const auto& cells = snapshot.pipe_states[pipe_index];
if (cells.empty()) return;
const float cell_width = (x1 - x0) / static_cast<float>(cells.size());
const auto& areas = snapshot.pipe_areas[pipe_index];
const auto& lengths = snapshot.pipe_lengths[pipe_index];
float total_length = 0;
for (float length : lengths) total_length += length;
float x = x0;
for (std::size_t i = 0; i < cells.size(); ++i) {
const auto& cell = cells[i];
const float thickness = std::clamp(6.0f + cell.pressure / 101325.0f * 0.7f, 6.0f, 20.0f);
const float cell_width = total_length > 0 ? (x1 - x0) * lengths[i] / total_length
: (x1 - x0) / cells.size();
const float thickness = areas.empty() ? 8 : 600 * std::sqrt(4 * areas[i] / pi);
sf::RectangleShape segment({cell_width + 1.0f, thickness});
segment.setPosition({x0 + static_cast<float>(i) * cell_width, center_y - thickness * 0.5f});
segment.setPosition({x, center_y - thickness * .5f});
segment.setFillColor(temperature_color(cell.pressure / std::max(cell.rho * 287.0f, 1e-9f)));
window.draw(segment);
x += cell_width;
}
}
void draw_cylinder(sf::RenderWindow& window, const sf::Font* font, float pressure,
float piston_fraction, float top, const char* name, bool intake_open, bool exhaust_open) {
constexpr float x = 470.0f, width = 74.0f, height = 142.0f;
float piston_fraction, float top, const char* name, bool intake_open, bool exhaust_open, float x = 470.0f) {
constexpr float width = 74.0f, height = 142.0f;
const float fraction = std::clamp(piston_fraction, 0.0f, 1.0f);
const float gas_height = height * fraction;
sf::RectangleShape wall({width, height});
@@ -219,18 +294,95 @@ void draw_dyno_curve(sf::RenderWindow& window, const sf::Font* font, const DynoC
}
}
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) {
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::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.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]"
<< " Shift [A/D], Neutral [Z]"
<< "\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");
if (font) {
sf::RectangleShape panel({1260.0f, 92.0f});
panel.setPosition({10.0f, 10.0f});
panel.setFillColor({12, 18, 26, 236});
panel.setOutlineColor({80, 110, 130});
panel.setOutlineThickness(1.0f);
window.draw(panel);
draw_label(window, font, status.str(), {22.0f, 20.0f}, sf::Color::White, 14);
draw_label(window, font,
"RPM " + std::to_string(static_cast<int>(state.rpm)) +
" Power " + std::to_string(state.average_power / 1000.0f) + " kW" +
" Vehicle kinetic energy " + std::to_string(static_cast<int>(state.kinetic_energy)) + " J",
{24.0f, 122.0f}, {220, 230, 240}, 17);
draw_label(window, font, state.two_stroke ? "REV LIMIT DISABLED" :
((state.rev_limit1 || state.rev_limit2) ? std::string("REV CUT") : "REV LIMIT " + std::to_string(int(state.rev_limit_rpm)) + " rpm"),
{24.0f, 151.0f}, {120, 220, 120}, 14);
}
if (state.turbo_enabled) {
const auto& t = state.turbo;
std::ostringstream text;
text << std::fixed << std::setprecision(0) << "VZ21-size: " << t.rpm << " rpm "
<< std::setprecision(2) << "CPR " << t.compressor_pressure_ratio
<< " Charge " << t.charge_pressure/1000 << " kPa / " << std::setprecision(0) << t.charge_temperature << " K"
<< "\nTurbine inlet " << t.turbine_inlet_pressure/1000 << " kPa Outlet " << t.turbine_outlet_pressure/1000 << " kPa"
<< " Power " << t.turbine_power << " / " << t.compressor_power << " W";
draw_label(window, font, text.str(), {625, 125}, {130, 215, 220}, 12);
}
}
void draw_two_stroke_scene(sf::RenderWindow& window,const sf::Font* font,const ScenarioSnapshot& s) {
window.clear({9,12,17});
draw_label(window,font,"YZ125 baseline | piston-controlled ports",
{650,185},sf::Color::White,14);
draw_cylinder(window,font,s.cylinder_pressure1,s.cylinder_piston_fraction1,220,"CYLINDER",
s.valve1_intake_area>0,s.valve1_exhaust_area>0);
draw_volume(window,s.crankcase_pressure,{{455,410},{110,95}});
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_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);
draw_graph_line(window,{510,505},{510,530},{180,190,200});
draw_graph_line(window,{510,530},{315,530},{180,190,200});
draw_graph_line(window,{315,530},{315,350},{180,190,200});
draw_graph_line(window,{450,350},{450,245},{180,190,200});
draw_graph_line(window,{450,245},{467,245},{180,190,200});
draw_label(window,font,"TRANSFER",{320,325},sf::Color::White,12);
draw_graph_line(window,{551,235},{590,235},{180,190,200});
draw_pipe(window,s,3,235,590,1230);
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";
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);
}
void draw_twin_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state) {
constexpr float intake_y = 310.0f;
constexpr float upper_y = 152.0f;
constexpr float lower_y = 400.0f;
window.clear({9, 12, 17});
const float power_kw = state.average_power / 1000.0f;
dyno_curve.update(state.rpm, power_kw, state.average_torque);
// Intake: ambient pipe → throttle → plenum → two cylinder runners.
draw_pipe(window, state, 0, intake_y, 36.0f, 165.0f);
draw_pipe(window, state, 0, intake_y, state.turbo_enabled ? 135.f : 36.f, 165.0f);
if (state.turbo_enabled) {
draw_pipe(window, state, 6, intake_y, 36, 90);
draw_volume(window, float(state.turbo.charge_pressure), {{95, intake_y-18}, {35, 36}});
draw_label(window, font, "COMP", {92, intake_y+30}, {130,215,220}, 10);
}
sf::RectangleShape throttle({8.0f, 28.0f});
throttle.setPosition({169.0f, intake_y - 14.0f});
throttle.setFillColor(sf::Color::Yellow);
@@ -247,7 +399,12 @@ void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSn
draw_pipe(window, state, 3, upper_y + 71.0f, 556.0f, 820.0f);
draw_pipe(window, state, 4, lower_y + 71.0f, 556.0f, 820.0f);
draw_volume(window, state.exhaust_pressure, {{838.0f, 305.0f}, {70.0f, 82.0f}});
draw_pipe(window, state, 5, 346.0f, 915.0f, 1236.0f);
draw_pipe(window, state, 5, 346.0f, 915.0f, state.turbo_enabled ? 1040.f : 1236.f);
if (state.turbo_enabled) {
draw_volume(window, float(state.turbo.turbine_inlet_pressure), {{1045, 323}, {45, 46}});
draw_label(window, font, "TURBINE", {1040, 380}, {130,215,220}, 10);
draw_pipe(window, state, 7, 346, 1100, 1236);
}
draw_label(window, font, "AMBIENT", {37.0f, intake_y + 22.0f}, {180, 190, 200}, 12);
draw_label(window, font, "THROTTLE", {151.0f, intake_y + 24.0f}, {240, 210, 90}, 11);
@@ -255,42 +412,49 @@ void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSn
draw_label(window, font, "SHARED EXHAUST", {818.0f, 285.0f}, sf::Color::White, 11);
draw_label(window, font, "TAILPIPE", {1084.0f, 362.0f}, {180, 190, 200}, 11);
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
<< "\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] Brake " << brake * 100.0f << "% [left/right]"
<< " Shift [A/D], Neutral [Z] Mechanical [M]: "
<< (state.mechanical_sound_enabled ? "ON" : "OFF");
if (font) {
sf::RectangleShape panel({1260.0f, 92.0f});
panel.setPosition({10.0f, 10.0f});
panel.setFillColor({12, 18, 26, 236});
panel.setOutlineColor({80, 110, 130});
panel.setOutlineThickness(1.0f);
window.draw(panel);
draw_label(window, font, status.str(), {22.0f, 20.0f}, sf::Color::White, 14);
draw_label(window, font,
"RPM " + std::to_string(static_cast<int>(state.rpm)) +
" Power " + std::to_string(state.average_power / 1000.0f) + " kW" +
" Bike kinetic energy " + std::to_string(static_cast<int>(state.kinetic_energy)) + " J",
{24.0f, 122.0f}, {220, 230, 240}, 17);
const sf::Color rev = state.rev_limit1 || state.rev_limit2 ? sf::Color::Red : sf::Color(120, 220, 120);
draw_label(window, font, (state.rev_limit1 || state.rev_limit2) ? "REV CUT" : "REV LIMIT 10000 rpm",
{24.0f, 151.0f}, rev, 14);
draw_label(window, font,
"Cylinder pressure " + std::to_string(state.cylinder_pressure1 / 1e5f) + " / " +
std::to_string(state.cylinder_pressure2 / 1e5f) + " bar Intake " +
std::to_string(state.intake_pressure / 1000.0f) + " kPa Exhaust " +
std::to_string(state.exhaust_pressure / 1000.0f) + " kPa",
{24.0f, 176.0f}, {205, 215, 225}, 13);
}
}
draw_dyno_curve(window, font, dyno_curve, 860.0f, 455.0f, 400.0f, 250.0f,
state.rpm, power_kw);
void draw_b205_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& s) {
window.clear({9, 12, 17});
draw_label(window, font, "SAAB B205 | 1985 cc inline-four | firing 1-3-4-2 | no turbo yet",
{260, 181}, sf::Color::White, 16);
draw_pipe(window, s, 0, 215, 25, 110);
draw_volume(window, s.intake_pressure, {{120, 195}, {65, 65}});
draw_label(window, font, "PLENUM", {120, 270}, sf::Color::White, 12);
draw_graph_line(window, {190, 211}, {820, 211}, {80, 145, 200});
draw_graph_line(window, {220, 420}, {850, 420}, {200, 120, 80});
for (int i = 0; i < 4; ++i) {
const float x = 230 + i*150;
const std::string title = "CYL " + std::to_string(i+1);
draw_cylinder(window, font, s.cylinder_pressures[i], s.piston_fractions[i], 255, title.c_str(),
s.intake_areas[i] > 1e-7f, s.exhaust_areas[i] > 1e-7f, x);
draw_pipe(window, s, 1+i, 235, x-15, x+75);
draw_pipe(window, s, 5+i, 410, x-15, x+75);
draw_graph_line(window, {x-15, 211}, {x-15, 235}, {80,145,200});
draw_graph_line(window, {x+75, 410}, {x+75, 420}, {200,120,80});
draw_label(window, font, std::to_string(int(s.cylinder_pressures[i]/1000))+" kPa",
{x, 445}, {180,190,205}, 12);
}
draw_volume(window, s.exhaust_pressure, {{850, 393}, {65, 48}});
draw_pipe(window, s, 9, 420, 920, 1240);
draw_label(window, font, "COLLECTOR / OPEN EXHAUST", {930, 390}, sf::Color::White, 12);
draw_label(window, font, "9-5 wagon baseline: 1675 kg with driver, five-speed manual, 4.05 final drive",
{25, 550}, sf::Color::White, 14);
draw_label(window, font, "Engine dimensions/firing from Saab WIS. Manifolds, cams, losses and ECU are estimates.",
{25, 580}, {180,190,205}, 12);
}
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) {
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);
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);
draw_dyno_curve(window, font, dyno_curve, 860, 455, 400, 250, state.rpm, power_kw);
window.display();
}
@@ -298,22 +462,72 @@ void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSn
int main(int argc, char** argv) {
using namespace fluidsim::port;
sf::RenderWindow window(sf::VideoMode({window_width, window_height}), "FluidSim C++ - parallel twin");
ScenarioKind selected_kind=ScenarioKind::Twin;
bool scenario_from_args=false;
bool turbo_from_args=false;
TwoStrokeConfig two_stroke_config;
OutdoorScene selected_scene=OutdoorScene::Wall;
for(int i=1;i<argc;++i){
const std::string option=argv[i];
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 :
(value=="two-stroke" ? ScenarioKind::TwoStroke : ScenarioKind::Twin);
scenario_from_args=true;
}else if(option=="--turbo"){
if(value!="none"&&value!="vz21"){std::cerr<<"Choose none or vz21 turbo\n";return 2;}
turbo_from_args=value=="vz21";
}else if(option=="--exhaust"){
if(value=="racing") two_stroke_config.exhaust=TwoStrokeExhaust::Racing;
else if(value=="straight") two_stroke_config.exhaust=TwoStrokeExhaust::Straight;
else { std::cerr<<"Choose racing or straight exhaust\n";return 2; }
}else{
if(value=="dry")selected_scene=OutdoorScene::Dry;
else if(value=="field")selected_scene=OutdoorScene::Field;
else if(value=="wall")selected_scene=OutdoorScene::Wall;
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]\n";return 2;}
}
const sf::Vector2u initial_size = scenario_from_args ? sf::Vector2u{window_width, window_height}
: sf::Vector2u{720, 606};
sf::RenderWindow window(sf::VideoMode(initial_size), "FluidSim");
window.setVerticalSyncEnabled(false);
window.setFramerateLimit(60);
window.setKeyRepeatEnabled(false);
sf::Font font;
std::filesystem::path font_path{"fonts/FiraCodeNerdFont-Medium.ttf"};
if (!std::filesystem::exists(font_path) && argc > 0) {
const auto executable_font = std::filesystem::path(argv[0]).parent_path() /
"fonts/FiraCodeNerdFont-Medium.ttf";
if (std::filesystem::exists(executable_font)) font_path = executable_font;
}
const bool has_font = font.openFromFile(font_path.string());
const sf::Font* font_ptr = has_font ? &font : nullptr;
std::vector<std::filesystem::path> font_paths{"fonts/FiraCodeNerdFont-Medium.ttf"};
if(argc>0)font_paths.push_back(std::filesystem::path(argv[0]).parent_path()/"fonts/FiraCodeNerdFont-Medium.ttf");
font_paths.emplace_back("/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf");
font_paths.emplace_back("/usr/share/fonts/TTF/DejaVuSansMono.ttf");
font_paths.emplace_back("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf");
if(const char* windows_directory=std::getenv("WINDIR"))font_paths.push_back(std::filesystem::path(windows_directory)/"Fonts/consola.ttf");
bool has_font=false;
for(const auto& path:font_paths)if(std::filesystem::exists(path)&&font.openFromFile(path)){has_font=true;break;}
if(!has_font)std::cerr<<"No UI font available; configure FLUIDSIM_FONT_FILE.\n";
const sf::Font* font_ptr=has_font?&font:nullptr;
FourStrokeTwinScenario scenario(44100);
if (!scenario_from_args) {
const auto choice = choose_scenario(window, font_ptr);
if (!choice) return 0;
selected_kind = *choice;
window.setSize({window_width, window_height});
window.setView(sf::View(sf::FloatRect({0, 0}, {float(window_width), float(window_height)})));
}
if(turbo_from_args) {
if(selected_kind!=ScenarioKind::Twin && selected_kind!=ScenarioKind::TurboTwin) {
std::cerr<<"The VZ21 turbo test uses the twin scenario\n";return 2;
}
selected_kind=ScenarioKind::TurboTwin;
}
window.setTitle(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);
scenario.set_audio_scene(selected_scene);
SimulationRingBuffer ring(ring_capacity);
for (std::size_t i = 0; i < preroll_samples; ++i) {
const AudioFrame audio = scenario.process();
@@ -325,17 +539,19 @@ int main(int argc, char** argv) {
audio.set_volume(100.0f);
audio.start();
std::jthread simulation([&](std::stop_token stop) {
while (ring.wait_for_space(target_fill, stop)) {
const AudioFrame frame = scenario.process();
if (!ring.write(frame.left, frame.right, stop)) break;
constexpr std::size_t batch_size = 512;
while (ring.wait_for_space(target_fill - batch_size, stop)) {
for (std::size_t i = 0; i < batch_size; ++i) {
const AudioFrame frame = scenario.process();
if (!ring.write(frame.left, frame.right, stop)) return;
}
}
});
double desired_speed = 0.001;
double desired_speed = 1.0;
double current_speed = desired_speed;
double last_normal_speed = 0.1;
double last_normal_speed = 1.0;
bool time_warp = false;
bool real_time = false;
float throttle_target = 1.0f, throttle_current = 0.0f;
float brake_target = 0.0f, brake_current = 0.0f;
float clutch_current = 0.0f;
@@ -350,7 +566,7 @@ int main(int argc, char** argv) {
switch (key->code) {
case sf::Keyboard::Key::Space:
time_warp = !time_warp;
if (!time_warp) { desired_speed = last_normal_speed; real_time = false; }
if (!time_warp) { desired_speed = last_normal_speed; }
break;
case sf::Keyboard::Key::W: throttle_held = true; break;
case sf::Keyboard::Key::C: clutch_held = true; break;
@@ -361,6 +577,10 @@ int main(int argc, char** argv) {
case sf::Keyboard::Key::D: scenario.shift_up(); break;
case sf::Keyboard::Key::A: scenario.shift_down(); break;
case sf::Keyboard::Key::Z: scenario.set_neutral(); break;
case sf::Keyboard::Key::R:
selected_scene=static_cast<OutdoorScene>((static_cast<int>(selected_scene)+1)%4);
scenario.set_audio_scene(selected_scene);
break;
case sf::Keyboard::Key::M: scenario.toggle_mechanical_sound(); break;
default: break;
}
@@ -373,7 +593,6 @@ int main(int argc, char** argv) {
desired_speed *= wheel->delta > 0.0f ? 1.1 : (wheel->delta < 0.0f ? 1.0 / 1.1 : 1.0);
desired_speed = std::clamp(desired_speed, 0.0001, 1.0);
last_normal_speed = desired_speed;
real_time = std::abs(desired_speed - 1.0) < 1e-6;
}
}
@@ -385,15 +604,14 @@ int main(int argc, char** argv) {
audio.set_speed(current_speed);
const float wanted_throttle = throttle_held ? throttle_target : 0.0f;
if (wanted_throttle == 0.0f) throttle_current = 0.0f;
else throttle_current += (wanted_throttle - throttle_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
throttle_current = wanted_throttle; // ECU actuator evolves at the simulation sample rate.
brake_current += (brake_target - brake_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
const float wanted_clutch = clutch_held ? 1.0f : 0.0f;
clutch_current += (wanted_clutch - clutch_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
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);
current_speed, ring.available_samples(), ring_capacity, time_warp, dyno_curve, audio.underrun_blocks());
}
simulation.request_stop();

56
src/ecu.cpp Normal file
View File

@@ -0,0 +1,56 @@
#include "FluidSim/Ecu.hpp"
#include <algorithm>
#include <cmath>
namespace fluidsim::port {
void Ecu::step(float dt, float pedal, float rpm) {
fault_ = !std::isfinite(dt) || dt <= 0.0f || !std::isfinite(pedal) || !std::isfinite(rpm) || rpm < 0.0f;
if (fault_) { target_ = 0.0f; idle_ = false; return; }
pedal = std::clamp(pedal, 0.0f, 1.0f);
if (!rev_limit_enabled) limiter_ = false;
else if (rpm >= rev_limit_rpm) limiter_ = true;
else if (rpm <= rev_resume_rpm) limiter_ = false;
if (pedal > 0.005f || rpm < overrun_resume_rpm) overrun_ = false;
else if (rpm > overrun_cut_rpm) overrun_ = true;
if (!initialized_) { filtered_rpm_ = rpm; initialized_ = true; }
filtered_rpm_ += (rpm - filtered_rpm_) * (-std::expm1(-dt / 0.05f));
idle_ = pedal <= 0.005f && !overrun_;
const float error = idle_rpm - filtered_rpm_;
if (idle_) {
const float proposed = integral_ + idle_ki * error * dt;
const float command = idle_base_opening + idle_kp * error + proposed;
// Conditional integration prevents windup at either actuator limit.
if ((command >= 0.0f && command <= idle_max_opening) ||
(command > idle_max_opening && error < 0.0f) || (command < 0.0f && error > 0.0f))
integral_ = proposed;
} else {
integral_ *= std::exp(-dt / 0.5f);
}
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;
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;
if (ignition_map.empty()) {
spark_ = 10;
} else {
spark_ = ignition_map.front().degrees_btdc;
for (std::size_t i = 1; i < ignition_map.size(); ++i) {
const auto a = ignition_map[i-1], b = ignition_map[i];
const float t = std::clamp((rpm-a.rpm) / std::max(b.rpm-a.rpm, 1e-6f), 0.f, 1.f);
spark_ = std::lerp(a.degrees_btdc, b.degrees_btdc, t);
if (rpm <= b.rpm) break;
}
}
if(idle_ && idle_spark_gain>0) spark_=std::clamp(spark_+idle_spark_gain*error,idle_minimum_advance,35.0f);
}
float Ecu::fuel_for_air(float air) const {
if (fuel_cut() || !std::isfinite(air) || air <= 0.0f) return 0.0f;
return air / std::max(afr_, 1.0f);
}
} // namespace fluidsim::port

View File

@@ -1,4 +1,5 @@
#include "FluidSim/Engine.hpp"
#include "FluidSim/Ecu.hpp"
#include <algorithm>
#include <cmath>
@@ -14,12 +15,15 @@ void Crankshaft::step(float dt) {
if (!std::isfinite(external_torque_)) external_torque_ = 0.0f;
previous_angle = angle;
const float sign = omega > 0.0f ? 1.0f : omega < 0.0f ? -1.0f : 0.0f;
const float friction = friction_constant * sign + friction_viscous * omega;
const float friction = friction_constant * sign + friction_viscous * omega +
friction_quadratic * omega * std::abs(omega);
const float net_torque = external_torque_ - friction;
const float total_torque = net_torque - std::max(load_torque, 0.0f) + drivetrain_torque;
omega = std::max(omega + total_torque / std::max(inertia, 1e-6f) * dt, 0.0f);
angle = std::fmod(angle + omega * dt, 4.0f * pi);
const float instant_power = total_torque * omega;
// Brake shaft output, before external test load/drivetrain reaction. Using
// acceleration torque reports zero dyno power at a loaded steady speed.
const float instant_power = net_torque * omega;
if (power_count_ == power_buffer_.size()) power_sum_ -= power_buffer_[power_index_];
else ++power_count_;
power_buffer_[power_index_] = instant_power;
@@ -29,8 +33,8 @@ void Crankshaft::step(float dt) {
if (torque_count_ == torque_buffer_.size()) torque_sum_ -= torque_buffer_[torque_index_];
else ++torque_count_;
torque_buffer_[torque_index_] = total_torque;
torque_sum_ += total_torque;
torque_buffer_[torque_index_] = net_torque;
torque_sum_ += net_torque;
torque_index_ = (torque_index_ + 1) % torque_buffer_.size();
average_torque = torque_sum_ / static_cast<float>(torque_count_);
external_torque_ = 0.0f;
@@ -77,6 +81,7 @@ void EngineCylinder::initialize(const Crankshaft& /*crank*/) {
exhaust_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;
sync_ports();
@@ -106,13 +111,18 @@ float FourStrokeCylinder::valve_area(float cycle_degrees, bool inlet) const {
const float t = (mapped - (opens + ramp + hold)) / ramp;
lift = peak_lift * (1.0f - t) * (1.0f - t) * (1.0f + 2.0f * t);
}
return pi * diameter * lift;
return pi * diameter * lift * (inlet ? intake_valve_count : exhaust_valve_count);
}
void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*/) {
const float rpm = crank.omega * 60.0f / (2.0f * pi);
spark_advance = std::clamp(10.0f + rpm * 0.002f, 5.0f, 40.0f);
update_rev_limiter(rpm, dt);
if (ecu) {
spark_advance = ecu->spark_advance();
rev_limit_active = ecu->rev_limited();
} else {
spark_advance = std::clamp(10.0f + rpm * 0.002f, 5.0f, 40.0f);
update_rev_limiter(rpm, dt);
}
const float theta = crank.angle + phase;
const float previous_volume = volume;
@@ -130,14 +140,13 @@ 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 = air_mass / std::max(afr, 1e-6f);
fuel_injected = true;
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f);
fuel_injected = fuel_mass > 0.0f;
}
const float spark_angle = wrap_degrees(720.0f - spark_advance);
const bool crossed_spark = (previous_degrees < spark_angle && current_degrees >= spark_angle) ||
(previous_degrees > spark_angle && current_degrees < spark_angle);
if (crossed_spark && fuel_injected && !combustion_active && !rev_limit_active && random_unit() >= misfire_probability) {
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) && random_unit() >= misfire_probability) {
combustion_active = true;
burn_fraction = 0.0f;
energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.0f);
@@ -164,7 +173,7 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
}
}
if (rev_limit_active) {
if (!ecu && rev_limit_active) {
fuel_mass = 0.0f;
fuel_injected = false;
combustion_active = false;

View File

@@ -10,8 +10,7 @@ HelmholtzScenario::HelmholtzScenario(int sample_rate)
boundaries_(pipes_), solver_(pipes_, boundaries_), sound_(sample_rate, 1.0f) {
pipes_.emplace_back(neck_area_, 0.050f, 20);
cavity_port_ = &cavity_.create_port();
boundaries_.add_orifice_with_inertance(*cavity_port_, 0, true, cavity_orifice_area_,
0.9f, 0.050f);
boundaries_.add_orifice(*cavity_port_, 0, true, cavity_orifice_area_, 0.9f);
boundaries_.add_open_end(0, false, 101325.0f, neck_area_);
solver_.minimum_substeps = 8;
solver_.add_component(cavity_);

View File

@@ -15,20 +15,34 @@ int main(int argc, char** argv) {
float duration_seconds = 1.0f;
float throttle = 0.35f;
std::string scenario_name = "twin";
TwoStrokeConfig two_stroke_config;
bool turbo_enabled = false;
for (int i = 1; i + 1 < argc; ++i) {
const std::string option = argv[i];
if (option == "--seconds") duration_seconds = std::max(0.0f, std::strtof(argv[++i], nullptr));
else if (option == "--throttle") throttle = std::clamp(std::strtof(argv[++i], nullptr), 0.0f, 1.0f);
else if (option == "--scenario") scenario_name = argv[++i];
else if (option == "--turbo") {
const std::string value = argv[++i];
if (value != "none" && value != "vz21") { std::cerr << "Choose none or vz21 turbo\n"; return 2; }
turbo_enabled = value == "vz21";
}
else if (option == "--exhaust") {
const std::string value = argv[++i];
if (value == "racing") two_stroke_config.exhaust = TwoStrokeExhaust::Racing;
else if (value == "straight") two_stroke_config.exhaust = TwoStrokeExhaust::Straight;
else { std::cerr << "Choose racing or straight exhaust\n"; return 2; }
}
}
if (turbo_enabled && scenario_name != "twin") { std::cerr << "The turbo test preset uses --scenario twin\n"; return 2; }
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") {
FourStrokeTwinScenario scenario;
if (scenario_name == "twin" || scenario_name == "b205") {
FourStrokeScenario scenario(sample_rate, scenario_name == "b205" ? FourStrokePreset::SaabB205 : FourStrokePreset::Twin, turbo_enabled);
scenario.throttle = throttle;
std::cout << "FluidSimCpp | 600 cc parallel twin | throttle " << std::fixed << std::setprecision(0)
std::cout << (scenario_name == "b205" ? "FluidSimCpp | Saab B205 / 9-5 wagon (no turbo) | 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();
@@ -41,11 +55,18 @@ int main(int argc, char** argv) {
<< " cyl=" << scenario.cylinder1.pressure / 1000.0f << "/"
<< scenario.cylinder2.pressure / 1000.0f << " kPa"
<< " intake=" << scenario.intake_plenum.pressure() / 1000.0f << " kPa"
<< " exhaust=" << scenario.exhaust_collector.pressure() / 1000.0f << " kPa\n";
<< " exhaust=" << scenario.exhaust_collector.pressure() / 1000.0f << " kPa";
if (scenario.turbo) {
const auto turbo = scenario.turbo->snapshot();
std::cout << " turbo_rpm=" << turbo.rpm << " CPR=" << turbo.compressor_pressure_ratio
<< " turbine_in=" << turbo.turbine_inlet_pressure/1000 << " kPa"
<< " charge_T=" << turbo.charge_temperature << " K";
}
std::cout << '\n';
}
}
} else if (scenario_name == "two-stroke") {
TwoStrokeScenario scenario;
TwoStrokeScenario scenario(sample_rate, two_stroke_config);
scenario.set_throttle(throttle);
std::cout << "FluidSimCpp | 125 cc crankcase-scavenged two-stroke | throttle "
<< std::fixed << std::setprecision(0) << throttle * 100.0f << "%\n";
@@ -88,7 +109,7 @@ int main(int argc, char** argv) {
}
} else {
std::cerr << "Unknown scenario '" << scenario_name
<< "'. Choose twin, two-stroke, helmholtz, or pulse.\n";
<< "'. Choose twin, two-stroke, b205, helmholtz, or pulse.\n";
return 2;
}
return 0;

171
src/outdoor_audio.cpp Normal file
View File

@@ -0,0 +1,171 @@
#include "FluidSim/Constants.hpp"
#include "FluidSim/OutdoorAudio.hpp"
#include <algorithm>
#include <bit>
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
namespace {
Vec3 sub(Vec3 a, Vec3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
float length(Vec3 a) { return std::sqrt(a.x * a.x + a.y * a.y + a.z * a.z); }
float distance(Vec3 a, Vec3 b) { return length(sub(a, b)); }
AudioFrame add(AudioFrame a, AudioFrame b) { return {a.left + b.left, a.right + b.right}; }
} // namespace
const char *scene_name(OutdoorScene scene) {
switch (scene) {
case OutdoorScene::Dry:
return "Dry";
case OutdoorScene::Field:
return "Open field";
case OutdoorScene::Wall:
return "Nearby wall";
case OutdoorScene::Woodland:
return "Woodland";
}
return "Unknown";
}
float OutdoorAudio::LowPass::process(float x) {
const float y = b0 * x + z1;
z1 = b1 * x - a1 * y + z2;
z2 = b2 * x - a2 * y;
if (std::abs(z1) < 1e-25f)
z1 = 0;
if (std::abs(z2) < 1e-25f)
z2 = 0;
return y;
}
OutdoorAudio::OutdoorAudio(int rate, OutdoorAudioConfig config) : rate_(rate), config_(config) {
if (rate < 8000 || !std::isfinite(config.sound_speed) || config.sound_speed <= 0 ||
!std::isfinite(config.monitor_cutoff_hz) || config.monitor_cutoff_hz <= 0 ||
!std::isfinite(config.pressure_gain) || config.pressure_gain < 0)
throw std::invalid_argument("invalid outdoor audio configuration");
const float norm = length(config_.listener_right);
if (!std::isfinite(norm) || norm < 1e-6f)
throw std::invalid_argument("invalid listener right vector");
config_.listener_right = {config_.listener_right.x / norm, config_.listener_right.y / norm,
config_.listener_right.z / norm};
configure_emitter(emitters_[0], config_.intake_position);
configure_emitter(emitters_[1], config_.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];
line.buffer.resize(std::max(1, int(std::lround(lengths[i] * rate_ / 44100.0))));
line.gain = std::pow(10.0f, -3.0f * (float(line.buffer.size()) / rate_) / 0.35f);
}
scene_alpha_ = 1 - std::exp(-1.0f / (rate_ * .02f));
tail_alpha_ = 1 - std::exp(-2 * pi * 2500 / rate_);
}
void OutdoorAudio::configure_emitter(Emitter &emitter, Vec3 source) {
emitter.filtered.resize(std::bit_ceil(std::size_t(rate_)));
emitter.raw.resize(emitter.filtered.size()); // One second, validated path range.
const auto ear = [&](float sign) {
return Vec3{config_.listener.x + sign * config_.listener_right.x * config_.ear_spacing * .5f,
config_.listener.y + sign * config_.listener_right.y * config_.ear_spacing * .5f,
config_.listener.z + sign * config_.listener_right.z * config_.ear_spacing * .5f};
};
const auto path = [&](Vec3 image, float coefficient, int group, float cutoff, float extra = 0,
float source_distance = 1) {
Path p;
p.group = group;
const float dl = std::max(distance(image, ear(-1)), .1f), dr = std::max(distance(image, ear(1)), .1f);
const float sl = (dl + extra) * rate_ / config_.sound_speed,
sr = (dr + extra) * rate_ / config_.sound_speed;
if (!std::isfinite(sl) || !std::isfinite(sr) || sl >= rate_ - 2 || sr >= rate_ - 2)
throw std::invalid_argument("outdoor propagation path exceeds one second");
p.delay_left = std::size_t(sl);
p.delay_right = std::size_t(sr);
p.fraction_left = sl - p.delay_left;
p.fraction_right = sr - p.delay_right;
p.gain_left = coefficient / (dl * source_distance);
p.gain_right = coefficient / (dr * source_distance);
p.alpha = cutoff > 0 ? 1 - std::exp(-2 * pi * cutoff / rate_) : 1;
emitter.paths.push_back(p);
};
path(source, 1, 0, 0);
path({source.x, 2 * config_.ground_y - source.y, source.z}, config_.ground_reflection, 1, 1800);
path({2 * config_.wall_x - source.x, source.y, source.z}, config_.wall_reflection, 2, 4500);
// Single-bounce point scatterers: geometric paths, empirical scattering strengths.
for (Vec3 tree :
std::array<Vec3, 6>{{{-4, 1, 3}, {3, 1, 5}, {5, 1, -2}, {-6, 2, -3}, {-2, 1, 7}, {6, 2, 6}}}) {
const float first = std::max(distance(source, tree), .1f);
path(tree, .35f, 3, 2400, first, first);
}
const float cutoff = std::min(config_.monitor_cutoff_hz, rate_ * .45f);
const float w = 2 * pi * cutoff / rate_, cs = std::cos(w), alpha = std::sin(w) / std::sqrt(2.0f),
a0 = 1 + alpha;
emitter.filter = {(1 - cs) * .5f / a0, (1 - cs) / a0, (1 - cs) * .5f / a0, -2 * cs / a0,
(1 - alpha) / a0};
}
AudioFrame OutdoorAudio::render(Emitter &e, float flow, AudioFrame &dry, AudioFrame &raw) {
const float pressure = (flow - e.previous_flow) * rate_ * config_.pressure_gain / (4 * pi);
e.previous_flow = flow;
const float filtered = e.filter.process(pressure);
e.raw[e.position] = pressure;
e.filtered[e.position] = monitor_filter_ ? filtered : pressure;
const auto read = [&](const std::vector<float> &data, std::size_t delay, float fraction) {
const auto i = (e.position + data.size() - delay) & (data.size() - 1);
const auto j = (i + data.size() - 1) & (data.size() - 1);
return data[i] + (data[j] - data[i]) * fraction;
};
AudioFrame result{};
for (auto &p : e.paths) {
const float l = read(e.filtered, p.delay_left, p.fraction_left) * p.gain_left;
const float r = read(e.filtered, p.delay_right, p.fraction_right) * p.gain_right;
p.state_left += p.alpha * (l - p.state_left);
p.state_right += p.alpha * (r - p.state_right);
if (std::abs(p.state_left) < 1e-25f)
p.state_left = 0;
if (std::abs(p.state_right) < 1e-25f)
p.state_right = 0;
result.left += p.state_left * weights_[p.group];
result.right += p.state_right * weights_[p.group];
if (p.group == 0) {
dry = add(dry, {l, r});
raw = add(raw, {read(e.raw, p.delay_left, p.fraction_left) * p.gain_left,
read(e.raw, p.delay_right, p.fraction_right) * p.gain_right});
}
}
e.position = (e.position + 1) & (e.filtered.size() - 1);
return result;
}
AudioFrame OutdoorAudio::diffuse(float input) {
std::array<float, 4> values{};
float sum = 0;
for (std::size_t i = 0; i < 4; ++i) {
auto &line = tail_[i];
values[i] = line.buffer[line.position];
sum += values[i];
}
for (std::size_t i = 0; i < 4; ++i) {
auto &line = tail_[i];
const float scattered = values[i] - .5f * sum;
line.state += tail_alpha_ * (scattered - line.state);
if (std::abs(line.state) < 1e-25f)
line.state = 0;
line.buffer[line.position] = line.gain * line.state + .5f * input;
line.position = (line.position + 1) % line.buffer.size();
}
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))
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,
scene_ == OutdoorScene::Woodland ? 1.f : 0.f};
for (std::size_t i = 0; i < 4; ++i)
weights_[i] += (target[i] - weights_[i]) * scene_alpha_;
const float target_tail =
scene_ == OutdoorScene::Wall
? .14f
: (scene_ == OutdoorScene::Woodland ? .25f : (scene_ == OutdoorScene::Field ? .025f : 0));
tail_mix_ += (target_tail - tail_mix_) * scene_alpha_;
last_dry_ = {};
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 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_};
}
} // namespace fluidsim::port

View File

@@ -5,168 +5,184 @@
namespace fluidsim::port {
FourStrokeTwinScenario::FourStrokeTwinScenario(int sample_rate)
: pipes(create_pipes()),
dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))),
boundaries_(pipes), solver_(pipes, boundaries_),
intake_radiation_(sample_rate), exhaust_radiation_(sample_rate),
reverb_(sample_rate), mechanical_sound_(sample_rate) {
crankshaft.omega = 2000.0f * 2.0f * pi / 60.0f;
crankshaft.inertia = 0.04f;
crankshaft.friction_constant = 5.0f;
crankshaft.friction_viscous = 0.003f;
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),
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) {
const bool saab = preset == FourStrokePreset::SaabB205;
const int count = cylinder_count();
crankshaft.omega = (saab ? 1200 : 2000) * 2 * pi / 60;
crankshaft.inertia = saab ? .18f : .04f;
crankshaft.friction_constant = saab ? 12.f : 5.f;
crankshaft.friction_viscous = saab ? .012f : .003f;
if (saab) {
// Rough 9-5 wagon with driver; FM55 B05 overall reductions from WIS.
vehicle.mass = 1600 + 75;
vehicle.primary_reduction = 1;
vehicle.final_drive_ratio = 4.05f;
vehicle.gear_ratios = {13.70f/4.05f, 7.12f/4.05f, 4.77f/4.05f, 3.62f/4.05f, 2.67f/4.05f};
vehicle.wheel_radius = .317f;
vehicle.rotational_inertia = 3.6f;
vehicle.drag_coefficient = .32f;
vehicle.frontal_area = 2.2f;
vehicle.clutch_capacity = 300;
ecu.idle_rpm = 850;
ecu.idle_spark_gain = .025f;
ecu.idle_minimum_advance = -10;
ecu.rev_limit_rpm = 6500;
ecu.rev_resume_rpm = 6250;
ecu.overrun_cut_rpm = 2000;
ecu.overrun_resume_rpm = 1300;
}
vehicle.set_neutral();
vehicle.clutch_input = clutch;
cylinder1.intake_valve_diameter = 0.030f;
cylinder1.intake_valve_lift = 0.0080f;
cylinder1.exhaust_valve_diameter = 0.026f;
cylinder1.exhaust_valve_lift = 0.0075f;
cylinder2.phase = pi;
cylinder2.intake_valve_diameter = 0.030f;
cylinder2.intake_valve_lift = 0.0080f;
cylinder2.exhaust_valve_diameter = 0.026f;
cylinder2.exhaust_valve_lift = 0.0075f;
cylinder1.rev_limit_enabled = cylinder2.rev_limit_enabled = true;
cylinder1.rev_limit_rpm = cylinder2.rev_limit_rpm = 10000.0f;
cylinder1.rev_limit_cut_time = cylinder2.rev_limit_cut_time = 0.020f;
cylinder1.rev_limit_hysteresis = cylinder2.rev_limit_hysteresis = 250.0f;
cylinder1.initialize(crankshaft);
cylinder2.initialize(crankshaft);
const float intake_area = area_for_diameter(0.0275f);
const float exhaust_area = area_for_diameter(0.028f);
runner_area_ = intake_area;
header_area_ = tail_area_ = exhaust_area;
for (auto& pipe : pipes) {
pipe.damping_multiplier = 1.0f;
pipe.energy_relaxation_rate = 0.5f;
pipe.ambient_pressure = 101325.0f;
const auto engines = cylinders();
const std::array<float, 4> phases{0, pi, 3*pi, 2*pi}; // I4 firing: 1-3-4-2
for (int i = 0; i < count; ++i) {
auto& c = *engines[i];
c.phase = phases[i];
if (saab) {
c.bore = .090f;
c.stroke = .078f;
c.rod = .153f; // estimated, not a verified B205 rod measurement
c.compression = 8.8f;
c.intake_valve_count = c.exhaust_valve_count = 2;
}
c.intake_valve_diameter = saab ? .032f : .030f;
c.intake_valve_lift = saab ? .0085f : .008f;
c.exhaust_valve_diameter = saab ? .028f : .026f;
c.exhaust_valve_lift = saab ? .008f : .0075f;
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) {
// Initialize each phased cylinder at its actual piston position.
c.volume = c.compute_volume(c.phase);
c.air_mass = ambient_density * c.volume;
c.energy = 101325 * c.volume / (gamma - 1);
c.update_state(0);
}
}
throttle_port_ = &intake_plenum.create_port();
runner_feed1_ = &intake_plenum.create_port();
runner_feed2_ = &intake_plenum.create_port();
collector_in1_ = &exhaust_collector.create_port();
collector_in2_ = &exhaust_collector.create_port();
collector_out_ = &exhaust_collector.create_port();
intake_radiation_.pipe_end = {-0.20f, 0.0f, 0.0f};
intake_radiation_.pipe_axis = {-1.0f, 0.0f, 0.0f};
intake_radiation_.gain = 30.0f;
intake_radiation_.directivity_balance = 0.30f;
intake_radiation_.flow_tau = 0.005f;
intake_radiation_.smooth_tau = 0.020f;
exhaust_radiation_.pipe_end = {0.55f, 0.0f, 0.0f};
exhaust_radiation_.pipe_axis = {1.0f, 0.0f, 0.0f};
exhaust_radiation_.gain = 25.0f;
exhaust_radiation_.directivity_balance = 0.30f;
exhaust_radiation_.flow_tau = 0.005f;
exhaust_radiation_.smooth_tau = 0.020f;
reverb_.dry_mix = 0.0f;
reverb_.early_mix = 0.30f;
reverb_.tail_mix = 0.22f;
reverb_.transient_feedback_boost = 0.40f;
reverb_.transient_hf_boost = 3.5f;
reverb_.saturation_drive = 1.5f;
solver_.add_component(cylinder1);
solver_.add_component(cylinder2);
intake_boundary_area_ = pipes.front().area;
runner_area_ = pipes[1].area;
header_area_ = pipes[1+count].area;
tail_area_ = pipes[1+2*count].area;
for (auto& pipe : pipes) {
pipe.damping_multiplier = saab ? 0.f : 1.f;
pipe.energy_relaxation_rate = 0;
}
auto& throttle_port = intake_plenum.create_port();
solver_.add_component(intake_atmosphere);
solver_.add_component(intake_plenum);
solver_.add_component(exhaust_collector);
boundaries_.add_open_end(0, true, 101325.0f, intake_area);
boundaries_.add_open_end(5, false, 101325.0f, exhaust_area);
boundaries_.add_orifice(*throttle_port_, 0, false, throttle_area_, 0.7f);
boundaries_.add_orifice(*runner_feed1_, 1, true, runner_area_);
boundaries_.add_orifice(*runner_feed2_, 2, true, runner_area_);
boundaries_.add_orifice(cylinder1.intake, 1, false, valve1_in_);
boundaries_.add_orifice(cylinder2.intake, 2, false, valve2_in_);
boundaries_.add_orifice(cylinder1.exhaust, 3, true, valve1_out_);
boundaries_.add_orifice(cylinder2.exhaust, 4, true, valve2_out_);
boundaries_.add_orifice(*collector_in1_, 3, false, header_area_);
boundaries_.add_orifice(*collector_in2_, 4, false, header_area_);
boundaries_.add_orifice(*collector_out_, 5, true, tail_area_);
if (turbo_enabled) {
turbo = std::make_unique<Turbocharger>();
const int fresh = 2+2*count, exhaust_exit = fresh+1;
boundaries_.add_orifice(intake_atmosphere.port, fresh, true, intake_boundary_area_);
turbo->connect(boundaries_, pipes, fresh, false, 0, true, 1+2*count, false, exhaust_exit, true);
solver_.add_component(*turbo);
} else {
boundaries_.add_orifice(intake_atmosphere.port, 0, true, intake_boundary_area_);
}
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();
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]);
boundaries_.add_orifice(engines[i]->exhaust, 1+count+i, true, exhaust_valve_areas_[i]);
boundaries_.add_orifice(collector_in, 1+count+i, false, header_area_);
}
auto& collector_out = exhaust_collector.create_port();
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);
}
AudioFrame FourStrokeTwinScenario::process() {
const std::lock_guard<std::mutex> guard(mutex_);
crankshaft.load_torque = std::clamp(load, 0.0f, 1.0f) * 30.0f;
vehicle.clutch_input = std::clamp(clutch, 0.0f, 1.0f);
crankshaft.step(dt_);
crankshaft.drivetrain_torque = vehicle.update(crankshaft.omega, crankshaft.inertia, dt_);
cylinder1.pre_step(crankshaft, dt_, throttle);
cylinder2.pre_step(crankshaft, dt_, throttle);
const float throttle_fraction = std::clamp(throttle, 0.0f, 1.0f);
throttle_area_ = throttle_fraction <= 0.001f ? 0.0f : area_for_diameter(0.026f) * throttle_fraction;
const auto cycle_degrees = [this](float phase) {
float value = std::fmod((crankshaft.angle + phase) * 180.0f / pi, 720.0f);
return value < 0.0f ? value + 720.0f : value;
};
valve1_in_ = cylinder1.valve_area(cycle_degrees(cylinder1.phase), true);
valve2_in_ = cylinder2.valve_area(cycle_degrees(cylinder2.phase), true);
valve1_out_ = cylinder1.valve_area(cycle_degrees(cylinder1.phase), false);
valve2_out_ = cylinder2.valve_area(cycle_degrees(cylinder2.phase), false);
AudioFrame FourStrokeScenario::process() {
const std::lock_guard guard(mutex_);
step_motorcycle(crankshaft, dt_, preset == FourStrokePreset::SaabB205 ? 200.f : 30.f);
ecu.step(dt_, throttle, crankshaft.omega * 60 / (2*pi));
const auto engines = cylinders();
for (int i = 0; i < cylinder_count(); ++i) {
auto& c = *engines[i];
c.pre_step(crankshaft, dt_, throttle);
const float degrees = std::fmod((crankshaft.angle+c.phase)*180/pi, 720.f);
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));
solver_.step(dt_);
intake_flow = boundaries_.open_end_mass_flow(0);
exhaust_flow = boundaries_.open_end_mass_flow(1);
const AudioFrame intake_audio = intake_radiation_.process(intake_flow);
const AudioFrame exhaust_audio = exhaust_radiation_.process(exhaust_flow);
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 mechanical = mechanical_sound_enabled
? mechanical_sound_.process(crankshaft, cylinder1, load) * mechanical_mix : 0.0f;
const float reverb_input = intake_radiation_.last_mono_pressure() * intake_send +
exhaust_radiation_.last_mono_pressure() * exhaust_send +
mechanical * mechanical_send;
const AudioFrame wet = reverb_.process_stereo(reverb_input);
const float flow_left = std::tanh(intake_audio.left + exhaust_audio.left + wet.left * reverb_send_gain);
const float flow_right = std::tanh(intake_audio.right + exhaust_audio.right + wet.right * reverb_send_gain);
last_mix_l = std::isfinite(flow_left + mechanical)
? std::clamp(flow_left + mechanical, -1.0f, 1.0f) : 0.0f;
last_mix_r = std::isfinite(flow_right + mechanical)
? std::clamp(flow_right + mechanical, -1.0f, 1.0f) : 0.0f;
? mechanical_sound_.process(crankshaft, cylinder1, load) * mechanical_mix : 0;
last_mix_l = rendered.left + mechanical;
last_mix_r = rendered.right + mechanical;
++sample_count;
return {last_mix_l, last_mix_r};
}
void FourStrokeTwinScenario::set_controls(float throttle_value, float load_value, float clutch_value) {
void FourStrokeScenario::set_audio_scene(OutdoorScene scene) {
const std::lock_guard<std::mutex> guard(mutex_); audio_.set_scene(scene);
}
void FourStrokeScenario::set_monitor_filter(bool enabled) {
const std::lock_guard<std::mutex> guard(mutex_); audio_.set_monitor_filter(enabled);
}
AudioFrame FourStrokeScenario::dry_audio() const {
const std::lock_guard<std::mutex> guard(mutex_); return audio_.dry_frame();
}
AudioFrame FourStrokeScenario::unfiltered_audio() const {
const std::lock_guard<std::mutex> guard(mutex_); return audio_.unfiltered_frame();
}
void FourStrokeScenario::set_controls(float throttle_value, float load_value, float clutch_value) {
const std::lock_guard<std::mutex> guard(mutex_);
throttle = std::clamp(throttle_value, 0.0f, 1.0f);
load = std::clamp(load_value, 0.0f, 1.0f);
clutch = std::clamp(clutch_value, 0.0f, 1.0f);
}
void FourStrokeTwinScenario::toggle_mechanical_sound() {
void FourStrokeScenario::toggle_mechanical_sound() {
const std::lock_guard<std::mutex> guard(mutex_);
mechanical_sound_enabled = !mechanical_sound_enabled;
}
void FourStrokeTwinScenario::shift_up() {
void FourStrokeScenario::shift_up() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.shift_up();
}
void FourStrokeTwinScenario::shift_down() {
void FourStrokeScenario::shift_down() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.shift_down();
}
void FourStrokeTwinScenario::set_neutral() {
void FourStrokeScenario::set_neutral() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.set_neutral();
}
void FourStrokeTwinScenario::set_first_gear() {
void FourStrokeScenario::set_first_gear() {
const std::lock_guard<std::mutex> guard(mutex_);
vehicle.set_first_gear();
}
ScenarioSnapshot FourStrokeTwinScenario::snapshot() const {
ScenarioSnapshot FourStrokeScenario::snapshot() const {
const std::lock_guard<std::mutex> guard(mutex_);
ScenarioSnapshot result;
result.turbo_enabled = bool(turbo);
if (turbo) result.turbo = turbo->snapshot();
result.solver_substeps = solver_.substep_count();
result.boundary_iterations = boundaries_.nonlinear_iterations;
result.rpm = crankshaft.omega * 60.0f / (2.0f * pi);
result.speed_kmh = vehicle.speed * 3.6f;
result.kinetic_energy = vehicle.kinetic_energy();
@@ -178,13 +194,18 @@ ScenarioSnapshot FourStrokeTwinScenario::snapshot() const {
result.cylinder_temperature2 = cylinder2.temperature();
result.cylinder_piston_fraction1 = (cylinder1.volume - cylinder1.clearance_volume()) / cylinder1.swept_volume();
result.cylinder_piston_fraction2 = (cylinder2.volume - cylinder2.clearance_volume()) / cylinder2.swept_volume();
result.valve1_intake_area = valve1_in_;
result.valve1_exhaust_area = valve1_out_;
result.valve2_intake_area = valve2_in_;
result.valve2_exhaust_area = valve2_out_;
result.valve1_intake_area = intake_valve_areas_[0];
result.valve1_exhaust_area = exhaust_valve_areas_[0];
result.valve2_intake_area = intake_valve_areas_[1];
result.valve2_exhaust_area = exhaust_valve_areas_[1];
result.intake_pressure = intake_plenum.pressure();
result.exhaust_pressure = exhaust_collector.pressure();
result.throttle = throttle;
result.throttle = ecu.throttle_opening();
result.pedal = throttle;
result.target_afr = ecu.target_afr();
result.idle_control = ecu.idle_active();
result.fuel_cut = ecu.fuel_cut();
result.audio_scene = audio_.scene();
result.clutch = clutch;
result.intake_flow = intake_flow;
result.exhaust_flow = exhaust_flow;
@@ -192,31 +213,39 @@ ScenarioSnapshot FourStrokeTwinScenario::snapshot() const {
result.rev_limit1 = cylinder1.rev_limit_active;
result.rev_limit2 = cylinder2.rev_limit_active;
result.mechanical_sound_enabled = mechanical_sound_enabled;
for (std::size_t pipe_index = 0; pipe_index < pipes.size(); ++pipe_index) {
auto& states = result.pipe_states[pipe_index];
states.reserve(pipes[pipe_index].cells.size());
for (std::size_t cell = 0; cell < pipes[pipe_index].cells.size(); ++cell)
states.push_back(pipes[pipe_index].state(static_cast<int>(cell)));
result.cylinder_count = cylinder_count();
result.rev_limit_rpm = ecu.rev_limit_rpm;
const std::array<const FourStrokeCylinder*, 4> engines{&cylinder1, &cylinder2, &cylinder3, &cylinder4};
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();
result.intake_areas[i] = intake_valve_areas_[i];
result.exhaust_areas[i] = exhaust_valve_areas_[i];
}
result.capture_pipes(pipes);
return result;
}
float FourStrokeScenario::area_for_diameter(float diameter) {
return pi * diameter * diameter * 0.25f;
}
std::vector<Pipe1D> FourStrokeScenario::create_pipes(FourStrokePreset preset, bool turbo_enabled) {
const bool saab = preset == FourStrokePreset::SaabB205;
const int count = saab ? 4 : 2;
std::vector<Pipe1D> result;
result.reserve(4+2*count);
result.emplace_back(area_for_diameter(saab ? .060f : .0275f), saab ? .15f : .06f, saab ? 8 : 10);
for (int i = 0; i < count; ++i)
result.emplace_back(area_for_diameter(saab ? .032f : .0275f), saab ? .25f : .12f, 10);
for (int i = 0; i < count; ++i)
result.emplace_back(area_for_diameter(saab ? .034f : .028f), saab ? .28f : .20f, saab ? 10 : 6);
result.emplace_back(area_for_diameter(saab ? .060f : .028f), turbo_enabled ? .12f : (saab ? 1.4f : .40f), saab ? 40 : 10);
if (turbo_enabled) {
result.emplace_back(area_for_diameter(saab ? .060f : .0275f), .10f, 8);
result.emplace_back(area_for_diameter(saab ? .060f : .032f), .60f, 24);
}
return result;
}
float FourStrokeTwinScenario::area_for_diameter(float diameter) {
return pi * diameter * diameter * 0.25f;
}
std::vector<Pipe1D> FourStrokeTwinScenario::create_pipes() {
const float intake_area = area_for_diameter(0.0275f);
const float exhaust_area = area_for_diameter(0.028f);
std::vector<Pipe1D> result;
result.reserve(6);
result.emplace_back(intake_area, 0.06f, 10);
result.emplace_back(intake_area, 0.12f, 10);
result.emplace_back(intake_area, 0.12f, 10);
result.emplace_back(exhaust_area, 0.20f, 6);
result.emplace_back(exhaust_area, 0.20f, 6);
result.emplace_back(exhaust_area, 0.40f, 10);
return result;
}
} // namespace fluidsim::port

View File

@@ -2,6 +2,7 @@
#include <algorithm>
#include <chrono>
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
@@ -13,9 +14,9 @@ SimulationRingBuffer::SimulationRingBuffer(std::size_t capacity)
}
std::size_t SimulationRingBuffer::available_samples() const {
const auto written = write_head_.load(std::memory_order_acquire);
const auto read = read_head_.load(std::memory_order_acquire);
return static_cast<std::size_t>(written - read);
const auto written = write_head_.load(std::memory_order_acquire);
return std::min(left_.size(), static_cast<std::size_t>(written - read));
}
std::size_t SimulationRingBuffer::free_space() const {
@@ -52,6 +53,7 @@ void SimulationRingBuffer::reset_consumer() {
std::size_t SimulationRingBuffer::read_interpolated(std::span<float> dst_left,
std::span<float> dst_right,
double speed) {
speed = std::isfinite(speed) ? std::clamp(speed, 0.0001, 1.0) : 1.0;
const std::size_t count = std::min(dst_left.size(), dst_right.size());
if (!consumer_initialized_) {
read_position_ = static_cast<double>(read_head_.load(std::memory_order_acquire));
@@ -70,7 +72,7 @@ std::size_t SimulationRingBuffer::read_interpolated(std::span<float> dst_left,
const float fraction = static_cast<float>(read_position_ - static_cast<double>(floor_position));
dst_left[produced] = left0 + (left1 - left0) * fraction;
dst_right[produced] = right0 + (right1 - right0) * fraction;
read_position_ += std::clamp(speed, 0.0001, 1.0);
read_position_ += speed;
}
const auto new_read_head = static_cast<std::uint64_t>(read_position_);
const auto previous_read_head = read_head_.load(std::memory_order_relaxed);

View File

@@ -4,23 +4,34 @@
namespace fluidsim::port {
Solver::Solver(std::vector<Pipe1D>& pipes, BoundarySystem& boundaries)
Solver::Solver(std::vector<Pipe1D> &pipes, BoundarySystem &boundaries)
: pipes_(pipes), boundaries_(boundaries) {}
void Solver::add_component(Component& component) {
components_.push_back(&component);
}
void Solver::add_component(Component &component) { components_.push_back(&component); }
void Solver::step(float dt) {
if (dt <= 0.0f) return;
int substeps = std::max(minimum_substeps, 1);
for (const auto& pipe : pipes_) substeps = std::max(substeps, pipe.required_substeps(dt, 0.8f));
const float sub_dt = dt / static_cast<float>(substeps);
for (int i = 0; i < substeps; ++i) {
if (!std::isfinite(dt) || dt < 0)
throw std::invalid_argument("invalid solver timestep");
if (dt == 0)
return;
boundaries_.validate_topology();
const float maximum_dt = dt / static_cast<float>(std::max(minimum_substeps, 1));
double remaining = dt;
int count = 0;
while (remaining > double(dt) * 1e-7) {
float sub_dt = std::min(maximum_dt, static_cast<float>(remaining));
for (auto &pipe : pipes_)
sub_dt = std::min(sub_dt, pipe.prepare_step());
if (!(sub_dt > 0) || ++count > 1000000)
throw std::runtime_error("solver timestep failed to progress");
++substep_count_;
boundaries_.resolve(sub_dt);
for (auto& pipe : pipes_) pipe.advance(sub_dt);
for (auto &pipe : pipes_)
pipe.advance(sub_dt);
for (Component *component : components_)
component->update_state(sub_dt);
remaining -= sub_dt;
}
for (Component* component : components_) component->update_state(dt);
++step_count_;
}

179
src/turbocharger.cpp Normal file
View File

@@ -0,0 +1,179 @@
#include "FluidSim/Turbocharger.hpp"
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
namespace {
constexpr double cp = double(gamma)*gas_constant/(gamma-1);
constexpr double exponent = double(gamma-1)/gamma;
TurboConfig checked(TurboConfig c) {
for (double x : {c.shaft_inertia, c.compressor_radius, c.turbine_radius,
c.compressor_head_coefficient, c.turbine_optimum_speed_ratio,
c.compressor_effective_area, c.turbine_effective_area, c.gate_diaphragm_area,
c.gate_spring_stiffness, c.gate_damping, c.gate_diameter, c.reference_pressure})
if (!std::isfinite(x) || x <= 0) throw std::invalid_argument("invalid turbo geometry/inertia");
for (double x : {c.compressor_efficiency, c.turbine_peak_efficiency})
if (!std::isfinite(x) || x <= 0 || x > 1) throw std::invalid_argument("invalid turbo efficiency");
for (double x : {c.bearing_viscous, c.windage, c.gate_spring_preload, c.gate_maximum_lift})
if (!std::isfinite(x) || x < 0) throw std::invalid_argument("invalid turbo loss coefficient");
for (float x : c.connection_diameters)
if (!std::isfinite(x) || x <= 0) throw std::invalid_argument("invalid turbo connection diameter");
return c;
}
}
Turbocharger::Turbocharger(TurboConfig configuration)
: config(checked(configuration)),
compressor_inlet(config.compressor_inlet_volume), compressor_outlet(config.compressor_outlet_volume),
turbine_inlet(config.turbine_inlet_volume), turbine_outlet(config.turbine_outlet_volume),
compressor_inlet_port(compressor_inlet.create_port()),
compressor_outlet_port(compressor_outlet.create_port()),
turbine_inlet_port(turbine_inlet.create_port()), turbine_outlet_port(turbine_outlet.create_port()),
internal_{&compressor_inlet.create_port(), &compressor_outlet.create_port(),
&turbine_inlet.create_port(), &turbine_outlet.create_port()} {}
void Turbocharger::connect(BoundarySystem& b, const std::vector<Pipe1D>& pipes,
int ci, bool cil, int co, bool col, int ti, bool til, int to, bool tol) {
if (connected_) throw std::logic_error("turbo already connected");
const std::array<int, 4> indices{ci, co, ti, to};
const std::array<bool, 4> left{cil, col, til, tol};
auto external = ports();
for (std::size_t i = 0; i < 4; ++i) {
const auto& p = pipes.at(indices[i]);
const float diameter = config.connection_diameters[i];
connection_areas_[i] = std::min(p.cell_area(left[i] ? 0 : int(p.cells.size())-1), pi*diameter*diameter*.25f);
b.add_orifice(*external[i], indices[i], left[i], connection_areas_[i]);
}
connected_ = true;
}
std::vector<Port*> Turbocharger::ports() {
return {&compressor_inlet_port, &compressor_outlet_port, &turbine_inlet_port, &turbine_outlet_port};
}
double Turbocharger::fluid_energy() const {
return double(compressor_inlet.internal_energy()) + compressor_outlet.internal_energy() +
turbine_inlet.internal_energy() + turbine_outlet.internal_energy();
}
void Turbocharger::set_initial_rpm(double rpm) {
if (!std::isfinite(rpm) || rpm < 0) throw std::invalid_argument("invalid initial turbo speed");
omega_ = rpm*2*pi/60;
}
double Turbocharger::nozzle(double p, double t, double back, double area) {
if (back >= p) return 0;
const double critical = std::pow(2/(gamma+1), gamma/(gamma-1));
const double ratio = std::max(back/p, critical);
const double factor = 2*gamma/(gas_constant*(gamma-1)) *
(std::pow(ratio, 2/gamma)-std::pow(ratio, (gamma+1)/gamma));
return area*p/std::sqrt(t)*std::sqrt(std::max(0., factor));
}
double Turbocharger::transfer_flow(Volume0D& in, Volume0D& out, Port& in_ext,
Port& out_ext, double requested, double dt) {
const bool forward = requested >= 0;
const auto& donor = forward ? in : out;
const auto& external = forward ? in_ext : out_ext;
const double h = cp*donor.temperature();
// Reserve the mass/energy already promised to the external pipe boundary.
const double mass = donor.mass()+std::min(0.f, external.mass_flow)*dt;
const double energy = donor.internal_energy()+std::min(0.f, external.mass_flow)*external.enthalpy*dt;
const double bound = .25*std::max(0., std::min(mass, energy/h))/dt;
const double actual = std::clamp(requested, -bound, bound);
if (actual != requested) ++limited_transfers_;
return actual;
}
void Turbocharger::set_transfer(Volume0D& in, Volume0D& out, Port& ip, Port& op,
double flow, double work, double dt) {
const auto& donor = flow >= 0 ? in : out;
const double h = cp*donor.temperature();
ip.mass_flow = float(-flow);
op.mass_flow = float(flow);
ip.air_fraction = op.air_fraction = donor.air_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.
if (std::abs(flow) > 0) {
auto& receiver = flow >= 0 ? op : ip;
receiver.enthalpy = float(h-work/(std::abs(flow)*dt));
}
}
void Turbocharger::update_state(float timestep) {
if (!std::isfinite(timestep) || timestep < 0) throw std::invalid_argument("invalid turbo timestep");
if (timestep == 0) return;
const double dt = timestep, initial_fluid = fluid_energy(), initial_shaft = shaft_energy();
const std::array<const Port*, 4> external{&compressor_inlet_port, &compressor_outlet_port,
&turbine_inlet_port, &turbine_outlet_port};
double external_work = 0;
for (const auto* p : external) external_work += double(p->mass_flow)*p->enthalpy*dt;
const double pin = compressor_inlet.pressure(), tin = compressor_inlet.temperature();
const double head = config.compressor_head_coefficient*std::pow(omega_*config.compressor_radius, 2);
const double pump_pressure = pin*std::pow(1+config.compressor_efficiency*head/(cp*tin), 1/exponent);
double mc = compressor_outlet.pressure() <= pump_pressure
? nozzle(pump_pressure, tin+head/cp, compressor_outlet.pressure(), config.compressor_effective_area)
: -nozzle(compressor_outlet.pressure(), compressor_outlet.temperature(), pump_pressure,
config.compressor_effective_area);
mc = transfer_flow(compressor_inlet, compressor_outlet, compressor_inlet_port, compressor_outlet_port, mc, dt);
const double tp = turbine_inlet.pressure(), tout = turbine_outlet.pressure();
const double can_force = std::max(0., double(compressor_outlet.pressure())-config.reference_pressure)*
config.gate_diaphragm_area;
const double equilibrium_lift = (can_force-config.gate_spring_preload)/config.gate_spring_stiffness;
// Overdamped spring-can linkage: b*dx/dt + k*x = F-Fpreload, with seats.
wastegate_lift_ = std::clamp(equilibrium_lift+(wastegate_lift_-equilibrium_lift)*
std::exp(-dt*config.gate_spring_stiffness/config.gate_damping), 0., config.gate_maximum_lift);
const double gate_area = pi*config.gate_diameter*wastegate_lift_;
double mt = tp >= tout ? nozzle(tp, turbine_inlet.temperature(), tout, config.turbine_effective_area)
: -nozzle(tout, turbine_outlet.temperature(), tp, config.turbine_effective_area);
double mw = tp >= tout ? nozzle(tp, turbine_inlet.temperature(), tout, gate_area)
: -nozzle(tout, turbine_outlet.temperature(), tp, gate_area);
const double requested = mt+mw;
const double actual = transfer_flow(turbine_inlet, turbine_outlet, turbine_inlet_port, turbine_outlet_port, requested, dt);
if (requested != 0) { mt *= actual/requested; mw *= actual/requested; }
const double ideal_drop = tp > tout ? cp*turbine_inlet.temperature()*(1-std::pow(tout/tp, exponent)) : 0;
const double jet = std::sqrt(2*ideal_drop), radius = config.turbine_radius;
const double ratio = config.turbine_optimum_speed_ratio, eta = config.turbine_peak_efficiency;
const double drive = std::max(0., mt)*eta*jet*radius/ratio;
const double turbine_drag = std::max(0., mt)*eta*.5*radius*radius/(ratio*ratio);
const double compressor_drag = std::max(0., mc)*config.compressor_head_coefficient*
config.compressor_radius*config.compressor_radius;
// Implicit midpoint solves I*domega/dt = drive - b*omega - c*omega^2.
// Evaluating work at midpoint conserves rotor kinetic energy, even at rest.
double wt = 0, wc = 0, loss = 0, remaining = dt;
int steps = 0;
while (remaining > dt*1e-12) {
const double drag = turbine_drag+compressor_drag+config.bearing_viscous+config.windage*omega_;
const double h = std::min(remaining, config.shaft_inertia/std::max(drag, 1e-300));
if (!(h > 0) || ++steps > 1000000) throw std::runtime_error("turbo shaft timestep failed");
const double b = 2*config.shaft_inertia/h+turbine_drag+compressor_drag+config.bearing_viscous;
const double rhs = 2*config.shaft_inertia/h*omega_+drive;
const double mid = 2*rhs/(b+std::sqrt(b*b+4*config.windage*rhs));
const double next = 2*mid-omega_;
if (next < 0) throw std::runtime_error("turbo shaft integration reversed unexpectedly");
wt += (drive-turbine_drag*mid)*mid*h;
wc += compressor_drag*mid*mid*h;
loss += (config.bearing_viscous+config.windage*mid)*mid*mid*h;
omega_ = next;
angle_ = std::fmod(angle_+mid*h, 2*double(pi));
remaining -= h;
}
bearing_heat_ += loss;
turbine_power_ = wt/dt;
compressor_power_ = wc/dt;
bearing_power_ = loss/dt;
turbine_flow_ = mt;
wastegate_flow_ = mw;
compressor_flow_ = mc;
set_transfer(compressor_inlet, compressor_outlet, *internal_[0], *internal_[1], mc, -wc, dt);
set_transfer(turbine_inlet, turbine_outlet, *internal_[2], *internal_[3], mt+mw, wt, dt);
compressor_inlet.update_state(timestep);
compressor_outlet.update_state(timestep);
turbine_inlet.update_state(timestep);
turbine_outlet.update_state(timestep);
energy_residual_ += fluid_energy()-initial_fluid + shaft_energy()-initial_shaft+loss-external_work;
}
TurboSnapshot Turbocharger::snapshot() const {
return {omega_*60/(2*pi), angle_, shaft_energy(), turbine_power_, compressor_power_, bearing_power_,
compressor_flow_, turbine_flow_, compressor_outlet.pressure()/compressor_inlet.pressure(),
turbine_inlet.pressure()/turbine_outlet.pressure(), turbine_inlet.pressure(),
turbine_outlet.pressure(), compressor_outlet.pressure(), compressor_outlet.temperature(),
bearing_heat_, energy_residual_, limited_transfers_, wastegate_flow_, wastegate_lift_};
}
} // namespace fluidsim::port

View File

@@ -1,3 +1,4 @@
#include "FluidSim/Ecu.hpp"
#include "FluidSim/TwoStrokeCylinder.hpp"
#include <algorithm>
@@ -6,12 +7,12 @@
namespace fluidsim::port {
TwoStrokeCylinder::TwoStrokeCylinder(float bore_value, float stroke_value,
float connecting_rod_length, float compression_ratio,
float transfer_duration_degrees, float exhaust_duration_degrees)
TwoStrokeCylinder::TwoStrokeCylinder(float bore_value, float stroke_value, float connecting_rod_length,
float compression_ratio, float transfer_duration_degrees,
float exhaust_duration_degrees)
: transfer_duration_(transfer_duration_degrees), exhaust_duration_(exhaust_duration_degrees) {
if (transfer_duration_ <= 0.0f || transfer_duration_ >= 360.0f ||
exhaust_duration_ <= 0.0f || exhaust_duration_ >= 360.0f)
if (transfer_duration_ <= 0.0f || transfer_duration_ >= 360.0f || exhaust_duration_ <= 0.0f ||
exhaust_duration_ >= 360.0f)
throw std::invalid_argument("two-stroke port durations must be between 0 and 360 degrees");
bore = bore_value;
stroke = stroke_value;
@@ -21,59 +22,43 @@ TwoStrokeCylinder::TwoStrokeCylinder(float bore_value, float stroke_value,
throw std::invalid_argument("two-stroke exhaust must open before the transfer port");
}
float TwoStrokeCylinder::lift(float angle, float opens, float closes, float peak_lift) {
float mapped = std::fmod(angle, 360.0f);
if (mapped < 0.0f) mapped += 360.0f;
float effective_close = closes;
if (effective_close < opens) effective_close += 360.0f;
if (mapped < opens) mapped += 360.0f;
const float duration = effective_close - opens;
if (mapped < opens || mapped > effective_close || duration <= 0.0f) return 0.0f;
const float ramp = duration * 0.25f;
const float hold = duration - 2.0f * ramp;
if (mapped < opens + ramp) {
const float t = (mapped - opens) / ramp;
return peak_lift * t * t * (3.0f - 2.0f * t);
}
if (mapped < opens + ramp + hold) return peak_lift;
const float t = (mapped - (opens + ramp + hold)) / ramp;
return peak_lift * (1.0f - t) * (1.0f - t) * (1.0f + 2.0f * t);
}
float TwoStrokeCylinder::valve_area(float cycle_degrees, bool inlet) const {
const float opens = inlet ? transfer_open() : exhaust_open();
const float closes = inlet ? transfer_close() : exhaust_close();
const float diameter = inlet ? intake_valve_diameter : exhaust_valve_diameter;
const float peak_lift = inlet ? intake_valve_lift : exhaust_valve_lift;
return pi * diameter * lift(cycle_degrees, opens, closes, peak_lift);
const float width = inlet ? transfer_width : exhaust_width;
const float piston_area = pi * bore * bore * .25f;
const float port_top = compute_volume(opens * pi / 180);
const float piston = compute_volume(cycle_degrees * pi / 180);
return width * std::max(0.0f, (piston - port_top) / piston_area);
}
void TwoStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*/) {
void TwoStrokeCylinder::pre_step(Crankshaft &crank, float dt, float /*throttle*/) {
const float rpm = crank.omega * 60.0f / (2.0f * pi);
spark_advance = std::clamp(10.0f + rpm * 0.002f, 5.0f, 40.0f);
update_rev_limiter(rpm, dt);
current_rpm_ = rpm;
if (ecu) {
spark_advance = ecu->spark_advance();
rev_limit_active = ecu->rev_limited();
} else {
spark_advance = std::clamp(10.0f + rpm * 0.002f, 5.0f, 40.0f);
update_rev_limiter(rpm, dt);
}
const float theta = crank.angle + phase;
const float previous_volume = volume;
volume = compute_volume(theta);
pressure = (gamma - 1.0f) * energy / std::max(volume, 1e-12f);
const float dvolume = volume - previous_volume;
const float radius = stroke * 0.5f;
const float obliquity = radius / rod;
const float sine = std::sin(theta), cosine = std::cos(theta);
const float term = std::sqrt(std::max(1.0f - obliquity * obliquity * sine * sine, 1e-12f));
const float dx_dtheta = radius * sine * (1.0f + obliquity * cosine / term);
const float piston_area = pi * 0.25f * bore * bore;
const float back_pressure = crankcase_ ? crankcase_->pressure() : 101325.0f;
crank.add_torque((pressure - back_pressure) * piston_area * dx_dtheta);
energy -= pressure * dvolume;
// Exact closed-gas geometry work; use the same work for crank torque.
const float old_energy=energy;
energy*=std::pow(previous_volume/volume,gamma-1.0f);
const float gas_work=old_energy-energy;
const float back_work=crankcase_?crankcase_->geometry_work():-101325.0f*(volume-previous_volume);
const float angle_step=crank.omega*dt;
if(angle_step>1e-9f)crank.add_torque((gas_work+back_work)/angle_step);
pressure=(gamma-1)*energy/volume;
const float previous_degrees = wrap_degrees((crank.previous_angle + phase) * 180.0f / pi, 360.0f);
const float current_degrees = wrap_degrees(theta * 180.0f / pi, 360.0f);
handle_cycle_events(previous_degrees, current_degrees);
if (rev_limit_active) {
if (!ecu && rev_limit_active) {
fuel_mass = 0.0f;
fuel_injected = false;
combustion_active = false;
@@ -83,29 +68,35 @@ void TwoStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*/
}
void TwoStrokeCylinder::handle_cycle_events(float previous_degrees, float current_degrees) {
if (previous_degrees >= transfer_open() && previous_degrees < transfer_close() &&
current_degrees >= transfer_close()) {
fuel_mass = air_mass / std::max(afr, 1e-6f);
fuel_injected = true;
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;
}
const float spark_angle = wrap_degrees(360.0f - spark_advance, 360.0f);
const bool crossed_spark = (previous_degrees < spark_angle && current_degrees >= spark_angle) ||
(previous_degrees > spark_angle && current_degrees < spark_angle);
if (crossed_spark && fuel_injected && !combustion_active && !rev_limit_active &&
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) &&
random_unit() >= misfire_probability) {
combustion_active = true;
burn_fraction = 0.0f;
++combustion_count_;
active_spark_angle_ = spark_angle;
// Freeze the duration and timing for this charge. Changing the ECU map
// while a charge burns must not move its combustion phase backwards.
active_burn_duration_ = std::max(burn_duration, current_rpm_ * 6 * minimum_burn_seconds);
energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.0f);
}
if (!combustion_active) return;
float angle_since_spark = current_degrees - spark_angle;
if (angle_since_spark < 0.0f) angle_since_spark += 360.0f;
const float phi = (angle_since_spark - burn_start) / std::max(burn_duration, 1e-6f);
if (!combustion_active)
return;
float angle_since_spark = current_degrees - active_spark_angle_;
if (angle_since_spark < 0.0f)
angle_since_spark += 360.0f;
const float phi = (angle_since_spark - burn_start) / std::max(active_burn_duration_, 1e-6f);
float fraction = phi <= 0.0f ? 0.0f : 1.0f - std::exp(-wiebe_a * std::pow(phi, wiebe_m + 1.0f));
fraction = std::max(burn_fraction, std::clamp(fraction, 0.0f, 1.0f));
if (fraction >= 1.0f || angle_since_spark > burn_duration + burn_start + spark_advance) {
if (fraction >= 1.0f || angle_since_spark > active_burn_duration_ + burn_start) {
fraction = 1.0f;
combustion_active = false;
exhaust_mass += air_mass;

View File

@@ -5,37 +5,60 @@
namespace fluidsim::port {
TwoStrokeScenario::TwoStrokeScenario(int sample_rate)
: cylinder(0.054f, 0.0545f, 0.110f, 7.2f, 140.0f, 195.0f),
crankcase(crankshaft, 0.0545f * 0.5f, 0.110f, 0.054f, 150e-6f),
pipes(create_pipes()), dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))),
max_throttle_area_(area_for_diameter(0.042f)),
reed_in_area_(area_for_diameter(0.040f)),
reed_out_area_(0.0f), transfer_pipe_area_(area_for_diameter(0.040f)),
transfer_valve_area_(0.0f), exhaust_valve_area_(0.0f), boundaries_(pipes), solver_(pipes, boundaries_),
intake_sound_(sample_rate), exhaust_sound_(sample_rate), reverb_(sample_rate) {
TwoStrokeScenario::TwoStrokeScenario(int sample_rate, TwoStrokeConfig config)
: configuration(config),
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)),
dt_(1.0f / static_cast<float>(std::max(sample_rate, 1))), max_throttle_area_(area_for_diameter(config.carburetor_diameter)),
reed_in_area_(area_for_diameter(0.040f)), reed_out_area_(0.0f),
transfer_pipe_area_(area_for_diameter(0.032f)), transfer_valve_area_(0.0f), exhaust_valve_area_(0.0f),
boundaries_(pipes), solver_(pipes, boundaries_), audio_(sample_rate) {
crankshaft.omega = 3000.0f * 2.0f * pi / 60.0f;
crankshaft.inertia = 0.01f;
crankshaft.friction_constant = 1.0f;
crankshaft.friction_viscous = 0.002f;
const float pressure_to_torque = cylinder.swept_volume() * 1e5f / (2 * pi);
const float piston_speed_per_omega = config.stroke / pi;
crankshaft.friction_constant = pressure_to_torque * config.friction_bar;
crankshaft.friction_viscous = pressure_to_torque * config.friction_bar_per_mps * piston_speed_per_omega;
crankshaft.friction_quadratic = pressure_to_torque * config.friction_bar_per_mps2 *
piston_speed_per_omega * piston_speed_per_omega;
cylinder.intake_valve_diameter = 0.030f;
cylinder.intake_valve_lift = 0.010f;
cylinder.exhaust_valve_diameter = 0.040f;
cylinder.exhaust_valve_lift = 0.010f;
cylinder.transfer_width = config.transfer_width;
cylinder.exhaust_width = config.exhaust_width;
cylinder.minimum_burn_seconds = config.minimum_burn_seconds;
// Yamaha's 2022 YZ125 published ratios; mass includes a nominal 75 kg rider.
vehicle.primary_reduction = 64.f / 19;
vehicle.final_drive_ratio = 49.f / 13;
vehicle.gear_ratios = {31.f/13, 29.f/15, 27.f/17, 23.f/17, 20.f/17, 19.f/18};
vehicle.mass = 95 + 75;
vehicle.wheel_radius = .3313f; // nominal 100/90-19 geometry; loaded radius unmeasured
// Estimated two-stroke ignition calibration, not a factory CDI map.
ecu.ignition_map = {{0, 10}, {3000, 18}, {6000, 24}, {9000, 18},
{11000, 12}, {13000, 6}, {15000, 2}, {18000, 0}};
ecu.idle_rpm = 3000;
ecu.rev_limit_enabled = false;
cylinder.rev_limit_enabled = false;
ecu.idle_base_opening = .08f;
ecu.idle_max_opening = .3f;
ecu.idle_spark_gain=.04f;
ecu.overrun_cut_rpm = 5000;
ecu.overrun_resume_rpm = 3500;
cylinder.ecu = &ecu;
cylinder.initialize(crankshaft);
cylinder.set_crankcase(crankcase);
for (auto& pipe : pipes) {
for (auto &pipe : pipes) {
pipe.damping_multiplier = 0.8f;
pipe.energy_relaxation_rate = 0.4f;
pipe.energy_relaxation_rate = 0.0f;
pipe.ambient_pressure = 101325.0f;
}
plenum_inlet_ = &intake_plenum.create_port();
plenum_outlet_ = &intake_plenum.create_port();
boundaries_.add_open_end(0, true, 101325.0f, pipes[0].area);
boundaries_.add_open_end(3, false, 101325.0f, pipes[3].cell_area(static_cast<int>(pipes[3].cells.size()) - 1));
intake_boundary_area_ = pipes[0].area;
boundaries_.add_orifice(intake_atmosphere.port, 0, true, intake_boundary_area_);
boundaries_.add_open_end(3, false, 101325.0f,
pipes[3].cell_area(static_cast<int>(pipes[3].cells.size()) - 1));
boundaries_.add_orifice(*plenum_inlet_, 0, false, max_throttle_area_, 0.72f);
boundaries_.add_orifice(*plenum_outlet_, 1, true, reed_in_area_);
boundaries_.add_orifice(crankcase.intake_port, 1, false, reed_out_area_, 0.9f);
@@ -43,103 +66,71 @@ TwoStrokeScenario::TwoStrokeScenario(int sample_rate)
boundaries_.add_orifice(cylinder.intake, 2, false, transfer_valve_area_);
boundaries_.add_orifice(cylinder.exhaust, 3, true, exhaust_valve_area_);
solver_.minimum_substeps = 4;
solver_.add_component(intake_atmosphere);
solver_.add_component(intake_plenum);
solver_.add_component(crankcase);
solver_.add_component(cylinder);
intake_sound_.gain = 4.5f;
exhaust_sound_.gain = 4.5f;
}
AudioFrame TwoStrokeScenario::process() {
const float reed_margin = 200.0f;
if (crankcase.pressure() < intake_plenum.pressure() - reed_margin) reed_open_ = true;
else if (crankcase.pressure() > intake_plenum.pressure() + reed_margin) reed_open_ = false;
const float throttle_fraction = std::clamp(throttle, 0.0f, 1.0f);
max_throttle_area_ = throttle_fraction < 0.001f ? 0.0f : area_for_diameter(0.042f) * throttle_fraction;
reed_out_area_ = reed_open_ ? reed_in_area_ : 0.0f;
if (step_count_ < 20000) crankshaft.add_torque(5.0f);
crankshaft.step(dt_);
const float upstream = pipes[1].state(static_cast<int>(pipes[1].cells.size()) - 1).pressure;
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));
step_motorcycle(crankshaft, dt_, 10.0f);
crankcase.pre_step(dt_);
cylinder.pre_step(crankshaft, dt_, throttle_fraction);
cylinder.pre_step(crankshaft, dt_, ecu.throttle_opening());
transfer_valve_area_ = cylinder.valve_area(crankshaft.angle * 180.0f / pi, true);
exhaust_valve_area_ = cylinder.valve_area(crankshaft.angle * 180.0f / pi, false);
solver_.step(dt_);
++step_count_;
const float intake = intake_sound_.process(boundaries_.open_end_mass_flow(0));
const float exhaust = exhaust_sound_.process(boundaries_.open_end_mass_flow(1));
const float reverb_input = 0.5f * (intake + exhaust);
return reverb_.process_stereo(reverb_input);
intake_flow = intake_atmosphere.port.mass_flow;
exhaust_flow = boundaries_.open_end_mass_flow(0);
return audio_.process(intake_flow, exhaust_flow);
}
void TwoStrokeScenario::set_throttle(float value) {
throttle = std::clamp(value, 0.0f, 1.0f);
void TwoStrokeScenario::set_throttle(float value) { throttle = std::clamp(value, 0.0f, 1.0f); }
void TwoStrokeScenario::set_controls(float pedal, float load_value, float clutch_value) {
set_throttle(pedal);
load = std::clamp(load_value, 0.0f, 1.0f);
clutch = std::clamp(clutch_value, 0.0f, 1.0f);
}
TwoStrokeSnapshot TwoStrokeScenario::snapshot() const {
return {
crankshaft.omega * 60.0f / (2.0f * pi), cylinder.pressure,
crankcase.pressure(), intake_plenum.pressure(), throttle,
cylinder.valve_area(crankshaft.angle * 180.0f / pi, true),
cylinder.valve_area(crankshaft.angle * 180.0f / pi, false), reed_open_
};
return {crankshaft.omega * 60.0f / (2.0f * pi),
cylinder.pressure,
crankcase.pressure(),
intake_plenum.pressure(),
ecu.throttle_opening(),
cylinder.valve_area(crankshaft.angle * 180.0f / pi, true),
cylinder.valve_area(crankshaft.angle * 180.0f / pi, false),
reed_open_,
ecu.fuel_cut(),
throttle,
audio_.scene()};
}
float TwoStrokeScenario::area_for_diameter(float diameter) {
return pi * diameter * diameter * 0.25f;
}
float TwoStrokeScenario::area_for_diameter(float diameter) { return pi * diameter * diameter * 0.25f; }
std::vector<Pipe1D> TwoStrokeScenario::create_pipes() {
const float intake_area = area_for_diameter(0.042f);
const float reed_area = area_for_diameter(0.040f);
const float transfer_area = area_for_diameter(0.040f);
std::vector<Pipe1D> TwoStrokeScenario::create_pipes(const TwoStrokeConfig& config) {
std::vector<Pipe1D> result;
result.reserve(4);
result.emplace_back(intake_area, 0.100f, 8);
result.emplace_back(reed_area, 0.030f, 4);
result.emplace_back(transfer_area, 0.200f, 8);
constexpr int exhaust_cells = 60;
constexpr float header_length = 0.130f, diffuser_length = 0.250f;
constexpr float belly_length = 0.220f, convergent_length = 0.160f;
constexpr float stinger_length = 0.080f;
constexpr float header_diameter = 0.040f, belly_diameter = 0.070f;
constexpr float stinger_diameter = 0.028f;
constexpr float total_length = header_length + diffuser_length + belly_length +
convergent_length + stinger_length;
const int header_cells = static_cast<int>(exhaust_cells * header_length / total_length);
const int diffuser_cells = static_cast<int>(exhaust_cells * diffuser_length / total_length);
const int belly_cells = static_cast<int>(exhaust_cells * belly_length / total_length);
const int convergent_cells = static_cast<int>(exhaust_cells * convergent_length / total_length);
const int stinger_cells = exhaust_cells - header_cells - diffuser_cells - belly_cells - convergent_cells;
std::vector<float> exhaust_areas;
std::vector<float> exhaust_lengths;
exhaust_areas.reserve(exhaust_cells);
exhaust_lengths.reserve(exhaust_cells);
const auto append_uniform = [&](int count, float diameter, float length) {
for (int i = 0; i < count; ++i) {
exhaust_areas.push_back(area_for_diameter(diameter));
exhaust_lengths.push_back(length / static_cast<float>(count));
}
};
append_uniform(header_cells, header_diameter, header_length);
for (int i = 0; i < diffuser_cells; ++i) {
const float t = diffuser_cells > 1 ? static_cast<float>(i) / (diffuser_cells - 1) : 0.0f;
const float diameter = header_diameter + (belly_diameter - header_diameter) * t;
exhaust_areas.push_back(area_for_diameter(diameter));
exhaust_lengths.push_back(diffuser_length / static_cast<float>(diffuser_cells));
result.emplace_back(area_for_diameter(config.carburetor_diameter), .100f, 10);
result.emplace_back(area_for_diameter(.040f), .030f, 4);
result.emplace_back(area_for_diameter(.032f), .060f, 6);
if (config.exhaust == TwoStrokeExhaust::Racing) {
result.push_back(make_profiled_pipe(config.racing_pipe, config.exhaust_cell_length));
} else {
float length = 0;
for (const auto& section : config.racing_pipe) length += section.length;
const PipeSection straight{length, config.racing_pipe.front().inlet_diameter,
config.racing_pipe.front().inlet_diameter};
result.push_back(make_profiled_pipe(std::span(&straight, 1), config.exhaust_cell_length));
}
append_uniform(belly_cells, belly_diameter, belly_length);
for (int i = 0; i < convergent_cells; ++i) {
const float t = convergent_cells > 1 ? static_cast<float>(i) / (convergent_cells - 1) : 0.0f;
const float diameter = belly_diameter + (stinger_diameter - belly_diameter) * t;
exhaust_areas.push_back(area_for_diameter(diameter));
exhaust_lengths.push_back(convergent_length / static_cast<float>(convergent_cells));
}
append_uniform(stinger_cells, stinger_diameter, stinger_length);
result.emplace_back(std::move(exhaust_areas), std::move(exhaust_lengths));
return result;
}