Introduction

In this chapter, I analyse the common elements of all TrackerEvents and build the handle_tracker_event module. This module exports some general-purpose functions that facilitate the processing of these events.

Exported Memebers

Constants

  • INTERVALS_BASE

Functions

  • calc_realtime_index

Events

In the previous module (<<Chapter 1 - Summarising Replays>>), I discuss how sc2reader packs all information necessary to reproduce a match in a Replay object. I also showed how to obtain some descriptive information about the game from this object and the Players linked to it.

Here, I review the data contained in the Replay's Event objects. These objects are crucial, given how

:All of the gameplay and state information contained in the replay is packed into events. (Kim, 2015, p. 22) The main types of events registered by sc2reader are:

  • Game Events: Human actions and certain triggered events.
  • Message Events: Message and Pings to other players.
  • Tracker Events: Game state information.

Additionally, users can access these objects through the Replay objects' events attribute.

For example, I can extract a list of all the events in a replay as follows:

rps_path = Path("./test_replays")
game_path = str(rps_path/"Jagannatha LE.SC2Replay")
single_replay = sc2reader.load_replay(game_path)
single_replay

# Extract all events
match_events = [event for event in single_replay.events]

# Separate the events by type
tracker_e = [event for event in single_replay.events 
            if isinstance(event, sc2reader.events.tracker.TrackerEvent)]

game_e = [event for event in single_replay.events 
         if isinstance(event, sc2reader.events.game.GameEvent)]

message_e = [event for event in single_replay.events 
            if isinstance(event, sc2reader.events.message.MessageEvent)]

print(f'The match contains {len(match_events)} events total')
print(f'The match contains {len(tracker_e)} events total')
print(f'The match contains {len(game_e)} events total')
print(f'The match contains {len(message_e)} events total')
The match contains 4988 events total
The match contains 830 events total
The match contains 4143 events total
The match contains 15 events total

In the following test, I check that the sum of the Tracker, Message and Game events effectively make up the whole of the events registered by the system.

ft.equals((len(tracker_e) + len(game_e) + len(message_e)), len(match_events))
True

Tracker Events

TrackerEvent objects store information about the state of the game. Users can use them to track various aspects of a player's performance. Here, I review different types of TrackerEvents, their attributes, and some of the issues I need to consider regarding each class.

In a Replay, one will typically found several kinds of TrackerEvents. In the following code, I build a set containing these types using a sample replay. This list shows all the TrackerEvent's classes.

tracker_e_types = {type(event) for event in single_replay.events 
             if isinstance(event, sc2reader.events.tracker.TrackerEvent)}

tracker_e_types
{sc2reader.events.tracker.PlayerSetupEvent,
 sc2reader.events.tracker.PlayerStatsEvent,
 sc2reader.events.tracker.UnitBornEvent,
 sc2reader.events.tracker.UnitDiedEvent,
 sc2reader.events.tracker.UnitDoneEvent,
 sc2reader.events.tracker.UnitInitEvent,
 sc2reader.events.tracker.UnitPositionsEvent,
 sc2reader.events.tracker.UnitTypeChangeEvent,
 sc2reader.events.tracker.UpgradeCompleteEvent}

PlayerSetupEvent

These are basic events used by the system to organise players at the start of the match. However, since most of the data they stored exists elsewhere, I will not use this type of events.

setup_e = [event for event in single_replay.events 
           if isinstance(event, sc2reader.events.tracker.PlayerSetupEvent) 
           and event.pid == 1]

PlayerStatsEvents

To extract some initial information regarding the game's flow, one can consult the instances of sc2reader.events.tracker.PlayerStatsEvent.

These events are generated for each player every ten seconds, regardless of the player's presence in the match. In other words, if one is analysing a game of more than two players and one leaves the match, the game still generates PlayerStatsEvents for that player up until the end of the match.

Following these rules, one can estimate the number of PlayerStatsEvents a match should have. For example, the following sample match lasted 590 seconds. Hence, there should be 59 PlayerStatEvents per player, plus two or three for start and end events. Similarly, taking into account the game's frames-per-second, one could estimate the duration of the match in frames.

Bellow, I show how one can get this information from a Replay object. This information is critical for debugging and managing these events afterwards.

rpl_length = single_replay.game_length.seconds
rpl_fps = single_replay.game_fps #frames-per-second
rpl_frame_length = single_replay.game_length.seconds * single_replay.game_fps

print(f'{"Game length in seconds:":<30}{rpl_length:>6}')
print(f'{"Game frames per second:":<30}{rpl_fps:>6}')
print(f'{"Total frames in the match:":<30}{rpl_frame_length:>6}')
Game length in seconds:          590
Game frames per second:         16.0
Total frames in the match:    9440.0

Likewise, in the following code, I extract the PlayerStatEvent related to one player in the test match I am analysing.

p_one_state = [event for event in match_events 
               if isinstance(event, sc2reader.events.tracker.PlayerStatsEvent) 
               and event.pid == 1]

print(f'Number of PlayerStatsEvents for player 1: {len(p_one_state)}')
Number of PlayerStatsEvents for player 1: 85

This information shows that there is a discrepancy in the number of PlayerStatsEvenst there should be (around 60 for the sample match) and the actual number there is (85).

Similarly, looking at the seconds and frame count registered in the last event, it is clear that they do not match the duration of the game (590 seconds, 9440 frames).

print(f'Second recorded in the last event:', end=' ')
print(f'{p_one_state[len(p_one_state)-1].second:>6}')

print(f'Frame recorded in the last event:', end=' ')
print(f'{p_one_state[len(p_one_state)-1].frame:>7}')
Second recorded in the last event:    826
Frame recorded in the last event:   13224

Fortunately, these mismatches are consistent for all the players. For example, look at the number of events, the last second and frame registered for Player 2. This player has one event less because they were the match's winner.

p_two_state = [event for event in match_events 
               if isinstance(event, sc2reader.events.tracker.PlayerStatsEvent) 
               and event.pid == 2]

print(f'Number of events recorded for p2: {len(p_two_state)}')

print(f'Second recorded in the last event:' , end=' ')
print(f'{p_two_state[len(p_two_state)-1].second}')

print(f'Frame recorded in the last event:', end=' ')
print(f'{p_two_state[len(p_two_state)-1].frame}')
Number of events recorded for p2: 84
Second recorded in the last event: 826
Frame recorded in the last event: 13224

Unit trackers

The following events contain data regarding the different units in the game. These units include player-controlled structures and troops, and some non-player controlled elements. However, in this case, I am primarily interested in the units controlled by players.

UnitBornEvent

Event generated any time a unit enters the game through a production building. This type of event stores the following useful attributes:

  • second (int): registered time of birth in seconds. Note that this timestamp does not match the match's duration. This inconsistency means that one must correct the time index of the event. (see calc_realtime_index function)
  • unit_type_name (str): type of the unit.
  • location (tuple(x, y)): tuple of ints that indicate the unit's x and y coordinates in the map.
  • control_pid (int): player id in the match.
  • unit: points to the unit object, which stores helpful information such as the minerals, vespene, and supply cost of the unit type (see Kim, 2015, p. 30).

The following code illustrates some uses of these attributes:

# of a single player.
UnitBorn_e = [event for event in match_events 
              if isinstance(event, sc2reader.events.tracker.UnitBornEvent) 
              and event.control_pid == 1] 

print(f'{"The recorded time of birth of a unit:":<40}', end=' ')
print(f'{UnitBorn_e[62].second:>10}')

print(f'{"The supply cost of the unit type:":<40}', end=' ')
print(f'{UnitBorn_e[62].unit.supply:>10}')

print(f'{"The unit type:":<40} {UnitBorn_e[62].unit_type_name:>10}')

print(f'{"The birth x, y location of the unit:":<40} ', end=' ')
print(f'{str(UnitBorn_e[62].location):>10}')
The recorded time of birth of a unit:           731
The supply cost of the unit type:                 4
The unit type:                             Immortal
The birth x, y location of the unit:        (72, 40)

To group all the game's UnitBornEvents, I can use the control_pid attribute, which points to the Participant that owns the unit.

With this grouping, I can start to compose that player's build order. That is their sequence of built units during the game (see <<Chapter 4 - Parsing Build Orders>>).

Additionally, having grouped all the UnitBornEvent instances related to a particular player, I notice the following pattern:

  • The first 15 units born (indexes 0-14) are Beacons sc2reader uses for tracking. I will ignore these objects.
  • Unit 15 is the starting base building (Nexus, CommandCenter, Hatchery).
  • Units 16-27 are the first worker units of each player.

This pattern means I should only consider whatever units are born after index 27 to track the player's build order.

The following code illustrates this pattern.

UnitBorn_e = [event for event in match_events
              if isinstance(event, sc2reader.events.tracker.UnitBornEvent) 
              and event.control_pid == 1]

born_order = {k:(k, event.unit_type_name, f'second: {event.second}') 
             for k, event in enumerate(UnitBorn_e) if k <=30}

pprint(born_order, compact=True)
{0: (0, 'BeaconArmy', 'second: 0'),
 1: (1, 'BeaconDefend', 'second: 0'),
 2: (2, 'BeaconAttack', 'second: 0'),
 3: (3, 'BeaconHarass', 'second: 0'),
 4: (4, 'BeaconIdle', 'second: 0'),
 5: (5, 'BeaconAuto', 'second: 0'),
 6: (6, 'BeaconDetect', 'second: 0'),
 7: (7, 'BeaconScout', 'second: 0'),
 8: (8, 'BeaconClaim', 'second: 0'),
 9: (9, 'BeaconExpand', 'second: 0'),
 10: (10, 'BeaconRally', 'second: 0'),
 11: (11, 'BeaconCustom1', 'second: 0'),
 12: (12, 'BeaconCustom2', 'second: 0'),
 13: (13, 'BeaconCustom3', 'second: 0'),
 14: (14, 'BeaconCustom4', 'second: 0'),
 15: (15, 'Nexus', 'second: 0'),
 16: (16, 'Probe', 'second: 0'),
 17: (17, 'Probe', 'second: 0'),
 18: (18, 'Probe', 'second: 0'),
 19: (19, 'Probe', 'second: 0'),
 20: (20, 'Probe', 'second: 0'),
 21: (21, 'Probe', 'second: 0'),
 22: (22, 'Probe', 'second: 0'),
 23: (23, 'Probe', 'second: 0'),
 24: (24, 'Probe', 'second: 0'),
 25: (25, 'Probe', 'second: 0'),
 26: (26, 'Probe', 'second: 0'),
 27: (27, 'Probe', 'second: 0'),
 28: (28, 'Probe', 'second: 18'),
 29: (29, 'Probe', 'second: 69'),
 30: (30, 'Probe', 'second: 133')}

This information can also serve as the basis for a unit count (see <<Chapter 4 - Parsing Build Orders>>).

For example, the following code produces a draft of the player's final unit tally.

counts = dict()

for event in UnitBorn_e:
    counts.setdefault(event.unit_type_name, 0)
    counts[event.unit_type_name] += 1
    
counts
{'BeaconArmy': 1,
 'BeaconDefend': 1,
 'BeaconAttack': 1,
 'BeaconHarass': 1,
 'BeaconIdle': 1,
 'BeaconAuto': 1,
 'BeaconDetect': 1,
 'BeaconScout': 1,
 'BeaconClaim': 1,
 'BeaconExpand': 1,
 'BeaconRally': 1,
 'BeaconCustom1': 1,
 'BeaconCustom2': 1,
 'BeaconCustom3': 1,
 'BeaconCustom4': 1,
 'Nexus': 1,
 'Probe': 45,
 'Stalker': 3,
 'Sentry': 1,
 'Immortal': 4}

UnitInitEvent

This type of event is a complement to the UnitBonrEvent, UnitDoneEvent and the UnitTypeChangeEvent.

These events exists because not all units enter the game via production structures. Instead, buildings (other than the initial base), warped units, mutated units, and others are instantiated through the UnitInitEvent with 0 life. Afterwards, they are built over time. During this building time, players may lose or cancel this unit or buildings under construction.

The information extracted from these type of events is similar to the information in the UnitBornedEvent. Here, again, I will use the following attributes:

  • second (int): registered time of birth in seconds. This attribute requires the same consideration as in the previous cases. (see calc_realtime_index function)
  • unit_type_name (str): type of the unit.
  • location (tuple(x, y)): tuple of ints that indicate the unit's x and y coordinates in the map.
  • control_pid (int): player id in the match.
  • unit: points to the unit object
UnitInit_e = [event for event in match_events 
              if isinstance(event, sc2reader.events.tracker.UnitInitEvent) 
              and event.control_pid == 1]

init_order = {k:(event.unit_type_name, f'second: {event.second}') 
             for k, event in enumerate(UnitInit_e)}

init_order
{0: ('Pylon', 'second: 47'),
 1: ('Pylon', 'second: 74'),
 2: ('Gateway', 'second: 91'),
 3: ('Gateway', 'second: 100'),
 4: ('Forge', 'second: 111'),
 5: ('CyberneticsCore', 'second: 170'),
 6: ('Assimilator', 'second: 182'),
 7: ('Assimilator', 'second: 188'),
 8: ('Pylon', 'second: 203'),
 9: ('Pylon', 'second: 224'),
 10: ('PhotonCannon', 'second: 241'),
 11: ('PhotonCannon', 'second: 244'),
 12: ('PhotonCannon', 'second: 260'),
 13: ('PhotonCannon', 'second: 264'),
 14: ('Pylon', 'second: 316'),
 15: ('Nexus', 'second: 322'),
 16: ('RoboticsFacility', 'second: 368'),
 17: ('Assimilator', 'second: 381'),
 18: ('Assimilator', 'second: 385'),
 19: ('RoboticsFacility', 'second: 408'),
 20: ('RoboticsFacility', 'second: 411'),
 21: ('Zealot', 'second: 479'),
 22: ('Zealot', 'second: 487'),
 23: ('Sentry', 'second: 540'),
 24: ('Pylon', 'second: 651'),
 25: ('Pylon', 'second: 662'),
 26: ('Nexus', 'second: 682'),
 27: ('PhotonCannon', 'second: 693'),
 28: ('PhotonCannon', 'second: 702'),
 29: ('Zealot', 'second: 705'),
 30: ('Zealot', 'second: 706'),
 31: ('Stalker', 'second: 755'),
 32: ('Stalker', 'second: 755'),
 33: ('Stalker', 'second: 803'),
 34: ('Stalker', 'second: 804')}

Also, given that these units can be buildings, troops or others, we can use this list to illustrate the use of the unit.is_army, unit.is_building, unit.is_worker attributes.

print(f'Unit_name: {UnitInit_e[20].unit.name}', end=' ') 
print(f'is building: {UnitInit_e[20].unit.is_building}')

print(f'Unit_name: {UnitInit_e[20].unit.name}', end=' ') 
print(f'is army: {UnitInit_e[20].unit.is_army}')

print(f'Unit_name: {UnitInit_e[20].unit.name}', end=' ') 
print(f'is worker: {UnitInit_e[20].unit.is_worker}')
Unit_name: RoboticsFacility is building: True
Unit_name: RoboticsFacility is army: False
Unit_name: RoboticsFacility is worker: False
print(f'Unit_name: {UnitInit_e[31].unit.name}', end=' ')
print(f'is building: {UnitInit_e[31].unit.is_building}')

print(f'Unit_name: {UnitInit_e[31].unit.name}', end=' ')
print(f'is army: {UnitInit_e[31].unit.is_army}')

print(f'Unit_name: {UnitInit_e[31].unit.name}', end=' ')
print(f'is worker: {UnitInit_e[31].unit.is_worker}')
Unit_name: Stalker is building: False
Unit_name: Stalker is army: True
Unit_name: Stalker is worker: False

UnitDoneEvent

This type of event is a complement to the UnitInitEvent.

After a UnitInitEvent and the unit's build time the unit comes into the game. At this point the unit triggers a UnitDoneEvent.

This type of event has much less attributes associated to it than the UnitBonrEvent and UnitInitEvent. In this case, I will use the second and unit attributes.

If I want to use these events to construct the build order based on completed units and not just only in units that where initiated, I need to use some of the unit object's attributes to complete the information. For example, to group the units per player I can use the unit.owner.pid attribute.

UnitDone_e = [event for event in match_events 
              if isinstance(event, sc2reader.events.tracker.UnitDoneEvent)
             and event.unit.owner.pid == 2]

The following code illustrates how an event's unit can offer some complementary attributes.

sample_unit = UnitDone_e[2].unit

print(f'Event\'s execution time: {UnitDone_e[2].second}')
print(f'Event\'s execution frame: {UnitDone_e[2].frame}')
print(sample_unit.name)
print(f'Init frame: {sample_unit.started_at}')
print(f'Game-enter frame: {sample_unit.finished_at}')
print(f'Owner: {str(sample_unit.owner)}')
Event's execution time: 124
Event's execution frame: 1999
Refinery
Init frame: 1519
Game-enter frame: 1999
Owner: Player 2 - MxChrisxM (Terran)

UpgradeCompleteEvent

Other than constructing buildings and training troops, the third way players can spend their resources is by researching upgrades. Each time a player completes an upgrade it generates a UpgradeCompleteEvent.

I will use the following features from this event type:

  • pid (int): player id in the match.
  • upgrade_type_name (str): name of the upgrade.
  • second (int): registered time of birth in seconds. This attribute requires the same consideration as in the previous cases. (see calc_realtime_index function)
Upgrades = [event for event in single_replay.events 
           if isinstance(event, sc2reader.events.tracker.UpgradeCompleteEvent) 
           and event.pid == 2]

upgrade_order = {k:(f'second: {event.second}', event.upgrade_type_name) 
                 for k, event in enumerate(Upgrades)}

pprint(upgrade_order)
{0: ('second: 0', 'RewardDanceGhost'),
 1: ('second: 0', 'RewardDanceMule'),
 2: ('second: 0', 'RewardDanceViking'),
 3: ('second: 0', 'SprayTerran'),
 4: ('second: 0', 'SprayTerran'),
 5: ('second: 165', 'SprayTerran'),
 6: ('second: 265', 'SprayTerran'),
 7: ('second: 550', 'SprayTerran'),
 8: ('second: 613', 'TerranVehicleWeaponsLevel1'),
 9: ('second: 650', 'SprayTerran'),
 10: ('second: 664', 'SmartServos'),
 11: ('second: 712', 'HighCapacityBarrels'),
 12: ('second: 819', 'SprayTerran')}

UnitTypeChangeEvent

During a game, some units and buildings appear (they change type) as the product of tech research, player ordered morphs, mergings and upgrades.

All these changes are registered by the UnitTypeChangeEvent and each unit's type_history attribute.

The following code lists two of player 1's GateWays, transformed into WarpGates via tech research.

prototype = [(event.second, event.unit.type_history, event.unit_id)
            for event in single_replay.events 
            if isinstance(event, sc2reader.events.tracker.UnitTypeChangeEvent)
            and event.unit.owner.pid == 1]

for unit in prototype:
    for stage in unit[1].values():
        print(f'Name: {stage.name} ID: {unit[2]}')
    
Name: Gateway ID: 58195969
Name: WarpGate ID: 58195969
Name: Gateway ID: 57671681
Name: WarpGate ID: 57671681

Meanwhile, in the following code, I show how one of Player 2's SiegeTanks changes back and forth between two states (i.e. SiegeTank and SiegeTankSieged). In contrast to the previous example, this change results from a player's direct command and can be reversed.

prototype_2 = [(event.second, event.unit.type_history, event.unit_id) 
            for event in single_replay.events 
            if isinstance(event, sc2reader.events.tracker.UnitTypeChangeEvent)
            and event.unit.owner.pid == 2 
            and 'SiegeTank' in event.unit_type_name]


for unit in prototype_2[:1]:
    for stage in unit[1].values():
        print(f'Name: {stage.name} ID: {unit[2]}')
Name: SiegeTank ID: 67633153
Name: SiegeTankSieged ID: 67633153
Name: SiegeTank ID: 67633153
Name: SiegeTankSieged ID: 67633153
Name: SiegeTank ID: 67633153
Name: SiegeTankSieged ID: 67633153
Name: SiegeTank ID: 67633153
Name: SiegeTankSieged ID: 67633153

UnitDiedEvent

These events are generated when units are taken off the match for any reason.

Since UnitDiedEvents can be triggered when a unit is morphed, merged or exhausted, it is important not to assume that every kill results from an opponent's actions.

Thus, in this case, I will use the following attributes:

  • second (int): registered time of birth in seconds. This attribute requires the same consideration as in the previous cases. (see calc_realtime_index function)
  • killer_pid (int): player id for the player who destroys a unit. This attribute can be a significant component to build a player kill list.
  • unit (unit)

The following code lists the units killed by Player 1 in the sample replay.

UnitDied_e = [event for event in single_replay.events 
              if isinstance(event, sc2reader.events.tracker.UnitDiedEvent)
             and event.killer_pid == 1 
             and event.unit.owner != None
             and event.unit.owner.pid == 2]

kill_list = {k:(f'second: {event.second} ' 
                + f'unit: {event.unit.name:<16} ' 
                + f'owner: {event.unit.owner.pid} ' 
                + f'killer: {event.killer_pid}') 
            for k, event in enumerate(UnitDied_e)}

print('Player 1 kill list:')
pprint(kill_list)
Player 1 kill list:
{0: 'second: 304 unit: SCV              owner: 2 killer: 1',
 1: 'second: 608 unit: Hellion          owner: 2 killer: 1',
 2: 'second: 617 unit: Hellion          owner: 2 killer: 1',
 3: 'second: 687 unit: BattleHellion    owner: 2 killer: 1',
 4: 'second: 703 unit: BattleHellion    owner: 2 killer: 1',
 5: 'second: 723 unit: SiegeTankSieged  owner: 2 killer: 1',
 6: 'second: 769 unit: BattleHellion    owner: 2 killer: 1',
 7: 'second: 781 unit: Hellion          owner: 2 killer: 1',
 8: 'second: 790 unit: BattleHellion    owner: 2 killer: 1'}

UnitPositionEvent

This events are generated every 15 seconds and they record "the positions of the first 255 units that were damaged in the last interval" (Kim, 2015, p. 22).

This events can be usefull to infer skirmishes during a match. However, I am not going to consider them into my analysis.

Module Exports

Exportable constants

Additionally, this module exports a constant INTERVALS_BASE which defines the number of seconds that should determine the time intervals used to analyse the replays.

In the following modules, I separate the analysis of many performance indicators derived from several types of Tracker Events into four game intervals, i.e. the whole, early, mid and late games. These intervals are meant to match the economy and initial strategy building portion of the game (i.e. the early game), the initial strategy execution and response state (i.e. the mid-game), and the counter strategic stage of the game (i.e. the late-game). The early match takes typically between 4 and 5 minutes, or 240 and 300 seconds. Afterwards, the mid-game takes about the same time. Suppose the game has not ended by minutes 8 to 10. In that case, players typically re-organise their strategy using updated, more advanced and expensive, units, i.e. late game.

Thus, in this case, I establish a base value in seconds that I use in later modules as the finish time for the early game. I also use it to calculate the end mark of the mid-game as double this base value. The late-game always covers the portion from the mid-game's end to the match's end.

I establish this constant here in case I need to change it. This way, any change will automatically be propagated through all modules.

Exportable Functions

Similarly, I export the calc_realtime_index function. I use this function in later modules to recalculate the time when an event took place in seconds to match the game's recorded length.

This recalculation is necessary because Tracker Events seem to record the time they happened as the quotient of their recorded execution frame and the match's frames-per-second, which does not match its duration,

calc_realtime_index[source]

calc_realtime_index(registered_time:int, rpl:Replay)

Calculate the time index of an event based on the replay recorded duration.

Given that the registered time index on TrackerEvents don not necessarily coincide with the replay duration, this function recalculates the time index of an event to correct this discrepancy.

Args

- registered_time (int)
    The time index in seconds recorded in the event. Normally
    accessible through the .second attribute.
- rpl (sc2reader.resources.Replay)
    Working replay

Returns

- float
    The time index that would match the replay's duration

The following code illustrates the use of this function to correct the time index of various UnitBornEvent. The function should work just as well with all TrakerEvents.

first_e = UnitBorn_e[27]
second_e = UnitBorn_e[28]
last_e = UnitBorn_e[-1]

print(f'Unit: {first_e.unit.name:>8}', 
    f'Birth recorded time: {first_e.second:>5}', 
    f'Birth real time: ', 
    f'{calc_realtime_index(first_e.second, single_replay):>7.2f}')

print(f'Unit: {second_e.unit.name:>8}', 
    f'Birth recorded time: {second_e.second:>5}', 
    f'Birth real time: ', 
    f'{calc_realtime_index(second_e.second, single_replay):>7.2f}')

print(f'Unit: {last_e.unit.name:>8}', 
    f'Birth recorded time: {last_e.second:>5}', 
    f'Birth real time: ', 
    f'{calc_realtime_index(last_e.second, single_replay):>7.2f}')

print(f'Unit: {"FinalFrame":>8}', 
    f'Birth recorded time: {826:>5}', 
    f'Birth real time: {calc_realtime_index(826, single_replay):>7.2f}')
Unit:    Probe Birth recorded time:     0 Birth real time:     0.00
Unit:    Probe Birth recorded time:    18 Birth real time:    12.86
Unit:    Probe Birth recorded time:   820 Birth real time:   585.71
Unit: FinalFrame Birth recorded time:   826 Birth real time:  590.00

References