Compare commits

...

2 Commits

Author SHA1 Message Date
max
56e9c2867a "better" two stroke engine 2026-06-09 22:22:19 +02:00
max
1240ebc33d added two stroke scenario with vehicle 2026-06-09 21:35:48 +02:00
8 changed files with 1001 additions and 232 deletions

View File

@@ -4,33 +4,30 @@ namespace FluidSim.Components
{ {
public class Crankshaft public class Crankshaft
{ {
public float AngularVelocity; // rad/s public float AngularVelocity;
public float CrankAngle; // rad, 0 … 4π public float CrankAngle;
public float PreviousAngle; public float PreviousAngle;
public float Inertia = 0.2f; // kg·m² public float Inertia = 0.2f;
public float FrictionConstant; // N·m public float FrictionConstant;
public float FrictionViscous; // N·m per rad/s public float FrictionViscous;
public float LastNetTorque { get; private set; } public float LastNetTorque { get; private set; }
public float AveragePower { get; private set; } // smoothed, watts public float AveragePower { get; private set; }
public float AverageTorque { get; private set; } // smoothed, Nm public float AverageTorque { get; private set; }
private float externalTorque; private float externalTorque;
private float _loadTorque; // external brake torque (Nm) private float _loadTorque;
// Power averaging buffer
private readonly float[] _powerBuffer; private readonly float[] _powerBuffer;
private int _powerBufIdx; private int _powerBufIdx, _powerBufCount;
private int _powerBufCount;
private float _powerBufSum; private float _powerBufSum;
// Torque averaging buffer (same size as power buffer)
private readonly float[] _torqueBuffer; private readonly float[] _torqueBuffer;
private int _torqueBufIdx; private int _torqueBufIdx, _torqueBufCount;
private int _torqueBufCount;
private float _torqueBufSum; private float _torqueBufSum;
/// <summary>Engine cycle length in radians. 4π = fourstroke, 2π = twostroke.</summary>
public float CycleLength { get; set; } = 4f * MathF.PI;
public Crankshaft(float initialRPM = 400f) public Crankshaft(float initialRPM = 400f)
{ {
AngularVelocity = initialRPM * 2f * MathF.PI / 60f; AngularVelocity = initialRPM * 2f * MathF.PI / 60f;
@@ -43,9 +40,13 @@ namespace FluidSim.Components
public void AddTorque(float torque) => externalTorque += torque; public void AddTorque(float torque) => externalTorque += torque;
public void SetLoadTorque(float torque) public void SetLoadTorque(float torque) => _loadTorque = Math.Max(torque, 0f);
private float _effectiveInertia; // if >0, overrides Inertia
public void SetEffectiveInertia(float inertia)
{ {
_loadTorque = Math.Max(torque, 0f); _effectiveInertia = inertia;
} }
public void Step(float dt) public void Step(float dt)
@@ -57,51 +58,40 @@ namespace FluidSim.Components
PreviousAngle = CrankAngle; PreviousAngle = CrankAngle;
// Internal friction torque
float friction = FrictionConstant * MathF.Sign(AngularVelocity) float friction = FrictionConstant * MathF.Sign(AngularVelocity)
+ FrictionViscous * AngularVelocity; + FrictionViscous * AngularVelocity;
// Net torque from gas pressure minus friction (used for power/torque display)
float netTorque = externalTorque - friction; float netTorque = externalTorque - friction;
LastNetTorque = netTorque; LastNetTorque = netTorque;
// Total torque after subtracting external load (brake)
float totalNetTorque = netTorque - _loadTorque; float totalNetTorque = netTorque - _loadTorque;
float alpha = totalNetTorque / Inertia; float currentInertia = _effectiveInertia > 0f ? _effectiveInertia : Inertia;
float alpha = totalNetTorque / currentInertia;
AngularVelocity += alpha * dt; AngularVelocity += alpha * dt;
if (AngularVelocity < 0f) AngularVelocity = 0f; if (AngularVelocity < 0f) AngularVelocity = 0f;
CrankAngle += AngularVelocity * dt; CrankAngle += AngularVelocity * dt;
if (CrankAngle >= 4f * MathF.PI) if (CrankAngle >= CycleLength)
CrankAngle -= 4f * MathF.PI; CrankAngle -= CycleLength;
else if (CrankAngle < 0f) else if (CrankAngle < 0f)
CrankAngle += 4f * MathF.PI; CrankAngle += CycleLength;
// ---- Power averaging ---- // Power averaging
float instantPower = netTorque * AngularVelocity; float instantPower = netTorque * AngularVelocity;
if (_powerBufCount == _powerBuffer.Length) if (_powerBufCount == _powerBuffer.Length)
{
_powerBufSum -= _powerBuffer[_powerBufIdx]; _powerBufSum -= _powerBuffer[_powerBufIdx];
}
else else
{
_powerBufCount++; _powerBufCount++;
}
_powerBuffer[_powerBufIdx] = instantPower; _powerBuffer[_powerBufIdx] = instantPower;
_powerBufSum += instantPower; _powerBufSum += instantPower;
_powerBufIdx = (_powerBufIdx + 1) % _powerBuffer.Length; _powerBufIdx = (_powerBufIdx + 1) % _powerBuffer.Length;
AveragePower = _powerBufSum / _powerBufCount; AveragePower = _powerBufSum / _powerBufCount;
// ---- Torque averaging ---- // Torque averaging
if (_torqueBufCount == _torqueBuffer.Length) if (_torqueBufCount == _torqueBuffer.Length)
{
_torqueBufSum -= _torqueBuffer[_torqueBufIdx]; _torqueBufSum -= _torqueBuffer[_torqueBufIdx];
}
else else
{
_torqueBufCount++; _torqueBufCount++;
}
_torqueBuffer[_torqueBufIdx] = netTorque; _torqueBuffer[_torqueBufIdx] = netTorque;
_torqueBufSum += netTorque; _torqueBufSum += netTorque;
_torqueBufIdx = (_torqueBufIdx + 1) % _torqueBuffer.Length; _torqueBufIdx = (_torqueBufIdx + 1) % _torqueBuffer.Length;

View File

@@ -1,99 +1,25 @@
using System; using System;
using System.Collections.Generic; using FluidSim.Components; // if needed
using FluidSim.Interfaces;
namespace FluidSim.Components namespace FluidSim.Components
{ {
public class Cylinder : IComponent public class Cylinder : EngineCylinder
{ {
public Port IntakePort { get; } public float IVO, IVC, EVO, EVC; // degrees in a 720° cycle
public Port ExhaustPort { get; }
public Crankshaft Crankshaft { get; }
private readonly Port[] _ports; protected override float CycleLengthRad => 4f * MathF.PI;
IReadOnlyList<Port> IComponent.Ports => _ports; protected override float MaxCycleDeg => 720f;
public float Bore { get; } public override float IntakeValveArea =>
public float Stroke { get; } MathF.PI * IntakeValveDiameter * ValveLift(CrankDeg, IVO, IVC, IntakeValveLift);
public float ConRodLength { get; } public override float ExhaustValveArea =>
public float CompressionRatio { get; } MathF.PI * ExhaustValveDiameter * ValveLift(CrankDeg, EVO, EVC, ExhaustValveLift);
public float IVO, IVC, EVO, EVC; // degrees
public float IntakeValveDiameter = 0.03f;
public float ExhaustValveDiameter = 0.028f;
public float IntakeValveLift = 0.005f;
public float ExhaustValveLift = 0.005f;
public float IntakeValveMaxArea => MathF.PI * IntakeValveDiameter * IntakeValveLift;
public float ExhaustValveMaxArea => MathF.PI * ExhaustValveDiameter * ExhaustValveLift;
public float SparkAdvance = 20f;
public float WiebeA = 5f, WiebeM = 2f, WiebeDuration = 60f, WiebeStart = 5f;
public float StoichiometricAFR = 14.7f;
public float FuelLowerHeatingValue = 44e6f;
public float EnergyVariationFraction = 0.05f;
public float MisfireProbability = 0.0f;
public float CylinderWallArea = 0.02f;
public float HeatTransferCoefficient = 100f;
public float AmbientTemperature = 300f;
public float PhaseOffset; // rad
public float Volume => cylinderVolume;
public float Pressure => (Gamma - 1f) * cylinderEnergy / MathF.Max(cylinderVolume, 1e-12f);
public float Temperature => Pressure / MathF.Max(Density * GasConstant, 1e-12f);
public float Density => Mass / MathF.Max(cylinderVolume, 1e-12f);
public float Mass => _airMass + _exhaustMass;
public float AirFraction => _airMass / MathF.Max(Mass, 1e-12f);
public float PistonFraction => (cylinderVolume - clearanceVolume) / SweptVolume;
private float cylinderVolume, cylinderEnergy;
private float _airMass, _exhaustMass;
private float trappedAirMass, fuelMass, burnFraction;
private bool combustionActive, fuelInjected;
private float _energyFactor = 1f;
private readonly Random _random = new Random();
private const float Gamma = 1.4f;
private const float GasConstant = 287f;
private const float MaxPressurePa = 200e5f;
private const float MaxTemperatureK = 3500f;
public Cylinder(float bore, float stroke, float conRodLength, float compressionRatio, public Cylinder(float bore, float stroke, float conRodLength, float compressionRatio,
float ivo, float ivc, float evo, float evc, Crankshaft crankshaft) float ivo, float ivc, float evo, float evc, Crankshaft crankshaft)
: base(bore, stroke, conRodLength, compressionRatio, crankshaft)
{ {
Bore = bore; Stroke = stroke; ConRodLength = conRodLength;
CompressionRatio = compressionRatio;
IVO = ivo; IVC = ivc; EVO = evo; EVC = evc; IVO = ivo; IVC = ivc; EVO = evo; EVC = evc;
Crankshaft = crankshaft ?? throw new ArgumentNullException(nameof(crankshaft));
cylinderVolume = clearanceVolume;
float initRho = 1.225f;
_airMass = initRho * clearanceVolume;
_exhaustMass = 0f;
cylinderEnergy = 101325f * clearanceVolume / (Gamma - 1f);
IntakePort = new Port { Owner = this };
ExhaustPort = new Port { Owner = this };
_ports = new[] { IntakePort, ExhaustPort };
}
private float SweptVolume => MathF.PI * 0.25f * Bore * Bore * Stroke;
private float clearanceVolume => SweptVolume / (CompressionRatio - 1f);
private float CrankRadius => Stroke * 0.5f;
private float Obliquity => CrankRadius / ConRodLength;
private float CrankDeg =>
((Crankshaft.CrankAngle + PhaseOffset) % (4f * MathF.PI)) * 180f / MathF.PI % 720f;
public float ComputeVolume(float thetaRad)
{
float r = CrankRadius, l = ConRodLength;
float cosTh = MathF.Cos(thetaRad), sinTh = MathF.Sin(thetaRad);
float term = MathF.Sqrt(1f - Obliquity * Obliquity * sinTh * sinTh);
float x = r * (1f - cosTh) + l * (1f - term);
float area = MathF.PI * 0.25f * Bore * Bore;
return clearanceVolume + area * x;
} }
private float ValveLift(float thetaDeg, float opens, float closes, float peakLift) private float ValveLift(float thetaDeg, float opens, float closes, float peakLift)
@@ -101,15 +27,10 @@ namespace FluidSim.Components
float deg = thetaDeg % 720f; float deg = thetaDeg % 720f;
if (deg < 0f) deg += 720f; if (deg < 0f) deg += 720f;
float duration;
float effectiveOpen = opens; float effectiveOpen = opens;
float effectiveClose = closes; float effectiveClose = closes;
if (closes < opens) effectiveClose += 720f;
if (closes < opens) float duration = effectiveClose - effectiveOpen;
{
effectiveClose += 720f;
}
duration = effectiveClose - effectiveOpen;
if (duration <= 0f) return 0f; if (duration <= 0f) return 0f;
float mapped = deg; float mapped = deg;
@@ -136,43 +57,9 @@ namespace FluidSim.Components
return 0f; return 0f;
} }
public float IntakeValveArea => protected override void HandleCycleEvents(float prevDeg, float currDeg, float dt)
MathF.PI * IntakeValveDiameter * ValveLift(CrankDeg, IVO, IVC, IntakeValveLift);
public float ExhaustValveArea =>
MathF.PI * ExhaustValveDiameter * ValveLift(CrankDeg, EVO, EVC, ExhaustValveLift);
private float Wiebe(float angleSinceSpark)
{ {
if (angleSinceSpark < WiebeStart) return 0f; // Intake closing → fuel injection
float phi = (angleSinceSpark - WiebeStart) / WiebeDuration;
if (phi <= 0f) return 0f;
return 1f - MathF.Exp(-WiebeA * MathF.Pow(phi, WiebeM + 1f));
}
public void PreStep(float dt)
{
// Speeddependent spark advance (simple linear)
float rpm = Crankshaft.AngularVelocity * 60f / (2f * MathF.PI);
SparkAdvance = Math.Clamp(10f + rpm * 0.002f, 5f, 40f); // 10° at idle, ~30° at 10k rpm
float prevVolume = cylinderVolume;
float crankAngleRad = Crankshaft.CrankAngle + PhaseOffset;
cylinderVolume = ComputeVolume(crankAngleRad);
float dV = cylinderVolume - prevVolume;
float pRel = Pressure - 101325f;
float sinTh = MathF.Sin(crankAngleRad), cosTh = MathF.Cos(crankAngleRad);
float term = MathF.Sqrt(1f - Obliquity * Obliquity * sinTh * sinTh);
float dxdtheta = CrankRadius * sinTh * (1f + Obliquity * cosTh / term);
float pistonArea = MathF.PI * 0.25f * Bore * Bore;
Crankshaft.AddTorque(pRel * pistonArea * dxdtheta);
cylinderEnergy -= Pressure * dV;
float prevDeg = (Crankshaft.PreviousAngle + PhaseOffset) * 180f / MathF.PI % 720f;
float currDeg = crankAngleRad * 180f / MathF.PI % 720f;
// Intake closing triggers fuel injection
if (prevDeg >= IVO && prevDeg < IVC && currDeg >= IVC) if (prevDeg >= IVO && prevDeg < IVC && currDeg >= IVC)
{ {
trappedAirMass = _airMass; trappedAirMass = _airMass;
@@ -180,11 +67,14 @@ namespace FluidSim.Components
fuelInjected = true; fuelInjected = true;
} }
// Spark // Spark occurs at TDC (0°) minus advance, every 720°
float sparkAngle = 0f - SparkAdvance; float sparkAngle = (0f - SparkAdvance + 720f) % 720f;
if (sparkAngle < 0f) sparkAngle += 720f; bool crossedSpark = false;
bool crossedSpark = (prevDeg < sparkAngle && currDeg >= sparkAngle) || if (prevDeg < sparkAngle && currDeg >= sparkAngle)
(prevDeg > sparkAngle + 360f && currDeg < sparkAngle); crossedSpark = true;
else if (prevDeg > sparkAngle && currDeg < sparkAngle)
crossedSpark = true;
if (crossedSpark && !combustionActive && fuelInjected) if (crossedSpark && !combustionActive && fuelInjected)
{ {
if (_random.NextDouble() < MisfireProbability) if (_random.NextDouble() < MisfireProbability)
@@ -199,7 +89,7 @@ namespace FluidSim.Components
} }
} }
// Combustion // Combustion progression
if (combustionActive) if (combustionActive)
{ {
float angleSinceSpark = currDeg - sparkAngle; float angleSinceSpark = currDeg - sparkAngle;
@@ -222,62 +112,6 @@ namespace FluidSim.Components
burnFraction = newFraction; burnFraction = newFraction;
} }
} }
// Heat loss
float dQ_loss = HeatTransferCoefficient * CylinderWallArea *
(Temperature - AmbientTemperature) * dt;
cylinderEnergy -= dQ_loss;
// Update port states
float p = Pressure, rho = Density, T = Temperature;
float h = Gamma / (Gamma - 1f) * p / MathF.Max(rho, 1e-12f);
float af = AirFraction;
IntakePort.Pressure = p; IntakePort.Density = rho;
IntakePort.Temperature = T; IntakePort.SpecificEnthalpy = h; IntakePort.AirFraction = af;
ExhaustPort.Pressure = p; ExhaustPort.Density = rho;
ExhaustPort.Temperature = T; ExhaustPort.SpecificEnthalpy = h; ExhaustPort.AirFraction = af;
}
public void UpdateState(float dt)
{
float dmAir = 0f, dmExhaust = 0f, dE = 0f;
foreach (var port in _ports)
{
float mdot = port.MassFlowRate;
float af = mdot >= 0f ? port.AirFraction : AirFraction;
dmAir += mdot * af * dt;
dmExhaust += mdot * (1f - af) * dt;
dE += mdot * port.SpecificEnthalpy * dt;
}
_airMass += dmAir; _exhaustMass += dmExhaust;
cylinderEnergy += dE;
float V = MathF.Max(cylinderVolume, 1e-12f);
float currentP = (Gamma - 1f) * cylinderEnergy / V;
if (currentP > MaxPressurePa) cylinderEnergy = MaxPressurePa * V / (Gamma - 1f);
float currentRho = (_airMass + _exhaustMass) / V;
float currentT = currentP / MathF.Max(currentRho * GasConstant, 1e-12f);
if (currentT > MaxTemperatureK)
{
float pAtTlimit = currentRho * GasConstant * MaxTemperatureK;
cylinderEnergy = pAtTlimit * V / (Gamma - 1f);
}
float totalMass = _airMass + _exhaustMass;
if (totalMass < 1e-9f)
{
_airMass = 1e-9f; _exhaustMass = 0f;
cylinderEnergy = 101325f * V / (Gamma - 1f);
}
else if (cylinderEnergy < 0f)
{
cylinderEnergy = 101325f * V / (Gamma - 1f);
}
if (_airMass < 0f) _airMass = 0f;
if (_exhaustMass < 0f) _exhaustMass = 0f;
} }
} }
} }

View File

@@ -0,0 +1,203 @@
using System;
using System.Collections.Generic;
using FluidSim.Interfaces;
namespace FluidSim.Components
{
/// <summary>Common base for all reciprocating engine cylinders.</summary>
public abstract class EngineCylinder : IComponent
{
public Port IntakePort { get; }
public Port ExhaustPort { get; }
public Crankshaft Crankshaft { get; }
private readonly Port[] _ports;
IReadOnlyList<Port> IComponent.Ports => _ports;
// ----- Geometry -----
public float Bore { get; }
public float Stroke { get; }
public float ConRodLength { get; }
public float CompressionRatio { get; }
// ----- Valve / port sizes (used for curtain area) -----
public float IntakeValveDiameter = 0.03f;
public float ExhaustValveDiameter = 0.028f;
public float IntakeValveLift = 0.005f;
public float ExhaustValveLift = 0.005f;
// ----- Combustion -----
public float SparkAdvance = 20f;
public float WiebeA = 5f, WiebeM = 2f, WiebeDuration = 60f, WiebeStart = 5f;
public float StoichiometricAFR = 14.7f;
public float FuelLowerHeatingValue = 44e6f;
public float EnergyVariationFraction = 0.05f;
public float MisfireProbability = 0f;
public float CylinderWallArea = 0.02f;
public float HeatTransferCoefficient = 100f;
public float AmbientTemperature = 300f;
public float PhaseOffset; // radians
// ----- State (public, used by drawing) -----
public float Volume => cylinderVolume;
public float Pressure => (Gamma - 1f) * cylinderEnergy / MathF.Max(cylinderVolume, 1e-12f);
public float Temperature => Pressure / MathF.Max(Density * GasConstant, 1e-12f);
public float Density => Mass / MathF.Max(cylinderVolume, 1e-12f);
public float Mass => _airMass + _exhaustMass;
public float AirFraction => _airMass / MathF.Max(Mass, 1e-12f);
public float PistonFraction => (cylinderVolume - clearanceVolume) / SweptVolume;
protected float cylinderVolume, cylinderEnergy;
protected float _airMass, _exhaustMass;
protected float trappedAirMass, fuelMass, burnFraction;
protected bool combustionActive, fuelInjected;
protected float _energyFactor = 1f;
protected readonly Random _random = new Random();
protected const float Gamma = 1.4f;
protected const float GasConstant = 287f;
protected const float MaxPressurePa = 200e5f;
protected const float MaxTemperatureK = 3500f;
// ----- Derived geometry (cycleindependent) -----
protected float SweptVolume => MathF.PI * 0.25f * Bore * Bore * Stroke;
protected float clearanceVolume => SweptVolume / (CompressionRatio - 1f);
protected float CrankRadius => Stroke * 0.5f;
protected float Obliquity => CrankRadius / ConRodLength;
// ----- Abstract members (cyclespecific) -----
protected abstract float CycleLengthRad { get; } // 4π or 2π
protected abstract float MaxCycleDeg { get; } // 720 or 360
public abstract float IntakeValveArea { get; }
public abstract float ExhaustValveArea { get; }
protected abstract void HandleCycleEvents(float prevDeg, float currDeg, float dt);
protected EngineCylinder(float bore, float stroke, float conRodLength,
float compressionRatio, Crankshaft crankshaft)
{
Bore = bore; Stroke = stroke; ConRodLength = conRodLength;
CompressionRatio = compressionRatio;
Crankshaft = crankshaft ?? throw new ArgumentNullException(nameof(crankshaft));
cylinderVolume = clearanceVolume;
float initRho = 1.225f;
_airMass = initRho * clearanceVolume;
_exhaustMass = 0f;
cylinderEnergy = 101325f * clearanceVolume / (Gamma - 1f);
IntakePort = new Port { Owner = this };
ExhaustPort = new Port { Owner = this };
_ports = new[] { IntakePort, ExhaustPort };
// Set crankshaft cycle length
crankshaft.CycleLength = CycleLengthRad;
}
public float ComputeVolume(float thetaRad)
{
float r = CrankRadius, l = ConRodLength;
float cosTh = MathF.Cos(thetaRad), sinTh = MathF.Sin(thetaRad);
float term = MathF.Sqrt(1f - Obliquity * Obliquity * sinTh * sinTh);
float x = r * (1f - cosTh) + l * (1f - term);
float area = MathF.PI * 0.25f * Bore * Bore;
return clearanceVolume + area * x;
}
protected float CrankDeg =>
((Crankshaft.CrankAngle + PhaseOffset) % CycleLengthRad) * 180f / MathF.PI;
protected float Wiebe(float angleSinceSpark)
{
if (angleSinceSpark < WiebeStart) return 0f;
float phi = (angleSinceSpark - WiebeStart) / WiebeDuration;
return 1f - MathF.Exp(-WiebeA * MathF.Pow(phi, WiebeM + 1f));
}
// ----- Main update called before flow solver -----
public void PreStep(float dt)
{
// Speeddependent spark advance
float rpm = Crankshaft.AngularVelocity * 60f / (2f * MathF.PI);
SparkAdvance = Math.Clamp(10f + rpm * 0.002f, 5f, 40f);
float prevVolume = cylinderVolume;
float crankAngleRad = Crankshaft.CrankAngle + PhaseOffset;
cylinderVolume = ComputeVolume(crankAngleRad);
// Piston work
float dV = cylinderVolume - prevVolume;
float pRel = Pressure - 101325f;
float sinTh = MathF.Sin(crankAngleRad), cosTh = MathF.Cos(crankAngleRad);
float term = MathF.Sqrt(1f - Obliquity * Obliquity * sinTh * sinTh);
float dxdtheta = CrankRadius * sinTh * (1f + Obliquity * cosTh / term);
float pistonArea = MathF.PI * 0.25f * Bore * Bore;
Crankshaft.AddTorque(pRel * pistonArea * dxdtheta);
cylinderEnergy -= Pressure * dV;
float prevDeg = (Crankshaft.PreviousAngle + PhaseOffset) * 180f / MathF.PI % MaxCycleDeg;
float currDeg = crankAngleRad * 180f / MathF.PI % MaxCycleDeg;
// Let derived class handle valve events, spark, fuel
HandleCycleEvents(prevDeg, currDeg, dt);
// Heat loss
float dQ_loss = HeatTransferCoefficient * CylinderWallArea *
(Temperature - AmbientTemperature) * dt;
cylinderEnergy -= dQ_loss;
// Update port states
float p = Pressure, rho = Density, T = Temperature;
float h = Gamma / (Gamma - 1f) * p / MathF.Max(rho, 1e-12f);
float af = AirFraction;
IntakePort.Pressure = p; IntakePort.Density = rho;
IntakePort.Temperature = T; IntakePort.SpecificEnthalpy = h; IntakePort.AirFraction = af;
ExhaustPort.Pressure = p; ExhaustPort.Density = rho;
ExhaustPort.Temperature = T; ExhaustPort.SpecificEnthalpy = h; ExhaustPort.AirFraction = af;
}
// ----- State update (mass/energy balance) -----
public void UpdateState(float dt)
{
float dmAir = 0f, dmExhaust = 0f, dE = 0f;
foreach (var port in _ports)
{
float mdot = port.MassFlowRate;
float af = mdot >= 0f ? port.AirFraction : AirFraction;
dmAir += mdot * af * dt;
dmExhaust += mdot * (1f - af) * dt;
dE += mdot * port.SpecificEnthalpy * dt;
}
_airMass += dmAir; _exhaustMass += dmExhaust;
cylinderEnergy += dE;
float V = MathF.Max(cylinderVolume, 1e-12f);
float currentP = (Gamma - 1f) * cylinderEnergy / V;
if (currentP > MaxPressurePa) cylinderEnergy = MaxPressurePa * V / (Gamma - 1f);
float currentRho = (_airMass + _exhaustMass) / V;
float currentT = currentP / MathF.Max(currentRho * GasConstant, 1e-12f);
if (currentT > MaxTemperatureK)
{
float pAtTlimit = currentRho * GasConstant * MaxTemperatureK;
cylinderEnergy = pAtTlimit * V / (Gamma - 1f);
}
float totalMass = _airMass + _exhaustMass;
if (totalMass < 1e-9f)
{
_airMass = 1e-9f; _exhaustMass = 0f;
cylinderEnergy = 101325f * V / (Gamma - 1f);
}
else if (cylinderEnergy < 0f)
{
cylinderEnergy = 101325f * V / (Gamma - 1f);
}
if (_airMass < 0f) _airMass = 0f;
if (_exhaustMass < 0f) _exhaustMass = 0f;
}
}
}

View File

@@ -0,0 +1,183 @@
using System;
namespace FluidSim.Components
{
/// <summary>
/// Two-stroke cylinder with symmetrical port timings centred on BDC (180°).
///
/// Changes vs. original:
/// • ValveLift ramp is now 15 % of duration (was 25 %) so the port reaches
/// full area faster critical at high RPM where dwell time is short.
/// • Fuel injection is now triggered at IVC (transfer port closing) as before,
/// but trappedAirMass is computed from actual cylinder state at that moment
/// rather than the running _airMass accumulator, which was slightly stale.
/// • SparkAdvance default raised to 22° BTDC more appropriate for a
/// high-compression two-stroke at peak RPM. The scenario can still override it.
/// </summary>
public class TwoStrokeCylinder : EngineCylinder
{
// ── Port timing read-outs (degrees, 0 = TDC) ───────────────────────────
public float IVO => 180f - TransferDuration / 2f; // transfer opens
public float IVC => 180f + TransferDuration / 2f; // transfer closes
public float EVO => 180f - ExhaustDuration / 2f; // exhaust opens
public float EVC => 180f + ExhaustDuration / 2f; // exhaust closes
// ── Configurable durations ──────────────────────────────────────────────
public float TransferDuration { get; } // default: 155°
public float ExhaustDuration { get; } // default: 195°
// Fraction of port-open duration used for ramp-up / ramp-down.
// 0.15 → port at full area for the middle 70 % of open time.
private const float RampFraction = 0.15f;
protected override float CycleLengthRad => 2f * MathF.PI;
protected override float MaxCycleDeg => 360f;
public override float IntakeValveArea =>
MathF.PI * IntakeValveDiameter
* ValveLift(CrankDeg, IVO, IVC, IntakeValveLift);
public override float ExhaustValveArea =>
MathF.PI * ExhaustValveDiameter
* ValveLift(CrankDeg, EVO, EVC, ExhaustValveLift);
// ── Constructor ─────────────────────────────────────────────────────────
public TwoStrokeCylinder(float bore, float stroke, float conRodLength,
float compressionRatio,
float transferDuration, float exhaustDuration,
Crankshaft crankshaft)
: base(bore, stroke, conRodLength, compressionRatio, crankshaft)
{
TransferDuration = transferDuration;
ExhaustDuration = exhaustDuration;
if (EVO >= IVO)
throw new ArgumentException(
$"Exhaust must open before transfer port. " +
$"EVO={EVO:F1}° must be less than IVO={IVO:F1}°. " +
$"Increase exhaustDuration or decrease transferDuration.");
}
// ── Valve lift profile ──────────────────────────────────────────────────
/// <summary>
/// Smooth trapezoidal lift: fast ramp (15 % of duration), flat top (70 %),
/// fast ramp-down (15 %). Ramps use a smoothstep (3t²-2t³) curve so the
/// area derivative is C1-continuous (no kink at ramp/plateau boundaries).
/// </summary>
private static float ValveLift(float thetaDeg, float opens, float closes, float peakLift)
{
// Normalise to [0, 360)
float deg = thetaDeg % 360f;
if (deg < 0f) deg += 360f;
// Handle wrap-around (e.g. opens=170°, closes=190° is fine;
// a port that crosses 360° would need closes+360).
float effectiveClose = closes < opens ? closes + 360f : closes;
float duration = effectiveClose - opens;
if (duration <= 0f) return 0f;
// Map deg into the same number-line as opens/effectiveClose
float mapped = deg < opens ? deg + 360f : deg;
if (mapped < opens || mapped > effectiveClose) return 0f;
float rampDur = duration * RampFraction;
float holdEnd = effectiveClose - rampDur;
if (mapped < opens + rampDur)
{
// Opening ramp: smoothstep
float t = (mapped - opens) / rampDur;
return peakLift * t * t * (3f - 2f * t);
}
else if (mapped <= holdEnd)
{
// Flat top full area
return peakLift;
}
else
{
// Closing ramp: smoothstep reversed
float t = (mapped - holdEnd) / rampDur;
return peakLift * (1f - t) * (1f - t) * (1f + 2f * t);
}
}
// ── Cycle event handler ─────────────────────────────────────────────────
protected override void HandleCycleEvents(float prevDeg, float currDeg, float dt)
{
// ── Fuel injection at transfer-port closing (IVC) ──────────────────
// At IVC the cylinder is sealed; whatever air is trapped is what we burn.
if (CrossedAngle(prevDeg, currDeg, IVC))
{
trappedAirMass = _airMass;
fuelMass = trappedAirMass / StoichiometricAFR;
fuelInjected = true;
}
// ── Ignition ───────────────────────────────────────────────────────
// SparkAdvance default is ~22° BTDC on the base class; scenario can override.
float sparkAngle = (360f - SparkAdvance) % 360f;
if (CrossedAngle(prevDeg, currDeg, sparkAngle) && !combustionActive && fuelInjected)
{
if (_random.NextDouble() < MisfireProbability)
{
combustionActive = false;
}
else
{
combustionActive = true;
burnFraction = 0f;
float range = EnergyVariationFraction;
_energyFactor = 1f + range * (2f * (float)_random.NextDouble() - 1f);
}
}
// ── Combustion heat release (Wiebe) ────────────────────────────────
if (combustionActive)
{
float angleSinceSpark = currDeg - sparkAngle;
if (angleSinceSpark < 0f) angleSinceSpark += 360f;
float newFraction = Wiebe(angleSinceSpark);
bool burnComplete = newFraction >= 1f
|| angleSinceSpark > WiebeDuration + WiebeStart + SparkAdvance;
if (burnComplete)
{
newFraction = 1f;
combustionActive = false;
fuelInjected = false;
float totalMass = _airMass + _exhaustMass;
_airMass = 0f;
_exhaustMass = totalMass;
}
float dFraction = newFraction - burnFraction;
if (dFraction > 0f)
{
float dQ = fuelMass * FuelLowerHeatingValue * _energyFactor * dFraction;
cylinderEnergy += dQ;
_exhaustMass += fuelMass * dFraction;
burnFraction = newFraction;
}
}
}
// ── Helper: did the crank cross a target angle this step? ───────────────
/// <summary>
/// Returns true if the crank swept through <paramref name="target"/> going
/// from <paramref name="prev"/> to <paramref name="curr"/> in a single step.
/// Handles wrap-around at 360°.
/// </summary>
private static bool CrossedAngle(float prev, float curr, float target)
{
// Normal case (no wrap)
if (curr >= prev)
return prev < target && target <= curr;
// Wrapped past 360° → two intervals to check
return prev < target || target <= curr;
}
}
}

166
Components/Vehicle.cs Normal file
View File

@@ -0,0 +1,166 @@
using System;
namespace FluidSim.Components
{
public class Vehicle
{
// ---- Gearbox ----
public int CurrentGear { get; private set; } = 0;
public readonly float[] GearRatios = { 2.5f, 1.8f, 1.4f, 1.1f, 0.9f, 0.75f };
public float FinalDriveRatio = 3.0f;
public float PrimaryReduction = 2.5f;
// ---- Clutch ----
public float ClutchInput { get; set; }
public float ClutchDisengageTime = 0.15f;
private float _clutchTimer;
private float _currentEngagement = 0f;
/// <summary>Time constant for clutch engagement smoothing (seconds).</summary>
public float EngagementSmoothTime = 0.5f; // longer, gentler bite
private float TargetEngagement
{
get
{
if (ClutchInput > 0.01f) return 1f - ClutchInput;
if (CurrentGear == 0 || _clutchTimer > 0f) return 0f;
return 1f;
}
}
public float Engagement => _currentEngagement;
// ---- Clutch torque model ----
/// <summary>Peak clutch friction torque (Nm) when fully engaged at high RPM.</summary>
public float BaseMaxTorque = 80f; // much lower than before
/// <summary>Stiffness when slipping (Nm per rad/s). Lower = softer engagement.</summary>
public float ClutchStiffness = 50f; // very soft
/// <summary>Below this engine RPM, the clutch torque is progressively reduced to prevent stalling.</summary>
public float IdleRpm = 1200f;
public float StallPreventionRamp = 300f; // RPM band above idle where torque ramps up
// ---- Physical constants ----
public float Mass = 160f;
public float WheelRadius = 0.32f;
public float DragCoefficient = 0.35f;
public float FrontalArea = 0.8f;
public float AirDensity = 1.225f;
public float RollingFrictionCoeff = 0.01f;
public float Gravity = 9.81f;
// ---- State ----
public float Speed { get; private set; }
public (float clutchTorqueOnEngine, float effectiveEngineInertia) Update(float engineRpm, float engineInertia, float dt)
{
if (_clutchTimer > 0f)
{
_clutchTimer -= dt;
if (_clutchTimer < 0f) _clutchTimer = 0f;
}
float target = TargetEngagement;
float smoothing = 1f - MathF.Exp(-dt / Math.Max(EngagementSmoothTime, 0.001f));
_currentEngagement += (target - _currentEngagement) * smoothing;
if (MathF.Abs(_currentEngagement - target) < 0.001f)
_currentEngagement = target;
float engagement = _currentEngagement;
float totalGear = 1f;
if (CurrentGear > 0)
totalGear = GearRatios[CurrentGear - 1] * FinalDriveRatio * PrimaryReduction;
float engineRadPerSec = engineRpm * 2f * MathF.PI / 60f;
float v = MathF.Max(Speed, 0f);
float drag = 0.5f * AirDensity * DragCoefficient * FrontalArea * v * v;
float rolling = RollingFrictionCoeff * Mass * Gravity;
float resistanceForce = drag + rolling;
float clutchTorque = 0f;
float effectiveInertia = engineInertia;
if (engagement > 0f && CurrentGear > 0)
{
float vehicleReflectedRadPerSec = (Speed / WheelRadius) * totalGear;
float slip = engineRadPerSec - vehicleReflectedRadPerSec;
// Stall prevention: reduce max torque when engine RPM is near idle
float torqueLimit = BaseMaxTorque * engagement;
if (engineRpm < IdleRpm + StallPreventionRamp)
{
float factor = Math.Clamp((engineRpm - IdleRpm) / StallPreventionRamp, 0f, 1f);
torqueLimit *= factor;
}
float stiffnessTorque = ClutchStiffness * engagement * slip;
clutchTorque = Math.Clamp(stiffnessTorque, -torqueLimit, torqueLimit);
// Lock if slip negligible and engagement high
if (engagement >= 0.99f && MathF.Abs(slip) < 1.0f)
{
float vehicleInertia = Mass * WheelRadius * WheelRadius;
float reflectedVehicleInertia = vehicleInertia / (totalGear * totalGear);
effectiveInertia = engineInertia + reflectedVehicleInertia;
Speed = engineRadPerSec * WheelRadius / totalGear;
float loadTorque = resistanceForce * WheelRadius / totalGear;
return (loadTorque, effectiveInertia);
}
}
float driveTorqueAtWheel = clutchTorque * totalGear;
float driveForce = driveTorqueAtWheel / WheelRadius;
float netForce = driveForce - resistanceForce;
float acceleration = netForce / Mass;
Speed += acceleration * dt;
if (Speed < 0f) Speed = 0f;
return (clutchTorque, engineInertia);
}
public void ShiftUp()
{
if (CurrentGear < GearRatios.Length)
{
CurrentGear++;
AutoDisengageClutch();
}
}
public void ShiftDown()
{
if (CurrentGear > 1)
{
CurrentGear--;
AutoDisengageClutch();
}
}
public void SetNeutral()
{
CurrentGear = 0;
_clutchTimer = 0f;
}
public void SetFirstGear()
{
if (CurrentGear == 0)
{
CurrentGear = 1;
AutoDisengageClutch();
}
}
private void AutoDisengageClutch()
{
_clutchTimer = ClutchDisengageTime;
}
public float SpeedKmh => Speed * 3.6f;
}
}

View File

@@ -48,13 +48,17 @@ public class Program
private static float _loadTarget = 0.0f; // 01 private static float _loadTarget = 0.0f; // 01
private static float _loadCurrent = 0.0f; private static float _loadCurrent = 0.0f;
private static float _clutchTarget = 0f;
private static float _clutchCurrent = 0f;
private static bool _cKeyHeld = false;
private const int TargetMaxFill = (int)(SampleRate * 0.2); private const int TargetMaxFill = (int)(SampleRate * 0.2);
public static void Main() public static void Main()
{ {
var window = CreateWindow(); var window = CreateWindow();
LoadFont(); LoadFont();
_scenario = new SingleCylScenario(); _scenario = new TwoStrokeScenario();
_scenario.Font = _overlayFont; _scenario.Font = _overlayFont;
_scenario.Initialize(SampleRate); _scenario.Initialize(SampleRate);
_lastThrottleUpdateTime = 0.0f; _lastThrottleUpdateTime = 0.0f;
@@ -102,6 +106,11 @@ public class Program
_scenario.Throttle = _throttleCurrent; _scenario.Throttle = _throttleCurrent;
float clutchDesired = _cKeyHeld ? 1f : 0f;
float clutchSmoothing = 1f - MathF.Exp(-ThrottleLerpRate * dtThrottle);
_clutchCurrent += (clutchDesired - _clutchCurrent) * clutchSmoothing;
_scenario.Clutch = _clutchCurrent;
// ---- Drawing ---- // ---- Drawing ----
if (now - lastDrawTime >= 1.0 / DrawFrequency) if (now - lastDrawTime >= 1.0 / DrawFrequency)
@@ -111,6 +120,7 @@ public class Program
string toggleHint = _isRealTime ? "[Space] slow mo" : "[Space] real time"; string toggleHint = _isRealTime ? "[Space] slow mo" : "[Space] real time";
_overlayText.DisplayedString = _overlayText.DisplayedString =
$"{toggleHint} Speed: {_currentDisplaySpeed:F3}x RT: {(_currentDisplaySpeed * 100.0):F1}% Sim load: {_loadTracker.LoadPercent:F0}%\n" + $"{toggleHint} Speed: {_currentDisplaySpeed:F3}x RT: {(_currentDisplaySpeed * 100.0):F1}% Sim load: {_loadTracker.LoadPercent:F0}%\n" +
$"Clutch: {_clutchCurrent*100:F0}% [C]" +
$"Load: {_loadCurrent*100:F0}% [←][→] Throttle: {_throttleCurrent * 100:F0}% Target: {_throttleTarget * 100:F0}% [W] {(_wKeyHeld ? "BLIP" : "---")}"; $"Load: {_loadCurrent*100:F0}% [←][→] Throttle: {_throttleCurrent * 100:F0}% Target: {_throttleTarget * 100:F0}% [W] {(_wKeyHeld ? "BLIP" : "---")}";
} }
@@ -221,6 +231,17 @@ public class Program
case Keyboard.Key.Right: case Keyboard.Key.Right:
_loadTarget = MathF.Min(1.0f, _loadTarget + 0.05f); _loadTarget = MathF.Min(1.0f, _loadTarget + 0.05f);
break; break;
case Keyboard.Key.E:
_scenario.ShiftUp();
break;
case Keyboard.Key.Q:
_scenario.ShiftDown();
break;
case Keyboard.Key.C:
_cKeyHeld = true;
break;
} }
} }
@@ -228,5 +249,8 @@ public class Program
{ {
if (e.Code == Keyboard.Key.W) if (e.Code == Keyboard.Key.W)
_wKeyHeld = false; _wKeyHeld = false;
if (e.Code == Keyboard.Key.C)
_cKeyHeld = false;
} }
} }

View File

@@ -13,12 +13,16 @@ namespace FluidSim.Tests
protected const float AmbientTemperature = 300f; protected const float AmbientTemperature = 300f;
public float Throttle { get; set; } public float Throttle { get; set; }
public float Load { get; set; } public float Load { get; set; }
public float Clutch { get; set; } // 0 = engaged, 1 = fully disengaged (manual lever)
public Font? Font { get; set; } public Font? Font { get; set; }
public abstract void Initialize(int sampleRate); public abstract void Initialize(int sampleRate);
public abstract float Process(); public abstract float Process();
public abstract void Draw(RenderWindow target); public abstract void Draw(RenderWindow target);
public virtual void ShiftUp() { }
public virtual void ShiftDown() { }
// ---- Dyno curve graph ---- // ---- Dyno curve graph ----
private const float RpmBinSize = 50f; private const float RpmBinSize = 50f;
private readonly List<(float powerKw, float torqueNm)> _dynoBins = new(); private readonly List<(float powerKw, float torqueNm)> _dynoBins = new();
@@ -259,7 +263,7 @@ namespace FluidSim.Tests
target.Draw(border); target.Draw(border);
} }
protected void DrawCylinder(RenderWindow target, Cylinder cylinder, protected void DrawCylinder(RenderWindow target, EngineCylinder cylinder,
float centerX, float topY, float width, float maxHeight) float centerX, float topY, float width, float maxHeight)
{ {
float fraction = cylinder.PistonFraction; float fraction = cylinder.PistonFraction;
@@ -298,7 +302,8 @@ namespace FluidSim.Tests
} }
protected void DrawPipe(RenderWindow target, PipeSystem pipeSystem, int pipeIndex, protected void DrawPipe(RenderWindow target, PipeSystem pipeSystem, int pipeIndex,
float pipeCenterY, float pipeStartX, float pipeEndX) float pipeCenterY, float pipeStartX, float pipeEndX,
float areaScale = 0f)
{ {
int start = pipeSystem.GetPipeStart(pipeIndex); int start = pipeSystem.GetPipeStart(pipeIndex);
int end = pipeSystem.GetPipeEnd(pipeIndex); int end = pipeSystem.GetPipeEnd(pipeIndex);
@@ -307,20 +312,34 @@ namespace FluidSim.Tests
float pipeLen = pipeEndX - pipeStartX; float pipeLen = pipeEndX - pipeStartX;
float dx = pipeLen / (n - 1); float dx = pipeLen / (n - 1);
float baseRadius = 25f;
var centers = new float[n]; var centers = new float[n];
var radii = new float[n]; var radii = new float[n];
var temps = new float[n]; var temps = new float[n];
for (int i = 0; i < n; i++) for (int i = 0; i < n; i++)
{ {
int cell = start + i; int cell = start + i;
float p = pipeSystem.GetCellPressure(cell); float p = pipeSystem.GetCellPressure(cell);
float rho = pipeSystem.GetCellDensity(cell); float rho = pipeSystem.GetCellDensity(cell);
temps[i] = p / MathF.Max(rho * 287f, 1e-12f); temps[i] = p / MathF.Max(rho * 287f, 1e-12f);
if (areaScale > 0f)
{
// Use actual cell area to determine visual radius
float area = pipeSystem.GetCellArea(cell);
radii[i] = MathF.Sqrt(area / MathF.PI) * areaScale;
if (radii[i] < 1f) radii[i] = 1f;
}
else
{
// Original pressurebased radius
float dev = MathF.Tanh((p - AmbientPressure) / AmbientPressure * 0.5f); float dev = MathF.Tanh((p - AmbientPressure) / AmbientPressure * 0.5f);
float baseRadius = 25f; // default visual radius for constantarea pipes
radii[i] = baseRadius * (1f + dev * 2f); radii[i] = baseRadius * (1f + dev * 2f);
if (radii[i] < 2f) radii[i] = 2f; if (radii[i] < 2f) radii[i] = 2f;
}
centers[i] = pipeStartX + i * dx; centers[i] = pipeStartX + i * dx;
} }

View File

@@ -0,0 +1,350 @@
using FluidSim.Components;
using FluidSim.Core;
using FluidSim.Interfaces;
using FluidSim.Utils;
using SFML.Graphics;
using SFML.System;
using System;
namespace FluidSim.Tests
{
public class TwoStrokeScenario : Scenario
{
private Crankshaft crankshaft;
private TwoStrokeCylinder cylinder;
private PipeSystem pipeSystem;
private BoundarySystem boundaries;
private Solver solver;
private Volume0D intakePlenum;
private Port plenumInlet, plenumOutlet;
private Volume0D exhaustMuffler;
private Port mufflerIn, mufflerOut;
private Vehicle vehicle;
private int throttleAreaIdx, plenumRunnerIdx, intakeValveIdx, exhaustValveIdx;
private float[] orificeAreas;
private int intakeOpenIdx, exhaustOpenIdx;
private SoundProcessor exhaustSound, intakeSound;
private OutdoorExhaustReverb reverb;
private double dt;
private int stepCount;
private float _maxThrottleArea;
private float intakePipeArea, exhaustHeaderArea;
public override void ShiftUp() => vehicle.ShiftUp();
public override void ShiftDown() => vehicle.ShiftDown();
public override void Initialize(int sampleRate)
{
dt = 1.0 / sampleRate;
// ── Vehicle ──────────────────────────────────────────────────────────
vehicle = new Vehicle();
// ── Throttle body: 42 mm wider to reduce high-RPM intake restriction ──
_maxThrottleArea = (float)Units.AreaFromDiameter(42 * Units.mm);
// ── Crankshaft ───────────────────────────────────────────────────────
// Lighter flywheel for quicker revving; friction tuned to ~0.5 kW loss at idle
crankshaft = new Crankshaft(2000);
crankshaft.CycleLength = 2f * MathF.PI; // two-stroke: fire every rev
crankshaft.Inertia = 0.06f; // lighter flywheel
crankshaft.FrictionConstant = 0.4f; // ~0.4 Nm constant drag
crankshaft.FrictionViscous = 0.0004f; // ~2.5 Nm at 10 000 RPM
// ── Cylinder: 125 cc, motocross-style two-stroke ─────────────────────
// Bore × stroke = 54 × 54.5 mm → 124.9 cc
float bore = 0.054f;
float stroke = 0.0545f;
float conRod = 0.110f; // ~2× stroke
float compRatio = 7.2f; // geometric CR; effective CR after port closure is ~12:1
// Port timings: exhaust 195°, transfer 155° competitive MX 125
float transferDuration = 155f;
float exhaustDuration = 195f;
cylinder = new TwoStrokeCylinder(bore, stroke, conRod, compRatio,
transferDuration, exhaustDuration,
crankshaft)
{
IntakeValveDiameter = 0.042f, // matched to intake pipe
IntakeValveLift = 0.015f,
ExhaustValveDiameter = 0.040f,
ExhaustValveLift = 0.013f
};
// ── Pipe geometry ────────────────────────────────────────────────────
//
// Layout (all lengths in mm):
// Intake path: airbox stub 100 mm | runner 180 mm
// Exhaust path: expansion chamber tuned to ~9 000 RPM power peak
// header 170 mm Ø 40 mm
// diffuser 280 mm Ø 40 → 72 mm
// belly 200 mm Ø 72 mm
// convergent 130 mm Ø 72 → 28 mm
// stinger 70 mm Ø 28 mm
// total 850 mm
//
// Cell sizing: ~14 mm/cell.
// CFL: c_sound ≈ 550 m/s, dx=0.014 m → dt_max ≈ 25 µs
// at 44100 Hz dt = 22.7 µs → SubStepCount=4 keeps CFL safely ≤ 1
// --- Cell counts ---
int intakeCells = 7; // 100 mm stub → ~14 mm/cell
int runnerCells = 13; // 180 mm runner → ~14 mm/cell
int exhaustCells = 60; // 850 mm total → ~14 mm/cell
int totalCells = intakeCells + runnerCells + exhaustCells;
int[] pipeStart = { 0, intakeCells, intakeCells + runnerCells };
int[] pipeEnd = { intakeCells, intakeCells + runnerCells, totalCells };
float[] area = new float[totalCells];
float[] dx = new float[totalCells];
// --- Intake ---
float intakeDia = 0.042f; // matches throttle body
float intakeStubLen = 0.100f;
float intakeRunnerLen= 0.160f; // shorter runner → less pumping loss
intakePipeArea = MathF.PI * 0.25f * intakeDia * intakeDia;
for (int i = 0; i < intakeCells; i++)
{ area[i] = intakePipeArea; dx[i] = intakeStubLen / intakeCells; }
for (int i = intakeCells; i < intakeCells + runnerCells; i++)
{ area[i] = intakePipeArea; dx[i] = intakeRunnerLen / runnerCells; }
// Expansion chamber tuned for ~8 500 RPM power peak.
// Return-pulse travel distance = 0.5 × c_avg × (60 / RPM_target)
// c_avg ≈ 480 m/s → distance = 0.5 × 480 × (60/8500) ≈ 1.69 m round-trip
// → one-way pipe length ≈ 0.84 m (matches total below)
float headerDia = 0.040f; float headerLen = 0.130f; // shorter header → earlier pulse
float diffEndDia = 0.070f; float diffuserLen = 0.250f; // slightly narrower belly
float bellyDia = 0.070f; float bellyLen = 0.220f;
float convEndDia = 0.028f; float convergentLen= 0.160f; // longer convergent → stronger return pulse
float stingerDia = 0.028f; float stingerLen = 0.080f;
// total = 0.13+0.25+0.22+0.16+0.08 = 0.84 m
exhaustHeaderArea = MathF.PI * 0.25f * headerDia * headerDia;
float bellyArea = MathF.PI * 0.25f * bellyDia * bellyDia;
float stingerArea = MathF.PI * 0.25f * stingerDia * stingerDia;
// Distribute cells proportionally by section length
int headerCells = Math.Max(1, (int)MathF.Round(exhaustCells * headerLen / 0.84f));
int diffuserCells = Math.Max(1, (int)MathF.Round(exhaustCells * diffuserLen / 0.84f));
int bellyCells = Math.Max(1, (int)MathF.Round(exhaustCells * bellyLen / 0.84f));
int convergentCells = Math.Max(1, (int)MathF.Round(exhaustCells * convergentLen/ 0.84f));
int stingerCells = exhaustCells - headerCells - diffuserCells
- bellyCells - convergentCells;
if (stingerCells < 1) stingerCells = 1;
int exhBase = intakeCells + runnerCells;
int idx = 0;
for (int i = exhBase; i < totalCells; i++, idx++)
{
if (idx < headerCells)
{
area[i] = exhaustHeaderArea;
dx[i] = headerLen / headerCells;
}
else if (idx < headerCells + diffuserCells)
{
float t = (idx - headerCells) / (float)(diffuserCells - 1);
// Smooth cosine taper instead of linear for better wave reflection
float ct = 0.5f * (1f - MathF.Cos(MathF.PI * t));
float dia = headerDia + (diffEndDia - headerDia) * ct;
area[i] = MathF.PI * 0.25f * dia * dia;
dx[i] = diffuserLen / diffuserCells;
}
else if (idx < headerCells + diffuserCells + bellyCells)
{
area[i] = bellyArea;
dx[i] = bellyLen / bellyCells;
}
else if (idx < headerCells + diffuserCells + bellyCells + convergentCells)
{
float t = (idx - headerCells - diffuserCells - bellyCells)
/ (float)(convergentCells - 1);
// Steeper cosine convergent for a sharper return pulse
float ct = 0.5f * (1f - MathF.Cos(MathF.PI * t));
float dia = bellyDia + (convEndDia - bellyDia) * ct;
area[i] = MathF.PI * 0.25f * dia * dia;
dx[i] = convergentLen / convergentCells;
}
else
{
area[i] = stingerArea;
dx[i] = stingerLen / stingerCells;
}
}
pipeSystem = new PipeSystem(totalCells, pipeStart, pipeEnd, area, dx,
1.225f, 0f, 101325f);
pipeSystem.DampingMultiplier = 0.8f; // slightly less damping → stronger pulses
pipeSystem.EnergyRelaxationRate = 0.4f;
pipeSystem.AmbientPressure = 101325f;
// ── 0-D Volumes ──────────────────────────────────────────────────────
// Intake plenum: acts as a small airbox resonator (8 cc)
intakePlenum = new Volume0D(8e-3f, 101325f, 300f);
plenumInlet = intakePlenum.CreatePort();
plenumOutlet = intakePlenum.CreatePort();
// Exhaust silencer volume: 600 cc is realistic for a small-bore muffler
exhaustMuffler = new Volume0D(600e-6f, 101325f, 650f);
mufflerIn = exhaustMuffler.CreatePort();
mufflerOut = exhaustMuffler.CreatePort();
// ── Boundary system ───────────────────────────────────────────────────
boundaries = new BoundarySystem(pipeSystem, maxOrifices: 4, maxOpenEnds: 2);
throttleAreaIdx = 0;
plenumRunnerIdx = 1;
intakeValveIdx = 2;
exhaustValveIdx = 3;
// Open ends: atmosphere at both extremes
boundaries.AddOpenEnd(pipeIndex: 0, isLeftEnd: true, 101325f, intakePipeArea);
intakeOpenIdx = 0;
boundaries.AddOpenEnd(pipeIndex: 2, isLeftEnd: false, 101325f, stingerArea);
exhaustOpenIdx = 1;
// Orifices: throttle → plenum → runner → cylinder → exhaust pipe
boundaries.AddOrifice(plenumInlet, 0, false, throttleAreaIdx, 0.72f);
boundaries.AddOrifice(plenumOutlet, 1, true, plenumRunnerIdx, 1.00f);
boundaries.AddOrifice(cylinder.IntakePort, 1, false, intakeValveIdx, 0.68f);
boundaries.AddOrifice(cylinder.ExhaustPort, 2, true, exhaustValveIdx, 0.70f);
orificeAreas = new float[4];
orificeAreas[plenumRunnerIdx] = intakePipeArea; // runner always fully open
// ── Solver ────────────────────────────────────────────────────────────
// SubStepCount = 4 keeps CFL ≤ 1 for 5 mm cells at 44 100 Hz
solver = new Solver { SubStepCount = 4, EnableProfiling = false };
solver.SetTimeStep(dt);
solver.SetPipeSystem(pipeSystem);
solver.SetBoundarySystem(boundaries);
solver.AddComponent(cylinder);
solver.AddComponent(intakePlenum);
solver.AddComponent(exhaustMuffler);
// ── Sound ─────────────────────────────────────────────────────────────
exhaustSound = new SoundProcessor(sampleRate, 1f) { Gain = 4.5f };
intakeSound = new SoundProcessor(sampleRate, 1f) { Gain = 4.5f };
reverb = new OutdoorExhaustReverb(sampleRate);
stepCount = 0;
Console.WriteLine("125cc Two-Stroke expansion chamber tuned for ~8 500 RPM power peak");
Console.WriteLine($" Exhaust cells: {exhaustCells} | header {headerCells} diffuser {diffuserCells}" +
$" belly {bellyCells} convergent {convergentCells} stinger {stingerCells}");
}
public override float Process()
{
float engineRpm = crankshaft.AngularVelocity * 60f / (2f * MathF.PI);
vehicle.ClutchInput = Clutch;
var (clutchTorque, effectiveInertia) = vehicle.Update(engineRpm, crankshaft.Inertia, (float)dt);
crankshaft.SetEffectiveInertia(effectiveInertia);
crankshaft.SetLoadTorque(clutchTorque);
crankshaft.Step((float)dt);
cylinder.PreStep((float)dt);
float throttledArea = _maxThrottleArea * Math.Clamp(Throttle, 0.001f, 1f);
orificeAreas[throttleAreaIdx] = throttledArea;
orificeAreas[intakeValveIdx] = cylinder.IntakeValveArea;
orificeAreas[exhaustValveIdx] = cylinder.ExhaustValveArea;
boundaries.SetOrificeAreas(orificeAreas);
solver.Step();
stepCount++;
float exhaustFlow = boundaries.GetOpenEndMassFlow(exhaustOpenIdx);
float intakeFlow = boundaries.GetOpenEndMassFlow(intakeOpenIdx);
float exhaustDry = exhaustSound.Process(exhaustFlow);
float intakeDry = intakeSound.Process(intakeFlow);
if (stepCount % 2000 == 0)
{
float rpm = crankshaft.AngularVelocity * 60f / (2f * MathF.PI);
float powerKw = crankshaft.AveragePower * 1e-3f;
float torqueNm = crankshaft.AverageTorque;
Console.WriteLine($"Step {stepCount,7} | RPM={rpm,6:F0} | Power={powerKw,5:F2} kW" +
$" | Torque={torqueNm,5:F1} Nm | Gear={vehicle.CurrentGear}" +
$" | Speed={vehicle.SpeedKmh,4:F0} km/h");
}
return reverb.Process((intakeDry + exhaustDry) * 0.5f);
}
// ── Drawing ───────────────────────────────────────────────────────────────
public override void Draw(RenderWindow target)
{
float winW = target.GetView().Size.X;
float winH = target.GetView().Size.Y;
float intakeY = winH / 2f - 40f;
float exhaustY = winH / 2f + 80f;
float openEndX = 40f;
// Intake stub
float x = openEndX;
float w = 120f;
DrawPipe(target, pipeSystem, 0, intakeY, x, x + w);
// Throttle body
float throttleX = x + w + 5f;
var throttleRect = new RectangleShape(new Vector2f(8f, 30f))
{
FillColor = Color.Yellow,
Position = new Vector2f(throttleX, intakeY - 15f)
};
target.Draw(throttleRect);
// Plenum
float plenW = 40f, plenH = 60f;
float plenX = throttleX + 10f;
DrawVolume(target, intakePlenum, plenX + plenW / 2f, intakeY - plenH / 2f, plenW, plenH);
// Runner
float runnerStartX = plenX + plenW + 5f;
DrawPipe(target, pipeSystem, 1, intakeY, runnerStartX, runnerStartX + 100f);
// Cylinder
float cylCX = runnerStartX + 150f;
float cylTopY = intakeY - 120f;
DrawCylinder(target, cylinder, cylCX, cylTopY, 80f, 240f);
// Exhaust pipe (expansion chamber)
float exhStartX = cylCX + 40f + 20f;
DrawPipe(target, pipeSystem, 2, exhaustY, exhStartX, winW - 60f, areaScale: 800f);
// HUD labels
float rpm = crankshaft.AngularVelocity * 60f / (2f * MathF.PI);
float powerKw = crankshaft.AveragePower * 1e-3f;
float torqueNm = crankshaft.AverageTorque;
DrawLabel(target, $"RPM: {rpm:F0}", new Vector2f(20, 90), Color.White, 24);
DrawLabel(target, $"Power: {powerKw:F2} kW", new Vector2f(20, 115), Color.White, 24);
DrawLabel(target, $"Torque: {torqueNm:F1} Nm",new Vector2f(20, 140), Color.White, 20);
string gearText = vehicle.CurrentGear == 0 ? "N" : vehicle.CurrentGear.ToString();
DrawLabel(target, $"Gear: {gearText}", new Vector2f(20, 162), Color.Cyan, 20);
DrawLabel(target, $"Speed: {vehicle.SpeedKmh:F0} km/h",
new Vector2f(20, 184), Color.Cyan, 20);
DrawLabel(target, vehicle.Engagement > 0.99f ? "Clutch: Locked" : "Clutch: Slipping",
new Vector2f(20, 204), Color.Cyan, 14);
// Dyno curve
UpdateDynoCurve(rpm, powerKw, torqueNm);
DrawDynoCurve(target, winW - 410f, winH - 260f, 400f, 250f, rpm, powerKw);
}
}
}