diff --git a/Audio/AudioOutputStream.cs b/Audio/AudioOutputStream.cs new file mode 100644 index 0000000..30a0a86 --- /dev/null +++ b/Audio/AudioOutputStream.cs @@ -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; // non‑volatile, 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 }); + } + + /// Playback speed (0.01 … 1.0 or higher for catch‑up). + 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(); + } +} \ No newline at end of file diff --git a/Audio/SimulationRingBuffer.cs b/Audio/SimulationRingBuffer.cs new file mode 100644 index 0000000..5d3efb3 --- /dev/null +++ b/Audio/SimulationRingBuffer.cs @@ -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)); + + /// Number of samples currently available for reading (integer count). + 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; + } + } +} \ No newline at end of file diff --git a/Audio/SoundEngine.cs b/Audio/SoundEngine.cs new file mode 100644 index 0000000..d0e4bba --- /dev/null +++ b/Audio/SoundEngine.cs @@ -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(); + } + } +} \ No newline at end of file diff --git a/Components/Crankshaft.cs b/Components/Crankshaft.cs index 240bb4b..94fff97 100644 --- a/Components/Crankshaft.cs +++ b/Components/Crankshaft.cs @@ -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; diff --git a/Components/Cylinder.cs b/Components/Cylinder.cs new file mode 100644 index 0000000..79238cb --- /dev/null +++ b/Components/Cylinder.cs @@ -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 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; // 0–1 + 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 re‑arm + + 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 (0‑720) + 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 cool‑down before next ignition + } + + double dFraction = newFraction - burnFraction; + if (dFraction > 0) + { + double dQ = fuelMass * FuelLowerHeatingValue * dFraction; + cylinderEnergy += dQ; + cylinderMass += fuelMass * dFraction; + burnFraction = newFraction; + } + } + + // ----- Re‑arm 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); + } + } +} \ No newline at end of file diff --git a/Components/Pipe1D.cs b/Components/Pipe1D.cs index 39171ef..4b0a122 100644 --- a/Components/Pipe1D.cs +++ b/Components/Pipe1D.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using FluidSim.Interfaces; namespace FluidSim.Components @@ -9,6 +10,9 @@ namespace FluidSim.Components /// public class Pipe1D : IComponent { + // ---------- Compile‑time 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 Lax‑Friedrichs ---- - // 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: Pre‑compute 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 n‑1 – 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); + } } - // ---------- Lax‑Friedrichs flux ---------- + // ---------- Local Lax‑Friedrichs 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; - - // Lax‑Friedrichs 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; + } } } \ No newline at end of file diff --git a/Core/OrificeLink.cs b/Core/OrificeLink.cs index 4d04d53..bc4cc4d 100644 --- a/Core/OrificeLink.cs +++ b/Core/OrificeLink.cs @@ -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. Steady‑state nozzle solution (gives correct exit pressure) ---- - double mdotSS; + // ---- Steady‑state 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 nozzle‑exit 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) diff --git a/Core/Solver.cs b/Core/Solver.cs index 40ba7e9..5ef78a6 100644 --- a/Core/Solver.cs +++ b/Core/Solver.cs @@ -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 /// CFL target for sub‑stepping (0.3‑0.8). Lower values are safer for shocks. 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().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, sub‑steps, 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($" Sub‑step 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; + } } } } \ No newline at end of file diff --git a/Core/SoundEngine.cs b/Core/SoundEngine.cs deleted file mode 100644 index bab240a..0000000 --- a/Core/SoundEngine.cs +++ /dev/null @@ -1,131 +0,0 @@ -using SFML.Audio; -using SFML.System; - -namespace FluidSim; - -#region Lock‑free 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 \ No newline at end of file diff --git a/Core/SoundProcessor.cs b/Core/SoundProcessor.cs index 59d9fba..fbb1fae 100644 --- a/Core/SoundProcessor.cs +++ b/Core/SoundProcessor.cs @@ -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); // Low‑pass time constant for the mass flow: 5 ms (kneecap high‑freq 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; } } } \ No newline at end of file diff --git a/Core/ThreadLoadTracker.cs b/Core/ThreadLoadTracker.cs new file mode 100644 index 0000000..4d7dda1 --- /dev/null +++ b/Core/ThreadLoadTracker.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading; + +namespace FluidSim +{ + /// + /// Tracks the duty cycle of a worker thread using an exponential moving average. + /// Thread‑safe: one writer (the sim thread), any reader (UI thread). + /// + public class ThreadLoadTracker + { + private double _loadPercent; // 0 .. 100, accessed with Volatile.Read/Write + private const double Alpha = 0.1; // smoothing factor (higher = faster response) + + /// + /// Update the load percentage with a new observation. + /// + /// Time spent on real work in the last cycle. + /// Total time of the last cycle (work + idle). If zero, ignored. + 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); + } + + /// Current smoothed load percentage (0‑100). + public double LoadPercent => Volatile.Read(ref _loadPercent); + } +} \ No newline at end of file diff --git a/FluidSim.csproj b/FluidSim.csproj index af90d28..12bd28c 100644 --- a/FluidSim.csproj +++ b/FluidSim.csproj @@ -14,6 +14,9 @@ + + Always + PreserveNewest diff --git a/Program.cs b/Program.cs index 657f299..11cd03d 100644 --- a/Program.cs +++ b/Program.cs @@ -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; // 0‑1, 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(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; + } } \ No newline at end of file diff --git a/Report20260507-1444 (2).diagsession b/Report20260507-1444 (2).diagsession new file mode 100644 index 0000000..189d540 Binary files /dev/null and b/Report20260507-1444 (2).diagsession differ diff --git a/Scenarios/Scenario.cs b/Scenarios/Scenario.cs index 8c4e231..0177104 100644 --- a/Scenarios/Scenario.cs +++ b/Scenarios/Scenario.cs @@ -7,39 +7,28 @@ namespace FluidSim.Tests { public abstract class Scenario { - /// Initialize the scenario with a given audio sample rate. public abstract void Initialize(int sampleRate); - - /// Advance one simulation step and return an audio sample. public abstract float Process(); - - /// Draw the current simulation state onto the given SFML render target. 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; - /// Map temperature [0 K … 2000 K] to a color: blue (0 K) → green (300 K) → red (2000 K). + // ---------- 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 0 K, 1 at 300 K + 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 300 K, 1 at 2000 K + 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); } - /// - /// Draws the pipe as a smooth triangle‑strip whose radius varies with cell pressure (for visibility), - /// but colored by temperature. - /// + // ---------- 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); } - // ----- Pre‑compute 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 triangle‑strip 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); diff --git a/Scenarios/TestScenario.cs b/Scenarios/TestScenario.cs index be9987a..4454a3e 100644 --- a/Scenarios/TestScenario.cs +++ b/Scenarios/TestScenario.cs @@ -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 high‑pressure 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("2‑Stroke 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 (slider‑crank) ---- - 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("4‑Stroke 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 & pre‑step + 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); } } } \ No newline at end of file diff --git a/trace.nettrace b/trace.nettrace deleted file mode 100644 index 3e4b2b1..0000000 Binary files a/trace.nettrace and /dev/null differ diff --git a/trace.nettrace.etlx b/trace.nettrace.etlx deleted file mode 100644 index 73a3517..0000000 Binary files a/trace.nettrace.etlx and /dev/null differ