using System; using System.Collections.Generic; using FluidSim.Interfaces; namespace FluidSim.Components { public class Cylinder : IComponent { 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 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; public double WiebeA { get; set; } = 5.0; public double WiebeM { get; set; } = 2.0; public double WiebeDuration { get; set; } = 60.0; public double WiebeStart { get; set; } = 5.0; // Fuel public double StoichiometricAFR { get; set; } = 14.7; public double FuelLowerHeatingValue { get; set; } = 44e6; // Heat loss public double CylinderWallArea { get; set; } = 0.02; public double HeatTransferCoefficient { get; set; } = 100.0; public double AmbientTemperature { get; set; } = 300.0; // State 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 => Mass / Math.Max(cylinderVolume, 1e-12); public double Mass => _airMass + _exhaustMass; public double AirFraction => _airMass / Math.Max(Mass, 1e-12); public double PistonFraction => (cylinderVolume - clearanceVolume) / SweptVolume; private double cylinderVolume; private double cylinderEnergy; private double _airMass; private double _exhaustMass; private double trappedAirMass; private double fuelMass; private double burnFraction; private bool combustionActive; private bool fuelInjected; private const double Gamma = 1.4; private const double GasConstant = 287.0; private const double MaxPressurePa = 200e5; private const double MaxTemperatureK = 3500.0; public Cylinder(double bore, double stroke, double conRodLength, double compressionRatio, double ivo, double ivc, double evo, double evc, Crankshaft crankshaft) { Bore = bore; Stroke = stroke; ConRodLength = conRodLength; CompressionRatio = compressionRatio; IVO = ivo; IVC = ivc; EVO = evo; EVC = evc; Crankshaft = crankshaft ?? throw new ArgumentNullException(nameof(crankshaft)); cylinderVolume = clearanceVolume; double initRho = 1.225; _airMass = initRho * clearanceVolume; _exhaustMass = 0.0; cylinderEnergy = 101325.0 * clearanceVolume / (Gamma - 1.0); IntakePort = new Port { Owner = this }; ExhaustPort = new Port { Owner = this }; _ports = new[] { IntakePort, ExhaustPort }; } 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; 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); double dV = cylinderVolume - prevVolume; // ---- Piston torque ---- double pRel = Pressure - 101325.0; // relative to ambient double sinTh = Math.Sin(crankAngleRad); double cosTh = Math.Cos(crankAngleRad); double term = Math.Sqrt(1.0 - Obliquity * Obliquity * sinTh * sinTh); double dxdtheta = CrankRadius * sinTh * (1.0 + Obliquity * cosTh / term); double pistonArea = Math.PI * 0.25 * Bore * Bore; double torque = pRel * pistonArea * dxdtheta; Crankshaft.AddTorque(torque); // Volume work (done BY gas, positive when expanding) 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 (air only!) if (prevDeg >= IVO && prevDeg < IVC && currDeg >= IVC) { trappedAirMass = _airMass; fuelMass = trappedAirMass / StoichiometricAFR; fuelInjected = true; } // Spark 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) { 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; // All gas becomes exhaust double totalMass = _airMass + _exhaustMass; _airMass = 0.0; _exhaustMass = totalMass; } double dFraction = newFraction - burnFraction; if (dFraction > 0) { double dQ = fuelMass * FuelLowerHeatingValue * dFraction; cylinderEnergy += dQ; _exhaustMass += fuelMass * dFraction; // burning fuel adds to exhaust burnFraction = newFraction; } } // Heat loss 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); double af = AirFraction; IntakePort.Pressure = p; IntakePort.Density = rho; IntakePort.Temperature = T; IntakePort.SpecificEnthalpy = h; IntakePort.AirFraction = af; ExhaustPort.Pressure = p; ExhaustPort.Density = rho; ExhaustPort.Temperature = T; ExhaustPort.SpecificEnthalpy = h; ExhaustPort.AirFraction = af; } public void UpdateState(double dt) { double dmAir = 0.0, dmExhaust = 0.0, dE = 0.0; foreach (var port in _ports) { double mdot = port.MassFlowRate; double af = mdot >= 0 ? port.AirFraction : AirFraction; dmAir += mdot * af * dt; dmExhaust += mdot * (1.0 - af) * dt; dE += mdot * port.SpecificEnthalpy * dt; } _airMass += dmAir; _exhaustMass += dmExhaust; cylinderEnergy += dE; double V = Math.Max(cylinderVolume, 1e-12); // Safety clamps double currentP = (Gamma - 1.0) * cylinderEnergy / V; if (currentP > MaxPressurePa) cylinderEnergy = MaxPressurePa * V / (Gamma - 1.0); double currentRho = (_airMass + _exhaustMass) / V; double currentT = currentP / Math.Max(currentRho * GasConstant, 1e-12); if (currentT > MaxTemperatureK) { double pAtTlimit = currentRho * GasConstant * MaxTemperatureK; cylinderEnergy = pAtTlimit * V / (Gamma - 1.0); } double totalMass = _airMass + _exhaustMass; if (totalMass < 1e-9) { _airMass = 1e-9; _exhaustMass = 0.0; cylinderEnergy = 101325.0 * V / (Gamma - 1.0); } else if (cylinderEnergy < 0.0) { cylinderEnergy = 101325.0 * V / (Gamma - 1.0); } if (_airMass < 0.0) _airMass = 0.0; if (_exhaustMass < 0.0) _exhaustMass = 0.0; } } }