engine almost working, backup before adding gas types.

This commit is contained in:
max
2026-05-07 20:07:15 +02:00
parent 14f5ba925f
commit 92d84eacfe
18 changed files with 1236 additions and 587 deletions

View File

@@ -0,0 +1,53 @@
using SFML.Audio;
using SFML.System;
namespace FluidSim.Audio
{
public class AudioOutputStream : SoundStream
{
private readonly SimulationRingBuffer _sourceBuffer;
private double _speed = 1.0; // nonvolatile, accessed with Volatile.Read/Write
public AudioOutputStream(SimulationRingBuffer sourceBuffer)
{
_sourceBuffer = sourceBuffer;
// 2 channels, 44.1 kHz, stereo
Initialize(2, 44100, new[] { SoundChannel.FrontLeft, SoundChannel.FrontRight });
}
/// <summary>Playback speed (0.01 … 1.0 or higher for catchup).</summary>
public double Speed
{
get => Volatile.Read(ref _speed);
set => Volatile.Write(ref _speed, value);
}
protected override bool OnGetData(out short[] samples)
{
const int monoBlockSize = 512;
float[] temp = new float[monoBlockSize];
int read = _sourceBuffer.ReadInterpolated(temp, monoBlockSize, Speed);
samples = new short[monoBlockSize * 2];
if (read > 0)
{
for (int i = 0; i < read; i++)
{
float clamped = Math.Clamp(temp[i], -1f, 1f);
short final = (short)(clamped * short.MaxValue);
samples[i * 2] = final; // left
samples[i * 2 + 1] = final; // right
}
}
// Fill rest with silence
for (int i = read * 2; i < samples.Length; i++)
samples[i] = 0;
return true;
}
protected override void OnSeek(Time timeOffset) =>
throw new NotSupportedException();
}
}

View File

@@ -0,0 +1,98 @@
namespace FluidSim.Audio
{
public class SimulationRingBuffer
{
private readonly float[] _buffer;
private readonly int _capacity;
private int _writeHead; // monotonic, producer only
private int _readHead; // monotonic, consumer advances after consumption
// Consumer interpolation state
private double _readPosFrac;
private bool _consumerInit;
// Events for signalling
private readonly AutoResetEvent _spaceAvailable = new AutoResetEvent(false);
private readonly AutoResetEvent _dataAvailable = new AutoResetEvent(false);
public SimulationRingBuffer(int capacity)
{
if ((capacity & (capacity - 1)) != 0)
throw new ArgumentException("Capacity must be a power of two.");
_capacity = capacity;
_buffer = new float[capacity];
}
// ---------- Producer ----------
public int FreeSpace => _capacity - (_writeHead - Volatile.Read(ref _readHead));
/// <summary>Number of samples currently available for reading (integer count).</summary>
public int AvailableSamples => Volatile.Read(ref _writeHead) - Volatile.Read(ref _readHead);
public void Write(float sample)
{
while (FreeSpace == 0)
_spaceAvailable.WaitOne();
int w = _writeHead;
int mask = _capacity - 1;
_buffer[w & mask] = sample;
Volatile.Write(ref _writeHead, w + 1);
_dataAvailable.Set();
}
public int Write(float[] data, int count)
{
int free = FreeSpace;
int toWrite = Math.Min(count, free);
int w = _writeHead;
int mask = _capacity - 1;
for (int i = 0; i < toWrite; i++)
_buffer[(w + i) & mask] = data[i];
Volatile.Write(ref _writeHead, w + toWrite);
if (toWrite > 0)
_dataAvailable.Set();
return toWrite;
}
// ---------- Consumer ----------
public void ResetConsumer() => _consumerInit = false;
public int ReadInterpolated(float[] dest, int destCount, double speed)
{
if (!_consumerInit)
{
_readPosFrac = Volatile.Read(ref _readHead);
_consumerInit = true;
}
int mask = _capacity - 1;
int writeHead = Volatile.Read(ref _writeHead);
int produced = 0;
for (int i = 0; i < destCount; i++)
{
int idxFloor = (int)_readPosFrac;
int idxCeil = idxFloor + 1;
if (idxCeil >= writeHead)
break;
float y0 = _buffer[idxFloor & mask];
float y1 = _buffer[idxCeil & mask];
double frac = _readPosFrac - idxFloor;
dest[i] = (float)(y0 + (y1 - y0) * frac);
_readPosFrac += speed;
produced++;
}
int newReadHead = (int)_readPosFrac;
if (newReadHead > Volatile.Read(ref _readHead))
{
Volatile.Write(ref _readHead, newReadHead);
_spaceAvailable.Set();
}
return produced;
}
}
}

45
Audio/SoundEngine.cs Normal file
View File

@@ -0,0 +1,45 @@
namespace FluidSim.Audio
{
public class SoundEngine : IDisposable
{
private readonly AudioOutputStream _stream;
private bool _isPlaying;
public SoundEngine(SimulationRingBuffer sourceBuffer, int bufferCapacity = 16384)
{
_stream = new AudioOutputStream(sourceBuffer);
}
public void Start()
{
if (_isPlaying) return;
_stream.Play();
_isPlaying = true;
}
public void Stop()
{
if (!_isPlaying) return;
_stream.Stop();
_isPlaying = false;
}
public double Speed
{
get => _stream.Speed;
set => _stream.Speed = value;
}
public float Volume
{
get => _stream.Volume;
set => _stream.Volume = value;
}
public void Dispose()
{
Stop();
_stream.Dispose();
}
}
}

View File

@@ -10,8 +10,8 @@ namespace FluidSim.Components
public double PreviousAngle { get; set; } // ← now has public setter
public double Inertia { get; set; } = 0.2;
public double FrictionConstant { get; set; } = 2.0; // N·m
public double FrictionViscous { get; set; } = 0.005; // N·m per rad/s
public double FrictionConstant { get; set; } = 0.0; // N·m
public double FrictionViscous { get; set; } = 0.000; // N·m per rad/s
private double externalTorque;

274
Components/Cylinder.cs Normal file
View File

@@ -0,0 +1,274 @@
using System;
using System.Collections.Generic;
using FluidSim.Interfaces;
namespace FluidSim.Components
{
public class Cylinder : IComponent
{
// Public ports
public Port IntakePort { get; }
public Port ExhaustPort { get; }
public Crankshaft Crankshaft { get; }
private readonly Port[] _ports;
IReadOnlyList<Port> IComponent.Ports => _ports;
// Geometry
public double Bore { get; }
public double Stroke { get; }
public double ConRodLength { get; }
public double CompressionRatio { get; }
// Valve timings (degrees, 0 = TDC compression, 720° full cycle)
public double IVO { get; }
public double IVC { get; }
public double EVO { get; }
public double EVC { get; }
// Valve areas
public double MaxIntakeArea { get; set; } = 0.0005;
public double MaxExhaustArea { get; set; } = 0.0005;
// Ignition and combustion
public double SparkAdvance { get; set; } = 20.0; // °BTDC
public double WiebeA { get; set; } = 5.0;
public double WiebeM { get; set; } = 2.0;
public double WiebeDuration { get; set; } = 60.0; // degrees
public double WiebeStart { get; set; } = 5.0; // degrees after spark
// Fuel
public double StoichiometricAFR { get; set; } = 14.7;
public double FuelLowerHeatingValue { get; set; } = 44e6; // J/kg
// Heat loss
public double CylinderWallArea { get; set; } = 0.02; // m²
public double HeatTransferCoefficient { get; set; } = 100.0; // W/(m²·K)
public double AmbientTemperature { get; set; } = 300.0; // K
// State (public for drawing)
public double Volume => cylinderVolume;
public double Pressure => (Gamma - 1.0) * cylinderEnergy / Math.Max(cylinderVolume, 1e-12);
public double Temperature => Pressure / Math.Max(Density * GasConstant, 1e-12);
public double Density => cylinderMass / Math.Max(cylinderVolume, 1e-12);
public double Mass => cylinderMass;
public double PistonFraction => (cylinderVolume - clearanceVolume) / SweptVolume;
private double cylinderVolume;
private double cylinderMass;
private double cylinderEnergy;
private double trappedAirMass;
private double fuelMass;
private double burnFraction; // 01
private bool combustionActive;
private bool fuelInjected;
// --- Debounce flag: allows combustion only below a certain temperature ---
private bool _canCombust = true;
private const double CombustionEnableTemperature = 800.0; // K must cool below this to rearm
private const double Gamma = 1.4;
private const double GasConstant = 287.0;
// Absolute safety limits
private const double MaxPressurePa = 200e5; // 200 bar
private const double MaxTemperatureK = 3500.0; // 3500 K
public Cylinder(double bore, double stroke, double conRodLength, double compressionRatio,
double ivo, double ivc, double evo, double evc, double initialRPM = 1000)
{
Bore = bore;
Stroke = stroke;
ConRodLength = conRodLength;
CompressionRatio = compressionRatio;
IVO = ivo;
IVC = ivc;
EVO = evo;
EVC = evc;
Crankshaft = new Crankshaft(initialRPM);
cylinderVolume = clearanceVolume;
cylinderMass = 1.225 * clearanceVolume;
cylinderEnergy = 101325.0 * clearanceVolume / (Gamma - 1.0);
IntakePort = new Port { Owner = this };
ExhaustPort = new Port { Owner = this };
_ports = new[] { IntakePort, ExhaustPort };
}
// Derived volumes
private double SweptVolume => Math.PI * 0.25 * Bore * Bore * Stroke;
private double clearanceVolume => SweptVolume / (CompressionRatio - 1.0);
private double CrankRadius => Stroke / 2.0;
private double Obliquity => CrankRadius / ConRodLength;
// Crank angle in degrees (0720)
private double CrankDeg => (Crankshaft.CrankAngle % (4.0 * Math.PI)) * 180.0 / Math.PI % 720.0;
public double ComputeVolume(double thetaRad)
{
double r = CrankRadius;
double l = ConRodLength;
double cosTh = Math.Cos(thetaRad);
double sinTh = Math.Sin(thetaRad);
double term = Math.Sqrt(1.0 - Obliquity * Obliquity * sinTh * sinTh);
double x = r * (1.0 - cosTh) + l * (1.0 - term);
double area = Math.PI * 0.25 * Bore * Bore;
return clearanceVolume + area * x;
}
public double IntakeValveArea => ValveArea(CrankDeg, IVO, IVC, MaxIntakeArea);
public double ExhaustValveArea => ValveArea(CrankDeg, EVO, EVC, MaxExhaustArea);
private double ValveArea(double thetaDeg, double opens, double closes, double maxArea)
{
double deg = thetaDeg % 720.0;
if (deg < 0) deg += 720.0;
if (deg >= opens && deg <= closes)
{
double half = (closes - opens) * 0.5;
double mid = opens + half;
double frac = 1.0 - Math.Abs(deg - mid) / half;
frac = Math.Clamp(frac, 0.0, 1.0);
return maxArea * frac;
}
return 0.0;
}
private double Wiebe(double angleSinceSpark)
{
if (angleSinceSpark < WiebeStart) return 0.0;
double phi = (angleSinceSpark - WiebeStart) / WiebeDuration;
if (phi <= 0) return 0.0;
return 1.0 - Math.Exp(-WiebeA * Math.Pow(phi, WiebeM + 1));
}
public void PreStep(double dt)
{
double prevVolume = cylinderVolume;
double crankAngleRad = Crankshaft.CrankAngle;
cylinderVolume = ComputeVolume(crankAngleRad);
// Volume work (done BY gas, positive when expanding)
double dV = cylinderVolume - prevVolume;
cylinderEnergy -= Pressure * dV;
double prevDeg = Crankshaft.PreviousAngle * 180.0 / Math.PI % 720.0;
double currDeg = crankAngleRad * 180.0 / Math.PI % 720.0;
// ----- Intake closing: capture trapped air mass and compute fuel -----
if (prevDeg >= IVO && prevDeg < IVC && currDeg >= IVC)
{
trappedAirMass = cylinderMass;
fuelMass = trappedAirMass / StoichiometricAFR;
fuelInjected = true;
}
// ----- Spark ignition (once per cycle, only if canCombust) -----
double sparkAngle = 0.0 - SparkAdvance;
if (sparkAngle < 0) sparkAngle += 720.0;
bool crossedSpark = (prevDeg < sparkAngle && currDeg >= sparkAngle) ||
(prevDeg > sparkAngle + 360.0 && currDeg < sparkAngle);
if (crossedSpark && !combustionActive && fuelInjected && _canCombust)
{
combustionActive = true;
burnFraction = 0.0;
}
// ----- Combustion progress -----
if (combustionActive)
{
double angleSinceSpark = currDeg - sparkAngle;
if (angleSinceSpark < 0) angleSinceSpark += 720.0;
double newFraction = Wiebe(angleSinceSpark);
if (newFraction >= 1.0 || angleSinceSpark > (WiebeDuration + WiebeStart + SparkAdvance))
{
newFraction = 1.0;
combustionActive = false;
_canCombust = false; // require cooldown before next ignition
}
double dFraction = newFraction - burnFraction;
if (dFraction > 0)
{
double dQ = fuelMass * FuelLowerHeatingValue * dFraction;
cylinderEnergy += dQ;
cylinderMass += fuelMass * dFraction;
burnFraction = newFraction;
}
}
// ----- Rearm combustion if temperature has dropped low enough -----
if (!combustionActive && !_canCombust && Temperature < CombustionEnableTemperature)
{
_canCombust = true;
}
// ----- Heat loss to cylinder walls -----
double dQ_loss = HeatTransferCoefficient * CylinderWallArea *
(Temperature - AmbientTemperature) * dt;
cylinderEnergy -= dQ_loss;
// Update port states
double p = Pressure, rho = Density, T = Temperature;
double h = Gamma / (Gamma - 1.0) * p / Math.Max(rho, 1e-12);
IntakePort.Pressure = p;
IntakePort.Density = rho;
IntakePort.Temperature = T;
IntakePort.SpecificEnthalpy = h;
ExhaustPort.Pressure = p;
ExhaustPort.Density = rho;
ExhaustPort.Temperature = T;
ExhaustPort.SpecificEnthalpy = h;
}
public void UpdateState(double dt)
{
double dm = 0.0;
double dE = 0.0;
foreach (var port in _ports)
{
dm += port.MassFlowRate * dt;
dE += port.MassFlowRate * port.SpecificEnthalpy * dt;
}
cylinderMass += dm;
cylinderEnergy += dE;
double V = Math.Max(cylinderVolume, 1e-12);
// --- Absolute pressure & temperature clamps ---
double currentP = (Gamma - 1.0) * cylinderEnergy / V;
if (currentP > MaxPressurePa)
cylinderEnergy = MaxPressurePa * V / (Gamma - 1.0);
double currentRho = cylinderMass / V;
double currentT = currentP / Math.Max(currentRho * GasConstant, 1e-12);
if (currentT > MaxTemperatureK)
{
double pAtTlimit = currentRho * GasConstant * MaxTemperatureK;
cylinderEnergy = pAtTlimit * V / (Gamma - 1.0);
}
// Existing safeguards
if (cylinderMass < 1e-9)
{
cylinderMass = 1e-9;
cylinderEnergy = 101325.0 * V / (Gamma - 1.0);
}
else if (cylinderEnergy < 0.0)
{
cylinderEnergy = 101325.0 * V / (Gamma - 1.0);
}
if (cylinderMass < 0.0) cylinderMass = 1e-9;
if (cylinderEnergy < 0.0) cylinderEnergy = 101325.0 * V / (Gamma - 1.0);
}
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using FluidSim.Interfaces;
namespace FluidSim.Components
@@ -9,6 +10,9 @@ namespace FluidSim.Components
/// </summary>
public class Pipe1D : IComponent
{
// ---------- Compiletime profiling flag ----------
public const bool EnableDetailedProfiling = false; // set to false in release builds
public Port PortA { get; }
public Port PortB { get; }
public double Area { get; }
@@ -32,7 +36,7 @@ namespace FluidSim.Components
private readonly double _gamma = 1.4;
private double[] _rho, _rhou, _E;
private double[] _fluxM, _fluxP, _fluxE; // flux at cell faces (0.._n)
private double[] _fluxM, _fluxP, _fluxE; // flux at cell faces (0.._n) kept for possible external use, not used internally anymore
private double _rhoGhostL, _uGhostL, _pGhostL;
private double _rhoGhostR, _uGhostR, _pGhostR;
@@ -41,6 +45,14 @@ namespace FluidSim.Components
private double _laminarCoeff;
private double _ambientEnergyReference;
// ---------- Profiling accumulators ----------
private long _profPrecomputeTicks;
private long _profLeftFluxTicks;
private long _profInteriorLoopTicks;
private long _profRightFluxTicks;
private long _profPortUpdateTicks;
private long _profCallCount;
public Pipe1D(double length, double area, int cellCount)
{
if (cellCount < 4) throw new ArgumentException("cellCount must be at least 4");
@@ -128,84 +140,142 @@ namespace FluidSim.Components
double dt = dtSub;
int n = _n;
// ---- Compute fluxes at all faces using LaxFriedrichs ----
// Left face (0): between ghostL and cell 0
double rL = Math.Max(_rhoGhostL, 1e-12);
double pL = _pGhostL;
double uL = _uGhostL;
double eL = pL / ((_gamma - 1.0) * rL) + 0.5 * uL * uL;
double rR = Math.Max(_rho[0], 1e-12);
double pR = PressureScalar(0);
double uR = _rhou[0] / rR;
double eR = pR / ((_gamma - 1.0) * rR) + 0.5 * uR * uR;
LaxFriedrichsFlux(rL, uL, pL, eL, rR, uR, pR, eR,
out _fluxM[0], out _fluxP[0], out _fluxE[0]);
// Internal faces (1 .. n-1)
for (int f = 1; f < n; f++)
{
int iL = f - 1;
int iR = f;
rL = Math.Max(_rho[iL], 1e-12);
pL = PressureScalar(iL);
uL = _rhou[iL] / rL;
eL = pL / ((_gamma - 1.0) * rL) + 0.5 * uL * uL;
rR = Math.Max(_rho[iR], 1e-12);
pR = PressureScalar(iR);
uR = _rhou[iR] / rR;
eR = pR / ((_gamma - 1.0) * rR) + 0.5 * uR * uR;
LaxFriedrichsFlux(rL, uL, pL, eL, rR, uR, pR, eR,
out _fluxM[f], out _fluxP[f], out _fluxE[f]);
}
// Right face (n): between cell n-1 and ghostR
rL = Math.Max(_rho[n - 1], 1e-12);
pL = PressureScalar(n - 1);
uL = _rhou[n - 1] / rL;
eL = pL / ((_gamma - 1.0) * rL) + 0.5 * uL * uL;
rR = Math.Max(_rhoGhostR, 1e-12);
pR = _pGhostR;
uR = _uGhostR;
eR = pR / ((_gamma - 1.0) * rR) + 0.5 * uR * uR;
LaxFriedrichsFlux(rL, uL, pL, eL, rR, uR, pR, eR,
out _fluxM[n], out _fluxP[n], out _fluxE[n]);
// ---- Cell update ----
double dt_dx = dt / _dx;
double coeff = _laminarCoeff * DampingMultiplier;
double relaxRate = EnergyRelaxationRate;
double gamma = _gamma;
double gm1 = gamma - 1.0;
// ---------- Profiling start ----------
long t0 = 0, t1 = 0;
if (EnableDetailedProfiling)
{
t0 = Stopwatch.GetTimestamp();
_profCallCount++;
}
// ---------- Phase 1: Precompute pressure and speed of sound ----------
double[] p = new double[n];
double[] c = new double[n];
for (int i = 0; i < n; i++)
{
double rho = Math.Max(_rho[i], 1e-12);
double u = _rhou[i] / rho;
p[i] = gm1 * (_E[i] - 0.5 * _rhou[i] * _rhou[i] / rho);
c[i] = Math.Sqrt(gamma * p[i] / rho);
}
if (EnableDetailedProfiling)
{
t1 = Stopwatch.GetTimestamp();
_profPrecomputeTicks += (t1 - t0);
t0 = t1;
}
// ---------- Phase 2: Left face flux (ghostL cell 0) ----------
double rL_ghost = Math.Max(_rhoGhostL, 1e-12);
double pL_ghost = _pGhostL;
double uL_ghost = _uGhostL;
double cL_ghost = Math.Sqrt(gamma * pL_ghost / rL_ghost);
LaxFlux(rL_ghost, uL_ghost, pL_ghost, cL_ghost,
_rho[0], _rhou[0] / Math.Max(_rho[0], 1e-12), p[0], c[0],
out double fluxM_left, out double fluxP_left, out double fluxE_left);
if (EnableDetailedProfiling)
{
t1 = Stopwatch.GetTimestamp();
_profLeftFluxTicks += (t1 - t0);
t0 = t1;
}
// ---------- Phase 3: Interior loop (fluxes + cell updates) ----------
double fluxM_prev = fluxM_left;
double fluxP_prev = fluxP_left;
double fluxE_prev = fluxE_left;
for (int i = 0; i < n - 1; i++)
{
int iL = i;
int iR = i + 1;
double rL = Math.Max(_rho[iL], 1e-12);
double uL = _rhou[iL] / rL;
double pL = p[iL];
double cL = c[iL];
double rR = Math.Max(_rho[iR], 1e-12);
double uR = _rhou[iR] / rR;
double pR = p[iR];
double cR = c[iR];
LaxFlux(rL, uL, pL, cL, rR, uR, pR, cR,
out double fluxM_right, out double fluxP_right, out double fluxE_right);
// Update cell i
double r = _rho[i];
double ru = _rhou[i];
double E = _E[i];
double dM = _fluxM[i + 1] - _fluxM[i];
double dP = _fluxP[i + 1] - _fluxP[i];
double dE_flux = _fluxE[i + 1] - _fluxE[i];
double newR = r - dt_dx * dM;
double newRu = ru - dt_dx * dP;
double newE = E - dt_dx * dE_flux;
double newR = r - dt_dx * (fluxM_right - fluxM_prev);
double newRu = ru - dt_dx * (fluxP_right - fluxP_prev);
double newE = E - dt_dx * (fluxE_right - fluxE_prev);
double dampingFactor = Math.Exp(-coeff / Math.Max(r, 1e-12) * dt);
newRu *= dampingFactor;
double relaxFactor = Math.Exp(-relaxRate * dt);
newE = _ambientEnergyReference + (newE - _ambientEnergyReference) * relaxFactor;
newR = Math.Max(newR, 1e-12);
double kin = 0.5 * newRu * newRu / Math.Max(newR, 1e-12);
double eMin = 100.0 / (_gamma - 1.0) + kin;
double eMin = 100.0 / gm1 + kin;
newE = Math.Max(newE, eMin);
_rho[i] = newR;
_rhou[i] = newRu;
_E[i] = newE;
fluxM_prev = fluxM_right;
fluxP_prev = fluxP_right;
fluxE_prev = fluxE_right;
}
if (EnableDetailedProfiling)
{
t1 = Stopwatch.GetTimestamp();
_profInteriorLoopTicks += (t1 - t0);
t0 = t1;
}
// ---------- Phase 4: Right face flux (cell n1 ghostR) ----------
double rR_ghost = Math.Max(_rhoGhostR, 1e-12);
double pR_ghost = _pGhostR;
double uR_ghost = _uGhostR;
double cR_ghost = Math.Sqrt(gamma * pR_ghost / rR_ghost);
LaxFlux(_rho[n - 1], _rhou[n - 1] / Math.Max(_rho[n - 1], 1e-12), p[n - 1], c[n - 1],
rR_ghost, uR_ghost, pR_ghost, cR_ghost,
out double fluxM_right_final, out double fluxP_right_final, out double fluxE_right_final);
// Update last cell (identical to interior, but with final fluxes)
{
int i = n - 1;
double r = _rho[i];
double ru = _rhou[i];
double E = _E[i];
double newR = r - dt_dx * (fluxM_right_final - fluxM_prev);
double newRu = ru - dt_dx * (fluxP_right_final - fluxP_prev);
double newE = E - dt_dx * (fluxE_right_final - fluxE_prev);
double dampingFactor = Math.Exp(-coeff / Math.Max(r, 1e-12) * dt);
newRu *= dampingFactor;
double relaxFactor = Math.Exp(-relaxRate * dt);
newE = _ambientEnergyReference + (newE - _ambientEnergyReference) * relaxFactor;
newR = Math.Max(newR, 1e-12);
double kin = 0.5 * newRu * newRu / Math.Max(newR, 1e-12);
double eMin = 100.0 / gm1 + kin;
newE = Math.Max(newE, eMin);
_rho[i] = newR;
@@ -213,43 +283,68 @@ namespace FluidSim.Components
_E[i] = newE;
}
// Update port states
if (EnableDetailedProfiling)
{
t1 = Stopwatch.GetTimestamp();
_profRightFluxTicks += (t1 - t0);
t0 = t1;
}
// ---------- Phase 5: Update port states ----------
(double rhoA, double uA, double pA) = GetInteriorStateLeft();
PortA.Pressure = pA; PortA.Density = rhoA;
PortA.Temperature = pA / (rhoA * 287.0);
PortA.SpecificEnthalpy = _gamma / (_gamma - 1.0) * pA / rhoA;
PortA.SpecificEnthalpy = gm1 / (gamma - 1.0) * pA / rhoA;
(double rhoB, double uB, double pB) = GetInteriorStateRight();
PortB.Pressure = pB; PortB.Density = rhoB;
PortB.Temperature = pB / (rhoB * 287.0);
PortB.SpecificEnthalpy = _gamma / (_gamma - 1.0) * pB / rhoB;
PortB.SpecificEnthalpy = gm1 / (gamma - 1.0) * pB / rhoB;
if (EnableDetailedProfiling)
{
t1 = Stopwatch.GetTimestamp();
_profPortUpdateTicks += (t1 - t0);
}
}
// ---------- LaxFriedrichs flux ----------
// ---------- Local LaxFriedrichs flux function ----------
private void LaxFlux(double rL, double uL, double pL, double cL,
double rR, double uR, double pR, double cR,
out double fm, out double fp, out double fe)
{
double gm1 = _gamma - 1.0;
double EL = pL / (gm1 * rL) + 0.5 * uL * uL;
double ER = pR / (gm1 * rR) + 0.5 * uR * uR;
double Fm_L = rL * uL;
double Fp_L = rL * uL * uL + pL;
double Fe_L = (rL * EL + pL) * uL;
double Fm_R = rR * uR;
double Fp_R = rR * uR * uR + pR;
double Fe_R = (rR * ER + pR) * uR;
double alpha = Math.Max(Math.Abs(uL) + cL, Math.Abs(uR) + cR);
fm = 0.5 * (Fm_L + Fm_R) - 0.5 * alpha * (rR - rL);
fp = 0.5 * (Fp_L + Fp_R) - 0.5 * alpha * (rR * uR - rL * uL);
fe = 0.5 * (Fe_L + Fe_R) - 0.5 * alpha * (rR * ER - rL * EL);
}
// Original LaxFriedrichsFlux (kept for compatibility, can be removed if unused)
private void LaxFriedrichsFlux(double rL, double uL, double pL, double eL,
double rR, double uR, double pR, double eR,
out double fm, out double fp, out double fe)
{
// Primitive states
double rhoL = rL, rhoR = rR;
double EL = rhoL * eL; // total energy per volume = rho * (specific total energy)
double EL = rhoL * eL;
double ER = rhoR * eR;
// Conserved vectors U = (ρ, ρu, E)
// Flux F = (ρu, ρu²+p, (E+p)u)
double Fm_L = rhoL * uL;
double Fp_L = rhoL * uL * uL + pL;
double Fe_L = (EL + pL) * uL;
double Fm_R = rhoR * uR;
double Fp_R = rhoR * uR * uR + pR;
double Fe_R = (ER + pR) * uR;
// LaxFriedrichs dissipation coefficient α = max(|u|+c) over whole domain, but here we use local max to be simple:
double cL = Math.Sqrt(_gamma * pL / rL);
double cR = Math.Sqrt(_gamma * pR / rR);
double alpha = Math.Max(Math.Abs(uL) + cL, Math.Abs(uR) + cR);
fm = 0.5 * (Fm_L + Fm_R) - 0.5 * alpha * (rhoR - rhoL);
fp = 0.5 * (Fp_L + Fp_R) - 0.5 * alpha * (rhoR * uR - rhoL * uL);
fe = 0.5 * (Fe_L + Fe_R) - 0.5 * alpha * (ER - EL);
@@ -291,5 +386,42 @@ namespace FluidSim.Components
double e = p / ((_gamma - 1.0) * rho);
_E[i] = rho * e + 0.5 * rho * u * u;
}
// ---------- Public profiling interface ----------
public void ResetDetailCounters()
{
_profPrecomputeTicks = 0;
_profLeftFluxTicks = 0;
_profInteriorLoopTicks = 0;
_profRightFluxTicks = 0;
_profPortUpdateTicks = 0;
_profCallCount = 0;
}
public string GetDetailProfileReport()
{
if (!EnableDetailedProfiling)
return "Detailed profiling disabled.";
double freq = Stopwatch.Frequency;
long totalTicks = _profPrecomputeTicks + _profLeftFluxTicks +
_profInteriorLoopTicks + _profRightFluxTicks +
_profPortUpdateTicks;
if (totalTicks == 0) return "No profiling data.";
double totalSec = totalTicks / freq;
double avgCallSec = totalSec / _profCallCount;
double avgCallUs = avgCallSec * 1e6;
string report = $" Pipe detailed (over {_profCallCount} calls, total {totalSec * 1000:F2} ms):\n";
report += $" Avg per call: {avgCallUs:F2} µs\n";
report += $" Precompute p,c: {_profPrecomputeTicks * 100.0 / totalTicks:F1} % ({_profPrecomputeTicks / freq * 1e6 / _profCallCount:F2} µs/call)\n";
report += $" Left face flux: {_profLeftFluxTicks * 100.0 / totalTicks:F1} % ({_profLeftFluxTicks / freq * 1e6 / _profCallCount:F2} µs/call)\n";
report += $" Interior loop: {_profInteriorLoopTicks * 100.0 / totalTicks:F1} % ({_profInteriorLoopTicks / freq * 1e6 / _profCallCount:F2} µs/call)\n";
report += $" Right face flux: {_profRightFluxTicks * 100.0 / totalTicks:F1} % ({_profRightFluxTicks / freq * 1e6 / _profCallCount:F2} µs/call)\n";
report += $" Port update: {_profPortUpdateTicks * 100.0 / totalTicks:F1} % ({_profPortUpdateTicks / freq * 1e6 / _profCallCount:F2} µs/call)\n";
return report;
}
}
}

View File

@@ -13,11 +13,12 @@ namespace FluidSim.Core
public double DischargeCoefficient { get; set; } = 0.62;
public double EffectiveLength { get; set; } = 0.001;
public bool UseInertance { get; set; } = true;
public bool UseInertance { get; set; } = false;
private double _mdot; // positive = volume → pipe
// Current mass flow (kg/s, positive = volume → pipe)
private double _mdot;
public double LastMassFlowRate { get; private set; }
public double LastMassFlowRate { get; private set; } // positive = into volume
public double LastFaceDensity { get; private set; }
public double LastFaceVelocity { get; private set; }
public double LastFacePressure { get; private set; }
@@ -41,10 +42,10 @@ namespace FluidSim.Core
}
// Gather states
double volP = VolumePort.Pressure;
double volP = VolumePort.Pressure;
double volRho = VolumePort.Density;
double volT = VolumePort.Temperature;
double volH = VolumePort.SpecificEnthalpy;
double volT = VolumePort.Temperature;
double volH = VolumePort.SpecificEnthalpy;
(double pipeRho, double pipeU, double pipeP) = IsPipeLeftEnd
? Pipe.GetInteriorStateLeft()
@@ -52,25 +53,34 @@ namespace FluidSim.Core
double pipeT = pipeP / Math.Max(pipeRho * 287.0, 1e-12);
double gamma = 1.4;
double R = 287.0;
double R = 287.0;
// ---- 1. Steadystate nozzle solution (gives correct exit pressure) ----
double mdotSS;
// ---- Steadystate nozzle solution (gives correct exit state) ----
double mdotSS; // positive = volume → pipe
double rhoFace0, uFace0, pFace0;
if (volP >= pipeP)
{
IsentropicOrifice.Compute(volP, volRho, volT, pipeP, gamma, R, area, DischargeCoefficient,
out double mdotUpToDown, out rhoFace0, out uFace0, out pFace0);
mdotSS = mdotUpToDown; // volume → pipe
mdotSS = mdotUpToDown;
}
else
{
IsentropicOrifice.Compute(pipeP, pipeRho, pipeT, volP, gamma, R, area, DischargeCoefficient,
out double mdotUpToDown, out rhoFace0, out uFace0, out pFace0);
mdotSS = -mdotUpToDown; // pipe → volume → negative for volume→pipe convention
mdotSS = -mdotUpToDown;
}
// ---- 2. Inertance dynamics ----
// ====== Hard physical cap: max sonic flow × 1.1 ======
double upRho = mdotSS >= 0 ? volRho : pipeRho;
double upT = mdotSS >= 0 ? volT : pipeT;
double upC = Math.Sqrt(gamma * R * upT);
double maxFlow = upRho * upC * area * 1.1;
if (Math.Abs(mdotSS) > maxFlow)
mdotSS = Math.Sign(mdotSS) * maxFlow;
// ====================================================
// ---- Dynamic update ----
if (UseInertance)
{
double rhoUp = _mdot >= 0 ? volRho : pipeRho;
@@ -85,39 +95,39 @@ namespace FluidSim.Core
_mdot = mdotSS;
}
// Clamp outflow to available mass
// Clamp outflow to available mass (if finite volume)
if (VolumePort.Owner is Volume0D vol)
{
double maxOut = vol.Mass / dtSub;
if (_mdot > maxOut) _mdot = maxOut;
}
// ---- 3. Ghost state (use nozzleexit pressure!) ----
double rhoFace = _mdot >= 0 ? volRho : pipeRho; // upstream density
double pFace = pFace0; // correct exit pressure (choked/subsonic)
// ---- Ghost state ----
double rhoFace = _mdot >= 0 ? volRho : pipeRho;
double pFace = pFace0;
double mdotMag = Math.Abs(_mdot);
double uFace = mdotMag / (rhoFace * area);
double uFace = mdotMag / (rhoFace * area);
if (IsPipeLeftEnd)
uFace = _mdot >= 0 ? uFace : -uFace; // left: +u into pipe
uFace = _mdot >= 0 ? uFace : -uFace;
else
uFace = _mdot >= 0 ? -uFace : uFace; // right: +u out of pipe
uFace = _mdot >= 0 ? -uFace : uFace;
if (IsPipeLeftEnd)
Pipe.SetGhostLeft(rhoFace, uFace, pFace);
else
Pipe.SetGhostRight(rhoFace, uFace, pFace);
// Store for monitoring
double mdotIntoVolume = -_mdot;
LastMassFlowRate = mdotIntoVolume;
LastFaceDensity = rhoFace;
// Store results (positive = into volume)
LastMassFlowRate = -_mdot;
LastFaceDensity = rhoFace;
LastFaceVelocity = uFace;
LastFacePressure = pFace;
VolumePort.MassFlowRate = mdotIntoVolume;
VolumePort.MassFlowRate = -_mdot;
if (mdotIntoVolume >= 0)
// Enthalpy transport
if (-_mdot >= 0) // inflow → pipe enthalpy
{
double hPipe = gamma / (gamma - 1.0) * pipeP / Math.Max(pipeRho, 1e-12);
VolumePort.SpecificEnthalpy = hPipe;
@@ -140,7 +150,7 @@ namespace FluidSim.Core
Pipe.SetGhostRight(rInt, -uInt, pInt);
LastMassFlowRate = 0.0;
LastFaceDensity = rInt;
LastFaceDensity = rInt;
LastFaceVelocity = 0.0;
LastFacePressure = pInt;
if (VolumePort != null)

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using FluidSim.Components;
using FluidSim.Interfaces;
@@ -18,6 +19,20 @@ namespace FluidSim.Core
/// <summary>CFL target for substepping (0.30.8). Lower values are safer for shocks.</summary>
public double CflTarget { get; set; } = 0.8;
// ---------- Timing accumulators (reset every LogInterval steps) ----------
private long _stepCount;
private double _timeTotal;
private double _timeCFL;
private double _timeOrifice;
private double _timeOpenEnd;
private double _timeJunction;
private double _timePipe;
private double _timeClearGhosts;
private double _timeUpdateState;
private const int LogInterval = 5000; // print once per second (at 44.1 kHz)
private const bool EnableLogging = false;
public void SetTimeStep(double dt) => _dt = dt;
public void AddComponent(IComponent component) => _components.Add(component);
@@ -30,11 +45,16 @@ namespace FluidSim.Core
var pipes = _components.OfType<Pipe1D>().ToList();
if (pipes.Count == 0) return;
var sw = Stopwatch.StartNew();
// CFL count
int nSub = 1;
foreach (var p in pipes)
nSub = Math.Max(nSub, p.GetRequiredSubSteps(_dt, CflTarget));
double dtSub = _dt / nSub;
_timeCFL += sw.Elapsed.TotalSeconds;
const int maxSubSteps = 10000;
if (nSub > maxSubSteps)
{
@@ -44,21 +64,86 @@ namespace FluidSim.Core
for (int sub = 0; sub < nSub; sub++)
{
double t0;
t0 = sw.Elapsed.TotalSeconds;
foreach (var link in _orificeLinks)
link.Resolve(dtSub);
_timeOrifice += sw.Elapsed.TotalSeconds - t0;
t0 = sw.Elapsed.TotalSeconds;
foreach (var link in _openEndLinks)
link.Resolve(dtSub);
_timeOpenEnd += sw.Elapsed.TotalSeconds - t0;
t0 = sw.Elapsed.TotalSeconds;
foreach (var junc in _junctions)
junc.Resolve(dtSub);
_timeJunction += sw.Elapsed.TotalSeconds - t0;
t0 = sw.Elapsed.TotalSeconds;
foreach (var p in pipes)
p.SimulateSingleStep(dtSub);
_timePipe += sw.Elapsed.TotalSeconds - t0;
}
double tCG = sw.Elapsed.TotalSeconds;
foreach (var p in pipes)
p.ClearGhostFlags();
_timeClearGhosts += sw.Elapsed.TotalSeconds - tCG;
double tUS = sw.Elapsed.TotalSeconds;
foreach (var comp in _components)
comp.UpdateState(_dt);
_timeUpdateState += sw.Elapsed.TotalSeconds - tUS;
// accumulate total step time (includes CFL, substeps, clear ghosts, update state)
_timeTotal += sw.Elapsed.TotalSeconds;
// ---------- Periodic report ----------
_stepCount++;
if (_stepCount % LogInterval == 0 && EnableLogging)
{
if (_timeTotal > 0)
{
double totalMs = _timeTotal * 1000.0;
double avgUs = (_timeTotal / LogInterval) * 1e6; // µs per step
double stepsPerSec = LogInterval / _timeTotal; // steps per second
Console.WriteLine($"--- Solver timing ({LogInterval} steps) ---");
Console.WriteLine($" Steps per second: {stepsPerSec:F1}");
Console.WriteLine($" Avg step time: {avgUs:F1} µs (last nSub = {nSub})");
Console.WriteLine($" CFL calc: {_timeCFL / _timeTotal * 100:F1} % ({_timeCFL * 1e6 / LogInterval:F1} µs/step)");
Console.WriteLine($" Substep loop:");
Console.WriteLine($" Orifice: {_timeOrifice / _timeTotal * 100:F1} % ({_timeOrifice * 1e6 / LogInterval:F1} µs/step)");
Console.WriteLine($" OpenEnd: {_timeOpenEnd / _timeTotal * 100:F1} % ({_timeOpenEnd * 1e6 / LogInterval:F1} µs/step)");
Console.WriteLine($" Junctions: {_timeJunction / _timeTotal * 100:F1} % ({_timeJunction * 1e6 / LogInterval:F1} µs/step)");
Console.WriteLine($" Pipe steps: {_timePipe / _timeTotal * 100:F1} % ({_timePipe * 1e6 / LogInterval:F1} µs/step)");
Console.WriteLine($" Clear ghosts: {_timeClearGhosts / _timeTotal * 100:F1} % ({_timeClearGhosts * 1e6 / LogInterval:F1} µs/step)");
Console.WriteLine($" Update state: {_timeUpdateState / _timeTotal * 100:F1} % ({_timeUpdateState * 1e6 / LogInterval:F1} µs/step)");
Console.WriteLine();
// ---------- Optional detailed pipe profiling ----------
if (Pipe1D.EnableDetailedProfiling)
{
foreach (var pipe in pipes)
{
Console.WriteLine(pipe.GetDetailProfileReport());
pipe.ResetDetailCounters();
}
}
}
// Reset accumulators for next interval
_timeTotal = 0;
_timeCFL = 0;
_timeOrifice = 0;
_timeOpenEnd = 0;
_timeJunction = 0;
_timePipe = 0;
_timeClearGhosts = 0;
_timeUpdateState = 0;
}
}
}
}

View File

@@ -1,131 +0,0 @@
using SFML.Audio;
using SFML.System;
namespace FluidSim;
#region Lockfree ring buffer (unchanged)
internal class RingBuffer
{
private readonly float[] buffer;
private volatile int readPos;
private volatile int writePos;
public RingBuffer(int capacity)
{
if ((capacity & (capacity - 1)) != 0)
throw new ArgumentException("Capacity must be a power of two.");
buffer = new float[capacity];
}
public int Count => (writePos - readPos) & (buffer.Length - 1);
public int Space => (readPos - writePos - 1) & (buffer.Length - 1);
public int Write(float[] data, int count)
{
int space = Space;
int toWrite = Math.Min(count, space);
int mask = buffer.Length - 1;
for (int i = 0; i < toWrite; i++)
buffer[(writePos + i) & mask] = data[i];
writePos = (writePos + toWrite) & mask;
return toWrite;
}
public int Read(float[] destination, int count)
{
int available = Count;
int toRead = Math.Min(count, available);
int mask = buffer.Length - 1;
for (int i = 0; i < toRead; i++)
destination[i] = buffer[(readPos + i) & mask];
readPos = (readPos + toRead) & mask;
return toRead;
}
}
#endregion
#region Stereo stream that consumes the ring buffer
internal class RingBufferStream : SoundStream
{
private readonly RingBuffer ringBuffer;
public RingBufferStream(RingBuffer buffer)
{
ringBuffer = buffer;
// 2 channels, 44.1 kHz, standard stereo mapping
Initialize(2, 44100, new[] { SoundChannel.FrontLeft, SoundChannel.FrontRight });
}
protected override bool OnGetData(out short[] samples)
{
const int monoBlockSize = 512; // number of mono samples we'll read
float[] temp = new float[monoBlockSize];
int read = ringBuffer.Read(temp, monoBlockSize);
samples = new short[monoBlockSize * 2];
if (read > 0)
{
for (int i = 0; i < read; i++)
{
float clamped = Math.Clamp(temp[i], -1f, 1f);
short final = (short)(clamped * short.MaxValue);
samples[i * 2] = final; // left
samples[i * 2 + 1] = final; // right
}
}
for (int i = read * 2; i < samples.Length; i++)
samples[i] = 0;
return true;
}
protected override void OnSeek(Time timeOffset) =>
throw new NotSupportedException();
}
#endregion
#region Public sound engine API (unchanged)
public class SoundEngine : IDisposable
{
private readonly RingBuffer ringBuffer;
private readonly RingBufferStream stream;
private bool isPlaying;
public SoundEngine(int bufferCapacity = 16384)
{
ringBuffer = new RingBuffer(bufferCapacity);
stream = new RingBufferStream(ringBuffer);
}
public void Start()
{
if (isPlaying) return;
stream.Play();
isPlaying = true;
}
public void Stop()
{
if (!isPlaying) return;
stream.Stop();
isPlaying = false;
float[] drain = new float[ringBuffer.Count];
ringBuffer.Read(drain, drain.Length);
}
public int WriteSamples(float[] data, int count) =>
ringBuffer.Write(data, count);
public float Volume
{
get => stream.Volume;
set => stream.Volume = value;
}
public void Dispose()
{
Stop();
stream.Dispose();
}
}
#endregion

View File

@@ -23,7 +23,7 @@ namespace FluidSim.Core
scaleFactor = 1.0 / (4.0 * Math.PI * listenerDistanceMeters);
// Smoothing time constant for the derivative: 10 ms (much smoother)
double tau = 0.010; // 10 ms
double tau = 0.005; // 10 ms
alpha = Math.Exp(-dt / tau);
// Lowpass time constant for the mass flow: 5 ms (kneecap highfreq directly)
@@ -49,7 +49,7 @@ namespace FluidSim.Core
double pressure = smoothDMdt * scaleFactor * Gain;
// Soft clip to ±1 (should rarely trigger now)
return (float)Math.Tanh(pressure);
return (float)pressure;
}
}
}

34
Core/ThreadLoadTracker.cs Normal file
View File

@@ -0,0 +1,34 @@
using System;
using System.Threading;
namespace FluidSim
{
/// <summary>
/// Tracks the duty cycle of a worker thread using an exponential moving average.
/// Threadsafe: one writer (the sim thread), any reader (UI thread).
/// </summary>
public class ThreadLoadTracker
{
private double _loadPercent; // 0 .. 100, accessed with Volatile.Read/Write
private const double Alpha = 0.1; // smoothing factor (higher = faster response)
/// <summary>
/// Update the load percentage with a new observation.
/// </summary>
/// <param name="busyMs">Time spent on real work in the last cycle.</param>
/// <param name="totalMs">Total time of the last cycle (work + idle). If zero, ignored.</param>
public void Record(double busyMs, double totalMs)
{
if (totalMs <= 0) return;
double instantLoad = busyMs / totalMs * 100.0;
// Exponential moving average
double old = Volatile.Read(ref _loadPercent);
double newLoad = old + Alpha * (instantLoad - old);
Volatile.Write(ref _loadPercent, newLoad);
}
/// <summary>Current smoothed load percentage (0100).</summary>
public double LoadPercent => Volatile.Read(ref _loadPercent);
}
}

View File

@@ -14,6 +14,9 @@
</ItemGroup>
<ItemGroup>
<None Update="fonts\FiraCodeNerdFont-Medium.ttf">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="fonts\LiberationMono-Regular.ttf">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>

View File

@@ -1,9 +1,13 @@
using SFML.Graphics;
using SFML.Window;
using SFML.System;
using System.Diagnostics;
using FluidSim.Audio;
using FluidSim.Core;
using FluidSim.Tests;
using SFML.Graphics;
using SFML.System;
using SFML.Window;
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace FluidSim;
@@ -11,190 +15,211 @@ public class Program
{
private const int SampleRate = 44100;
private const double DrawFrequency = 60.0;
private static Scenario scenario;
// Speed control
private static double desiredSpeed = 0.01;
private static double currentSpeed = desiredSpeed;
// Playback speed
private static double _desiredSpeed = 0.01;
private static double _currentDisplaySpeed = _desiredSpeed;
private const double MinSpeed = 0.0001;
private const double MaxSpeed = 1.0;
private const double ScrollFactor = 1.1;
private static double _lastNormalSpeed = 0.1;
private static bool _isRealTime = false;
private static double lastDesiredSpeed = 0.1;
private static bool isRealTime = false;
private static volatile bool _timeWarpActive;
// Throttle smoothing (unused but kept)
private static double targetThrottle = 0.0;
private static double currentThrottle = 0.0;
private const double ThrottleSmoothing = 20.0;
// Thread load tracking
private static ThreadLoadTracker _loadTracker = new ThreadLoadTracker();
private static volatile bool running = true;
// Audio & simulation
private static SimulationRingBuffer _simRingBuffer = null!;
private static SoundEngine _soundEngine = null!;
private static TestScenario _scenario = null!; // cast to access ThrottleArea
private static Font? _overlayFont;
private static Text? _overlayText;
// ---- Overlay text ----
private static Font? overlayFont;
private static Text? overlayText;
// Throttle control
private static double _throttleTarget = 1.0; // 01, set by arrow keys
private static double _throttleCurrent = 0.0; // actual current fraction (lerped)
private const double ThrottleLerpRate = 5.0; // times per second (speed of movement)
private const double ThrottleMinArea = 0.0000000000001; // 3.7e-5 m² ≈ 0.37 cm² (10% of pipe)
private const double ThrottleMaxArea = 0.00000000001; // 3.7 cm² (full open)
private static bool _wKeyHeld = false;
private static double _lastThrottleUpdateTime;
private const int TargetMaxFill = (int)(SampleRate * 0.2);
public static void Main()
{
var mode = new VideoMode(new Vector2u(1280, 720));
var window = new RenderWindow(mode, "FluidSim");
window.SetVerticalSyncEnabled(true);
window.Closed += (_, _) => { running = false; window.Close(); };
window.MouseWheelScrolled += OnMouseWheel;
window.KeyPressed += OnKeyPressed;
var window = CreateWindow();
LoadFont();
_scenario = (TestScenario)InitializeScenario();
_lastThrottleUpdateTime = 0.0;
// ---- Load font ----
try
{
overlayFont = new Font("fonts/FiraCodeNerdFont-Medium.ttf");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to load font 'fonts/LiberationMono-Regular.ttf': {ex.Message}");
overlayFont = null; // will skip text drawing
}
_simRingBuffer = new SimulationRingBuffer(131072);
_soundEngine = new SoundEngine(_simRingBuffer) { Volume = 100 };
_soundEngine.Start();
if (overlayFont != null)
{
// SFML 3 Text(font, character size in pixels)
overlayText = new Text(overlayFont)
{
FillColor = Color.White,
Position = new Vector2f(10, 10)
};
}
var soundEngine = new SoundEngine(bufferCapacity: 16384);
soundEngine.Volume = 100;
soundEngine.Start();
scenario = new TestScenario();
scenario.Initialize(SampleRate);
var cts = new CancellationTokenSource();
Task.Run(() => SimulationLoop(cts.Token), cts.Token);
var stopwatch = Stopwatch.StartNew();
double lastDrawTime = 0.0;
double drawInterval = 1.0 / DrawFrequency;
double lastSpeedUpdateTime = stopwatch.Elapsed.TotalSeconds;
var simBuffer = new List<float>(4096);
double readIndex = 0.0;
for (int i = 0; i < 4; i++)
simBuffer.Add(scenario.Process());
long totalSimSteps = simBuffer.Count;
long totalOutputSamples = 0;
const int outputChunk = 256;
float[] outputBuf = new float[outputChunk];
while (window.IsOpen)
{
window.DispatchEvents();
double currentRealTime = stopwatch.Elapsed.TotalSeconds;
double dtClock = currentRealTime - lastSpeedUpdateTime;
lastSpeedUpdateTime = currentRealTime;
double now = stopwatch.Elapsed.TotalSeconds;
// Smooth simulation speed
double speedSmoothing = 8.0;
currentSpeed += (desiredSpeed - currentSpeed) * (1.0 - Math.Exp(-speedSmoothing * dtClock));
// ---- Playback speed smoothing ----
double targetSpeed = _timeWarpActive ? 1.0 : _desiredSpeed;
_currentDisplaySpeed += (targetSpeed - _currentDisplaySpeed) *
(1.0 - Math.Exp(-8.0 * (now - lastDrawTime)));
_soundEngine.Speed = _currentDisplaySpeed;
// Generate audio
double targetAudioClock = currentRealTime + 0.05;
while (totalOutputSamples < targetAudioClock * SampleRate && running)
// ---- Throttle update ----
double dtThrottle = now - _lastThrottleUpdateTime;
_lastThrottleUpdateTime = now;
double throttleDesiredFraction = _wKeyHeld ? _throttleTarget : 0.0;
// Snap to zero instantly when target is zero (key released)
if (throttleDesiredFraction == 0.0)
{
int toGenerate = (int)Math.Min(
(long)outputChunk,
(long)(targetAudioClock * SampleRate) - totalOutputSamples
);
if (toGenerate <= 0) break;
double maxIndex = readIndex + (toGenerate - 1) * currentSpeed + 2;
int requiredSimIndex = (int)Math.Ceiling(maxIndex);
while (simBuffer.Count - 1 < requiredSimIndex)
{
simBuffer.Add(scenario.Process());
totalSimSteps++;
}
for (int i = 0; i < toGenerate; i++)
{
int i0 = (int)readIndex;
int i1 = i0 + 1;
double frac = readIndex - i0;
float y0 = simBuffer[Math.Clamp(i0, 0, simBuffer.Count - 1)];
float y1 = simBuffer[Math.Clamp(i1, 0, simBuffer.Count - 1)];
outputBuf[i] = (float)(y0 + (y1 - y0) * frac);
readIndex += currentSpeed;
while (readIndex >= 1.0 && simBuffer.Count > 2)
{
simBuffer.RemoveAt(0);
readIndex -= 1.0;
}
}
int accepted = soundEngine.WriteSamples(outputBuf, toGenerate);
totalOutputSamples += accepted;
if (accepted < toGenerate)
break;
_throttleCurrent = 0.0;
}
else
{
double smoothing = 1.0 - Math.Exp(-ThrottleLerpRate * dtThrottle);
_throttleCurrent += (throttleDesiredFraction - _throttleCurrent) * smoothing;
}
// Drawing
if (currentRealTime - lastDrawTime >= drawInterval)
{
double actualSpeed = totalOutputSamples / (currentRealTime * SampleRate);
double realtimePercent = totalOutputSamples / (currentRealTime * SampleRate) * 100.0;
double actualArea = ThrottleMinArea + (ThrottleMaxArea - ThrottleMinArea) * _throttleCurrent;
_scenario.ThrottleArea = actualArea;
// Update overlay text
if (overlayText != null)
// ---- Drawing ----
if (now - lastDrawTime >= 1.0 / DrawFrequency)
{
if (_overlayText != null)
{
string toggleHint = isRealTime ? "[Space] slow mo" : "[Space] real time";
string throttleHint = Keyboard.IsKeyPressed(Keyboard.Key.W) ? "W held" : "W released";
overlayText.DisplayedString =
$"{toggleHint} {throttleHint} " +
$"Speed: {currentSpeed:F3}x " +
$"RT: {realtimePercent:F1}%";
string toggleHint = _isRealTime ? "[Space] slow mo" : "[Space] real time";
_overlayText.DisplayedString =
$"{toggleHint} Speed: {_currentDisplaySpeed:F3}x RT: {(_currentDisplaySpeed * 100.0):F1}% Sim load: {_loadTracker.LoadPercent:F0}%\n" +
$"Throttle: {_throttleCurrent * 100:F0}% Target: {_throttleTarget * 100:F0}% [W] {(_wKeyHeld ? "BLIP" : "---")}";
}
window.Clear(Color.Black);
scenario.Draw(window);
// Draw the overlay on top
if (overlayText != null)
window.Draw(overlayText);
_scenario.Draw(window);
if (_overlayText != null) window.Draw(_overlayText);
window.Display();
lastDrawTime = currentRealTime;
lastDrawTime = now;
}
}
soundEngine.Dispose();
cts.Cancel();
_soundEngine.Dispose();
window.Dispose();
}
private static void SimulationLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
long cycleStart = Stopwatch.GetTimestamp();
long workStart = Stopwatch.GetTimestamp();
float sample = _scenario.Process();
_simRingBuffer.Write(sample);
long workEnd = Stopwatch.GetTimestamp();
while (_simRingBuffer.AvailableSamples > TargetMaxFill &&
!token.IsCancellationRequested)
{
Thread.Sleep(1);
}
long cycleEnd = Stopwatch.GetTimestamp();
double busyMs = (workEnd - workStart) / (double)Stopwatch.Frequency * 1000.0;
double totalMs = (cycleEnd - cycleStart) / (double)Stopwatch.Frequency * 1000.0;
_loadTracker.Record(busyMs, totalMs);
}
}
// ---------- Window & input ----------
private static RenderWindow CreateWindow()
{
var mode = new VideoMode(new Vector2u(1280, 720));
var window = new RenderWindow(mode, "FluidSim");
window.SetVerticalSyncEnabled(false);
window.SetFramerateLimit(60);
window.Closed += (_, _) => window.Close();
window.MouseWheelScrolled += OnMouseWheel;
window.KeyPressed += OnKeyPressed;
window.KeyReleased += OnKeyReleased;
return window;
}
private static void LoadFont()
{
try { _overlayFont = new Font("fonts/FiraCodeNerdFont-Medium.ttf"); }
catch { _overlayFont = null; }
if (_overlayFont != null)
_overlayText = new Text(_overlayFont)
{
FillColor = Color.White,
Position = new Vector2f(10, 10)
};
}
private static Scenario InitializeScenario()
{
var sc = new TestScenario();
sc.Initialize(SampleRate);
return sc;
}
private static void OnMouseWheel(object? sender, MouseWheelScrollEventArgs e)
{
bool wasRealTime = Math.Abs(desiredSpeed - 1.0) < 1e-6;
if (e.Delta > 0)
desiredSpeed *= ScrollFactor;
else if (e.Delta < 0)
desiredSpeed /= ScrollFactor;
desiredSpeed = Math.Clamp(desiredSpeed, MinSpeed, MaxSpeed);
if (!wasRealTime || Math.Abs(desiredSpeed - 1.0) > 1e-6)
lastDesiredSpeed = desiredSpeed;
isRealTime = Math.Abs(desiredSpeed - 1.0) < 1e-6;
if (_timeWarpActive) return;
if (e.Delta > 0) _desiredSpeed *= ScrollFactor;
else if (e.Delta < 0) _desiredSpeed /= ScrollFactor;
_desiredSpeed = Math.Clamp(_desiredSpeed, MinSpeed, MaxSpeed);
_lastNormalSpeed = _desiredSpeed;
_isRealTime = Math.Abs(_desiredSpeed - 1.0) < 1e-6;
}
private static void OnKeyPressed(object? sender, KeyEventArgs e)
{
if (e.Code == Keyboard.Key.Space)
switch (e.Code)
{
if (isRealTime)
desiredSpeed = lastDesiredSpeed;
else
desiredSpeed = 1.0;
isRealTime = !isRealTime;
case Keyboard.Key.Space:
_timeWarpActive = !_timeWarpActive;
if (!_timeWarpActive)
{
_desiredSpeed = _lastNormalSpeed;
_isRealTime = false;
}
break;
case Keyboard.Key.W:
_wKeyHeld = true;
break;
case Keyboard.Key.Up:
_throttleTarget = Math.Min(1.0, _throttleTarget + 0.05);
break;
case Keyboard.Key.Down:
_throttleTarget = Math.Max(0.0, _throttleTarget - 0.05);
break;
}
}
private static void OnKeyReleased(object? sender, KeyEventArgs e)
{
if (e.Code == Keyboard.Key.W)
_wKeyHeld = false;
}
}

Binary file not shown.

View File

@@ -7,39 +7,28 @@ namespace FluidSim.Tests
{
public abstract class Scenario
{
/// <summary>Initialize the scenario with a given audio sample rate.</summary>
public abstract void Initialize(int sampleRate);
/// <summary>Advance one simulation step and return an audio sample.</summary>
public abstract float Process();
/// <summary>Draw the current simulation state onto the given SFML render target.</summary>
public abstract void Draw(RenderWindow target);
// ---------- Shared drawing helpers ----------
protected const double AmbientPressure = 101325.0;
protected const double AmbientTemperature = 300.0; // K
protected const double AmbientTemperature = 300.0;
/// <summary>Map temperature [0K … 2000K] to a color: blue (0K) → green (300K) → red (2000K).</summary>
// ---------- Color helper ----------
protected Color TemperatureColor(double temperature)
{
// Clamp to the range we want to display
double t = Math.Clamp(temperature, 0.0, 2000.0);
byte r, g, b;
if (t < AmbientTemperature)
{
// Blue → Green
double factor = t / AmbientTemperature; // 0 at 0K, 1 at 300K
double factor = t / AmbientTemperature;
r = 0;
g = (byte)(255 * factor);
b = (byte)(255 * (1.0 - factor));
}
else
{
// Green → Red
double factor = (t - AmbientTemperature) / (2000.0 - AmbientTemperature); // 0 at 300K, 1 at 2000K
double factor = (t - AmbientTemperature) / (2000.0 - AmbientTemperature);
r = (byte)(255 * factor);
g = (byte)(255 * (1.0 - factor));
b = 0;
@@ -47,30 +36,84 @@ namespace FluidSim.Tests
return new Color(r, g, b);
}
/// <summary>
/// Draws the pipe as a smooth trianglestrip whose radius varies with cell pressure (for visibility),
/// but colored by temperature.
/// </summary>
// ---------- Draw a generic volume (e.g. plenum) ----------
protected void DrawVolume(RenderWindow target, Volume0D volume,
float centerX, float topY, float width, float height)
{
var rect = new RectangleShape(new Vector2f(width, height))
{
FillColor = TemperatureColor(volume.Temperature),
Position = new Vector2f(centerX - width / 2f, topY)
};
target.Draw(rect);
var border = new RectangleShape(new Vector2f(width, height))
{
FillColor = Color.Transparent,
OutlineColor = Color.White,
OutlineThickness = 1f,
Position = new Vector2f(centerX - width / 2f, topY)
};
target.Draw(border);
}
// ---------- Draw an engine cylinder ----------
protected void DrawCylinder(RenderWindow target, Cylinder cylinder,
float centerX, float topY, float width, float maxHeight)
{
double fraction = cylinder.PistonFraction; // 0 = TDC, 1 = BDC
float currentHeight = (float)(maxHeight * fraction);
// Walls
var wall = new RectangleShape(new Vector2f(width, maxHeight));
wall.FillColor = new Color(60, 60, 60);
wall.Position = new Vector2f(centerX - width / 2f, topY);
target.Draw(wall);
// Gas
float gasTop = topY;
var gasRect = new RectangleShape(new Vector2f(width, currentHeight));
gasRect.FillColor = TemperatureColor(cylinder.Temperature);
gasRect.Position = new Vector2f(centerX - width / 2f, gasTop);
target.Draw(gasRect);
// Piston line
var pistonLine = new RectangleShape(new Vector2f(width, 4f));
pistonLine.FillColor = Color.White;
pistonLine.Position = new Vector2f(centerX - width / 2f, topY + currentHeight);
target.Draw(pistonLine);
// Valve indicators
float valveW = 6f, valveH = 10f, valveY = topY + 4f;
var intakeValve = new RectangleShape(new Vector2f(valveW, valveH));
intakeValve.FillColor = cylinder.IntakeValveArea > 0 ? Color.Green : Color.Red;
intakeValve.Position = new Vector2f(centerX - width / 2f - valveW - 2f, valveY);
target.Draw(intakeValve);
var exhaustValve = new RectangleShape(new Vector2f(valveW, valveH));
exhaustValve.FillColor = cylinder.ExhaustValveArea > 0 ? Color.Green : Color.Red;
exhaustValve.Position = new Vector2f(centerX + width / 2f + 2f, valveY);
target.Draw(exhaustValve);
}
// ---------- Draw a pipe ----------
protected void DrawPipe(RenderWindow target, Pipe1D pipe, float pipeCenterY, float pipeStartX, float pipeEndX)
{
int n = pipe.CellCount;
if (n < 2) return;
float pipeLengthPx = pipeEndX - pipeStartX;
float dx = pipeLengthPx / (n - 1); // spacing between cell centres
float dx = pipeLengthPx / (n - 1);
float baseRadius = 25f;
float rangeFactor = 2f;
float scaleFactor = 2f;
// ----- smoothstep helper -----
static float SmoothStep(float edge0, float edge1, float x)
{
float t = Math.Clamp((x - edge0) / (edge1 - edge0), 0f, 1f);
return t * t * (3f - 2f * t);
}
// ----- Precompute cell positions, radii, and temperatures -----
var centers = new float[n];
var radii = new float[n];
var temperatures = new double[n];
@@ -80,7 +123,7 @@ namespace FluidSim.Tests
{
double p = pipe.GetCellPressure(i);
double rho = pipe.GetCellDensity(i);
double T = p / Math.Max(rho * R_gas, 1e-12); // ideal gas
double T = p / Math.Max(rho * R_gas, 1e-12);
temperatures[i] = T;
float deviation = (float)Math.Tanh((p - AmbientPressure) / AmbientPressure / rangeFactor);
@@ -89,7 +132,6 @@ namespace FluidSim.Tests
centers[i] = pipeStartX + i * dx;
}
// ----- Build trianglestrip vertices -----
int segmentsPerCell = 8;
int totalPoints = n + (n - 1) * segmentsPerCell;
Vertex[] stripVertices = new Vertex[totalPoints * 2];
@@ -112,7 +154,7 @@ namespace FluidSim.Tests
float st = SmoothStep(0f, 1f, t);
float xi = centers[i] + (centers[i + 1] - centers[i]) * t;
float ri = radii[i] + (radii[i + 1] - radii[i]) * st;
double Ti = temperatures[i] + (temperatures[i + 1] - temperatures[i]) * st; // linear interpolation
double Ti = temperatures[i] + (temperatures[i + 1] - temperatures[i]) * st;
Color coli = TemperatureColor(Ti);
stripVertices[idx++] = new Vertex(new Vector2f(xi, pipeCenterY - ri), coli);

View File

@@ -3,239 +3,218 @@ using SFML.Graphics;
using SFML.System;
using FluidSim.Components;
using FluidSim.Core;
using FluidSim.Utils;
namespace FluidSim.Tests
{
public class TestScenario : Scenario
{
// Simulation core
private Solver solver;
private double dt;
// Engine
private Cylinder cylinder;
// Engine components
private Volume0D cylinder;
// Intake side
private Pipe1D intakePipeBeforeThrottle; // pipe from ambient to plenum
private Volume0D intakePlenum; // plenum (100 mL)
private Pipe1D intakeRunner; // pipe from plenum to cylinder
// Exhaust side
private Pipe1D exhaustPipe;
private OrificeLink exhaustPort;
private OpenEndLink pipeOpenEnd;
private Crankshaft crankshaft;
// Audio
// Links
private OpenEndLink intakeOpenEnd; // ambient → left end of first pipe
private OrificeLink throttleOrifice; // first pipe right end → plenum inlet (variable area)
private OrificeLink plenumToRunner; // plenum outlet → runner left end (fixed area)
private OrificeLink intakeValve; // runner right end → cylinder intake port
private OrificeLink exhaustValve;
private OpenEndLink exhaustOpenEnd;
private Solver solver;
private SoundProcessor soundProcessor;
// Engine geometry (Suzuki TS125 Jones Appendix 1)
private const double Bore = 0.056; // m
private const double Stroke = 0.050; // m
private const double ConRodLength = 0.110; // m (typical)
private const double CrankRadius = Stroke / 2.0;
private const double Obliquity = CrankRadius / ConRodLength;
private const double CompressionRatio = 6.7; // from Jones
// Derived volumes
private double sweptVolume;
private double clearanceVolume;
// Port timing (degrees from TDC)
private const double ExhaustPortOpens = 98.0; // °ATDC
private const double ExhaustPortCloses = 262.0; // °ATDC
private const double PortWidth = 0.025; // m (estimated)
private const double MaxPortArea = 0.001; // m² (fully open)
// Engine state
private double crankAngle; // rad
private double engineSpeed; // rad/s
private bool combustionPending; // true when ready to fire at TDC
// Logging
private double dt;
private int stepCount;
public double ThrottleArea { get; set; } = 0.0; // controlled externally
public override void Initialize(int sampleRate)
{
dt = 1.0 / sampleRate;
// Audio
soundProcessor = new SoundProcessor(sampleRate, 1) { Gain = 1f };
// Solver
solver = new Solver();
solver.SetTimeStep(dt);
solver.CflTarget = 0.4; // safe CFL for highpressure pulses
solver.CflTarget = 0.9;
// Compute engine volumes
double boreArea = Math.PI * 0.25 * Bore * Bore;
sweptVolume = boreArea * Stroke;
clearanceVolume = sweptVolume / (CompressionRatio - 1.0);
double initialVolume = clearanceVolume; // at TDC
// Cylinder
cylinder = new Volume0D(initialVolume, 101325.0, 300.0)
// ---- Cylinder (no valve overlap to avoid backflow) ----
double bore = 0.056, stroke = 0.050, conRod = 0.110, compRatio = 10.0;
double ivo = 370.0, ivc = 580.0, evo = 120.0, evc = 350.0;
cylinder = new Cylinder(bore, stroke, conRod, compRatio, ivo, ivc, evo, evc, 1000)
{
Dvdt = 0.0
MaxIntakeArea = 0.00037,
MaxExhaustArea = 0.00037,
};
solver.AddComponent(cylinder);
// Exhaust pipe (1 m, 1 cm², 100 cells)
exhaustPipe = new Pipe1D(0.5, 10e-4, 20);
double pipeArea = 0.00037; // 3.7 cm²
// ---- Pipes ----
intakePipeBeforeThrottle = new Pipe1D(0.15, pipeArea, 5); // short pipe before throttle
intakeRunner = new Pipe1D(0.10, pipeArea, 5); // runner after plenum
exhaustPipe = new Pipe1D(1.00, pipeArea, 80);
solver.AddComponent(intakePipeBeforeThrottle);
solver.AddComponent(intakeRunner);
solver.AddComponent(exhaustPipe);
// Exhaust port orifice with variable area
var cylPort = cylinder.CreatePort();
exhaustPort = new OrificeLink(cylPort, exhaustPipe, isPipeLeftEnd: true,
areaProvider: () => ComputeExhaustPortArea(crankAngle))
{
DischargeCoefficient = 0.8,
UseInertance = false
};
solver.AddOrificeLink(exhaustPort);
// ---- Plenum (100 mL) ----
intakePlenum = new Volume0D(0.0001, 101325.0, 300.0); // 0.0001 m³
var plenumInlet = intakePlenum.CreatePort(); // from throttle
var plenumOutlet = intakePlenum.CreatePort(); // to runner
solver.AddComponent(intakePlenum);
// Pipe open end
pipeOpenEnd = new OpenEndLink(exhaustPipe, isLeftEnd: false)
// ---- Intake open end (ambient → left end of first pipe) ----
intakeOpenEnd = new OpenEndLink(intakePipeBeforeThrottle, isLeftEnd: true)
{
AmbientPressure = 101325.0,
Gamma = 1.4
};
solver.AddOpenEndLink(pipeOpenEnd);
solver.AddOpenEndLink(intakeOpenEnd);
// Crankshaft (3000 rpm)
crankshaft = new Crankshaft(initialRPM: 10000.0);
crankAngle = crankshaft.CrankAngle;
engineSpeed = crankshaft.AngularVelocity;
combustionPending = false; // first combustion will occur at next TDC
// ---- Throttle orifice (first pipe right end → plenum inlet) ----
throttleOrifice = new OrificeLink(plenumInlet, intakePipeBeforeThrottle, isPipeLeftEnd: false,
areaProvider: () => ThrottleArea)
{
DischargeCoefficient = 0.1, // realistic throttle Cd
UseInertance = false
};
solver.AddOrificeLink(throttleOrifice);
// ---- Plenum → runner (fixed area = pipe area) ----
plenumToRunner = new OrificeLink(plenumOutlet, intakeRunner, isPipeLeftEnd: true,
areaProvider: () => pipeArea)
{
DischargeCoefficient = 1.0,
UseInertance = false
};
solver.AddOrificeLink(plenumToRunner);
// ---- Intake valve (runner right end → cylinder intake port) ----
intakeValve = new OrificeLink(cylinder.IntakePort, intakeRunner, isPipeLeftEnd: false,
areaProvider: () => cylinder.IntakeValveArea)
{
DischargeCoefficient = 1.0,
UseInertance = false
};
solver.AddOrificeLink(intakeValve);
// ---- Exhaust valve ----
exhaustValve = new OrificeLink(cylinder.ExhaustPort, exhaustPipe, isPipeLeftEnd: true,
areaProvider: () => cylinder.ExhaustValveArea)
{
DischargeCoefficient = 1.0,
UseInertance = false
};
solver.AddOrificeLink(exhaustValve);
// ---- Exhaust open end ----
exhaustOpenEnd = new OpenEndLink(exhaustPipe, isLeftEnd: false)
{
AmbientPressure = 101325.0,
Gamma = 1.4
};
solver.AddOpenEndLink(exhaustOpenEnd);
stepCount = 0;
Console.WriteLine("2Stroke engine test");
Console.WriteLine($"Engine: {Bore*1000:F0} mm x {Stroke*1000:F0} mm, {sweptVolume*1e6:F0} cc");
Console.WriteLine($"Compression ratio: {CompressionRatio:F1}, clearance volume: {clearanceVolume*1e6:F2} cc");
Console.WriteLine($"Exhaust port opens at {ExhaustPortOpens}° ATDC, closes at {ExhaustPortCloses}° ATDC");
}
// ---- Port area vs crank angle (linear ramp, symmetric) ----
private double ComputeExhaustPortArea(double thetaRad)
{
double thetaDeg = thetaRad * 180.0 / Math.PI;
// Wrap to [0,360) for easier logic
thetaDeg %= 360.0;
// Exhaust open period
if (thetaDeg >= ExhaustPortOpens && thetaDeg <= ExhaustPortCloses)
{
// Ramp up from 0 to Max, then back down
double halfPeriod = (ExhaustPortCloses - ExhaustPortOpens) / 2.0;
double midPoint = ExhaustPortOpens + halfPeriod;
double distFromMid = Math.Abs(thetaDeg - midPoint) / halfPeriod;
double fraction = 1.0 - distFromMid;
fraction = Math.Clamp(fraction, 0.0, 1.0);
return MaxPortArea * fraction;
}
return 0.0;
}
// ---- Cylinder volume vs crank angle (slidercrank) ----
private double ComputeCylinderVolume(double thetaRad)
{
// thetaRad = crank angle from TDC (0 at TDC)
double r = CrankRadius;
double l = ConRodLength;
double cosTh = Math.Cos(thetaRad);
double sinTh = Math.Sin(thetaRad);
double term = Math.Sqrt(1.0 - Obliquity * Obliquity * sinTh * sinTh);
double x = r * (1.0 - cosTh) + l * (1.0 - term);
double area = Math.PI * 0.25 * Bore * Bore;
double deltaV = area * x;
return clearanceVolume + deltaV;
}
// ---- Combustion: set cylinder pressure AND temperature ----
private void Combustion()
{
double peakPressure = 20.0 * Units.atm; // 30 bar
double peakTemperature = 2000.0; // K
cylinder.SetPressure(peakPressure, peakTemperature);
Console.WriteLine("4Stroke engine test (plenum + two pipes)");
Console.WriteLine($"Bore {bore * 1000:F0}mm, Stroke {stroke * 1000:F0}mm, CR {compRatio}");
Console.WriteLine($"IVO {ivo}°, IVC {ivc}°, EVO {evo}°, EVC {evc}° (no overlap)");
}
public override float Process()
{
// Previous crank angle for detecting TDC crossing
double prevAngle = crankshaft.CrankAngle;
// 1. Advance crankshaft & prestep
cylinder.Crankshaft.Step(dt);
cylinder.PreStep(dt);
// Advance crankshaft
crankshaft.Step(dt);
crankAngle = crankshaft.CrankAngle;
engineSpeed = crankshaft.AngularVelocity;
// Update cylinder volume to match current crank angle
double newVolume = ComputeCylinderVolume(crankAngle);
cylinder.Dvdt = (newVolume - cylinder.Volume) / dt;
cylinder.Volume = newVolume;
// ----- Ignition (once per revolution at TDC) -----
const double TwoPi = 2.0 * Math.PI;
double prevMod = prevAngle % TwoPi;
double currMod = crankAngle % TwoPi;
// Detect crossing of 0 mod 2π (TDC) going from near 2π to near 0
if (prevMod > Math.PI * 1.8 && currMod < Math.PI * 0.2)
{
if (!combustionPending)
{
Combustion();
combustionPending = true; // prevent multiple firings during the crossing
}
}
else if (currMod > Math.PI * 0.2 && currMod < Math.PI * 1.8)
{
combustionPending = false; // reset flag once clear of TDC
}
// Run solver
// 2. Run solver
solver.Step();
stepCount++;
// Log every 500 steps
if (stepCount % 50000 == 0)
// 3. Log every 200 steps
if (stepCount % 200 == 0)
{
int midCell = exhaustPipe.CellCount / 2;
double crankDeg = cylinder.Crankshaft.CrankAngle * 180.0 / Math.PI % 720.0;
double cylP = cylinder.Pressure / 1e5;
double cylT = cylinder.Temperature;
double cylMass = cylinder.Mass * 1e6;
double mdotI = intakeValve.LastMassFlowRate;
double mdotE = exhaustValve.LastMassFlowRate;
double pipeR = exhaustPipe.GetCellPressure(exhaustPipe.CellCount - 1) / 1e5;
double plenumP = intakePlenum.Pressure / 1e5;
double cylP_bar = cylinder.Pressure / 1e5;
double cylT_K = cylinder.Temperature;
double cylVol_cc = cylinder.Volume * 1e6;
double pipeL_bar = exhaustPipe.GetCellPressure(0) / 1e5;
double pipeM_bar = exhaustPipe.GetCellPressure(midCell) / 1e5;
double pipeR_bar = exhaustPipe.GetCellPressure(exhaustPipe.CellCount - 1) / 1e5;
double mdotExh = exhaustPort.LastMassFlowRate; // kg/s, positive into cylinder
double mdotOpen = pipeOpenEnd.LastMassFlowRate; // kg/s, positive out
Console.WriteLine(
$"Step {stepCount}: Angle={crankAngle*180.0/Math.PI % 360.0:F1}°, " +
$"CylP={cylP_bar:F2} bar, CylT={cylT_K:F0} K, Vol={cylVol_cc:F1} cc, " +
$"PipeL={pipeL_bar:F2} bar, PipeM={pipeM_bar:F2} bar, PipeR={pipeR_bar:F2} bar, " +
$"mdot_exh={mdotExh:E4} kg/s, mdot_open={mdotOpen:E4} kg/s"
);
Console.WriteLine($"Step {stepCount}: Angle={crankDeg:F1}°, " +
$"CylP={cylP:F2} bar, T={cylT:F0} K, mass={cylMass:F1} mg, " +
$"mdotI={mdotI:E4} kg/s, mdotE={mdotE:E4} kg/s, PipeR={pipeR:F2} bar");
Console.WriteLine($"Throttle area = {ThrottleArea * 1e6:F2} mm², Plenum P = {plenumP:F3} bar");
}
if (double.IsNaN(exhaustPipe.GetCellPressure(0)))
{
Console.WriteLine("NaN detected stopping.");
return 0f;
}
// Audio from open end
return soundProcessor.Process(pipeOpenEnd);
return soundProcessor.Process(exhaustOpenEnd);
}
public override void Draw(RenderWindow target)
{
float winWidth = target.GetView().Size.X;
float winHeight = target.GetView().Size.Y;
float pipeCenterY = winHeight / 2f;
float margin = 60f;
float pipeStartX = margin;
float pipeEndX = winWidth - margin;
DrawPipe(target, exhaustPipe, pipeCenterY, pipeStartX, pipeEndX);
float winW = target.GetView().Size.X;
float winH = target.GetView().Size.Y;
// Fixed vertical centres for intake and exhaust
float intakeY = winH / 2f - 40f;
float exhaustY = winH / 2f + 80f;
// ---- 1. Open end (ambient air source) ----
float openEndX = 40f;
var openEndMark = new CircleShape(5f) { FillColor = Color.Cyan };
openEndMark.Position = new Vector2f(openEndX - 5f, intakeY - 5f);
target.Draw(openEndMark);
// ---- 2. First intake pipe (ambient → throttle) ----
float pipe1StartX = openEndX;
float pipe1EndX = pipe1StartX + 120f;
DrawPipe(target, intakePipeBeforeThrottle, intakeY, pipe1StartX, pipe1EndX);
// ---- 3. Throttle (symbolic restriction) ----
float throttleX = pipe1EndX + 5f;
var throttleRect = new RectangleShape(new Vector2f(8f, 30f))
{
FillColor = Color.Yellow,
Position = new Vector2f(throttleX, intakeY - 15f)
};
target.Draw(throttleRect);
// ---- 4. Plenum ----
float plenW = 60f, plenH = 80f;
float plenLeftX = throttleX + 10f;
float plenCenterX = plenLeftX + plenW / 2f;
float plenTopY = intakeY - plenH / 2f;
DrawVolume(target, intakePlenum, plenCenterX, plenTopY, plenW, plenH);
// ---- 5. Runner pipe (plenum → cylinder) ----
float runnerStartX = plenLeftX + plenW + 5f;
float runnerEndX = runnerStartX + 100f;
DrawPipe(target, intakeRunner, intakeY, runnerStartX, runnerEndX);
// ---- 6. Cylinder ----
float cylCX = runnerEndX + 50f; // center X
float cylTopY = intakeY - 120f; // top of cylinder (so it sits above the pipe)
float cylW = 80f, cylMaxH = 240f;
DrawCylinder(target, cylinder, cylCX, cylTopY, cylW, cylMaxH);
// ---- 7. Exhaust pipe (cylinder → open end) ----
float exhStartX = cylCX + cylW / 2f + 20f;
float exhEndX = winW - 60f;
DrawPipe(target, exhaustPipe, exhaustY, exhStartX, exhEndX);
// Exhaust open end marker
var exhOpenEndMark = new CircleShape(5f) { FillColor = Color.Magenta };
exhOpenEndMark.Position = new Vector2f(exhEndX - 5f, exhaustY - 5f);
target.Draw(exhOpenEndMark);
}
}
}

Binary file not shown.

Binary file not shown.