diff --git a/Components/TwoStrokeCylinder.cs b/Components/TwoStrokeCylinder.cs index ae6d9d3..5c928bc 100644 --- a/Components/TwoStrokeCylinder.cs +++ b/Components/TwoStrokeCylinder.cs @@ -3,103 +3,122 @@ using System; namespace FluidSim.Components { /// - /// Two‑stroke cylinder with forced symmetrical port timings around BDC (180°). - /// All angles are in degrees within a 360° cycle. + /// 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 { - // --- Public read‑only properties for drawing --- - public float IVO => 180f - transferDuration / 2f; - public float IVC => 180f + transferDuration / 2f; - public float EVO => 180f - exhaustDuration / 2f; - public float EVC => 180f + exhaustDuration / 2f; + // ── 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 (set in constructor) --- - private readonly float transferDuration; // e.g. 120° - private readonly float exhaustDuration; // e.g. 180° + // ── 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; + 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); + MathF.PI * IntakeValveDiameter + * ValveLift(CrankDeg, IVO, IVC, IntakeValveLift); - /// - /// Create a two‑stroke cylinder with forced symmetrical port timing. - /// - /// Total transfer port open duration in degrees (e.g. 120°). - /// Total exhaust port open duration in degrees (e.g. 180°). + 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) { - this.transferDuration = transferDuration; - this.exhaustDuration = exhaustDuration; + TransferDuration = transferDuration; + ExhaustDuration = exhaustDuration; - // Safety check: exhaust must open before transfer 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 ----- - private float ValveLift(float thetaDeg, float opens, float closes, float peakLift) + // ── 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; - float effectiveOpen = opens; - float effectiveClose = closes; - if (closes < opens) effectiveClose += 360f; - float duration = effectiveClose - effectiveOpen; + // 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; - float mapped = deg; - if (mapped < opens) mapped += 360f; + // 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 * 0.25f; - float holdDur = duration - 2f * rampDur; + float rampDur = duration * RampFraction; + float holdEnd = effectiveClose - rampDur; - if (mapped >= opens && mapped < opens + rampDur) + if (mapped < opens + rampDur) { + // Opening ramp: smoothstep float t = (mapped - opens) / rampDur; 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; } - 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 0f; } + // ── Cycle event handler ───────────────────────────────────────────────── protected override void HandleCycleEvents(float prevDeg, float currDeg, float dt) { - // Transfer port closing → fuel injection - if (prevDeg >= IVO && prevDeg < IVC && currDeg >= IVC) + // ── 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; + fuelMass = trappedAirMass / StoichiometricAFR; + fuelInjected = true; } - // Spark every 360° at TDC (0°) minus advance - float sparkAngle = (0f - SparkAdvance + 360f) % 360f; - bool crossedSpark = false; - if (prevDeg < sparkAngle && currDeg >= sparkAngle) - crossedSpark = true; - else if (prevDeg > sparkAngle && currDeg < sparkAngle) - crossedSpark = true; + // ── Ignition ─────────────────────────────────────────────────────── + // SparkAdvance default is ~22° BTDC on the base class; scenario can override. + float sparkAngle = (360f - SparkAdvance) % 360f; - if (crossedSpark && !combustionActive && fuelInjected) + if (CrossedAngle(prevDeg, currDeg, sparkAngle) && !combustionActive && fuelInjected) { if (_random.NextDouble() < MisfireProbability) { @@ -107,34 +126,58 @@ namespace FluidSim.Components } else { - combustionActive = true; burnFraction = 0f; + 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); - if (newFraction >= 1f || angleSinceSpark > (WiebeDuration + WiebeStart + SparkAdvance)) + bool burnComplete = newFraction >= 1f + || angleSinceSpark > WiebeDuration + WiebeStart + SparkAdvance; + + if (burnComplete) { - newFraction = 1f; combustionActive = false; - float totalMass = _airMass + _exhaustMass; - _airMass = 0f; _exhaustMass = totalMass; + newFraction = 1f; + combustionActive = false; + fuelInjected = false; + float totalMass = _airMass + _exhaustMass; + _airMass = 0f; + _exhaustMass = totalMass; } - fuelInjected = false; float dFraction = newFraction - burnFraction; if (dFraction > 0f) { float dQ = fuelMass * FuelLowerHeatingValue * _energyFactor * dFraction; cylinderEnergy += dQ; - _exhaustMass += fuelMass * dFraction; - burnFraction = newFraction; + _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; + } } } \ No newline at end of file diff --git a/Scenarios/TwoStrokeScenario.cs b/Scenarios/TwoStrokeScenario.cs index 1ed0ca2..f450946 100644 --- a/Scenarios/TwoStrokeScenario.cs +++ b/Scenarios/TwoStrokeScenario.cs @@ -37,146 +37,194 @@ namespace FluidSim.Tests private float _maxThrottleArea; 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 Initialize(int sampleRate) { dt = 1.0 / sampleRate; - // ---- Vehicle ---- + // ── Vehicle ────────────────────────────────────────────────────────── vehicle = new Vehicle(); - // ---- Throttle (38 mm) ---- - _maxThrottleArea = (float)Units.AreaFromDiameter(38 * Units.mm); + // ── Throttle body: 42 mm – wider to reduce high-RPM intake restriction ── + _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.CycleLength = 2f * MathF.PI; // two‑stroke - crankshaft.Inertia = 0.05f; // engine's own inertia (light) - crankshaft.FrictionConstant = 2.5f; - crankshaft.FrictionViscous = 0.0015f; + crankshaft.CycleLength = 2f * MathF.PI; // two-stroke: fire every rev + crankshaft.Inertia = 0.06f; // lighter flywheel + crankshaft.FrictionConstant = 0.4f; // ~0.4 Nm constant drag + crankshaft.FrictionViscous = 0.0004f; // ~2.5 Nm at 10 000 RPM - // ---- Cylinder (125cc) ---- - float bore = 0.054f, stroke = 0.0545f, conRod = 0.109f, compRatio = 12.5f; + // ── Cylinder: 125 cc, motocross-style two-stroke ───────────────────── + // 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) - float transferDuration = 130f; // 130° - float exhaustDuration = 190f; // 190° + // Port timings: exhaust 195°, transfer 155° – competitive MX 125 + float transferDuration = 155f; + float exhaustDuration = 195f; cylinder = new TwoStrokeCylinder(bore, stroke, conRod, compRatio, transferDuration, exhaustDuration, crankshaft) { - IntakeValveDiameter = 0.038f, - IntakeValveLift = 0.010f, + IntakeValveDiameter = 0.042f, // matched to intake pipe + IntakeValveLift = 0.015f, ExhaustValveDiameter = 0.040f, - ExhaustValveLift = 0.010f + ExhaustValveLift = 0.013f }; - // ---- Pipe system (60 exhaust cells, simple diffuser) ---- - int intakeCells = 8; - int runnerCells = 8; - int exhaustCells = 60; - int totalCells = intakeCells + runnerCells + exhaustCells; - int[] pipeStart = { 0, intakeCells, intakeCells + runnerCells }; - int[] pipeEnd = { intakeCells, intakeCells + runnerCells, totalCells }; + // ── Pipe geometry ──────────────────────────────────────────────────── + // + // Layout (all lengths in mm): + // 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[] pipeStart = { 0, intakeCells, intakeCells + runnerCells }; + int[] pipeEnd = { intakeCells, intakeCells + runnerCells, totalCells }; float[] area = new float[totalCells]; float[] dx = new float[totalCells]; - float intakeDia = 0.038f; - float intakeLenBefore = 0.15f; - float intakeLenRunner = 0.20f; + // --- Intake --- + float intakeDia = 0.042f; // matches throttle body + float intakeStubLen = 0.100f; + float intakeRunnerLen= 0.160f; // shorter runner → less pumping loss intakePipeArea = MathF.PI * 0.25f * intakeDia * intakeDia; - // Single‑stage diffuser – 840 mm total, easy to tune - float headerDia = 0.042f, headerLen = 0.160f; - float diffuserLen = 0.250f, diffuserEndDia = 0.070f; // belly - float bellyLen = 0.240f; - float convergentLen = 0.120f; - float stingerDia = 0.026f, stingerLen = 0.070f; - // total = 0.16 + 0.25 + 0.24 + 0.12 + 0.07 = 0.84 m - - exhaustHeaderArea = MathF.PI * 0.25f * headerDia * headerDia; - float bellyArea = MathF.PI * 0.25f * diffuserEndDia * diffuserEndDia; - float stingerArea = MathF.PI * 0.25f * stingerDia * stingerDia; - - float totalExhaustLen = headerLen + diffuserLen + bellyLen + convergentLen + stingerLen; // 840 mm - int headerCells = (int)(exhaustCells * (headerLen / totalExhaustLen)); - int diffuserCells = (int)(exhaustCells * (diffuserLen / totalExhaustLen)); - int bellyCells = (int)(exhaustCells * (bellyLen / totalExhaustLen)); - int convergentCells = (int)(exhaustCells * (convergentLen / totalExhaustLen)); - int stingerCells = exhaustCells - headerCells - diffuserCells - bellyCells - convergentCells; - - // Fill cells 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; } + { area[i] = intakePipeArea; dx[i] = intakeStubLen / intakeCells; } - int exhStart = intakeCells + runnerCells; + for (int i = intakeCells; i < intakeCells + runnerCells; i++) + { area[i] = intakePipeArea; dx[i] = intakeRunnerLen / runnerCells; } + + // 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; + float bellyArea = MathF.PI * 0.25f * bellyDia * bellyDia; + float stingerArea = MathF.PI * 0.25f * stingerDia * stingerDia; + + // Distribute cells proportionally by section length + int headerCells = Math.Max(1, (int)MathF.Round(exhaustCells * headerLen / 0.84f)); + int diffuserCells = Math.Max(1, (int)MathF.Round(exhaustCells * diffuserLen / 0.84f)); + int bellyCells = Math.Max(1, (int)MathF.Round(exhaustCells * bellyLen / 0.84f)); + int convergentCells = Math.Max(1, (int)MathF.Round(exhaustCells * convergentLen/ 0.84f)); + int stingerCells = exhaustCells - headerCells - diffuserCells + - bellyCells - convergentCells; + if (stingerCells < 1) stingerCells = 1; + + int exhBase = intakeCells + runnerCells; int idx = 0; - for (int i = exhStart; i < totalCells; i++) + for (int i = exhBase; i < totalCells; i++, idx++) { if (idx < headerCells) - { area[i] = exhaustHeaderArea; dx[i] = headerLen / headerCells; } + { + area[i] = exhaustHeaderArea; + dx[i] = headerLen / headerCells; + } else if (idx < headerCells + diffuserCells) { - float t = (idx - headerCells) / (float)(diffuserCells - 1); - float dia = headerDia + (diffuserEndDia - headerDia) * t; + float t = (idx - headerCells) / (float)(diffuserCells - 1); + // 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; - dx[i] = diffuserLen / diffuserCells; + dx[i] = diffuserLen / diffuserCells; } 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) { - float t = (idx - headerCells - diffuserCells - bellyCells) / (float)(convergentCells - 1); - float dia = diffuserEndDia + (stingerDia - diffuserEndDia) * t; + float t = (idx - headerCells - diffuserCells - bellyCells) + / (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; - dx[i] = convergentLen / convergentCells; + dx[i] = convergentLen / convergentCells; } else - { area[i] = stingerArea; dx[i] = stingerLen / stingerCells; } - idx++; + { + area[i] = stingerArea; + dx[i] = stingerLen / stingerCells; + } } pipeSystem = new PipeSystem(totalCells, pipeStart, pipeEnd, area, dx, 1.225f, 0f, 101325f); - pipeSystem.DampingMultiplier = 1.0f; - pipeSystem.EnergyRelaxationRate = 0.5f; - pipeSystem.AmbientPressure = 101325f; + pipeSystem.DampingMultiplier = 0.8f; // slightly less damping → stronger pulses + pipeSystem.EnergyRelaxationRate = 0.4f; + pipeSystem.AmbientPressure = 101325f; - // ---- Volumes ---- - intakePlenum = new Volume0D(0.5e-3f, 101325f, 300f); + // ── 0-D Volumes ────────────────────────────────────────────────────── + // Intake plenum: acts as a small airbox resonator (8 cc) + intakePlenum = new Volume0D(8e-3f, 101325f, 300f); plenumInlet = 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(); mufflerOut = exhaustMuffler.CreatePort(); - // ---- Boundary system ---- + // ── Boundary system ─────────────────────────────────────────────────── 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); - intakeOpenIdx = 0; + intakeOpenIdx = 0; boundaries.AddOpenEnd(pipeIndex: 2, isLeftEnd: false, 101325f, stingerArea); exhaustOpenIdx = 1; - boundaries.AddOrifice(plenumInlet, 0, false, throttleAreaIdx, 0.7f); - boundaries.AddOrifice(plenumOutlet, 1, true, plenumRunnerIdx, 1.0f); - boundaries.AddOrifice(cylinder.IntakePort, 1, false, intakeValveIdx, 0.65f); - boundaries.AddOrifice(cylinder.ExhaustPort,2, true, exhaustValveIdx, 0.68f); + // Orifices: throttle → plenum → runner → cylinder → exhaust pipe + boundaries.AddOrifice(plenumInlet, 0, false, throttleAreaIdx, 0.72f); + boundaries.AddOrifice(plenumOutlet, 1, true, plenumRunnerIdx, 1.00f); + boundaries.AddOrifice(cylinder.IntakePort, 1, false, intakeValveIdx, 0.68f); + boundaries.AddOrifice(cylinder.ExhaustPort, 2, true, exhaustValveIdx, 0.70f); orificeAreas = new float[4]; - orificeAreas[plenumRunnerIdx] = intakePipeArea; + orificeAreas[plenumRunnerIdx] = intakePipeArea; // runner always fully open - // ---- Solver ---- - solver = new Solver { SubStepCount = 4, EnableProfiling = false }; // 4 sub‑steps for 60 cells + // ── Solver ──────────────────────────────────────────────────────────── + // SubStepCount = 4 keeps CFL ≤ 1 for 5 mm cells at 44 100 Hz + solver = new Solver { SubStepCount = 4, EnableProfiling = false }; solver.SetTimeStep(dt); solver.SetPipeSystem(pipeSystem); solver.SetBoundarySystem(boundaries); @@ -184,13 +232,15 @@ namespace FluidSim.Tests solver.AddComponent(intakePlenum); solver.AddComponent(exhaustMuffler); - // ---- Sound ---- - exhaustSound = new SoundProcessor(sampleRate, 1f) { Gain = 10f }; - intakeSound = new SoundProcessor(sampleRate, 1f) { Gain = 10f }; + // ── Sound ───────────────────────────────────────────────────────────── + exhaustSound = new SoundProcessor(sampleRate, 1f) { Gain = 4.5f }; + intakeSound = new SoundProcessor(sampleRate, 1f) { Gain = 4.5f }; reverb = new OutdoorExhaustReverb(sampleRate); stepCount = 0; - Console.WriteLine("125cc Two‑Stroke 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() @@ -201,15 +251,15 @@ namespace FluidSim.Tests var (clutchTorque, effectiveInertia) = vehicle.Update(engineRpm, crankshaft.Inertia, (float)dt); crankshaft.SetEffectiveInertia(effectiveInertia); - crankshaft.SetLoadTorque(clutchTorque); // clutch torque now includes drag when locked + crankshaft.SetLoadTorque(clutchTorque); crankshaft.Step((float)dt); cylinder.PreStep((float)dt); float throttledArea = _maxThrottleArea * Math.Clamp(Throttle, 0.001f, 1f); - orificeAreas[throttleAreaIdx] = throttledArea; - orificeAreas[intakeValveIdx] = cylinder.IntakeValveArea; - orificeAreas[exhaustValveIdx] = cylinder.ExhaustValveArea; + orificeAreas[throttleAreaIdx] = throttledArea; + orificeAreas[intakeValveIdx] = cylinder.IntakeValveArea; + orificeAreas[exhaustValveIdx] = cylinder.ExhaustValveArea; boundaries.SetOrificeAreas(orificeAreas); solver.Step(); @@ -223,33 +273,38 @@ namespace FluidSim.Tests if (stepCount % 2000 == 0) { - 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 rpm = crankshaft.AngularVelocity * 60f / (2f * MathF.PI); + 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); } + // ── Drawing ─────────────────────────────────────────────────────────────── public override void Draw(RenderWindow target) { float winW = target.GetView().Size.X; float winH = target.GetView().Size.Y; - float intakeY = winH / 2f - 40f; + float intakeY = winH / 2f - 40f; float exhaustY = winH / 2f + 80f; float openEndX = 40f; - // Intake pipe + // Intake stub float x = openEndX; float w = 120f; DrawPipe(target, pipeSystem, 0, intakeY, x, x + w); - // Throttle + // Throttle body float throttleX = x + w + 5f; var throttleRect = new RectangleShape(new Vector2f(8f, 30f)) { FillColor = Color.Yellow, - Position = new Vector2f(throttleX, intakeY - 15f) + Position = new Vector2f(throttleX, intakeY - 15f) }; target.Draw(throttleRect); @@ -263,31 +318,33 @@ namespace FluidSim.Tests DrawPipe(target, pipeSystem, 1, intakeY, runnerStartX, runnerStartX + 100f); // Cylinder - float cylCX = runnerStartX + 150f; + float cylCX = runnerStartX + 150f; float cylTopY = intakeY - 120f; DrawCylinder(target, cylinder, cylCX, cylTopY, 80f, 240f); - // Exhaust pipe + // Exhaust pipe (expansion chamber) 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 - float rpm = crankshaft.AngularVelocity * 60f / (2f * MathF.PI); + // HUD labels + float rpm = crankshaft.AngularVelocity * 60f / (2f * MathF.PI); float powerKw = crankshaft.AveragePower * 1e-3f; - 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, $"Gear: {vehicle.CurrentGear}", new Vector2f(20, 140), Color.Cyan, 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); + + 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, $"Torque: {torqueNm:F1} Nm",new Vector2f(20, 140), Color.White, 20); string gearText = vehicle.CurrentGear == 0 ? "N" : vehicle.CurrentGear.ToString(); - DrawLabel(target, $"Gear: {gearText}", new Vector2f(20, 140), Color.Cyan, 20); - DrawLabel(target, $"Speed: {vehicle.SpeedKmh:F0} km/h", new Vector2f(20, 160), Color.Cyan, 20); - DrawLabel(target, vehicle.Engagement > 0.99f ? "Clutch Locked" : "Clutch Slipping", new Vector2f(20, 180), Color.Cyan, 14); + DrawLabel(target, $"Gear: {gearText}", new Vector2f(20, 162), Color.Cyan, 20); + DrawLabel(target, $"Speed: {vehicle.SpeedKmh:F0} km/h", + 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); } } } \ No newline at end of file