Play back the black box of the simulator: map, cab, timeline, timetable and a summary

The simulator writes a black box of each run to physicslog/*.blackbox
(blackbox_doc.md in the maszyna repository). Three new projects read and
show it:

- Eu07.BlackBox: the reader of the records, the recording as a whole
  (the user's vehicle and consist at a time, the timetable shown, the
  progress along it with arrival and departure delays, whether the
  train stood at the platform), the light patterns Pc 1, Pc 2, Pc 5 and
  Tb 1 with the ones the run asks for, and a summary of the driving.
- Eu07.BlackBox.Views: the player window, with the scenery map (tracks,
  signals, W4 stop points with their heading and W32, the train, the
  cameras), the cab, the timeline of speed against the AI's reference,
  the events, the timetable and the summary.
- Eu07.BlackBox.Viewer: the window on its own, with options for the
  time, following the train and screenshots.

The tapes window of the starter lists the black boxes of each session
beside the Hasler tapes and opens them in the player.
This commit is contained in:
maj00r
2026-11-11 00:39:35 +01:00
parent e7d8596652
commit e530b95352
40 changed files with 6953 additions and 20 deletions

View File

@@ -0,0 +1,320 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using System.Text;
namespace Eu07.BlackBox;
/// <summary>
/// Reads a black box into a <see cref="Recording"/>, as much of it as is there. A file
/// the simulator still writes is read again from where the last read stopped: a record
/// cut short waits for the next read, so a running session can be followed every few
/// seconds without reading it from the start.
/// </summary>
public sealed class BlackBoxReader
{
public static ReadOnlySpan<byte> Magic => "EU07BBX\0"u8;
public const int FormatVersion = 1;
public const int HeaderSize = 12;
public const int RecordHeaderSize = 5;
private long _offset;
public Recording Recording { get; } = new();
/// <summary>Bytes taken so far: the header and the whole records.</summary>
public long Offset => _offset;
/// <summary>Opens the file shared, as the simulator may be writing it, and reads what is new.</summary>
public int ReadMore(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return ReadMore(stream);
}
/// <summary>Reads the whole records added since the last read. Returns how many were read.</summary>
/// <exception cref="InvalidDataException">Not a black box, or of a version this reader doesn't know.</exception>
public int ReadMore(Stream stream)
{
if (stream.CanSeek)
{
if (stream.Length < _offset)
throw new InvalidDataException("The black box got shorter than what was read of it: it was written anew.");
stream.Seek(_offset, SeekOrigin.Begin);
}
using var added = new MemoryStream();
stream.CopyTo(added);
var bytes = added.GetBuffer().AsSpan(0, (int)added.Length);
int at = 0;
if (_offset == 0)
{
if (bytes.Length < HeaderSize)
return 0;
ReadHeader(bytes[..HeaderSize]);
at = HeaderSize;
}
int read = 0;
while (bytes.Length - at >= RecordHeaderSize)
{
var type = (RecordType)bytes[at];
uint length = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(at + 1, 4));
if (length > int.MaxValue || bytes.Length - at - RecordHeaderSize < length)
break;
var payload = bytes.Slice(at + RecordHeaderSize, (int)length);
try
{
Take(type, new PayloadReader(payload));
}
catch (EndOfStreamException)
{
// Shorter than its type says: passed over, as one of an unknown type is.
Recording.Malformed++;
}
at += RecordHeaderSize + (int)length;
++read;
}
_offset += at;
Recording.Changed();
return read;
}
/// <summary>Reads a whole file at once.</summary>
public static Recording Read(string path)
{
var reader = new BlackBoxReader();
reader.ReadMore(path);
return reader.Recording;
}
public static Recording Read(Stream stream)
{
var reader = new BlackBoxReader();
reader.ReadMore(stream);
return reader.Recording;
}
/// <summary>Whether the bytes start as a black box does.</summary>
public static bool LooksLikeBlackBox(ReadOnlySpan<byte> start) => start.Length >= Magic.Length && start[..Magic.Length].SequenceEqual(Magic);
private void ReadHeader(ReadOnlySpan<byte> header)
{
if (!LooksLikeBlackBox(header))
throw new InvalidDataException("Not a black box of the simulator.");
int version = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(8, 2));
if (version != FormatVersion)
throw new InvalidDataException($"A black box of version {version}, this reader knows version {FormatVersion}.");
Recording.Version = version;
}
private void Take(RecordType type, PayloadReader r)
{
var rec = Recording;
switch (type)
{
case RecordType.Info:
rec.Info = new RecordingInfo(r.Text(), r.Text(), r.U16(), r.U16(), r.F64(), r.Text(), r.Text(),
r.F32(), r.F32(), r.F32());
break;
case RecordType.Track:
{
int index = (int)r.U32();
string name = r.Text();
var kind = (TrackKind)r.U8();
int branch = r.U8();
int category = r.U8();
uint count = r.U32();
if (count > r.Remaining / 12)
throw new EndOfStreamException();
var points = new Vector3[count];
for (int i = 0; i < points.Length; i++)
points[i] = r.Vec3();
rec.AddTrack(new MapTrack(index, name, kind, branch, category, points));
break;
}
case RecordType.Signal:
rec.AddSignal(new MapSignal((int)r.U32(), r.Text(), r.DVec3()));
break;
case RecordType.Vehicle:
rec.AddVehicle(new VehicleInfo((int)r.U32(), r.Text(), r.Text(), r.Text(), r.F32(), r.F32(), r.U8()));
break;
case RecordType.VehicleRemoved:
rec.RemoveVehicle(r.F64(), (int)r.U32());
break;
case RecordType.Cab:
{
var sample = new CabSample(r.F64(), (int)r.U32(), r.DVec3(), r.F32(), r.F32(), r.F32(), (Driver)r.U8(),
(CabFlags)r.U8(), r.I8(), r.I8(), r.I8(), r.F32(), r.F32(), r.F32(), r.F32(),
r.F32(),
new AiReference(r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32()),
(CabDevices)r.U32(), r.F32(), r.F32(), r.F32(), (Lights)r.U16(), (Lights)r.U16());
// The cab, the train protection and the lateral acceleration came later: a recording
// made before them ends here.
if (r.Remaining >= 6)
sample = sample with { CabEnd = r.I8(), Security = (SecurityFlags)r.U8(), LateralAcceleration = r.F32() };
// What the driver went by and the lights asked for came later still.
if (r.Remaining >= 5)
sample = sample with { Movement = (Movement)r.U8(), LightHintHead = (short)r.U16(), LightHintTail = (short)r.U16() };
rec.AddCab(sample);
break;
}
case RecordType.StopPoint:
{
var point = new MapStopPoint((int)r.U32(), r.Text(), r.DVec3(), r.F32(), r.F32());
// The heading came later: a recording made before it ends here.
if (r.Remaining >= 4)
point = point with { Heading = r.F32() };
rec.AddStopPoint(point);
break;
}
case RecordType.HornPoint:
rec.AddHornPoint(new MapHornPoint((int)r.U32(), r.Text(), r.DVec3(), r.F32(), r.F32()));
break;
case RecordType.Timetable:
{
double time = r.F64();
int vehicle = (int)r.U32();
string train = r.Text(), category = r.Text(), from = r.Text(), to = r.Text();
float vmax = r.F32();
uint count = r.U32();
// A row holds at least its empty name, four numbers and a flag.
if (count > r.Remaining / 18)
throw new EndOfStreamException();
var rows = new TimetableRow[count];
for (int i = 0; i < rows.Length; i++)
rows[i] = new TimetableRow(r.Text(), r.F32(), r.F32(), r.F32(), r.F32(), r.U8() != 0);
rec.AddTimetable(new Timetable(time, vehicle, train, category, from, to, vmax, rows));
break;
}
case RecordType.Station:
rec.AddStation(new StationEvent(r.F64(), (int)r.U32(), (StationKind)r.U8(), r.U16(), r.Text(), r.DVec3(),
r.F32(), r.F32(), r.F32(), r.F32(), r.F32(), r.F32()));
break;
case RecordType.Camera:
rec.AddCamera(new CameraState(r.F64(), (CameraMode)r.U8(), r.U8(), (int)r.U32(), r.DVec3(), r.F32(), r.F32()));
break;
case RecordType.Coupling:
rec.AddCoupling(new CouplingChange(r.F64(), (int)r.U32(), r.U8(), (int)r.U32(), r.U8(), (CouplingFlags)r.U8(),
(CouplingFlags)r.U8(), (int)r.U32(), r.DVec3()));
break;
case RecordType.HornRequest:
rec.AddHornRequest(new HornRequest(r.F64(), (int)r.U32(), r.F32(), r.F32(), r.DVec3()));
break;
case RecordType.Vehicles:
{
double time = r.F64();
bool keyframe = r.U8() != 0;
uint count = r.U32();
if (count > r.Remaining / 25)
throw new EndOfStreamException();
var states = new VehicleState[count];
for (int i = 0; i < states.Length; i++)
states[i] = new VehicleState((int)r.U32(), r.Vec3(), r.F32(), r.F32(), (Driver)r.U8());
rec.AddLook(new VehicleLook(time, keyframe, states));
break;
}
case RecordType.SwitchState:
rec.AddSwitch(new SwitchChange(r.F64(), (int)r.U32(), r.U8()));
break;
case RecordType.SignalAspect:
rec.AddAspect(new AspectChange(r.F64(), (int)r.U32(), r.F32(), r.F32(), r.Text()));
break;
case RecordType.Command:
{
double time = r.F64();
int vehicle = (int)r.U32();
bool pressed = r.U8() != 0;
double param1 = r.F64(), param2 = r.F64();
rec.AddCommand(new CommandEntry(time, vehicle, pressed, param1, param2, r.Text()));
break;
}
case RecordType.DriverChange:
rec.AddDriverChange(new DriverChange(r.F64(), (int)r.U32(), (Driver)r.U8()));
break;
case RecordType.SignalPassed:
{
double time = r.F64();
int vehicle = (int)r.U32();
int signal = unchecked((int)r.U32());
rec.AddPassing(new SignalPassing(time, vehicle, signal, r.Text(), r.DVec3(), r.F32(), r.F32(), r.F32(),
r.F32(), (PassingFlags)r.U8()));
break;
}
case RecordType.UserVehicle:
{
var change = new UserVehicleChange(r.F64(), (int)r.U32(), r.U8() != 0);
if (r.Remaining >= 1)
change = change with { CabEnd = r.I8() };
rec.AddUserVehicle(change);
break;
}
case RecordType.Clock:
rec.AddClock(new ClockEntry(r.F64(), r.U16(), r.U16(), r.F64()));
break;
case RecordType.End:
rec.EndTime = r.F64();
break;
default:
// A type of a later version: its length lets it be passed over.
rec.Unknown++;
break;
}
}
/// <summary>The fields of a record in the order they come, little-endian. A field past the end throws.</summary>
private ref struct PayloadReader
{
private readonly ReadOnlySpan<byte> _bytes;
private int _at;
public PayloadReader(ReadOnlySpan<byte> bytes)
{
_bytes = bytes;
_at = 0;
}
public int Remaining => _bytes.Length - _at;
private ReadOnlySpan<byte> Take(int count)
{
if (Remaining < count)
throw new EndOfStreamException();
var span = _bytes.Slice(_at, count);
_at += count;
return span;
}
public byte U8() => Take(1)[0];
public sbyte I8() => unchecked((sbyte)Take(1)[0]);
public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
public float F32() => BinaryPrimitives.ReadSingleLittleEndian(Take(4));
public double F64() => BinaryPrimitives.ReadDoubleLittleEndian(Take(8));
public Vector3 Vec3() => new(F32(), F32(), F32());
public Position DVec3() => new(F64(), F64(), F64());
/// <summary>UTF-8 after its length in 7 bit groups, low first, as BinaryWriter writes a string.</summary>
public string Text()
{
int length = 0;
for (int shift = 0; ; shift += 7)
{
if (shift > 28)
throw new EndOfStreamException();
byte b = U8();
length |= (b & 0x7f) << shift;
if (b < 0x80)
break;
}
if (length < 0)
throw new EndOfStreamException();
return length == 0 ? "" : Encoding.UTF8.GetString(Take(length));
}
}
}

View File

@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Eu07.BlackBox;
/// <summary>How a sample of the user's train stands against what the AI would have done.</summary>
public enum SpeedJudgement
{
/// <summary>Nothing to set it against: the consist not started, or the user out of the cab.</summary>
NoData,
/// <summary>The AI drove.</summary>
Autopilot,
/// <summary>At most the speed the AI would hold.</summary>
Within,
/// <summary>Faster, by no more than the tolerance.</summary>
Slightly,
/// <summary>Faster by more than the tolerance.</summary>
Over
}
/// <summary>What the user's driving came to, set against the AI's.</summary>
public sealed record DrivingSummary(
double Duration,
double DrivenSeconds,
double AutopilotSeconds,
double DistanceKm,
float MaxSpeedKmh,
double OverAiSeconds,
double WellOverAiSeconds,
float MaxOverAiKmh,
double MaxOverAiAt,
int SignalsPassed,
IReadOnlyList<SignalPassing> PassedAtDanger,
int Commands,
int AutopilotSwitches,
float MaxAcceleration,
float MaxDeceleration,
float MaxLateralAcceleration,
int Stops,
int StopsMissed,
double? MaxDelayMinutes,
int HornPoints,
int HornsMissed,
int SecurityBrakings)
{
/// <summary>The largest delay the user's train left a station or passed it with [min], null without any.</summary>
public double? MaxDepartureDelayMinutes { get; init; }
/// <summary>How long the train moved with its light signals known to be right or wrong [s].</summary>
public double LightsJudgedSeconds { get; init; }
/// <summary>How long of that its head or its tail showed other signals than it was to [s].</summary>
public double WrongLightsSeconds { get; init; }
/// <summary>Each stretch the train moved with the wrong signals, as they were.</summary>
public IReadOnlyList<LightsFault> LightsFaults { get; init; } = Array.Empty<LightsFault>();
/// <summary>The stops where the train did not fit along the platform, and how far its tail stood off it [m].</summary>
public IReadOnlyList<(string Station, double Metres)> OffPlatform { get; init; } = Array.Empty<(string, double)>();
/// <summary>[km/h] over the speed of the AI before it counts as well over.</summary>
public const float DefaultTolerance = 5f;
/// <summary>
/// The speeds are whole km/h for the AI and the needle of the user's train shakes:
/// half a km/h over is still within.
/// </summary>
private const float Rounding = 0.5f;
public static SpeedJudgement Judge(CabSample sample, float tolerance = DefaultTolerance)
{
if (sample.Driving == Driver.Ai)
return SpeedJudgement.Autopilot;
if (!sample.AiValid || sample.Driving != Driver.Human)
return SpeedJudgement.NoData;
float over = sample.AbsSpeedKmh - sample.Ai.TargetKmh;
return over <= Rounding ? SpeedJudgement.Within : over <= tolerance ? SpeedJudgement.Slightly : SpeedJudgement.Over;
}
internal static DrivingSummary Of(Recording recording, float tolerance)
{
var cab = recording.Cab;
double driven = 0, autopilot = 0, distance = 0, over = 0, wellOver = 0, maxOverAt = 0;
float maxSpeed = 0, maxOver = 0, maxAcceleration = 0, maxDeceleration = 0, maxLateral = 0;
// The accelerations over half a second: a sample alone jumps with each jolt of the couplers.
var window = new Queue<(float Along, float Across)>();
float sumAlong = 0, sumAcross = 0;
double lightsJudged = 0, lightsWrong = 0;
var faults = new List<LightsFault>();
// A step longer than this is a gap in the samples, not driving.
double gap = (recording.Info is { CabInterval: > 0 } info ? info.CabInterval : 0.1) * 5;
for (int i = 1; i < cab.Count; i++)
{
var before = cab[i - 1];
var sample = cab[i];
double step = sample.Time - before.Time;
if (step <= 0 || step > gap || sample.Vehicle != before.Vehicle)
{
window.Clear();
sumAlong = sumAcross = 0;
continue;
}
distance += (before.AbsSpeedKmh + sample.AbsSpeedKmh) / 2 / 3.6 * step / 1000;
window.Enqueue((sample.Acceleration, sample.LateralAcceleration));
sumAlong += sample.Acceleration;
sumAcross += sample.LateralAcceleration;
if (window.Count > 5)
{
var (along, across) = window.Dequeue();
sumAlong -= along;
sumAcross -= across;
}
if (window.Count == 5 && sample.AbsSpeedKmh > 1)
{
maxAcceleration = Math.Max(maxAcceleration, sumAlong / 5);
maxDeceleration = Math.Max(maxDeceleration, -sumAlong / 5);
maxLateral = Math.Max(maxLateral, Math.Abs(sumAcross / 5));
}
maxSpeed = Math.Max(maxSpeed, sample.AbsSpeedKmh);
if (sample.Driving == Driver.Ai)
autopilot += step;
else if (sample.Driving == Driver.Human && sample.AbsSpeedKmh > Rounding)
driven += step;
// the light signals at both ends, while the train moves
if (sample.AbsSpeedKmh > 1 && HeadCodes.Right(sample) is { } right)
{
lightsJudged += step;
if (!right)
{
lightsWrong += step;
var fault = new LightsFault(before.Time, sample.Time, HeadCodes.Of(sample.LightsHead), HeadCodes.RequiredHead(sample)!.Value,
HeadCodes.Of(sample.LightsTail), HeadCodes.RequiredTail(sample)!.Value);
if (faults.Count > 0 && faults[^1].SameAs(fault) && fault.Start - faults[^1].End < 2)
faults[^1] = faults[^1] with { End = sample.Time };
else
faults.Add(fault);
}
}
var judgement = Judge(sample, tolerance);
if (judgement is SpeedJudgement.Slightly or SpeedJudgement.Over)
over += step;
if (judgement == SpeedJudgement.Over)
wellOver += step;
if (judgement is SpeedJudgement.Slightly or SpeedJudgement.Over)
{
float by = sample.AbsSpeedKmh - sample.Ai.TargetKmh;
if (by > maxOver)
{
maxOver = by;
maxOverAt = sample.Time;
}
}
}
var passed = recording.UserPassings().ToList();
int switches = recording.DriverChanges.Count(change =>
recording.CabAt(change.Time + 0.5) is { } s && s.Vehicle == change.Vehicle);
// Every timetable the user's train had, as one going on from another at a terminus.
var stations = recording.Timetables.SelectMany(timetable => StationProgress.Of(recording, timetable)).ToList();
// the largest delay at any station, coming to it or leaving it
var delays = stations.SelectMany(s => new[] { s.ArrivalDelay, s.DepartureDelay }).OfType<double>().ToList();
var departures = stations.Select(s => s.DepartureDelay).OfType<double>().ToList();
var offPlatform = stations.Where(s => s.OnPlatform == false)
.Select(s => (StationNames.Display(s.Row.Station), s.OffPlatform ?? 0))
.ToList();
var horns = recording.UserHornChecks().ToList();
return new DrivingSummary(recording.Duration, driven, autopilot, distance, maxSpeed, over, wellOver, maxOver,
maxOverAt, passed.Count, passed.Where(p => p.AtDanger).ToList(),
recording.UserCommands().Count(), switches, maxAcceleration, maxDeceleration, maxLateral,
stations.Count(s => s.Outcome == StationOutcome.Stopped),
stations.Count(s => s.Outcome == StationOutcome.Missed),
delays.Count > 0 ? delays.Max() : null,
horns.Count, horns.Count(h => !h.Sounded), recording.SecurityBrakings().Count)
{
MaxDepartureDelayMinutes = departures.Count > 0 ? departures.Max() : null,
LightsJudgedSeconds = lightsJudged,
WrongLightsSeconds = lightsWrong,
LightsFaults = faults,
OffPlatform = offPlatform
};
}
}
/// <summary>A stretch the user's train moved with other light signals than it was to show, at its head and its tail.</summary>
public sealed record LightsFault(double Start, double End, HeadCode Head, HeadCode HeadRequired, HeadCode Tail, HeadCode TailRequired)
{
public bool HeadWrong => Head != HeadRequired;
public bool TailWrong => Tail != TailRequired;
public bool SameAs(LightsFault other) =>
Head == other.Head && HeadRequired == other.HeadRequired && Tail == other.Tail && TailRequired == other.TailRequired;
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Reads the black box the simulator writes to physicslog/*.blackbox. Only the
base library: the starter, the viewer and any other program can use it, and
it goes through NativeAOT and trimming as they do. -->
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<IsAotCompatible>true</IsAotCompatible>
<IsTrimmable>true</IsTrimmable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>Eu07.BlackBox</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,91 @@
using System;
namespace Eu07.BlackBox;
/// <summary>What the driver of the user's train went by: the lights it is to show depend on it.</summary>
public enum Movement : byte
{
/// <summary>No driver, or standing by.</summary>
Other = 0,
/// <summary>A train run: Pc 1 or Pc 2 at the head, Pc 5 at the tail.</summary>
Train = 1,
/// <summary>Shunting: Tb 1 at both ends.</summary>
Shunting = 2,
/// <summary>A recording made before it was written.</summary>
Unknown = 0xFF
}
/// <summary>The light signals at an end of a train.</summary>
public enum HeadCode
{
Off,
/// <summary>The head of a train: both headlights and the upper one.</summary>
Pc1,
/// <summary>The head of a train on the wrong track: the left red marker, the right headlight and the upper one.</summary>
Pc2,
/// <summary>The tail of a train: both red markers.</summary>
Pc5,
/// <summary>Shunting: one headlight.</summary>
Tb1,
/// <summary>Any other pattern.</summary>
Other
}
/// <summary>The light signals a train shows and those it is to show, as the simulator's driver sets them.</summary>
public static class HeadCodes
{
private const Lights Signals = Lights.HeadlightLeft | Lights.RedMarkerLeft | Lights.HeadlightUpper | Lights.HeadlightRight |
Lights.RedMarkerRight | Lights.RearEndSignals;
/// <summary>The signal the lamps given show; the high beams and the auxiliary lamps tell nothing.</summary>
public static HeadCode Of(Lights lights)
{
var lit = lights & Signals;
if ((lit & Lights.RearEndSignals) != 0)
lit = (lit & ~Lights.RearEndSignals) | Lights.RedMarkerLeft | Lights.RedMarkerRight;
return lit switch
{
Lights.None => HeadCode.Off,
Lights.HeadlightLeft | Lights.HeadlightRight | Lights.HeadlightUpper => HeadCode.Pc1,
Lights.RedMarkerLeft | Lights.HeadlightRight | Lights.HeadlightUpper => HeadCode.Pc2,
Lights.RedMarkerLeft | Lights.RedMarkerRight => HeadCode.Pc5,
Lights.HeadlightLeft or Lights.HeadlightRight => HeadCode.Tb1,
_ => HeadCode.Other
};
}
/// <summary>The signal the head of the train is to show, null when nothing tells it.</summary>
public static HeadCode? RequiredHead(CabSample sample) => sample.Movement switch
{
Movement.Train => sample.LightHintHead < 0 ? HeadCode.Pc1 : Of((Lights)sample.LightHintHead),
Movement.Shunting => HeadCode.Tb1,
_ => null
};
/// <summary>The signal the tail of the train is to show, null when nothing tells it.</summary>
public static HeadCode? RequiredTail(CabSample sample) => sample.Movement switch
{
Movement.Train => sample.LightHintTail < 0 ? HeadCode.Pc5 : Of((Lights)sample.LightHintTail),
Movement.Shunting => HeadCode.Tb1,
_ => null
};
/// <summary>Whether both ends showed what they were to while the train moved; null when nothing tells it.</summary>
public static bool? Right(CabSample sample)
{
if (RequiredHead(sample) is not { } head || RequiredTail(sample) is not { } tail)
return null;
return Of(sample.LightsHead) == head && Of(sample.LightsTail) == tail;
}
/// <summary>The name of a signal as the rules give it.</summary>
public static string Name(HeadCode code) => code switch
{
HeadCode.Pc1 => "Pc 1",
HeadCode.Pc2 => "Pc 2",
HeadCode.Pc5 => "Pc 5",
HeadCode.Tb1 => "Tb 1",
HeadCode.Off => "—",
_ => "?"
};
}

492
Eu07.BlackBox/Recording.cs Normal file
View File

@@ -0,0 +1,492 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
namespace Eu07.BlackBox;
/// <summary>A span of time, start included, end not.</summary>
public readonly record struct TimeSpanRange(double Start, double End)
{
public double Length => End - Start;
public bool Contains(double t) => t >= Start && t < End;
}
/// <summary>
/// What a black box holds, as far as it has been read: the map, the vehicles and what
/// happened to them over the time of the recording, which runs from 0 in seconds of
/// simulation. The lists grow as more of a file still written is read; the queries
/// answer for any moment of the time read so far.
/// </summary>
public sealed class Recording
{
private readonly List<MapTrack> _tracks = new();
private readonly Dictionary<int, List<MapTrack>> _trackIndex = new();
private readonly List<MapSignal> _signals = new();
private readonly Dictionary<int, MapSignal> _signalIndex = new();
private readonly Dictionary<int, VehicleInfo> _vehicles = new();
private readonly List<CabSample> _cab = new();
private readonly List<VehicleLook> _looks = new();
private readonly Dictionary<int, List<(double Time, VehicleState State)>> _history = new();
private readonly List<SwitchChange> _switches = new();
private readonly Dictionary<int, List<SwitchChange>> _switchIndex = new();
private readonly List<AspectChange> _aspects = new();
private readonly Dictionary<int, List<AspectChange>> _aspectIndex = new();
private readonly List<CommandEntry> _commands = new();
private readonly List<DriverChange> _driverChanges = new();
private readonly List<SignalPassing> _passings = new();
private readonly List<UserVehicleChange> _userVehicles = new();
private readonly List<ClockEntry> _clocks = new();
private readonly List<MapStopPoint> _stopPoints = new();
private readonly List<MapHornPoint> _hornPoints = new();
private readonly List<Timetable> _timetables = new();
private readonly List<StationEvent> _stations = new();
private readonly List<HornRequest> _hornRequests = new();
private readonly List<CameraState> _cameras = new();
private readonly List<CouplingChange> _couplings = new();
private double _lastTime;
public int Version { get; internal set; }
public RecordingInfo? Info { get; internal set; }
/// <summary>The time of the end record: null while the simulator still writes, or if it didn't end the recording.</summary>
public double? EndTime { get; internal set; }
public bool Complete => EndTime is not null;
/// <summary>Records of types this reader doesn't know, passed over.</summary>
public int Unknown { get; internal set; }
/// <summary>Records shorter than their type says, passed over.</summary>
public int Malformed { get; internal set; }
/// <summary>Goes up with every read that added something, for views to tell they have to look again.</summary>
public int Revision { get; private set; }
public IReadOnlyList<MapTrack> Tracks => _tracks;
public IReadOnlyList<MapSignal> Signals => _signals;
public IReadOnlyDictionary<int, VehicleInfo> Vehicles => _vehicles;
public IReadOnlyList<CabSample> Cab => _cab;
public IReadOnlyList<VehicleLook> Looks => _looks;
public IReadOnlyList<SwitchChange> Switches => _switches;
public IReadOnlyList<AspectChange> Aspects => _aspects;
public IReadOnlyList<CommandEntry> Commands => _commands;
public IReadOnlyList<DriverChange> DriverChanges => _driverChanges;
public IReadOnlyList<SignalPassing> Passings => _passings;
public IReadOnlyList<UserVehicleChange> UserVehicles => _userVehicles;
public IReadOnlyList<ClockEntry> Clocks => _clocks;
public IReadOnlyList<MapStopPoint> StopPoints => _stopPoints;
public IReadOnlyList<MapHornPoint> HornPoints => _hornPoints;
public IReadOnlyList<Timetable> Timetables => _timetables;
public IReadOnlyList<StationEvent> Stations => _stations;
public IReadOnlyList<HornRequest> HornRequests => _hornRequests;
public IReadOnlyList<CameraState> Cameras => _cameras;
public IReadOnlyList<CouplingChange> Couplings => _couplings;
/// <summary>The time the recording covers so far.</summary>
public double Duration => Math.Max(EndTime ?? 0, _lastTime);
/// <summary>The seconds between two looks at the vehicles.</summary>
public double LookInterval => Info is { VehiclesInterval: > 0 } info ? info.VehiclesInterval : 1.0;
// ----- filled by the reader -----
internal void Changed() => ++Revision;
private void Saw(double time) => _lastTime = Math.Max(_lastTime, time);
internal void AddTrack(MapTrack track)
{
_tracks.Add(track);
if (!_trackIndex.TryGetValue(track.Index, out var ways))
_trackIndex[track.Index] = ways = new List<MapTrack>(1);
ways.Add(track);
}
internal void AddSignal(MapSignal signal)
{
_signals.Add(signal);
_signalIndex[signal.Index] = signal;
}
internal void AddVehicle(VehicleInfo vehicle) => _vehicles[vehicle.Id] = vehicle;
internal void RemoveVehicle(double time, int id)
{
Saw(time);
if (_vehicles.TryGetValue(id, out var vehicle))
vehicle.RemovedAt = time;
}
internal void AddCab(CabSample sample)
{
Saw(sample.Time);
_cab.Add(sample);
}
internal void AddLook(VehicleLook look)
{
Saw(look.Time);
_looks.Add(look);
foreach (var state in look.States)
{
if (!_history.TryGetValue(state.Id, out var history))
_history[state.Id] = history = new List<(double, VehicleState)>();
history.Add((look.Time, state));
}
}
internal void AddSwitch(SwitchChange change)
{
Saw(change.Time);
_switches.Add(change);
if (!_switchIndex.TryGetValue(change.Track, out var list))
_switchIndex[change.Track] = list = new List<SwitchChange>();
list.Add(change);
}
internal void AddAspect(AspectChange change)
{
Saw(change.Time);
_aspects.Add(change);
if (!_aspectIndex.TryGetValue(change.Signal, out var list))
_aspectIndex[change.Signal] = list = new List<AspectChange>();
list.Add(change);
}
internal void AddCommand(CommandEntry command)
{
Saw(command.Time);
_commands.Add(command);
}
internal void AddDriverChange(DriverChange change)
{
Saw(change.Time);
_driverChanges.Add(change);
}
internal void AddPassing(SignalPassing passing)
{
Saw(passing.Time);
_passings.Add(passing);
}
internal void AddUserVehicle(UserVehicleChange change)
{
Saw(change.Time);
_userVehicles.Add(change);
}
internal void AddClock(ClockEntry clock)
{
Saw(clock.Time);
_clocks.Add(clock);
}
internal void AddStopPoint(MapStopPoint point) => _stopPoints.Add(point);
internal void AddHornPoint(MapHornPoint point) => _hornPoints.Add(point);
internal void AddTimetable(Timetable timetable)
{
Saw(timetable.Time);
_timetables.Add(timetable);
}
internal void AddStation(StationEvent station)
{
Saw(station.Time);
_stations.Add(station);
}
internal void AddCamera(CameraState camera)
{
Saw(camera.Time);
_cameras.Add(camera);
}
internal void AddCoupling(CouplingChange change)
{
Saw(change.Time);
_couplings.Add(change);
}
internal void AddHornRequest(HornRequest request)
{
Saw(request.Time);
_hornRequests.Add(request);
}
// ----- queries -----
public IReadOnlyList<MapTrack> TrackWays(int index) =>
_trackIndex.TryGetValue(index, out var ways) ? ways : Array.Empty<MapTrack>();
public MapSignal? Signal(int index) => _signalIndex.GetValueOrDefault(index);
public VehicleInfo? Vehicle(int id) => _vehicles.GetValueOrDefault(id);
/// <summary>The last of a list sorted by time at or before t: -1 if none is.</summary>
private static int LastAtOrBefore<T>(IReadOnlyList<T> list, double t, Func<T, double> time)
{
int low = 0, high = list.Count - 1, found = -1;
while (low <= high)
{
int middle = low + (high - low) / 2;
if (time(list[middle]) <= t)
{
found = middle;
low = middle + 1;
}
else
high = middle - 1;
}
return found;
}
/// <summary>The index of the sample of the user's train at or before t, -1 before the first.</summary>
public int CabIndexAt(double t) => LastAtOrBefore(_cab, t, s => s.Time);
/// <summary>The sample of the user's train at or before t.</summary>
public CabSample? CabAt(double t) => CabIndexAt(t) is var i and >= 0 ? _cab[i] : null;
/// <summary>
/// Where the user's train was at t, between the two samples around it. Null before
/// the first sample, and in a gap: the user out of any vehicle.
/// </summary>
public Position? CabPositionAt(double t)
{
int i = CabIndexAt(t);
if (i < 0)
return null;
var before = _cab[i];
if (i + 1 >= _cab.Count)
return t - before.Time <= 1.0 ? before.Position : null;
var after = _cab[i + 1];
if (after.Vehicle != before.Vehicle || after.Time - before.Time > 1.0)
return t - before.Time <= 1.0 ? before.Position : null;
double f = (t - before.Time) / Math.Max(1e-9, after.Time - before.Time);
return Position.Lerp(before.Position, after.Position, Math.Clamp(f, 0, 1));
}
/// <summary>
/// Every vehicle on the scenery at t, where it was. A vehicle is listed only when it
/// moved, so between two listings far apart it stood at the first place until the
/// look before the second.
/// </summary>
public IReadOnlyList<VehicleState> VehiclesAt(double t)
{
var states = new List<VehicleState>(_history.Count);
double interval = LookInterval;
foreach (var (id, history) in _history)
{
if (_vehicles.TryGetValue(id, out var info) && info.RemovedAt is { } removed && removed <= t)
continue;
int i = LastAtOrBefore(history, t, entry => entry.Time);
if (i < 0)
continue;
var (time, state) = history[i];
if (i + 1 < history.Count)
{
var (nextTime, next) = history[i + 1];
double from = Math.Max(time, nextTime - interval * 1.5);
if (t > from)
state = Lerp(state, next, (t - from) / Math.Max(1e-9, nextTime - from));
}
states.Add(state);
}
return states;
}
private static VehicleState Lerp(VehicleState a, VehicleState b, double f)
{
float k = (float)Math.Clamp(f, 0, 1);
float turn = b.Heading - a.Heading;
while (turn > MathF.PI) turn -= 2 * MathF.PI;
while (turn < -MathF.PI) turn += 2 * MathF.PI;
return a with
{
Position = Vector3.Lerp(a.Position, b.Position, k),
Heading = a.Heading + turn * k,
SpeedKmh = a.SpeedKmh + (b.SpeedKmh - a.SpeedKmh) * k,
Driving = k < 0.5f ? a.Driving : b.Driving
};
}
/// <summary>The aspect a signal showed at t, null before it was first seen.</summary>
public AspectChange? AspectAt(int signal, double t) =>
_aspectIndex.TryGetValue(signal, out var list) && LastAtOrBefore(list, t, c => c.Time) is var i and >= 0
? list[i]
: null;
/// <summary>The position of a switch at t, -1 if unknown.</summary>
public int SwitchAt(int track, double t) =>
_switchIndex.TryGetValue(track, out var list) && LastAtOrBefore(list, t, c => c.Time) is var i and >= 0
? list[i].State
: -1;
/// <summary>The time of day of the simulation at t.</summary>
public TimeSpan ClockAt(double t)
{
int i = LastAtOrBefore(_clocks, t, c => c.Time);
double second = i >= 0
? _clocks[i].SecondOfDay + (t - _clocks[i].Time)
: (Info?.SecondOfDay ?? 0) + t;
second %= 86400;
if (second < 0)
second += 86400;
return TimeSpan.FromSeconds(second);
}
/// <summary>The vehicle the user was in at t, and whether in its cab; null before it was known.</summary>
public UserVehicleChange? UserVehicleAt(double t) =>
LastAtOrBefore(_userVehicles, t, c => c.Time) is var i and >= 0 ? _userVehicles[i] : null;
/// <summary>The stretches of the samples of the user's train the condition holds over.</summary>
public IReadOnlyList<TimeSpanRange> CabSpans(Func<CabSample, bool> condition)
{
var spans = new List<TimeSpanRange>();
double? start = null;
double gap = (Info is { CabInterval: > 0 } info ? info.CabInterval : 0.1) * 5;
for (int i = 0; i < _cab.Count; i++)
{
var sample = _cab[i];
bool holds = condition(sample);
bool broken = i > 0 && sample.Time - _cab[i - 1].Time > gap;
if (start is { } open && (!holds || broken))
{
spans.Add(new TimeSpanRange(open, broken ? _cab[i - 1].Time : sample.Time));
start = null;
}
if (holds && start is null)
start = sample.Time;
}
if (start is { } last && _cab.Count > 0)
spans.Add(new TimeSpanRange(last, _cab[^1].Time));
return spans;
}
/// <summary>When the AI drove the user's train: the autopilot.</summary>
public IReadOnlyList<TimeSpanRange> AutopilotSpans() => CabSpans(s => s.Driving == Driver.Ai);
/// <summary>The signals the user's train passed: those passed by the vehicle the samples are of at the time.</summary>
public IEnumerable<SignalPassing> UserPassings() =>
_passings.Where(p => IsUserTrain(p.Time + 0.5, p.Vehicle, p.Position, Math.Max(0, p.Overrun) + 250));
/// <summary>
/// Whether a vehicle was of the user's train at t. The records of a train give the
/// vehicle at its head, which is not the one the user is in when the train is driven
/// from its other end or from a car in the middle: those count when the place the
/// record is about lies within reach of the user's vehicle.
/// </summary>
public bool IsUserTrain(double t, int vehicle, Position place, double reach)
{
if (CabAt(t) is not { } s || t - s.Time > 1.5)
return false;
if (s.Vehicle == vehicle)
return true;
// The couplings tell the train exactly; a recording made before them has the distance only.
return _couplings.Count > 0 ? ConsistAt(s.Vehicle, t).Contains(vehicle) : s.Position.DistanceTo(place) <= reach;
}
/// <summary>The vehicles coupled into one train with the one given at t, itself included.</summary>
public IReadOnlySet<int> ConsistAt(int vehicle, double t)
{
// What each coupler held at t: the couplings in the order written, each telling both ends.
var held = new Dictionary<(int Vehicle, int Side), (int Other, int OtherSide)>();
foreach (var change in _couplings)
{
if (change.Time > t)
break;
if (held.Remove((change.Vehicle, change.Side), out var before))
held.Remove((before.Other, before.OtherSide));
if (change.Other != 0 && (change.Flags & CouplingFlags.Coupler) != 0)
{
held[(change.Vehicle, change.Side)] = (change.Other, change.OtherSide);
held[(change.Other, change.OtherSide)] = (change.Vehicle, change.Side);
}
}
var consist = new HashSet<int> { vehicle };
var open = new Stack<int>();
open.Push(vehicle);
while (open.Count > 0)
{
int at = open.Pop();
for (int side = 0; side < 2; side++)
if (held.TryGetValue((at, side), out var next) && consist.Add(next.Other))
open.Push(next.Other);
}
return consist;
}
/// <summary>Where the user looked from at t, null before it was known.</summary>
public CameraState? CameraAt(double t) =>
LastAtOrBefore(_cameras, t, c => c.Time) is var i and >= 0 ? _cameras[i] : null;
/// <summary>
/// The couplings made and undone after the start: those at t = 0 are what the trains
/// were made of, not something done.
/// </summary>
public IEnumerable<CouplingChange> CouplingEvents() =>
_couplings.Where(c => c.Time > 0.01 && (c.Coupled || c.Uncoupled || c.Flags != c.PreviousFlags));
/// <summary>The couplings the user's train took part in, before or after them.</summary>
public IEnumerable<CouplingChange> UserCouplings() =>
CouplingEvents().Where(c => CabAt(c.Time) is { } s && c.Time - s.Time <= 1.5 &&
(ConsistAt(s.Vehicle, c.Time - 0.01).Contains(c.Vehicle) ||
ConsistAt(s.Vehicle, c.Time + 0.01).Contains(c.Vehicle)));
/// <summary>
/// The timetable of the train the user was in at t: the last one written by then, else the first; none when the
/// user had gone over to a vehicle of another consist, a train without a timetable or one standing on its own.
/// </summary>
public Timetable? TimetableAt(double t)
{
var timetable = LastAtOrBefore(_timetables, t, x => x.Time) is var i and >= 0 ? _timetables[i] : _timetables.FirstOrDefault();
if (timetable is null || CabAt(t) is not { } sample || sample.Vehicle == timetable.Vehicle)
return timetable;
// another vehicle of the same train (the other cab, a car of it) keeps the timetable
return ConsistAt(sample.Vehicle, t).Contains(timetable.Vehicle) ? timetable : null;
}
/// <summary>The stations of the timetable of the user's train, as it came to them.</summary>
public IEnumerable<StationEvent> UserStations() =>
_stations.Where(s => IsUserTrain(s.Time, s.Vehicle, s.Position, Math.Max(s.TrainLength, 50) + 150));
/// <summary>The horn points the user's train passed, and whether the horn sounded there.</summary>
public IEnumerable<HornCheck> UserHornChecks()
{
foreach (var request in _hornRequests)
{
if (!IsUserTrain(request.Time, request.Vehicle, request.Position, 300))
continue;
bool autopilot = CabAt(request.Time) is { Driving: Driver.Ai };
yield return new HornCheck(request, HornSounded(request), autopilot);
}
}
/// <summary>The time given to sound the horn after a horn point, beyond how long it is to sound.</summary>
public const double HornGrace = 8;
/// <summary>Whether the horn of the user's train sounded from just before the horn point until a while after.</summary>
private bool HornSounded(HornRequest request)
{
double until = request.Time + Math.Max(request.Duration, 1) + HornGrace;
for (int i = Math.Max(0, CabIndexAt(request.Time - 2)); i < _cab.Count && _cab[i].Time <= until; i++)
if (_cab[i].Has(CabDevices.Horn))
return true;
return false;
}
/// <summary>When the train protection braked the user's train: the alerter or the cab signalling not acknowledged.</summary>
public IReadOnlyList<TimeSpanRange> SecurityBrakings() => CabSpans(s => s.Has(SecurityFlags.Braking));
/// <summary>The commands the user gave in a vehicle, presses only.</summary>
public IEnumerable<CommandEntry> UserCommands() => _commands.Where(c => c.Pressed && c.Vehicle != 0);
public DrivingSummary Summarize(float tolerance = DrivingSummary.DefaultTolerance) => DrivingSummary.Of(this, tolerance);
}
/// <summary>A horn point the user's train passed: whether the horn sounded, and whether the autopilot drove then.</summary>
public sealed record HornCheck(HornRequest Request, bool Sounded, bool Autopilot);

355
Eu07.BlackBox/Records.cs Normal file
View File

@@ -0,0 +1,355 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace Eu07.BlackBox;
/// <summary>The types of the records, as the simulator writes them (blackbox_doc.md in its repository).</summary>
public enum RecordType : byte
{
Info = 0x01,
Track = 0x10,
Signal = 0x11,
Vehicle = 0x12,
VehicleRemoved = 0x13,
StopPoint = 0x14,
HornPoint = 0x15,
Cab = 0x20,
Vehicles = 0x21,
SwitchState = 0x22,
SignalAspect = 0x23,
Timetable = 0x24,
Camera = 0x25,
Command = 0x30,
DriverChange = 0x31,
SignalPassed = 0x32,
UserVehicle = 0x33,
Clock = 0x34,
Station = 0x35,
HornRequest = 0x36,
Coupling = 0x37,
End = 0x3f
}
/// <summary>Who drives a vehicle.</summary>
public enum Driver : byte
{
None = 0,
Human = 1,
Ai = 2
}
[Flags]
public enum CabFlags : byte
{
None = 0,
/// <summary>The user is in the cab, not looking around outside.</summary>
InCab = 0x01,
/// <summary>The consist is started, so what the AI would do is worked out.</summary>
AiValid = 0x02,
/// <summary>The alerter or the cab signalling blinks.</summary>
AlerterBlinking = 0x04,
/// <summary>The alerter or the cab signalling beeps.</summary>
AlerterBeeping = 0x08,
WheelSlip = 0x10,
RadioStop = 0x20,
AlarmChain = 0x40
}
/// <summary>What is switched on in the user's train.</summary>
[Flags]
public enum CabDevices : uint
{
None = 0,
Battery = 0x0001,
MainBreaker = 0x0002,
PantographFront = 0x0004,
PantographRear = 0x0008,
Converter = 0x0010,
Compressor = 0x0020,
/// <summary>The motors are connected.</summary>
LineContactors = 0x0040,
/// <summary>Tripped.</summary>
OverloadRelay = 0x0080,
Sanding = 0x0100,
Horn = 0x0200,
DoorsLeft = 0x0400,
DoorsRight = 0x0800,
Radio = 0x1000,
Heating = 0x2000,
/// <summary>Applied.</summary>
SpringBrake = 0x4000,
ShuntMode = 0x8000
}
/// <summary>The train protection: the alerter (CA) and the cab signalling (SHP) apart.</summary>
[Flags]
public enum SecurityFlags : byte
{
None = 0,
/// <summary>The alerter lamp blinks: the driver is to acknowledge.</summary>
Vigilance = 0x01,
/// <summary>The cab signalling lamp blinks: an SHP magnet was passed.</summary>
CabSignal = 0x02,
VigilanceBeeping = 0x04,
CabSignalBeeping = 0x08,
/// <summary>Not acknowledged in time: the train protection brakes the train.</summary>
Braking = 0x10
}
/// <summary>What a train did at a station of its timetable.</summary>
public enum StationKind : byte
{
/// <summary>Stopped at the stop point of a stop of its timetable.</summary>
Stopped = 1,
/// <summary>Passed a station where the timetable has no stop.</summary>
Passed = 2,
/// <summary>Left the stop point behind before it was time to go: without stopping, or leaving early.</summary>
Missed = 3,
/// <summary>Still at the stop point when it was time to go.</summary>
Ready = 4
}
/// <summary>The lights lit at an end of a vehicle.</summary>
[Flags]
public enum Lights : ushort
{
None = 0,
HeadlightLeft = 0x001,
RedMarkerLeft = 0x002,
HeadlightUpper = 0x004,
HeadlightRight = 0x010,
RedMarkerRight = 0x020,
RearEndSignals = 0x040,
AuxiliaryLeft = 0x080,
AuxiliaryRight = 0x100,
HighBeamLeft = 0x200,
HighBeamRight = 0x400
}
[Flags]
public enum PassingFlags : byte
{
None = 0,
/// <summary>A shunting signal.</summary>
Shunt = 0x01,
/// <summary>Passed showing stop: a signal passed at danger.</summary>
AtStop = 0x02,
/// <summary>Stop of an automatic block signal, which may be passed slowly after a stop.</summary>
AutomaticBlock = 0x04
}
public enum TrackKind : byte
{
Normal = 0,
SwitchBranch = 1
}
/// <summary>A position in the frame of the scenery: metres, y up.</summary>
public readonly record struct Position(double X, double Y, double Z)
{
public static Position Lerp(Position a, Position b, double f) =>
new(a.X + (b.X - a.X) * f, a.Y + (b.Y - a.Y) * f, a.Z + (b.Z - a.Z) * f);
public double DistanceTo(Position other)
{
double dx = X - other.X, dy = Y - other.Y, dz = Z - other.Z;
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
}
}
public sealed record RecordingInfo(string Scenery, string Started, int Year, int YearDay, double SecondOfDay,
string StartVehicle, string SimulatorVersion, float CabInterval,
float VehiclesInterval, float KeyframeInterval)
{
/// <summary>The time of day the simulation started at.</summary>
public TimeSpan StartClock => TimeSpan.FromSeconds(SecondOfDay);
}
/// <summary>One rail track of the map, or one of the two ways of a switch, which share the number.</summary>
public sealed record MapTrack(int Index, string Name, TrackKind Kind, int Branch, int Category,
IReadOnlyList<Vector3> Points);
public sealed record MapSignal(int Index, string Name, Position Position);
/// <summary>
/// A stop point of a passenger train (W4). StopAt: below 0 the head stops that many metres
/// before it, above 0 the middle of the train. Platform: above 0 its length, below 0 the
/// stop is for trains shorter than that (W32), 0 not given; its last digit also tells the side of
/// the platform (see <see cref="Platforms"/>).
/// </summary>
public sealed record MapStopPoint(int Index, string Station, Position Position, float StopAt, float Platform)
{
/// <summary>
/// The heading of the trains it is for [rad, from +z towards +x], NaN when not known: on no track, on tracks of
/// both ways, or in a recording made before it was written.
/// </summary>
public float Heading { get; init; } = float.NaN;
public bool HasHeading => !float.IsNaN(Heading);
public bool HasPlatform => Platform > 0;
/// <summary>A W32: the stop of the trains shorter than its length; the longer ones go on to another stop point.</summary>
public bool ForShortTrains => Platform < 0;
/// <summary>The length of the platform, or of the trains a W32 is for [m].</summary>
public double PlatformLength => Platforms.Length(Platform);
}
/// <summary>
/// The second value of a stop point, as the simulator's driver reads it: the whole of it the metres of the
/// platform, and its last digit, besides, the side the doors open on (1, 2, both 3); 201 is 201 m.
/// </summary>
public static class Platforms
{
public static double Length(float value) => Math.Abs(value);
public static int Side(float value) => (int)Math.Floor(Math.Abs(value)) % 10;
}
/// <summary>A horn point (W6): the trains passing it are to sound the horn this long, in this tone (1 low, 2 high).</summary>
public sealed record MapHornPoint(int Index, string Name, Position Position, float Duration, float Tone);
/// <summary>A station of a timetable; the times in minutes of the day, below 0 none.</summary>
public sealed record TimetableRow(string Station, float Km, float Vmax, float Arrival, float Departure, bool Maintenance)
{
/// <summary>The train stops there: a station passed through has a departure only.</summary>
public bool IsStop => Arrival >= 0;
}
/// <summary>The timetable of the train the user was in. The rows are numbered from 1, the station records give their number.</summary>
public sealed record Timetable(double Time, int Vehicle, string Train, string Category, string From, string To, float Vmax,
IReadOnlyList<TimetableRow> Rows)
{
public TimetableRow? Row(int number) => number >= 1 && number <= Rows.Count ? Rows[number - 1] : null;
}
/// <summary>
/// A train at a station of its timetable, at its stop point. Distance: of its head before
/// the stop point, below 0 past it; StopAt: where the head was to stop, that many metres before it.
/// </summary>
public sealed record StationEvent(double Time, int Vehicle, StationKind Kind, int Row, string Station, Position Position,
float Distance, float StopAt, float TrainLength, float Platform, float Arrival,
float Departure)
{
/// <summary>How far from the place it was to stop the head stood: above 0 short of it, below 0 past it.</summary>
public float StopError => Distance - StopAt;
/// <summary>
/// Whether the whole train stood along the platform, when its length is known: as the driver takes it, the length of
/// the trains a W32 is for is the room it has there too.
/// </summary>
public bool? OnPlatform => Platform != 0 ? Distance >= -2 && Distance + TrainLength <= PlatformLength + 2 : null;
/// <summary>The length of the platform [m], without the digit of its side.</summary>
public double PlatformLength => Platforms.Length(Platform);
/// <summary>How far the tail stood off the platform [m], 0 along it.</summary>
public double OffPlatform => Platform != 0 ? Math.Max(0, Distance + TrainLength - PlatformLength) : 0;
}
public enum CameraMode : byte
{
/// <summary>From the cab of the vehicle the user is in.</summary>
Cab = 0,
/// <summary>One of the outside views of the user's train.</summary>
External = 1,
/// <summary>The free camera.</summary>
Free = 2,
Debug = 3
}
/// <summary>Where the user looked from. Heading as that of a vehicle: towards (sin, cos) in (x, z).</summary>
public readonly record struct CameraState(double Time, CameraMode Mode, int View, int Vehicle, Position Position,
float Heading, float Pitch);
/// <summary>What a coupling joins: the bits of the simulator's coupling.</summary>
[Flags]
public enum CouplingFlags : byte
{
None = 0,
Coupler = 0x01,
BrakePipe = 0x02,
Control = 0x04,
Power = 0x08,
Gangway = 0x10,
MainPipe = 0x20,
Heating = 0x40,
/// <summary>Not undone in shunting: between the cars of a unit.</summary>
Permanent = 0x80
}
/// <summary>A coupler coupled, uncoupled, or its hoses and cables changed. Side 0 the front coupler, 1 the rear one.</summary>
public sealed record CouplingChange(double Time, int Vehicle, int Side, int Other, int OtherSide, CouplingFlags Flags,
CouplingFlags PreviousFlags, int PreviousOther, Position Position)
{
public bool Coupled => Other != 0 && (Flags & CouplingFlags.Coupler) != 0 && PreviousOther != Other;
public bool Uncoupled => Other == 0 && PreviousOther != 0;
}
/// <summary>A train told to sound the horn at a horn point.</summary>
public readonly record struct HornRequest(double Time, int Vehicle, float Duration, float Tone, Position Position);
/// <summary>A vehicle as it is first seen. Id 0 is no vehicle.</summary>
public sealed record VehicleInfo(int Id, string Name, string Type, string Train, float Length, float Width,
int Category)
{
public double? RemovedAt { get; internal set; }
}
/// <summary>What the AI driver would do at that moment, from that place and at that speed.</summary>
public readonly record struct AiReference(float TargetKmh, float NextLimitKmh, float NextLimitDistance,
float Acceleration, float BrakingDistance, float TimetableVmaxKmh,
float TrackVmaxKmh)
{
public bool HasNextLimit => NextLimitKmh >= 0 && NextLimitDistance >= 0;
}
/// <summary>One look at the user's train, ten a second.</summary>
public readonly record struct CabSample(double Time, int Vehicle, Position Position, float SpeedKmh,
float Acceleration, float OdometerKm, Driver Driving, CabFlags Flags,
sbyte MainController, sbyte ScndController, sbyte Reverser,
float TrainBrake, float LocalBrake, float BrakeCylinder, float BrakePipe,
float MotorCurrent, AiReference Ai, CabDevices Devices,
float MainReservoir, float Voltage, float EngineRpm,
Lights LightsHead, Lights LightsTail, sbyte CabEnd = 0,
SecurityFlags Security = SecurityFlags.None, float LateralAcceleration = 0,
Movement Movement = Movement.Unknown, short LightHintHead = -1, short LightHintTail = -1)
{
public bool Has(CabDevices device) => (Devices & device) == device;
public bool Has(SecurityFlags flag) => (Security & flag) == flag;
public bool AiValid => (Flags & CabFlags.AiValid) != 0;
public bool InCab => (Flags & CabFlags.InCab) != 0;
public float AbsSpeedKmh => Math.Abs(SpeedKmh);
}
/// <summary>One vehicle in a look at the vehicles.</summary>
public readonly record struct VehicleState(int Id, Vector3 Position, float Heading, float SpeedKmh, Driver Driving);
/// <summary>A look at the vehicles: those which moved, or all of them in a keyframe.</summary>
public sealed record VehicleLook(double Time, bool Keyframe, IReadOnlyList<VehicleState> States);
public readonly record struct SwitchChange(double Time, int Track, int State);
/// <summary>What a signal shows: the speeds its memory cell holds, 0 stop, -1 no limit, and the command.</summary>
public readonly record struct AspectChange(double Time, int Signal, float Speed1, float Speed2, string Command);
public sealed record CommandEntry(double Time, int Vehicle, bool Pressed, double Param1, double Param2, string Name);
public readonly record struct DriverChange(double Time, int Vehicle, Driver Driving);
public sealed record SignalPassing(double Time, int Vehicle, int Signal, string Event, Position Position,
float Speed1, float Speed2, float SpeedKmh, float Overrun, PassingFlags Flags)
{
/// <summary>No signal of the map: the passing came from an event the map doesn't hold.</summary>
public const int NoSignal = -1;
public bool AtDanger => (Flags & PassingFlags.AtStop) != 0;
}
/// <summary>The vehicle the user was in, whether in its cab, and which cab: 1 the front one, -1 the rear one, 0 none.</summary>
public readonly record struct UserVehicleChange(double Time, int Vehicle, bool InCab, sbyte CabEnd = 0);
public readonly record struct ClockEntry(double Time, int Year, int YearDay, double SecondOfDay);

View File

@@ -0,0 +1,13 @@
namespace Eu07.BlackBox;
/// <summary>The names of the stations as shown: the scenery tells the stop points of one station apart after a <c>#</c>.</summary>
public static class StationNames
{
/// <summary>The name up to a <c>#</c>, with the underscores of the scenery as spaces.</summary>
public static string Display(string name)
{
int hash = name.IndexOf('#');
var shown = hash >= 0 ? name[..hash] : name;
return shown.Replace('_', ' ').Trim();
}
}

View File

@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Eu07.BlackBox;
/// <summary>How the user's train kept to a station of its timetable.</summary>
public enum StationOutcome
{
/// <summary>Not reached yet in the recording.</summary>
Ahead,
/// <summary>Stopped where the timetable has a stop.</summary>
Stopped,
/// <summary>Passed where the timetable has no stop.</summary>
Passed,
/// <summary>Left a stop behind without stopping, or before the departure.</summary>
Missed
}
/// <summary>How late the user's train was at a moment [min, above 0 late], after the station named, standing there or not.</summary>
public sealed record TrainDelay(string Train, double Minutes, string Station, bool Standing);
/// <summary>
/// A row of the timetable of the user's train, with what happened there. The times are
/// minutes of the day, as in the timetable; the delays are in minutes, above 0 late.
/// </summary>
public sealed record StationProgress(
int Number,
TimetableRow Row,
StationOutcome Outcome,
double? ArrivedAt,
double? DepartedAt,
double? ArrivalClock,
double? DepartureClock,
StationEvent? Event)
{
public double? ArrivalDelay => Delay(ArrivalClock, Row.Arrival);
/// <summary>Of the departure, or of the pass for a station passed through.</summary>
public double? DepartureDelay => Delay(DepartureClock, Row.Departure);
/// <summary>Above 0 the head stood short of where it was to stop, below 0 past it [m].</summary>
public float? StopError => Outcome == StationOutcome.Stopped && Event is { } e ? e.StopError : null;
public bool? OnPlatform => Outcome == StationOutcome.Stopped ? Event?.OnPlatform : null;
/// <summary>How far the tail stood off the platform [m], when it stopped.</summary>
public double? OffPlatform => Outcome == StationOutcome.Stopped ? Event?.OffPlatform : null;
/// <summary>The difference of two times of the day in minutes, across midnight the short way.</summary>
internal static double? Delay(double? actual, float planned)
{
if (actual is not { } a || planned < 0)
return null;
double d = a - planned;
if (d > 720) d -= 1440;
if (d < -720) d += 1440;
return d;
}
/// <summary>
/// The rows of the timetable the user's train had at t, each with what the train did
/// there by the end of the recording read so far.
/// </summary>
public static IReadOnlyList<StationProgress> Of(Recording recording, double t) =>
recording.TimetableAt(t) is { } timetable ? Of(recording, timetable) : Array.Empty<StationProgress>();
/// <summary>The rows of a timetable of the recording, each with what the train did there.</summary>
public static IReadOnlyList<StationProgress> Of(Recording recording, Timetable timetable)
{
// The records of this train: from its timetable on, until another one is written. A train given its next
// timetable may come to the first station of it at once, and a recording made before the simulator wrote the
// timetable first has that station up to a look (1 s) before it: it goes with the timetable after.
int next = IndexOf(recording.Timetables, timetable) + 1;
double until = next < recording.Timetables.Count ? recording.Timetables[next].Time - 1 : double.MaxValue;
var events = recording.UserStations()
.Where(e => e.Time >= timetable.Time - 1 && e.Time < until)
.ToList();
var rows = new List<StationProgress>(timetable.Rows.Count);
for (int number = 1; number <= timetable.Rows.Count; number++)
{
var row = timetable.Rows[number - 1];
var mine = events.Where(e => e.Row == number).ToList();
var stopped = mine.FirstOrDefault(e => e.Kind == StationKind.Stopped);
var passed = mine.FirstOrDefault(e => e.Kind == StationKind.Passed);
var missed = mine.FirstOrDefault(e => e.Kind == StationKind.Missed);
if (stopped is not null)
{
double? departed = DepartureAfter(recording, stopped.Time);
rows.Add(new StationProgress(number, row, missed is not null ? StationOutcome.Missed : StationOutcome.Stopped,
stopped.Time, departed, Minutes(recording, stopped.Time),
departed is { } d ? Minutes(recording, d) : null, stopped));
}
else if (passed is not null)
rows.Add(new StationProgress(number, row, StationOutcome.Passed, null, passed.Time, null,
Minutes(recording, passed.Time), passed));
else if (missed is not null)
rows.Add(new StationProgress(number, row, StationOutcome.Missed, null, missed.Time, null,
Minutes(recording, missed.Time), missed));
else
rows.Add(new StationProgress(number, row, StationOutcome.Ahead, null, null, null, null, null));
}
return rows;
}
/// <summary>When the user's train got moving again after a stop, null while it still stood.</summary>
private static double? DepartureAfter(Recording recording, double stoppedAt)
{
var cab = recording.Cab;
for (int i = Math.Max(0, recording.CabIndexAt(stoppedAt)); i < cab.Count; i++)
if (cab[i].Time > stoppedAt && cab[i].AbsSpeedKmh > 2)
return cab[i].Time;
return null;
}
/// <summary>
/// The delay of the user's train at t, from the last station of its timetable it had come to
/// by then: of the departure or the pass once it left, of the arrival while it stands, growing
/// while it stands past the departure, and growing on the way once it is past the time it was
/// to be at the next station. Null before the first station or without a timetable.
/// </summary>
public static TrainDelay? DelayAt(Recording recording, double t)
{
if (recording.TimetableAt(t) is not { } timetable || t < timetable.Time - 1)
return null;
double clock = recording.ClockAt(t).TotalMinutes;
TrainDelay? delay = null;
var rows = Of(recording, timetable);
int reached = -1;
for (int i = 0; i < rows.Count; i++)
{
var row = rows[i];
if (row.DepartedAt is { } departed && departed <= t)
delay = new TrainDelay(timetable.Train, row.DepartureDelay ?? row.ArrivalDelay ?? delay?.Minutes ?? 0, row.Row.Station, false);
else if (row.ArrivedAt is { } arrived && arrived <= t)
{
double minutes = row.ArrivalDelay ?? delay?.Minutes ?? 0;
if (Delay(clock, row.Row.Departure) is { } overdue)
minutes = Math.Max(minutes, overdue);
delay = new TrainDelay(timetable.Train, minutes, row.Row.Station, true);
}
else
continue;
reached = i;
}
// On the way, past the time it was to be at the next station: at least that late, and more as it stays away.
if (delay is { Standing: false } && reached + 1 < rows.Count)
{
var next = rows[reached + 1].Row;
if (Delay(clock, next.Arrival >= 0 ? next.Arrival : next.Departure) is { } overdue && overdue > delay.Minutes)
delay = delay with { Minutes = overdue };
}
return delay;
}
/// <summary>The delays the user's train had at the stations it had left or stood at by t, by the name shown.</summary>
public static IReadOnlyDictionary<string, double> StationDelays(Recording recording, double t)
{
var delays = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var timetable in recording.Timetables)
{
if (timetable.Time > t)
break;
foreach (var row in Of(recording, timetable))
{
double? at = row.ArrivedAt ?? row.DepartedAt;
double? delay = row.DepartedAt <= t ? row.DepartureDelay ?? row.ArrivalDelay : row.ArrivalDelay;
if (at <= t && delay is { } d)
delays[StationNames.Display(row.Row.Station)] = d;
}
}
return delays;
}
private static int IndexOf(IReadOnlyList<Timetable> timetables, Timetable timetable)
{
for (int i = 0; i < timetables.Count; i++)
if (ReferenceEquals(timetables[i], timetable))
return i;
return -1;
}
private static double Minutes(Recording recording, double t) => recording.ClockAt(t).TotalMinutes;
}