183 lines
8.6 KiB
C#
183 lines
8.6 KiB
C#
using System;
|
||
|
||
namespace FluidSim.Components
|
||
{
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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 ──────────────────────────────────────────────────
|
||
/// <summary>
|
||
/// 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).
|
||
/// </summary>
|
||
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? ───────────────
|
||
/// <summary>
|
||
/// Returns true if the crank swept through <paramref name="target"/> going
|
||
/// from <paramref name="prev"/> to <paramref name="curr"/> in a single step.
|
||
/// Handles wrap-around at 360°.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
} |