This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Train simulation v3

Modeling and API design of train simulations

This work is pending implementation, and has not yet been adjusted to reflect potential required adjustments.

These articles describe the design of the new train simulation system.

This system should be simpler and more stable than the current one, and should enable more advanced features in the future.

1 - Overview

This work is pending implementation, and has not yet been adjusted to reflect potential required adjustments.

After two years of extending a fairly simple simulation engine, it appeared that fundamental changes are required to meet expectations.

System requirements

The new system is expected to:

  1. handle reactions to signaling
  2. handle rich train state (pantograph position, battery state)
  3. allow for different margin algorithms
  4. integrate driver behavior properties
  5. be easy to integrate with timetable v2
  6. handle both:
    • simulations of a full trip, with a complete known path, possibly following a schedule
    • simulations where the path is discovered incrementally
  7. provide a low-level API, usable independently

In the long-term, this system is also expected to:

  • be used to drive multi-train simulations
  • handling switching rolling stock at stops

Concepts

flowchart TD
subgraph Input
    InitTrainState[initial train state]
    PathPhysicsProps[path physics properties]
    AbstractDrivingInstructions[abstract driving instructions]
    TargetSchedule[target schedule]
end

DrivingInstructionCompiler([driving instruction compiler])
ConcreteDrivingInstructions[driving instructions + limits]
ScheduleController([schedule controller])
DriverBehaviorModule([driver behavior module])

TargetSchedule --> ScheduleController
ScheduleController -- adjusts slowdown coefficient --> DriverBehaviorModule
AbstractDrivingInstructions --> DrivingInstructionCompiler
PathPhysicsProps --> DrivingInstructionCompiler
ScheduleController -- tracks train state --> TrainSim

DriverBehaviorModule -- makes decisions --> TrainSim
ConcreteDrivingInstructions --> DriverBehaviorModule
DrivingInstructionCompiler --> ConcreteDrivingInstructions

InitTrainState --> ScheduleController

TrainSim --> SimResults

TrainSim([train simulator])
SimResults[simulation result curve]

Target schedule

The target schedule is a list of target arrival times at points specified along the path. To respect the schedule, the train may have to not use its maximum traction.

Train state

The train state is a vector of properties describing the train at a given point in time.

  • position
  • speed
  • position of pantographs
  • driver reaction times ?
  • battery state ?
  • time elapsed since the last update

Driving instructions

Driving instructions model what the train has to do along its path. They are linked to conditions on their application, and can interact with each other. They are generated using domain constraints such as speed limits or stops.

See the dedicated page for more details.

Path properties

Path properties are the physical properties of the path, namely elevation, curves and electrification.

Driver behavior module

The driver behavior modules update the train state based on:

  • the current train state
  • the path properties
  • the driving instructions
  • a slowdown coefficient (1 = no slowdown, 0 = full stop)

The train state changes should be physically realistic.

See the dedicated page for more details.

Schedule controller

The schedule controller manages the slowdown coefficient given to the driver behavior module in order to respect the target schedule.

It adjusts the slowdown coefficient iteratively, using a dichotomous search, re-simulating the train behavior between two time-targeted points.

Simulation results

The output of the simulation is the list of train states at each time step.

Design overview

The main idea of the new train simulator is to have a simulation which is computed step by step and not post-processed. This would ensure the physical consistency of the simulation.

The challenge is then to add ways to lose some time, in order to respect the target schedule.
This is done by iterating over the sections between two scheduled points, while adjusting a slowdown factor. This slowdown factor would be used to control how the driver behavior module would lose time while still being physically realistic.
See the driver behavior module dedicated page for more details.

In order to accommodate an infrastructure which could change with time (like signals), we introduce driving instructions. These instructions are generated from the path properties and the target schedule, and are used to update the train state. Instructions can be conditional, and can interact with each other.
The algorithm is described in detail in the dedicated page.

Algorithm flow chart

Design limits

  • trains do not anticipate margin transitions: only the next target arrival time matters for finding the slowdown factor

2 - Prior art

The current implementation has a number of shortcomings making it pretty much impossible to evolve to meet current system requirements. It also has a number of less severe flaws, such as the over-reliance on floating point, especially for input and output.

The previous implementation cannot be changed to:

  • react to signaling, as constraints stay the same as the simulation evolves
  • handle rich train state vectors, due to the way margins are implemented
  • be usable for both incremental simulation and batch

These limitations are the primary reasons for this redesign.

Margins

  • are defined as post-processing filter passes on simulation results. This has a number of undesirable side effects:

    • margin algorithms produce the final simulation results. They may produce physically unrealistic simulations results

    • because margins are applied after the simulation, the simulation can’t adjust to impossible margin values. Thus the simulation fails instead of giving a “best effort” result.

    • margin algorithms have no choice but to piece together results of different simulations:

      • engineering margins are defined such that their effect has to be entirely contained within their bounds. even though it’s a desirable property, it means that simulations become a multi-pass affair, with no obvious way of keeping train behavior consistent across passes and boundaries.
      • this can only be done if the train state is entirely described by its location and speed, otherwise simulation results cannot be pieced together.
      • piecing together simulation results is very hard to execute reliably, as there are many corner cases to be considered. the algorithm is quite brittle.
  • how much time should be lost and where isn’t defined in a way that makes scheduled points implementation easy

  • when a transition between two margin values occurs, slow downs occur before value changes, and speed ups after value changes. This is nice in theory, because it makes the graphs look nicer. The downside is that it makes margin values interdependent at each slow-down, as how much speed needs to be lost affects the time lost in the section.

Input modeling

With the previous implementation, the simulation takes sequence of constraint position and speed curves as an input (continuous in position, can be discontinuous in speed), and produces a continuous curve.

The output is fine, but the input is troublesome:

  • braking curves have to be part of constraint curves
  • these constraint curves don’t have a direct match with actual constraints, such as speed limits, stops, or reaction to signal
  • constraints cannot evolve over time, and cannot be interpreted differently depending on when the train reached these constraints
  • constraints cannot overlap. the input is pre-processed to filter out obscured constraints

3 - Driving instructions

Driving instructions model what the train has to do, and under what conditions. Driving instructions are generated using domain constraints such as:

  • unsignaled line speed limits
  • permanent signaled speed limits
  • temporary speed limits
  • dynamic signaling:
    • block / moving block
    • dynamically signaled speed restrictions
  • neutral zones
  • stops
  • margins

There are two types of driving instructions:

  • Abstract driving instructions model the high-level, rolling stock independent range of acceptable behavior: reach 30km/h at this location
  • Concrete driving instructions model the specific range of acceptable behavior for a specific rolling stock, using limit curves: don’t go faster than this curve
flowchart TD
Constraint[constraint]
AbstractDrivingInstruction[abstract driving instruction]
ConcreteDrivingInstruction[concrete driving instruction]
RollingStockIntegrator[rolling stock integrator]
Compiler([compiler])

Constraint -- generates one or more --> AbstractDrivingInstruction
AbstractDrivingInstruction --> Compiler
RollingStockIntegrator --> Compiler
Compiler --> ConcreteDrivingInstruction

After reviewing the design document, the necessity to distinguish between abstract and concrete driving instructions was questioned.

Indeed, it isn’t clear whether the limit curves are used for the driving instructions interpretation algorithm. If it isn’t, the computation of limit curves could be moved inside the driver behavior module.

TODO: remove this message or fix the design document after implementation.

Interpreting driving instructions

During the simulation, driving instructions are partitioned into 4 sets:

  • PENDING instructions may apply at some point in the future
  • RECEIVED instructions aren’t enforced yet, but will be unless overridden
  • ENFORCED instructions influence train behavior
  • DISABLED instructions don’t ever have to be considered anymore. There are multiple ways instructions can be disabled:
    • SKIPPED instructions were not received
    • RETIRED instructions expired by themselves
    • OVERRIDDEN instructions were removed by another instruction
flowchart TD

subgraph disabled
    skipped
    retired
    overridden
end

subgraph active
    received
    enforced
end

pending --> received
pending --> skipped
received --> enforced
received --> overridden
enforced --> retired
enforced --> overridden

These sets evolve as follows:

  • when an integration steps overlaps a PENDING instruction’s received condition, it is RECEIVED and becomes a candidate to execution
    • existing instructions may be OVERRIDDEN due to an override_on_received operation
  • if an instruction cannot ever be received at any future simulation state, it transitions to the SKIPPED state
  • when simulation state exceeds an instruction’s enforcement position, it becomes ENFORCED. Only enforced instructions influence train behavior.
    • existing instructions may be OVERRIDDEN due to an override_on_enforced operation
  • when simulation state exceeds an instruction’s retirement position, it becomes RETIRED

Overrides

When an instruction transitions to the RECEIVED or ENFORCED state, it can disable active instructions which match some metadata predicate. There are two metadata attributes which can be relied on for overrides:

  • the kind allows overriding previous instructions for a given domain, such as spacing or block signaled speed limits
  • the rank can be used as a “freshness” or “priority” field. If two instructions overriding each other are received (such as when a train sees two signals), the rank allows deciding which instruction should be prioritized.

This is required to implement a number of signaling features, as well as stops, where the stop instruction is overridden by the restart instruction.

Data model

struct ReceivedCond {
    position_in: Option<PosRange>,
    time_in: Option<TimeRange>,
}

struct InstructionMetadata {
    // state transitions
    received_when: ReceivedCond,
    enforced_at: Position,
    retired_at: Option<Position>,

    // instruction metadata, used by override filters. if an instruction
    // has no metadata nor retiring condition, it cannot be overridden.
    kind: Option<InstructionKindId>,  // could be SPACING, SPEED_LIMIT
    rank: Option<usize>,

    // when the instruction transitions to a given state,
    // instructions matching any filter are overridden
    override_on_received: Vec<OverrideFilter>,
    override_on_enforced: Vec<OverrideFilter>,
}

enum AbstractInstruction {
    NeutralZone,
    SpeedTarget {
        at: Position,
        speed: Speed,
    }
}

enum ConcreteInstruction {
    NeutralZone,
    SpeedTarget {
        braking_curve: SpeedPosCurve,
    },
}

struct OverrideFilter {
    kind: InstructionKindId,
    rank: Option<(RankRelation, usize)>,
}

enum RankRelation {
    LT, LE, EQ, GE, GT
}

Design decisions

Lowering constraints to an intermediate representation

Early on, we started making lists of what domain constraints can have an impact on train behavior. Meanwhile, to simulate train behavior, we figured out that we need to know which constraints apply at any given time.

There’s a fundamental tension between these two design constraints, which can be resolved in one of two ways:

  • either treat each type of constraint as its own thing during the simulation
  • abstract away constraints into a common representation, and then simulate that

❌ Distinct constraint types

When we first started drafting architecture diagrams, the train simulation API directly took a bunch of constraint types as an input. It brought up a number of issues:

  • the high diversity of constraint types makes it almost impossible to describe all interactions between all constraint types
  • the domain of some of these interactions is very complex (block signaling)
  • when simulating, it does not seem to matter why a constraint is there, only what to do about it

We couldn’t find clear benefits to dragging distinctions between constraint types deep into the implementation.

❌ Internal constraint types abstraction

We then realized that abstracting over constraint types during simulation had immense benefits:

  • it allows expressing requirements on what constraints need to be enforceable
  • it greatly simplifies the process of validating constraint semantics: instead of having to validate interactions between every possible type of constraints, we only have to validate that the semantics of each constraint type can be transferred to the abstract constraint type

We decided to explore the possibility of keeping constraint types distinct in the external API, but lowering these constraints into an intermediary representation internally. We found a number of downsides:

  • the public simulation API would still bear the complexity of dealing with many constraint types
  • there would be a need to incrementally generate internal abstracted constraints to support the incremental API

✅ External constraint types abstraction

We tried to improve over the previous proposal by moving the burden of converting many constraints into a common abstraction out of the simulation API.

Instead of having many constraint types as an input, the simulation API takes a collection of a single abstract constraint type. The task of converting domain constraints to abstract driving instructions is left to the API user.

We found that doing so:

  • reduces the API surface of the train simulation module
  • decouples behavior from constraint types: if a new constraint type needs to be added, the simulation API only needs expansion if the expected behavior expected for this constraint isn’t part of the API.

Interpreting driving instructions

As the train progresses through the simulation, it reacts according to driving instructions which depend on more than the bare train physics state (position, time, and speed):

  • the behavior of a train on each block depends on the state of the last passed block signal
  • if a train encounters a yellow light, then a red light, stops before the red light, and the red light turns green, the train may have to keep applying the driving instruction from the yellow signal until the green light is passed

Thus, given:

  • set of all possible driving instructions (alongside applicability metadata)
  • the result of previous integration steps (which may be extended to hold metadata)

There is a need to know what driving instructions are applicable to the current integration step.

Overrides are a way of modeling instructions which disable previous ones. Here are some examples:

  • if a driver watches a signal change state, its new aspect’s instruction might take precedence over the previous one
  • as block signaling slows a train down, new signals can override instructions from previous signals, as they encode information that is more up to date

We identified multiple filtering needs:

  • overrides happen as a given kind of restriction is updated: SPACING instructions might override other SPACING instructions, but wish to leave other speed restrictions unaffected
  • as multiple block signals can be visible at once, there’s a need to avoid overriding instructions of downstream signals with updates to upstream signals

We quickly settled on adding a kind field, but had a lengthy discussion over how to discriminate upstream and downstream signals. We explored the following options:

  • ❌ adding source metadata, which was rejected as it does not address the issue of upstream / downstream
  • ❌ adding identifiers to instructions, and overriding specific instructions, which was rejected as it makes instruction generation and processing more complex
  • ✅ adding some kind of priority / rank field, which was adopted

4 - Driver behavior modules

Design specs

General pitch

Driver behavior modules are responsible for making driving decisions. Its main responsibility, given the state of the train and an instruction, is to react to the instruction. This reaction is expressed as a new train state.

To perform this critical task, it needs access to additional context:

  • the physical properties of the path, which are used to make coasting decisions, and to model natural forces.
  • a slowdown coefficient, which is used to adjust how much the train is slowed down compared to a full power simulation.

The driver behavior modules are supposed to have different implementations, which would interpret the slow down coefficient differently.

API

One driver behavior module is instantiated per driving instruction. It takes at initialization:

  • a slowdown coefficient
  • the driving instruction
  • the path properties

It has two public methods:

  • enact_decision(current_state: TrainState, t: float) -> (TrainState, float)

    Which returns what the next train state would be if there was only this one instruction to follow, and the time delta to reach this state.

  • truncate_integration_step(current_state: TrainState, potential_state: TrainState, t: float, dt: float) -> (TrainState, float)

    Which returns a state and time delta which respects the instruction, and is as close as possible to the potential state.

Loop

At a given train state, we know which driving instructions are enforced.

For each enforced driving instruction, we query the corresponding driver behavior module.

This gives a set of different train states. From this, we coalesce a single train state which respects all instructions.

To do so, we:

  1. Find the states which are most constraining for “constraining properties” (speed and pantograph state).
  • Most constraining state regarding speed is the one with the lowest acceleration (taking sign into account).
  • Most constraining state regarding pantograph state is the one which sets the pantograph down the earliest.
  1. Interpolate the constraining states to the smallest dt they are associated with.
  2. Merge the constraining states into a single potential state:
  • for speed, we take the lowest acceleration
  • for pantograph state, we take the earliest pantograph state
  • other properties should be identical
  1. Submit the potential state for truncation to all driver behavior modules, chaining the outputs of truncate_integration_step.

There is a heavy underlying assumption that “constraining properties” can be combined in a new state which is valid. This underlies the step 3. It is not yet clear if this assumption will always be valid in the future.

Also: what component should be in charge of instantiating all the driver behavior modules with the right implementation ?

Here is a schema summarizing the process:

Driver behavior modules

A short case for why step 4 is needed.

most constraining state overshoots

Here the constraints are in red, and the next state chosen by the driver behavior modules are in black.

In this example, the most constraining state is A, since it’s the one which accelerates the least. However, it overshoots constraint B, thus we need to select the state which respects both constraints.

Decision process

Unifying driver behavior and margin distribution algorithms

When this design project started, driver behavior was left completely undefined. We assumed that a set of driving instructions can be unambiguously interpreted given a starting point. This assumption was then decided to be relied on to search which margin speed ceiling yields expected arrival times.

We also knew this assumption to be false: there are many ways instructions can be interpreted. Worse yet, different use cases for OSRD have different needs:

  • some users might want to reproduce existing timetables, which exhibit naive driver behavior: aggressive accelerations, aggressive breaking behavior.
  • some users want to evaluate the feasibility of timetables, and thus want somewhat realistic driver behavior, with less aggressive acceleration and cautious breaking behavior.

To resolve this tension, we thought of adding support for pluggable driver behavior. Doing so, however, would create two ways a timetable can be loosened (loose time):

  • lowering the margin speed ceiling
  • making driver behavior less aggressive

Let’s say we want to loosen the timetable by 1 minute on a given section. It could be achieved by:

  • lowering the speed ceiling using margins while keeping aggressive driver behavior
  • making driving behavior very conservative, but using no margins at all
  • lowering the speed ceiling a little, and making driving behavior a little more conservative
  • any other combination of the two factors

This is an issue, as it might make simulation results unstable: because there possibly are many ways to achieve the requested schedule, it would be very challenging to reliably choose a solution which matches expectations.

  • ❌ We considered ignoring the issue, as driver behavior was initially out of the scope of this design project. We decided not to, as we expected the cost of making later changes to integrate driver behavior to be significant.
  • ✅ We decided to avoid this shortcoming by making margin distribution part of driver behavior. Driver behavior modules are controlled by a slowdown coefficient between 0 (loose as much time as shall be achieved) and 1 (loose no time).

Interfacing driver behavior, driving instructions, and numerical integration

Driver behavior can be formally modeled as a local decision function f, which takes the state of the train as an input, including position and speed, and returns an acceleration.

To best integrate this acceleration over the given time step, it is best not to use only the acceleration at (t). Since it may vary a lot along [t, t+dt]. To approximate the acceleration within this interval, we would need a better estimator, using a numerical method such as RK4. Such estimator then needs to call f multiple times.

A number of questions came up:

  • should numerical integration within the driver behavior module, or outside
  • are driver behavior modules queried about their reaction to a specific instruction, or in general
  • does the driver behavior module return decisions, or parameters used to make decisions (such as curves)
  • if decisions are returned, is it a force, an acceleration, or a new state
  • if a new state is returned, how to deal with heterogenous time steps
  • do we check decisions for correctness? that is, if a decision causes the train to overshoot a limit curve, do we do anything?

Do we have a single DBM for all driving instructions, or one per driving instruction?

We identified that this API choice shouldn’t constrain the implementation. We decided to go the conservative route and have one DBM per driving instructions as it reduces the API surface and relieves DBM from the responsibility of finding the most restrictive instruction.

How do we prevent overshooting?

We identified that DBMs need the ability to follow internal target curves (distinct from limit curves).

To do so we could either:

  1. Have a way to short-circuit our integration scheme, to snap to target curves without overshooting.
  2. Accept oscillations around target curves (and thus overshooting).
  3. Setup a feedback loop mechanism to avoid overshooting.

We decided that only the first option was desirable.

The design choices then are:

❌ Make the DBM as close as possible to a decision function

Then the DBM would not be aware of the time step it is called with, and would return an acceleration. Then the module should expose two methods:

  • One for taking decisions, akin to f.
    Called several times depending on the integration method.

  • One for correcting an integration step (i.e. a time step and a new state), if it happened to overshoot its internal goal curves (for example MARECO which sets it’s own speed limits).
    Called on the integration step results from this DBM, and the other DBMs integration step results.

✅ The DBM returns a new state

The module would then expose two methods:

  • One for taking decisions, which, given a train state and a desired/maximum time step, returns a new state (which does not overshoot) and a new current time.

  • One for correcting an integration step (i.e. a time step and a new state), if it happened to overshoot its internal goal curves (for example MARECO which sets it’s own speed limits).
    Called only on other DBMs integration step results.

How do we combine the decisions from all DBMs?

  1. For each state property, find the most constraining value and dt.
  2. Find the smallest dt amongst constraining properties. Interpolate remaining properties to this dt, to build a provisional state.
  3. Submit this provisional state for truncation to all DBMs and take the truncation with the smallest dt.

To understand how this algorithm is designed, we need to consider two example cases:

  • For steps 1 and 2: if a neutral zone and a breaking instruction overlap, both are most constraining to different state properties: the neutral zone affects pantograph state, and the breaking instruction affects speed. The final state has to be a combination of both.
  • For step 3: We need to truncate integration steps to avoid overshoots, and thus avoid the need for feedback loops. Ideally, we want to truncate to the exact overshoot location. This overshoot location is not the same as the initial dt for the overshot constraint.

Should truncate_integration_step depend on the driver behavior module?

Yes: DBMs may use internal representations that the new state should not overshoot. For instance, when passed a driving instruction with a speed limit of 60km/h, a DBM wishing to lose time may reduce the speed to 50 km/h.