"better" two stroke engine

This commit is contained in:
max
2026-06-09 22:22:19 +02:00
parent 1240ebc33d
commit 56e9c2867a
2 changed files with 267 additions and 167 deletions

View File

@@ -3,103 +3,122 @@ using System;
namespace FluidSim.Components namespace FluidSim.Components
{ {
/// <summary> /// <summary>
/// Twostroke cylinder with forced symmetrical port timings around BDC (180°). /// Two-stroke cylinder with symmetrical port timings centred on BDC (180°).
/// All angles are in degrees within a 360° cycle. ///
/// 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> /// </summary>
public class TwoStrokeCylinder : EngineCylinder public class TwoStrokeCylinder : EngineCylinder
{ {
// --- Public readonly properties for drawing --- // ── Port timing read-outs (degrees, 0 = TDC) ───────────────────────────
public float IVO => 180f - transferDuration / 2f; public float IVO => 180f - TransferDuration / 2f; // transfer opens
public float IVC => 180f + transferDuration / 2f; public float IVC => 180f + TransferDuration / 2f; // transfer closes
public float EVO => 180f - exhaustDuration / 2f; public float EVO => 180f - ExhaustDuration / 2f; // exhaust opens
public float EVC => 180f + exhaustDuration / 2f; public float EVC => 180f + ExhaustDuration / 2f; // exhaust closes
// --- Configurable durations (set in constructor) --- // ── Configurable durations ──────────────────────────────────────────────
private readonly float transferDuration; // e.g. 120° public float TransferDuration { get; } // default: 155°
private readonly float exhaustDuration; // e.g. 180° 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 CycleLengthRad => 2f * MathF.PI;
protected override float MaxCycleDeg => 360f; protected override float MaxCycleDeg => 360f;
public override float IntakeValveArea => public override float IntakeValveArea =>
MathF.PI * IntakeValveDiameter * ValveLift(CrankDeg, IVO, IVC, IntakeValveLift); MathF.PI * IntakeValveDiameter
public override float ExhaustValveArea => * ValveLift(CrankDeg, IVO, IVC, IntakeValveLift);
MathF.PI * ExhaustValveDiameter * ValveLift(CrankDeg, EVO, EVC, ExhaustValveLift);
/// <summary> public override float ExhaustValveArea =>
/// Create a twostroke cylinder with forced symmetrical port timing. MathF.PI * ExhaustValveDiameter
/// </summary> * ValveLift(CrankDeg, EVO, EVC, ExhaustValveLift);
/// <param name="transferDuration">Total transfer port open duration in degrees (e.g. 120°).</param>
/// <param name="exhaustDuration">Total exhaust port open duration in degrees (e.g. 180°).</param> // ── Constructor ─────────────────────────────────────────────────────────
public TwoStrokeCylinder(float bore, float stroke, float conRodLength, public TwoStrokeCylinder(float bore, float stroke, float conRodLength,
float compressionRatio, float compressionRatio,
float transferDuration, float exhaustDuration, float transferDuration, float exhaustDuration,
Crankshaft crankshaft) Crankshaft crankshaft)
: base(bore, stroke, conRodLength, compressionRatio, crankshaft) : base(bore, stroke, conRodLength, compressionRatio, crankshaft)
{ {
this.transferDuration = transferDuration; TransferDuration = transferDuration;
this.exhaustDuration = exhaustDuration; ExhaustDuration = exhaustDuration;
// Safety check: exhaust must open before transfer
if (EVO >= IVO) if (EVO >= IVO)
throw new ArgumentException("Exhaust must open before transfer port (exhaust duration > transfer duration)."); 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 same implementation, now uses the computed IVO/IVC/EVO/EVC ----- // ── Valve lift profile ──────────────────────────────────────────────────
private float ValveLift(float thetaDeg, float opens, float closes, float peakLift) /// <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; float deg = thetaDeg % 360f;
if (deg < 0f) deg += 360f; if (deg < 0f) deg += 360f;
float effectiveOpen = opens; // Handle wrap-around (e.g. opens=170°, closes=190° is fine;
float effectiveClose = closes; // a port that crosses 360° would need closes+360).
if (closes < opens) effectiveClose += 360f; float effectiveClose = closes < opens ? closes + 360f : closes;
float duration = effectiveClose - effectiveOpen; float duration = effectiveClose - opens;
if (duration <= 0f) return 0f; if (duration <= 0f) return 0f;
float mapped = deg; // Map deg into the same number-line as opens/effectiveClose
if (mapped < opens) mapped += 360f; float mapped = deg < opens ? deg + 360f : deg;
if (mapped < opens || mapped > effectiveClose) return 0f; if (mapped < opens || mapped > effectiveClose) return 0f;
float rampDur = duration * 0.25f; float rampDur = duration * RampFraction;
float holdDur = duration - 2f * rampDur; float holdEnd = effectiveClose - rampDur;
if (mapped >= opens && mapped < opens + rampDur) if (mapped < opens + rampDur)
{ {
// Opening ramp: smoothstep
float t = (mapped - opens) / rampDur; float t = (mapped - opens) / rampDur;
return peakLift * t * t * (3f - 2f * t); return peakLift * t * t * (3f - 2f * t);
} }
else if (mapped >= opens + rampDur && mapped < opens + rampDur + holdDur) else if (mapped <= holdEnd)
{ {
// Flat top full area
return peakLift; return peakLift;
} }
else if (mapped >= opens + rampDur + holdDur && mapped <= effectiveClose) else
{ {
float t = (mapped - (opens + rampDur + holdDur)) / rampDur; // Closing ramp: smoothstep reversed
float t = (mapped - holdEnd) / rampDur;
return peakLift * (1f - t) * (1f - t) * (1f + 2f * t); return peakLift * (1f - t) * (1f - t) * (1f + 2f * t);
} }
return 0f;
} }
// ── Cycle event handler ─────────────────────────────────────────────────
protected override void HandleCycleEvents(float prevDeg, float currDeg, float dt) protected override void HandleCycleEvents(float prevDeg, float currDeg, float dt)
{ {
// Transfer port closing → fuel injection // ── Fuel injection at transfer-port closing (IVC) ──────────────────
if (prevDeg >= IVO && prevDeg < IVC && currDeg >= IVC) // At IVC the cylinder is sealed; whatever air is trapped is what we burn.
if (CrossedAngle(prevDeg, currDeg, IVC))
{ {
trappedAirMass = _airMass; trappedAirMass = _airMass;
fuelMass = trappedAirMass / StoichiometricAFR; fuelMass = trappedAirMass / StoichiometricAFR;
fuelInjected = true; fuelInjected = true;
} }
// Spark every 360° at TDC (0°) minus advance // ── Ignition ───────────────────────────────────────────────────────
float sparkAngle = (0f - SparkAdvance + 360f) % 360f; // SparkAdvance default is ~22° BTDC on the base class; scenario can override.
bool crossedSpark = false; float sparkAngle = (360f - SparkAdvance) % 360f;
if (prevDeg < sparkAngle && currDeg >= sparkAngle)
crossedSpark = true;
else if (prevDeg > sparkAngle && currDeg < sparkAngle)
crossedSpark = true;
if (crossedSpark && !combustionActive && fuelInjected) if (CrossedAngle(prevDeg, currDeg, sparkAngle) && !combustionActive && fuelInjected)
{ {
if (_random.NextDouble() < MisfireProbability) if (_random.NextDouble() < MisfireProbability)
{ {
@@ -107,24 +126,32 @@ namespace FluidSim.Components
} }
else else
{ {
combustionActive = true; burnFraction = 0f; combustionActive = true;
burnFraction = 0f;
float range = EnergyVariationFraction; float range = EnergyVariationFraction;
_energyFactor = 1f + range * (2f * (float)_random.NextDouble() - 1f); _energyFactor = 1f + range * (2f * (float)_random.NextDouble() - 1f);
} }
} }
// ── Combustion heat release (Wiebe) ────────────────────────────────
if (combustionActive) if (combustionActive)
{ {
float angleSinceSpark = currDeg - sparkAngle; float angleSinceSpark = currDeg - sparkAngle;
if (angleSinceSpark < 0f) angleSinceSpark += 360f; if (angleSinceSpark < 0f) angleSinceSpark += 360f;
float newFraction = Wiebe(angleSinceSpark); float newFraction = Wiebe(angleSinceSpark);
if (newFraction >= 1f || angleSinceSpark > (WiebeDuration + WiebeStart + SparkAdvance)) bool burnComplete = newFraction >= 1f
|| angleSinceSpark > WiebeDuration + WiebeStart + SparkAdvance;
if (burnComplete)
{ {
newFraction = 1f; combustionActive = false; newFraction = 1f;
float totalMass = _airMass + _exhaustMass; combustionActive = false;
_airMass = 0f; _exhaustMass = totalMass;
}
fuelInjected = false; fuelInjected = false;
float totalMass = _airMass + _exhaustMass;
_airMass = 0f;
_exhaustMass = totalMass;
}
float dFraction = newFraction - burnFraction; float dFraction = newFraction - burnFraction;
if (dFraction > 0f) if (dFraction > 0f)
@@ -136,5 +163,21 @@ namespace FluidSim.Components
} }
} }
} }
// ── 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;
}
} }
} }

View File

@@ -37,7 +37,6 @@ namespace FluidSim.Tests
private float _maxThrottleArea; private float _maxThrottleArea;
private float intakePipeArea, exhaustHeaderArea; private float intakePipeArea, exhaustHeaderArea;
// -- Override shift from Scenario base class --
public override void ShiftUp() => vehicle.ShiftUp(); public override void ShiftUp() => vehicle.ShiftUp();
public override void ShiftDown() => vehicle.ShiftDown(); public override void ShiftDown() => vehicle.ShiftDown();
@@ -45,40 +44,62 @@ namespace FluidSim.Tests
{ {
dt = 1.0 / sampleRate; dt = 1.0 / sampleRate;
// ---- Vehicle ---- // ── Vehicle ──────────────────────────────────────────────────────────
vehicle = new Vehicle(); vehicle = new Vehicle();
// ---- Throttle (38 mm) ---- // ── Throttle body: 42 mm wider to reduce high-RPM intake restriction ──
_maxThrottleArea = (float)Units.AreaFromDiameter(38 * Units.mm); _maxThrottleArea = (float)Units.AreaFromDiameter(42 * Units.mm);
// ---- Crankshaft ---- // ── Crankshaft ───────────────────────────────────────────────────────
// Lighter flywheel for quicker revving; friction tuned to ~0.5 kW loss at idle
crankshaft = new Crankshaft(2000); crankshaft = new Crankshaft(2000);
crankshaft.CycleLength = 2f * MathF.PI; // twostroke crankshaft.CycleLength = 2f * MathF.PI; // two-stroke: fire every rev
crankshaft.Inertia = 0.05f; // engine's own inertia (light) crankshaft.Inertia = 0.06f; // lighter flywheel
crankshaft.FrictionConstant = 2.5f; crankshaft.FrictionConstant = 0.4f; // ~0.4 Nm constant drag
crankshaft.FrictionViscous = 0.0015f; crankshaft.FrictionViscous = 0.0004f; // ~2.5 Nm at 10 000 RPM
// ---- Cylinder (125cc) ---- // ── Cylinder: 125 cc, motocross-style two-stroke ─────────────────────
float bore = 0.054f, stroke = 0.0545f, conRod = 0.109f, compRatio = 12.5f; // Bore × stroke = 54 × 54.5 mm → 124.9 cc
float bore = 0.054f;
float stroke = 0.0545f;
float conRod = 0.110f; // ~2× stroke
float compRatio = 7.2f; // geometric CR; effective CR after port closure is ~12:1
// Symmetric durations (around BDC) // Port timings: exhaust 195°, transfer 155° competitive MX 125
float transferDuration = 130f; // 130° float transferDuration = 155f;
float exhaustDuration = 190f; // 190° float exhaustDuration = 195f;
cylinder = new TwoStrokeCylinder(bore, stroke, conRod, compRatio, cylinder = new TwoStrokeCylinder(bore, stroke, conRod, compRatio,
transferDuration, exhaustDuration, transferDuration, exhaustDuration,
crankshaft) crankshaft)
{ {
IntakeValveDiameter = 0.038f, IntakeValveDiameter = 0.042f, // matched to intake pipe
IntakeValveLift = 0.010f, IntakeValveLift = 0.015f,
ExhaustValveDiameter = 0.040f, ExhaustValveDiameter = 0.040f,
ExhaustValveLift = 0.010f ExhaustValveLift = 0.013f
}; };
// ---- Pipe system (60 exhaust cells, simple diffuser) ---- // ── Pipe geometry ────────────────────────────────────────────────────
int intakeCells = 8; //
int runnerCells = 8; // Layout (all lengths in mm):
int exhaustCells = 60; // Intake path: airbox stub 100 mm | runner 180 mm
// Exhaust path: expansion chamber tuned to ~9 000 RPM power peak
// header 170 mm Ø 40 mm
// diffuser 280 mm Ø 40 → 72 mm
// belly 200 mm Ø 72 mm
// convergent 130 mm Ø 72 → 28 mm
// stinger 70 mm Ø 28 mm
// total 850 mm
//
// Cell sizing: ~14 mm/cell.
// CFL: c_sound ≈ 550 m/s, dx=0.014 m → dt_max ≈ 25 µs
// at 44100 Hz dt = 22.7 µs → SubStepCount=4 keeps CFL safely ≤ 1
// --- Cell counts ---
int intakeCells = 7; // 100 mm stub → ~14 mm/cell
int runnerCells = 13; // 180 mm runner → ~14 mm/cell
int exhaustCells = 60; // 850 mm total → ~14 mm/cell
int totalCells = intakeCells + runnerCells + exhaustCells; int totalCells = intakeCells + runnerCells + exhaustCells;
int[] pipeStart = { 0, intakeCells, intakeCells + runnerCells }; int[] pipeStart = { 0, intakeCells, intakeCells + runnerCells };
int[] pipeEnd = { intakeCells, intakeCells + runnerCells, totalCells }; int[] pipeEnd = { intakeCells, intakeCells + runnerCells, totalCells };
@@ -86,97 +107,124 @@ namespace FluidSim.Tests
float[] area = new float[totalCells]; float[] area = new float[totalCells];
float[] dx = new float[totalCells]; float[] dx = new float[totalCells];
float intakeDia = 0.038f; // --- Intake ---
float intakeLenBefore = 0.15f; float intakeDia = 0.042f; // matches throttle body
float intakeLenRunner = 0.20f; float intakeStubLen = 0.100f;
float intakeRunnerLen= 0.160f; // shorter runner → less pumping loss
intakePipeArea = MathF.PI * 0.25f * intakeDia * intakeDia; intakePipeArea = MathF.PI * 0.25f * intakeDia * intakeDia;
// Singlestage diffuser 840 mm total, easy to tune for (int i = 0; i < intakeCells; i++)
float headerDia = 0.042f, headerLen = 0.160f; { area[i] = intakePipeArea; dx[i] = intakeStubLen / intakeCells; }
float diffuserLen = 0.250f, diffuserEndDia = 0.070f; // belly
float bellyLen = 0.240f; for (int i = intakeCells; i < intakeCells + runnerCells; i++)
float convergentLen = 0.120f; { area[i] = intakePipeArea; dx[i] = intakeRunnerLen / runnerCells; }
float stingerDia = 0.026f, stingerLen = 0.070f;
// total = 0.16 + 0.25 + 0.24 + 0.12 + 0.07 = 0.84 m // Expansion chamber tuned for ~8 500 RPM power peak.
// Return-pulse travel distance = 0.5 × c_avg × (60 / RPM_target)
// c_avg ≈ 480 m/s → distance = 0.5 × 480 × (60/8500) ≈ 1.69 m round-trip
// → one-way pipe length ≈ 0.84 m (matches total below)
float headerDia = 0.040f; float headerLen = 0.130f; // shorter header → earlier pulse
float diffEndDia = 0.070f; float diffuserLen = 0.250f; // slightly narrower belly
float bellyDia = 0.070f; float bellyLen = 0.220f;
float convEndDia = 0.028f; float convergentLen= 0.160f; // longer convergent → stronger return pulse
float stingerDia = 0.028f; float stingerLen = 0.080f;
// total = 0.13+0.25+0.22+0.16+0.08 = 0.84 m
exhaustHeaderArea = MathF.PI * 0.25f * headerDia * headerDia; exhaustHeaderArea = MathF.PI * 0.25f * headerDia * headerDia;
float bellyArea = MathF.PI * 0.25f * diffuserEndDia * diffuserEndDia; float bellyArea = MathF.PI * 0.25f * bellyDia * bellyDia;
float stingerArea = MathF.PI * 0.25f * stingerDia * stingerDia; float stingerArea = MathF.PI * 0.25f * stingerDia * stingerDia;
float totalExhaustLen = headerLen + diffuserLen + bellyLen + convergentLen + stingerLen; // 840 mm // Distribute cells proportionally by section length
int headerCells = (int)(exhaustCells * (headerLen / totalExhaustLen)); int headerCells = Math.Max(1, (int)MathF.Round(exhaustCells * headerLen / 0.84f));
int diffuserCells = (int)(exhaustCells * (diffuserLen / totalExhaustLen)); int diffuserCells = Math.Max(1, (int)MathF.Round(exhaustCells * diffuserLen / 0.84f));
int bellyCells = (int)(exhaustCells * (bellyLen / totalExhaustLen)); int bellyCells = Math.Max(1, (int)MathF.Round(exhaustCells * bellyLen / 0.84f));
int convergentCells = (int)(exhaustCells * (convergentLen / totalExhaustLen)); int convergentCells = Math.Max(1, (int)MathF.Round(exhaustCells * convergentLen/ 0.84f));
int stingerCells = exhaustCells - headerCells - diffuserCells - bellyCells - convergentCells; int stingerCells = exhaustCells - headerCells - diffuserCells
- bellyCells - convergentCells;
if (stingerCells < 1) stingerCells = 1;
// Fill cells int exhBase = intakeCells + runnerCells;
for (int i = 0; i < intakeCells; i++)
{ area[i] = intakePipeArea; dx[i] = intakeLenBefore / intakeCells; }
for (int i = intakeCells; i < intakeCells + runnerCells; i++)
{ area[i] = intakePipeArea; dx[i] = intakeLenRunner / runnerCells; }
int exhStart = intakeCells + runnerCells;
int idx = 0; int idx = 0;
for (int i = exhStart; i < totalCells; i++) for (int i = exhBase; i < totalCells; i++, idx++)
{ {
if (idx < headerCells) if (idx < headerCells)
{ area[i] = exhaustHeaderArea; dx[i] = headerLen / headerCells; } {
area[i] = exhaustHeaderArea;
dx[i] = headerLen / headerCells;
}
else if (idx < headerCells + diffuserCells) else if (idx < headerCells + diffuserCells)
{ {
float t = (idx - headerCells) / (float)(diffuserCells - 1); float t = (idx - headerCells) / (float)(diffuserCells - 1);
float dia = headerDia + (diffuserEndDia - headerDia) * t; // Smooth cosine taper instead of linear for better wave reflection
float ct = 0.5f * (1f - MathF.Cos(MathF.PI * t));
float dia = headerDia + (diffEndDia - headerDia) * ct;
area[i] = MathF.PI * 0.25f * dia * dia; area[i] = MathF.PI * 0.25f * dia * dia;
dx[i] = diffuserLen / diffuserCells; dx[i] = diffuserLen / diffuserCells;
} }
else if (idx < headerCells + diffuserCells + bellyCells) else if (idx < headerCells + diffuserCells + bellyCells)
{ area[i] = bellyArea; dx[i] = bellyLen / bellyCells; } {
area[i] = bellyArea;
dx[i] = bellyLen / bellyCells;
}
else if (idx < headerCells + diffuserCells + bellyCells + convergentCells) else if (idx < headerCells + diffuserCells + bellyCells + convergentCells)
{ {
float t = (idx - headerCells - diffuserCells - bellyCells) / (float)(convergentCells - 1); float t = (idx - headerCells - diffuserCells - bellyCells)
float dia = diffuserEndDia + (stingerDia - diffuserEndDia) * t; / (float)(convergentCells - 1);
// Steeper cosine convergent for a sharper return pulse
float ct = 0.5f * (1f - MathF.Cos(MathF.PI * t));
float dia = bellyDia + (convEndDia - bellyDia) * ct;
area[i] = MathF.PI * 0.25f * dia * dia; area[i] = MathF.PI * 0.25f * dia * dia;
dx[i] = convergentLen / convergentCells; dx[i] = convergentLen / convergentCells;
} }
else else
{ area[i] = stingerArea; dx[i] = stingerLen / stingerCells; } {
idx++; area[i] = stingerArea;
dx[i] = stingerLen / stingerCells;
}
} }
pipeSystem = new PipeSystem(totalCells, pipeStart, pipeEnd, area, dx, pipeSystem = new PipeSystem(totalCells, pipeStart, pipeEnd, area, dx,
1.225f, 0f, 101325f); 1.225f, 0f, 101325f);
pipeSystem.DampingMultiplier = 1.0f; pipeSystem.DampingMultiplier = 0.8f; // slightly less damping → stronger pulses
pipeSystem.EnergyRelaxationRate = 0.5f; pipeSystem.EnergyRelaxationRate = 0.4f;
pipeSystem.AmbientPressure = 101325f; pipeSystem.AmbientPressure = 101325f;
// ---- Volumes ---- // ── 0-D Volumes ──────────────────────────────────────────────────────
intakePlenum = new Volume0D(0.5e-3f, 101325f, 300f); // Intake plenum: acts as a small airbox resonator (8 cc)
intakePlenum = new Volume0D(8e-3f, 101325f, 300f);
plenumInlet = intakePlenum.CreatePort(); plenumInlet = intakePlenum.CreatePort();
plenumOutlet = intakePlenum.CreatePort(); plenumOutlet = intakePlenum.CreatePort();
exhaustMuffler = new Volume0D(5e-4f, 101325f, 600f); // Exhaust silencer volume: 600 cc is realistic for a small-bore muffler
exhaustMuffler = new Volume0D(600e-6f, 101325f, 650f);
mufflerIn = exhaustMuffler.CreatePort(); mufflerIn = exhaustMuffler.CreatePort();
mufflerOut = exhaustMuffler.CreatePort(); mufflerOut = exhaustMuffler.CreatePort();
// ---- Boundary system ---- // ── Boundary system ───────────────────────────────────────────────────
boundaries = new BoundarySystem(pipeSystem, maxOrifices: 4, maxOpenEnds: 2); boundaries = new BoundarySystem(pipeSystem, maxOrifices: 4, maxOpenEnds: 2);
throttleAreaIdx = 0; plenumRunnerIdx = 1; intakeValveIdx = 2; exhaustValveIdx = 3; throttleAreaIdx = 0;
plenumRunnerIdx = 1;
intakeValveIdx = 2;
exhaustValveIdx = 3;
// Open ends: atmosphere at both extremes
boundaries.AddOpenEnd(pipeIndex: 0, isLeftEnd: true, 101325f, intakePipeArea); boundaries.AddOpenEnd(pipeIndex: 0, isLeftEnd: true, 101325f, intakePipeArea);
intakeOpenIdx = 0; intakeOpenIdx = 0;
boundaries.AddOpenEnd(pipeIndex: 2, isLeftEnd: false, 101325f, stingerArea); boundaries.AddOpenEnd(pipeIndex: 2, isLeftEnd: false, 101325f, stingerArea);
exhaustOpenIdx = 1; exhaustOpenIdx = 1;
boundaries.AddOrifice(plenumInlet, 0, false, throttleAreaIdx, 0.7f); // Orifices: throttle → plenum → runner → cylinder → exhaust pipe
boundaries.AddOrifice(plenumOutlet, 1, true, plenumRunnerIdx, 1.0f); boundaries.AddOrifice(plenumInlet, 0, false, throttleAreaIdx, 0.72f);
boundaries.AddOrifice(cylinder.IntakePort, 1, false, intakeValveIdx, 0.65f); boundaries.AddOrifice(plenumOutlet, 1, true, plenumRunnerIdx, 1.00f);
boundaries.AddOrifice(cylinder.ExhaustPort,2, true, exhaustValveIdx, 0.68f); boundaries.AddOrifice(cylinder.IntakePort, 1, false, intakeValveIdx, 0.68f);
boundaries.AddOrifice(cylinder.ExhaustPort, 2, true, exhaustValveIdx, 0.70f);
orificeAreas = new float[4]; orificeAreas = new float[4];
orificeAreas[plenumRunnerIdx] = intakePipeArea; orificeAreas[plenumRunnerIdx] = intakePipeArea; // runner always fully open
// ---- Solver ---- // ── Solver ────────────────────────────────────────────────────────────
solver = new Solver { SubStepCount = 4, EnableProfiling = false }; // 4 substeps for 60 cells // SubStepCount = 4 keeps CFL ≤ 1 for 5 mm cells at 44 100 Hz
solver = new Solver { SubStepCount = 4, EnableProfiling = false };
solver.SetTimeStep(dt); solver.SetTimeStep(dt);
solver.SetPipeSystem(pipeSystem); solver.SetPipeSystem(pipeSystem);
solver.SetBoundarySystem(boundaries); solver.SetBoundarySystem(boundaries);
@@ -184,13 +232,15 @@ namespace FluidSim.Tests
solver.AddComponent(intakePlenum); solver.AddComponent(intakePlenum);
solver.AddComponent(exhaustMuffler); solver.AddComponent(exhaustMuffler);
// ---- Sound ---- // ── Sound ─────────────────────────────────────────────────────────────
exhaustSound = new SoundProcessor(sampleRate, 1f) { Gain = 10f }; exhaustSound = new SoundProcessor(sampleRate, 1f) { Gain = 4.5f };
intakeSound = new SoundProcessor(sampleRate, 1f) { Gain = 10f }; intakeSound = new SoundProcessor(sampleRate, 1f) { Gain = 4.5f };
reverb = new OutdoorExhaustReverb(sampleRate); reverb = new OutdoorExhaustReverb(sampleRate);
stepCount = 0; stepCount = 0;
Console.WriteLine("125cc TwoStroke with vehicle coupling ready."); Console.WriteLine("125cc Two-Stroke expansion chamber tuned for ~8 500 RPM power peak");
Console.WriteLine($" Exhaust cells: {exhaustCells} | header {headerCells} diffuser {diffuserCells}" +
$" belly {bellyCells} convergent {convergentCells} stinger {stingerCells}");
} }
public override float Process() public override float Process()
@@ -201,7 +251,7 @@ namespace FluidSim.Tests
var (clutchTorque, effectiveInertia) = vehicle.Update(engineRpm, crankshaft.Inertia, (float)dt); var (clutchTorque, effectiveInertia) = vehicle.Update(engineRpm, crankshaft.Inertia, (float)dt);
crankshaft.SetEffectiveInertia(effectiveInertia); crankshaft.SetEffectiveInertia(effectiveInertia);
crankshaft.SetLoadTorque(clutchTorque); // clutch torque now includes drag when locked crankshaft.SetLoadTorque(clutchTorque);
crankshaft.Step((float)dt); crankshaft.Step((float)dt);
cylinder.PreStep((float)dt); cylinder.PreStep((float)dt);
@@ -224,12 +274,17 @@ namespace FluidSim.Tests
if (stepCount % 2000 == 0) if (stepCount % 2000 == 0)
{ {
float rpm = crankshaft.AngularVelocity * 60f / (2f * MathF.PI); float rpm = crankshaft.AngularVelocity * 60f / (2f * MathF.PI);
Console.WriteLine($"Step {stepCount}, RPM={rpm:F0}, Gear={vehicle.CurrentGear}, Speed={vehicle.SpeedKmh:F0} km/h"); float powerKw = crankshaft.AveragePower * 1e-3f;
float torqueNm = crankshaft.AverageTorque;
Console.WriteLine($"Step {stepCount,7} | RPM={rpm,6:F0} | Power={powerKw,5:F2} kW" +
$" | Torque={torqueNm,5:F1} Nm | Gear={vehicle.CurrentGear}" +
$" | Speed={vehicle.SpeedKmh,4:F0} km/h");
} }
return reverb.Process((intakeDry + exhaustDry) * 0.5f); return reverb.Process((intakeDry + exhaustDry) * 0.5f);
} }
// ── Drawing ───────────────────────────────────────────────────────────────
public override void Draw(RenderWindow target) public override void Draw(RenderWindow target)
{ {
float winW = target.GetView().Size.X; float winW = target.GetView().Size.X;
@@ -239,12 +294,12 @@ namespace FluidSim.Tests
float exhaustY = winH / 2f + 80f; float exhaustY = winH / 2f + 80f;
float openEndX = 40f; float openEndX = 40f;
// Intake pipe // Intake stub
float x = openEndX; float x = openEndX;
float w = 120f; float w = 120f;
DrawPipe(target, pipeSystem, 0, intakeY, x, x + w); DrawPipe(target, pipeSystem, 0, intakeY, x, x + w);
// Throttle // Throttle body
float throttleX = x + w + 5f; float throttleX = x + w + 5f;
var throttleRect = new RectangleShape(new Vector2f(8f, 30f)) var throttleRect = new RectangleShape(new Vector2f(8f, 30f))
{ {
@@ -267,27 +322,29 @@ namespace FluidSim.Tests
float cylTopY = intakeY - 120f; float cylTopY = intakeY - 120f;
DrawCylinder(target, cylinder, cylCX, cylTopY, 80f, 240f); DrawCylinder(target, cylinder, cylCX, cylTopY, 80f, 240f);
// Exhaust pipe // Exhaust pipe (expansion chamber)
float exhStartX = cylCX + 40f + 20f; float exhStartX = cylCX + 40f + 20f;
DrawPipe(target, pipeSystem, 2, exhaustY, exhStartX, winW - 60f, areaScale: 1000f); DrawPipe(target, pipeSystem, 2, exhaustY, exhStartX, winW - 60f, areaScale: 800f);
// Labels // HUD labels
float rpm = crankshaft.AngularVelocity * 60f / (2f * MathF.PI); float rpm = crankshaft.AngularVelocity * 60f / (2f * MathF.PI);
float powerKw = crankshaft.AveragePower * 1e-3f; float powerKw = crankshaft.AveragePower * 1e-3f;
float torqueNm = crankshaft.AverageTorque;
DrawLabel(target, $"RPM: {rpm:F0}", new Vector2f(20, 90), Color.White, 24); DrawLabel(target, $"RPM: {rpm:F0}", new Vector2f(20, 90), Color.White, 24);
DrawLabel(target, $"Power: {powerKw:F2} kW", new Vector2f(20, 115), Color.White, 24); DrawLabel(target, $"Power: {powerKw:F2} kW", new Vector2f(20, 115), Color.White, 24);
DrawLabel(target, $"Gear: {vehicle.CurrentGear}", new Vector2f(20, 140), Color.Cyan, 20); DrawLabel(target, $"Torque: {torqueNm:F1} Nm",new Vector2f(20, 140), Color.White, 20);
DrawLabel(target, $"Speed: {vehicle.SpeedKmh:F0} km/h", new Vector2f(20, 160), Color.Cyan, 20);
// Dyno curve
float torqueNm = crankshaft.AverageTorque;
UpdateDynoCurve(rpm, powerKw, torqueNm);
DrawDynoCurve(target, winW - 410f, winH - 260f, 400f, 250f, rpm, powerKw);
string gearText = vehicle.CurrentGear == 0 ? "N" : vehicle.CurrentGear.ToString(); string gearText = vehicle.CurrentGear == 0 ? "N" : vehicle.CurrentGear.ToString();
DrawLabel(target, $"Gear: {gearText}", new Vector2f(20, 140), Color.Cyan, 20); DrawLabel(target, $"Gear: {gearText}", new Vector2f(20, 162), Color.Cyan, 20);
DrawLabel(target, $"Speed: {vehicle.SpeedKmh:F0} km/h", new Vector2f(20, 160), Color.Cyan, 20); DrawLabel(target, $"Speed: {vehicle.SpeedKmh:F0} km/h",
DrawLabel(target, vehicle.Engagement > 0.99f ? "Clutch Locked" : "Clutch Slipping", new Vector2f(20, 180), Color.Cyan, 14); new Vector2f(20, 184), Color.Cyan, 20);
DrawLabel(target, vehicle.Engagement > 0.99f ? "Clutch: Locked" : "Clutch: Slipping",
new Vector2f(20, 204), Color.Cyan, 14);
// Dyno curve
UpdateDynoCurve(rpm, powerKw, torqueNm);
DrawDynoCurve(target, winW - 410f, winH - 260f, 400f, 250f, rpm, powerKw);
} }
} }
} }