Files
FluidSim/Components/Cylinder.cs
2026-05-07 23:55:02 +02:00

328 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 geometry
public double IntakeValveDiameter { get; set; } = 0.030;
public double ExhaustValveDiameter { get; set; } = 0.028;
public double IntakeValveLift { get; set; } = 0.005;
public double ExhaustValveLift { get; set; } = 0.005;
public double IntakeValveMaxArea => Math.PI * IntakeValveDiameter * IntakeValveLift;
public double ExhaustValveMaxArea => Math.PI * ExhaustValveDiameter * ExhaustValveLift;
// 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;
// Cycletocycle randomness
/// <summary>Fractional variation in fuel energy (±). 0.05 = ±5%.</summary>
public double EnergyVariationFraction { get; set; } = 0.05;
/// <summary>Probability of a misfire (01).</summary>
public double MisfireProbability { get; set; } = 0.01;
// 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;
// percycle randomness
private double _energyFactor = 1.0; // applied to FuelLowerHeatingValue this cycle
private readonly Random _random = new Random();
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 };
}
// 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;
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;
}
private double ValveLift(double thetaDeg, double opens, double closes, double peakLift)
{
double deg = thetaDeg % 720.0;
if (deg < 0) deg += 720.0;
double duration = closes - opens;
if (duration <= 0) return 0.0;
double rampDur = duration * 0.25;
double holdDur = duration - 2.0 * rampDur;
if (deg >= opens && deg < opens + rampDur)
{
double t = (deg - opens) / rampDur;
return peakLift * t * t * (3.0 - 2.0 * t);
}
else if (deg >= opens + rampDur && deg < opens + rampDur + holdDur)
{
return peakLift;
}
else if (deg >= opens + rampDur + holdDur && deg <= closes)
{
double t = (deg - (opens + rampDur + holdDur)) / rampDur;
return peakLift * (1.0 - t) * (1.0 - t) * (1.0 + 2.0 * t);
}
return 0.0;
}
public double IntakeValveArea =>
Math.PI * IntakeValveDiameter * ValveLift(CrankDeg, IVO, IVC, IntakeValveLift);
public double ExhaustValveArea =>
Math.PI * ExhaustValveDiameter * ValveLift(CrankDeg, EVO, EVC, ExhaustValveLift);
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;
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);
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 = _airMass;
fuelMass = trappedAirMass / StoichiometricAFR;
fuelInjected = true;
}
// ----- Spark ignition (once per cycle, with misfire chance) -----
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)
{
// Decide misfire
bool misfire = _random.NextDouble() < MisfireProbability;
if (misfire)
{
combustionActive = false; // no combustion this cycle
// fuel is not burned will remain in cylinder and eventually exit as unburned mixture
}
else
{
combustionActive = true;
burnFraction = 0.0;
// Energy variation factor for this cycle
double range = EnergyVariationFraction;
_energyFactor = 1.0 + range * (2.0 * _random.NextDouble() - 1.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 * _energyFactor * dFraction;
cylinderEnergy += dQ;
_exhaustMass += fuelMass * dFraction;
burnFraction = newFraction;
}
}
// ----- 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);
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);
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;
}
}
}