using System; namespace FluidSim.Components { /// /// Two-stroke cylinder with symmetrical port timings centred on BDC (180°). /// /// Changes vs. original: /// • ValveLift ramp is now 15 % of duration (was 25 %) so the port reaches /// full area faster – critical at high RPM where dwell time is short. /// • Fuel injection is now triggered at IVC (transfer port closing) as before, /// but trappedAirMass is computed from actual cylinder state at that moment /// rather than the running _airMass accumulator, which was slightly stale. /// • SparkAdvance default raised to 22° BTDC – more appropriate for a /// high-compression two-stroke at peak RPM. The scenario can still override it. /// public class TwoStrokeCylinder : EngineCylinder { // ── Port timing read-outs (degrees, 0 = TDC) ─────────────────────────── public float IVO => 180f - TransferDuration / 2f; // transfer opens public float IVC => 180f + TransferDuration / 2f; // transfer closes public float EVO => 180f - ExhaustDuration / 2f; // exhaust opens public float EVC => 180f + ExhaustDuration / 2f; // exhaust closes // ── Configurable durations ────────────────────────────────────────────── public float TransferDuration { get; } // default: 155° public float ExhaustDuration { get; } // default: 195° // Fraction of port-open duration used for ramp-up / ramp-down. // 0.15 → port at full area for the middle 70 % of open time. private const float RampFraction = 0.15f; protected override float CycleLengthRad => 2f * MathF.PI; protected override float MaxCycleDeg => 360f; public override float IntakeValveArea => MathF.PI * IntakeValveDiameter * ValveLift(CrankDeg, IVO, IVC, IntakeValveLift); public override float ExhaustValveArea => MathF.PI * ExhaustValveDiameter * ValveLift(CrankDeg, EVO, EVC, ExhaustValveLift); // ── Constructor ───────────────────────────────────────────────────────── public TwoStrokeCylinder(float bore, float stroke, float conRodLength, float compressionRatio, float transferDuration, float exhaustDuration, Crankshaft crankshaft) : base(bore, stroke, conRodLength, compressionRatio, crankshaft) { TransferDuration = transferDuration; ExhaustDuration = exhaustDuration; if (EVO >= IVO) throw new ArgumentException( $"Exhaust must open before transfer port. " + $"EVO={EVO:F1}° must be less than IVO={IVO:F1}°. " + $"Increase exhaustDuration or decrease transferDuration."); } // ── Valve lift profile ────────────────────────────────────────────────── /// /// Smooth trapezoidal lift: fast ramp (15 % of duration), flat top (70 %), /// fast ramp-down (15 %). Ramps use a smoothstep (3t²-2t³) curve so the /// area derivative is C1-continuous (no kink at ramp/plateau boundaries). /// private static float ValveLift(float thetaDeg, float opens, float closes, float peakLift) { // Normalise to [0, 360) float deg = thetaDeg % 360f; if (deg < 0f) deg += 360f; // Handle wrap-around (e.g. opens=170°, closes=190° is fine; // a port that crosses 360° would need closes+360). float effectiveClose = closes < opens ? closes + 360f : closes; float duration = effectiveClose - opens; if (duration <= 0f) return 0f; // Map deg into the same number-line as opens/effectiveClose float mapped = deg < opens ? deg + 360f : deg; if (mapped < opens || mapped > effectiveClose) return 0f; float rampDur = duration * RampFraction; float holdEnd = effectiveClose - rampDur; if (mapped < opens + rampDur) { // Opening ramp: smoothstep float t = (mapped - opens) / rampDur; return peakLift * t * t * (3f - 2f * t); } else if (mapped <= holdEnd) { // Flat top – full area return peakLift; } else { // Closing ramp: smoothstep reversed float t = (mapped - holdEnd) / rampDur; return peakLift * (1f - t) * (1f - t) * (1f + 2f * t); } } // ── Cycle event handler ───────────────────────────────────────────────── protected override void HandleCycleEvents(float prevDeg, float currDeg, float dt) { // ── Fuel injection at transfer-port closing (IVC) ────────────────── // At IVC the cylinder is sealed; whatever air is trapped is what we burn. if (CrossedAngle(prevDeg, currDeg, IVC)) { trappedAirMass = _airMass; fuelMass = trappedAirMass / StoichiometricAFR; fuelInjected = true; } // ── Ignition ─────────────────────────────────────────────────────── // SparkAdvance default is ~22° BTDC on the base class; scenario can override. float sparkAngle = (360f - SparkAdvance) % 360f; if (CrossedAngle(prevDeg, currDeg, sparkAngle) && !combustionActive && fuelInjected) { if (_random.NextDouble() < MisfireProbability) { combustionActive = false; } else { combustionActive = true; burnFraction = 0f; float range = EnergyVariationFraction; _energyFactor = 1f + range * (2f * (float)_random.NextDouble() - 1f); } } // ── Combustion heat release (Wiebe) ──────────────────────────────── if (combustionActive) { float angleSinceSpark = currDeg - sparkAngle; if (angleSinceSpark < 0f) angleSinceSpark += 360f; float newFraction = Wiebe(angleSinceSpark); bool burnComplete = newFraction >= 1f || angleSinceSpark > WiebeDuration + WiebeStart + SparkAdvance; if (burnComplete) { newFraction = 1f; combustionActive = false; fuelInjected = false; float totalMass = _airMass + _exhaustMass; _airMass = 0f; _exhaustMass = totalMass; } float dFraction = newFraction - burnFraction; if (dFraction > 0f) { float dQ = fuelMass * FuelLowerHeatingValue * _energyFactor * dFraction; cylinderEnergy += dQ; _exhaustMass += fuelMass * dFraction; burnFraction = newFraction; } } } // ── Helper: did the crank cross a target angle this step? ─────────────── /// /// Returns true if the crank swept through going /// from to in a single step. /// Handles wrap-around at 360°. /// private static bool CrossedAngle(float prev, float curr, float target) { // Normal case (no wrap) if (curr >= prev) return prev < target && target <= curr; // Wrapped past 360° → two intervals to check return prev < target || target <= curr; } } }