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); } } }