Skip to content

Commit c64eb9f

Browse files
zebadrabbitclaude
andcommitted
The outer seat feels the roll, and the envelope reaches the panel
OffsetProfile (GEnvelope.h) turns a heartline profile into the one a seat a lateral distance y off it would have recorded: alpha * y of vertical snap from the roll's angular acceleration, and -omega^2 * y of lateral pull back toward the heartline. Both from RollRateDegPerSec, which every sample already carried. At 1.2 m through a 180 deg/s roll that is +0.38 g of snap and 1.21 g lateral that the centre seat reports as zero -- the wing-coaster difference COASTER_TYPES.md describes, and the same term at a smaller y for the outer seat of any wide train. Asserted: centre unchanged to the bit, left and right mirror, magnitudes real. JudgeRideProfile had only ever been called from its own suite. BuildDiagnostics now runs it on a completed ride for the centre seat and the outer seat (BodyWidth/4, the seat the camera already uses) and every finding is an Envelope row with a metre to go and look at. -TUSmokeTest asserts the showcase is judged and within envelope at both seats, so a layout that snapped an outboard rider would fail the build rather than demonstrate it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 50a0b01 commit c64eb9f

4 files changed

Lines changed: 144 additions & 3 deletions

File tree

‎Prototypes/TrainPhysics/GEnvelope.h‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,3 +549,51 @@ inline FGVerdict JudgeRideProfile(const FRideProfile& P,
549549
// dropped, because the numbers are part of the table and losing them loses the
550550
// intent. Add it when a layout is found that trips it; nothing measured so far
551551
// reverses that fast.
552+
553+
// ===================== A SEAT OFF THE HEARTLINE =====================
554+
//
555+
// Felt G is computed at the heartline, and a rider who is not on it feels
556+
// something else through every roll: the seat swings about the heartline at
557+
// the roll rate, so it carries the heartline's acceleration PLUS the
558+
// acceleration of that swing. For a seat a lateral distance y from the
559+
// heartline (+y is the rider's left, the frame's own sign):
560+
//
561+
// vertical: alpha * y the angular ACCELERATION lifts or drops the seat --
562+
// the "snap" an outboard seat gets that the centre
563+
// never does, and why a wing coaster feels the way it does
564+
// lateral: -omega^2 * y the centripetal pull back toward the heartline
565+
//
566+
// COASTER_TYPES.md argues this is worth building for every wide train rather
567+
// than for wing coasters alone; what a four-across train's outer seat feels is
568+
// the same term at a smaller y. Nothing in it is new data: RollRateDegPerSec
569+
// is already on every sample, so this is a transform of a profile into the
570+
// profile that seat would have recorded. Judge it with JudgeRideProfile like
571+
// any other, which is the "run it once per seat" rule the facing sign follows.
572+
//
573+
// Signs follow the frame: Tangent x Lateral = Up, so a positive roll rate
574+
// carries +Lateral toward +Up, and a seat at +y rises -- an upward seat
575+
// acceleration presses the rider into it, which is +Gz. The centripetal term
576+
// points at the heartline: for +y that is -Lateral, which the rider feels as a
577+
// push toward their RIGHT (LateralG's positive sense), hence the sign below.
578+
// The finite difference for alpha is per-sample and noisy; the 5 Hz filter
579+
// the judge already applies is what makes it a measurement.
580+
inline FRideProfile OffsetProfile(const FRideProfile& P, double LateralM)
581+
{
582+
FRideProfile Out = P;
583+
if (Out.Samples.size() < 2 || LateralM == 0.0) { return Out; }
584+
const double G = 9.80665;
585+
const double ToRad = 3.14159265358979323846 / 180.0;
586+
for (std::size_t i = 0; i < Out.Samples.size(); ++i)
587+
{
588+
const std::size_t A = (i == 0) ? 0 : i - 1;
589+
const std::size_t B = (i + 1 < Out.Samples.size()) ? i + 1 : i;
590+
const double Dt = P.Samples[B].Time - P.Samples[A].Time;
591+
const double Omega = P.Samples[i].RollRateDegPerSec * ToRad;
592+
const double Alpha = (Dt > 1e-9)
593+
? (P.Samples[B].RollRateDegPerSec - P.Samples[A].RollRateDegPerSec) * ToRad / Dt
594+
: 0.0;
595+
Out.Samples[i].VerticalG += Alpha * LateralM / G;
596+
Out.Samples[i].LateralG += Omega * Omega * LateralM / G;
597+
}
598+
return Out;
599+
}

‎Prototypes/TrainPhysics/test_genvelope.cpp‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,52 @@ void TestTheSAMEEventIsJudgedDifferentlyByFacing()
580580
// Recorded here so a verified table has something to answer.
581581
}
582582

583+
// ===================== THE OUTER SEAT =====================
584+
void TestAnOffsetSeatFeelsTheRollAndTheCentreDoesNot()
585+
{
586+
// Level track, 1 g, with the roll rate ramping 0 -> 180 deg/s over one
587+
// second then holding: alpha = pi rad/s^2 during the ramp, omega = pi after.
588+
FRideProfile P = Hold(1.0, 0.0, 0.0, 4.0);
589+
for (FRideSample& S : P.Samples)
590+
{
591+
S.RollRateDegPerSec = S.Time < 1.0 ? 180.0 * S.Time : 180.0;
592+
}
593+
const double G = 9.80665, Pi = 3.14159265358979323846;
594+
595+
// The centre seat is unchanged, to the bit.
596+
const FRideProfile C = OffsetProfile(P, 0.0);
597+
for (std::size_t i = 0; i < P.Samples.size(); ++i)
598+
{
599+
assert(C.Samples[i].VerticalG == P.Samples[i].VerticalG);
600+
assert(C.Samples[i].LateralG == P.Samples[i].LateralG);
601+
}
602+
603+
// 1.2 m out on the rider's left (a wing seat), mid-ramp at t = 0.5 s:
604+
// vertical snap alpha * y / g, no steady centripetal yet to speak of.
605+
const FRideProfile L = OffsetProfile(P, 1.2);
606+
const std::size_t Mid = 100; // 0.5 s at 200 Hz
607+
const double ExpectSnap = Pi * 1.2 / G;
608+
assert(std::fabs(L.Samples[Mid].VerticalG - (1.0 + ExpectSnap)) < 1e-3);
609+
// On the plateau at t = 3 s: no snap, centripetal omega^2 y / g toward the
610+
// heartline, felt as a push to the rider's right (positive LateralG).
611+
const std::size_t Late = 600;
612+
const double ExpectCent = Pi * Pi * 1.2 / G;
613+
assert(std::fabs(L.Samples[Late].VerticalG - 1.0) < 1e-3);
614+
assert(std::fabs(L.Samples[Late].LateralG - ExpectCent) < 1e-3);
615+
616+
// The right-hand seat mirrors BOTH: the snap drops it, and the pull is
617+
// toward the heartline from the other side.
618+
const FRideProfile R = OffsetProfile(P, -1.2);
619+
assert(std::fabs(R.Samples[Mid].VerticalG - (1.0 - ExpectSnap)) < 1e-3);
620+
assert(std::fabs(R.Samples[Late].LateralG + ExpectCent) < 1e-3);
621+
622+
// And the magnitudes are real: 180 deg/s at 1.2 m is about 0.38 g of snap
623+
// and 1.2 g of lateral that the heartline reports as zero.
624+
assert(ExpectSnap > 0.35 && ExpectCent > 1.1);
625+
std::printf(" outer seat at 1.2 m through a 180 deg/s roll: +%.2f g snap, %.2f g lateral; centre 0\n",
626+
ExpectSnap, ExpectCent);
627+
}
628+
583629
int main()
584630
{
585631
std::printf("GEnvelope: judging a ride against acceleration envelopes\n");
@@ -602,6 +648,7 @@ int main()
602648
TestRealRideIsJudgedAndLocatable();
603649
TestABackwardFacingSeatFlipsFOREAFTAndLATERALButNotVERTICAL();
604650
TestTheSAMEEventIsJudgedDifferentlyByFacing();
651+
TestAnOffsetSeatFeelsTheRollAndTheCentreDoesNot();
605652

606653
std::printf("\nAll GEnvelope assertions passed.\n");
607654
return 0;

‎Source/TrackUnlimited/TUCoasterRide.cpp‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5935,13 +5935,27 @@ bool ATUCoasterRide::RunDocumentSmokeTest()
59355935
if (S.Zone == ETUSegmentZone::Brake) { ++Trims; }
59365936
}
59375937

5938+
// AND IT IS WITHIN THE ENVELOPE, at the centre and at the outer seat.
5939+
// Judged for real (both verdicts run on a completed ride and their
5940+
// findings land in the panel), so a showcase that snapped an outboard
5941+
// rider through its helix would fail here rather than demonstrate it.
5942+
int32 EnvelopeRows = 0;
5943+
for (std::size_t i = 0; i < Diagnostics.Num(); ++i)
5944+
{
5945+
if (Diagnostics.At(i).Group == "Envelope") { ++EnvelopeRows; }
5946+
}
5947+
const FGVerdict Outer = JudgeRideProfile(OffsetProfile(Profile_,
5948+
TrainMeshSettings().BodyWidthM * 0.25));
5949+
const bool bJudged = Outer.SamplesJudged > 0;
5950+
59385951
if (!bTrackIsCircuit || !Profile_.bCompleted || Pads < 2 || Trims != 1
5939-
|| !bEmptied || !bReturned || !bTrainExact)
5952+
|| !bEmptied || !bReturned || !bTrainExact || EnvelopeRows != 0 || !bJudged)
59405953
{
59415954
UE_LOG(LogTUEvents, Error,
59425955
TEXT("smoke: the showcase is wrong (circuit %d, completed %d, pads %d, ")
5943-
TEXT("trims %d, shed to zero %d, returned %d, train exact %d)"),
5944-
bTrackIsCircuit, Profile_.bCompleted, Pads, Trims, bEmptied, bReturned, bTrainExact);
5956+
TEXT("trims %d, shed to zero %d, returned %d, train exact %d, envelope rows %d, judged %d)"),
5957+
bTrackIsCircuit, Profile_.bCompleted, Pads, Trims, bEmptied, bReturned, bTrainExact,
5958+
EnvelopeRows, bJudged);
59455959
Failures.Add(TEXT("showcase"));
59465960
}
59475961
else
@@ -6521,6 +6535,37 @@ void ATUCoasterRide::BuildDiagnostics()
65216535
Diagnostics.AddRideProfile(Profile_.bCompleted, Profile_.StalledAtS,
65226536
Top, PeakG, PeakAt, Lap);
65236537

6538+
// ===================== THE ENVELOPE, AT LAST IN THE PANEL =====================
6539+
//
6540+
// JudgeRideProfile had only ever been called from its own suite. It runs
6541+
// here on a COMPLETED ride only (the suite's own old failure: a verdict on a
6542+
// ride that did not happen), for the centre seat and for the outer seat --
6543+
// OffsetProfile adds what a rider BodyWidth/4 off the heartline feels
6544+
// through every roll, which the heartline reports as nothing. Two runs,
6545+
// one per seat, which is the rule the facing sign already follows.
6546+
// Limits are unverified research (the header says so); a finding is a row
6547+
// to go and look at, never a repair.
6548+
if (Profile_.bCompleted)
6549+
{
6550+
const double OuterM = TrainMeshSettings().BodyWidthM * 0.25;
6551+
struct { const char* Seat; double LateralM; } Seats[] = {{"centre", 0.0}, {"outer seat", OuterM}};
6552+
for (const auto& Seat : Seats)
6553+
{
6554+
const FGVerdict V = JudgeRideProfile(OffsetProfile(Profile_, Seat.LateralM));
6555+
for (const FGFinding& F : V.Findings)
6556+
{
6557+
static const char* KindName[] = {"sustained", "impact", "jerk", "combined", "reversal"};
6558+
FDiagTarget T;
6559+
T.S = F.AtS;
6560+
Diagnostics.Add(EDiagSeverity::Warning, "Envelope",
6561+
TCHAR_TO_UTF8(*FString::Printf(TEXT("%s %s %.2f g for %.2f s, limit %.2f (%s, %s)"),
6562+
UTF8_TO_TCHAR(F.Axis), UTF8_TO_TCHAR(KindName[static_cast<int32>(F.Kind)]),
6563+
F.Value, F.Duration, F.Limit, UTF8_TO_TCHAR(Seat.Seat),
6564+
V.Standard == EGStandard::ASTM_F2291 ? TEXT("ASTM") : TEXT("EN"))), T);
6565+
}
6566+
}
6567+
}
6568+
65246569
// Support placement, which refuses more than it places on a layout with a
65256570
// helix — and the hole its refusals leave is the number an engineer asks for.
65266571
if (bShowSupportFindings)

‎Source/TrackUnlimited/TUCoasterRide.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
#include "TrainPhysics/TrainPhysics.h"
1616
#include "TrainPhysics/Seat.h"
17+
#include "TrainPhysics/GEnvelope.h"
1718
#include "TrainPhysics/RideProfile.h"
1819
#include "TrackSpline/TrackProfile.h"
1920
// FTrackDiagnostic is a MEMBER type below, not just something the .cpp uses.

0 commit comments

Comments
 (0)