Skip to content

Modified ITC Ephemeris Format

The Modified ITC format is the Space-Track ephemeris exchange format defined in the Spaceflight Safety Handbook for Satellite Operators (version 1.7). A file holds a short header followed by one record per epoch: a line giving the YYYYDDDHHMMSS.sss UTC epoch, position (km) and velocity (km/s), optionally followed by the lower triangle of a 6x6 position and velocity covariance (21 values, km-based units) in the frame the header names. Brahe reads and writes the format through ITC, converting positions and velocities to SI units (m, m/s) and covariance elements by \(10^6\) on input, so every quantity on an ITC object is already in SI.

Reading a File

import numpy as np

import brahe as bh

PATH = "test_assets/starlink/MEME_100001_STARLINK-38128_2540142_Operational_1473385380_UNCLASSIFIED.txt"

itc = bh.ITC.from_file(PATH)
header = itc.header

print(f"Source file:      {itc.source_name}")
print(f"Created:          {header.created}")
print(f"Ephemeris start:  {header.ephemeris_start}")
print(f"Ephemeris stop:   {header.ephemeris_stop}")
print(f"Step size:        {header.step_size} s")
print(f"State frame:      {header.state_frame}")
print(f"Covariance frame: {header.covariance_frame}")
print(f"Records:          {len(itc)}")
print(f"Has covariance:   {itc.has_covariance}")

first = itc.states[0]
print(f"First epoch:      {first.epoch}")
print(f"Position [m]:     {np.array2string(first.position, precision=3)}")
print(f"Velocity [m/s]:   {np.array2string(first.velocity, precision=6)}")

sigma = np.sqrt(np.diag(itc.covariances[0]))
print(f"1-sigma RTN position [m]:   {np.array2string(sigma[:3], precision=3)}")
print(f"1-sigma RTN velocity [m/s]: {np.array2string(sigma[3:], precision=6)}")
use brahe as bh;

const PATH: &str = "test_assets/starlink/MEME_100001_STARLINK-38128_2540142_Operational_1473385380_UNCLASSIFIED.txt";

fn main() {
    let itc = bh::itc::ITC::from_file(PATH).unwrap();
    let header = &itc.header;

    println!("Source file:      {}", itc.source_name.as_ref().map(|n| n.to_string()).unwrap_or_default());
    println!("Created:          {}", header.created.map(|e| e.to_string()).unwrap_or_default());
    println!("Ephemeris start:  {}", header.ephemeris_start.map(|e| e.to_string()).unwrap_or_default());
    println!("Ephemeris stop:   {}", header.ephemeris_stop.map(|e| e.to_string()).unwrap_or_default());
    println!("Step size:        {} s", header.step_size.unwrap_or(0.0));
    println!("State frame:      {}", header.state_frame);
    println!("Covariance frame: {}", header.covariance_frame);
    println!("Records:          {}", itc.len());
    println!("Has covariance:   {}", itc.has_covariance());

    let first = &itc.states[0];
    println!("First epoch:      {}", first.epoch);
    println!("Position [m]:     [{:.3}, {:.3}, {:.3}]", first.position[0], first.position[1], first.position[2]);
    println!("Velocity [m/s]:   [{:.6}, {:.6}, {:.6}]", first.velocity[0], first.velocity[1], first.velocity[2]);

    let cov = &itc.covariances[0];
    let sigma: Vec<f64> = (0..6).map(|i| cov[(i, i)].sqrt()).collect();
    println!("1-sigma RTN position [m]:   [{:.3}, {:.3}, {:.3}]", sigma[0], sigma[1], sigma[2]);
    println!("1-sigma RTN velocity [m/s]: [{:.6}, {:.6}, {:.6}]", sigma[3], sigma[4], sigma[5]);
}
Output
Source file:      MEME_100001_STARLINK-38128_2540142_Operational_1473385380_UNCLASSIFIED.txt
Created:          2026-09-11 01:55:52.000 UTC
Ephemeris start:  2026-09-11 01:42:42.000 UTC
Ephemeris stop:   2026-09-14 01:42:42.000 UTC
Step size:        60.0 s
State frame:      EME2000
Covariance frame: UVW
Records:          4321
Has covariance:   True
First epoch:      2026-09-11 01:42:42.000 UTC
Position [m]:     [4244346.537 1264325.489 5043982.644]
Velocity [m/s]:   [ 3595.154763  5258.795658 -4335.035291]
1-sigma RTN position [m]:   [0.681 0.882 1.098]
1-sigma RTN velocity [m/s]: [0.001415 0.000721 0.00233 ]
Source file:      MEME_100001_STARLINK-38128_2540142_Operational_1473385380_UNCLASSIFIED.txt
Created:          2026-09-11 01:55:52.000 UTC
Ephemeris start:  2026-09-11 01:42:42.000 UTC
Ephemeris stop:   2026-09-14 01:42:42.000 UTC
Step size:        60 s
State frame:      EME2000
Covariance frame: UVW
Records:          4321
Has covariance:   true
First epoch:      2026-09-11 01:42:42.000 UTC
Position [m]:     [4244346.537, 1264325.489, 5043982.644]
Velocity [m/s]:   [3595.154763, 5258.795658, -4335.035291]
1-sigma RTN position [m]:   [0.681, 0.882, 1.098]
1-sigma RTN velocity [m/s]: [0.001415, 0.000721, 0.002330]

ITC.from_file infers the state frame and the object's identity from a Space-Track compliant file name: the DataType field MEME, EME2000 or J2000 all map to EME2000, and TEME and ITRF map to those frames; the decoded name is kept on source_name. ITC.from_str parses only the file text and leaves the state frame at its default, EME2000, since there is no file name to read a DataType from. The header carries the creation time, the declared ephemeris start, stop and step, a free-text source label, and the covariance frame; the state frame comes from the file name rather than the body. states holds the parsed records and covariances holds one matrix per record when the file carries covariance; has_covariance is true only when every record does, since a Modified ITC message's covariance is all-or-none.

Frames and Covariance

The state frame is the file's MEME J2000.0, the mean equator and mean equinox of J2000.0, which brahe treats as CelestialFrame.EME2000; it is related to GCRF by the constant frame bias. The covariance frame UVW is RTN (radial, in-track, cross-track), with

\[ \hat{R} = \frac{\mathbf{r}}{|\mathbf{r}|}, \quad \hat{N} = \frac{\mathbf{r} \times \mathbf{v}}{|\mathbf{r} \times \mathbf{v}|}, \quad \hat{T} = \hat{N} \times \hat{R}. \]

When a message's covariance frame is UVW (RTN), converting it to a trajectory rotates each record's covariance from RTN into the state frame with the block-diagonal Jacobian \(\mathrm{blockdiag}(R, R)\) by default, following the NASA Conjunction Assessment Risk Analysis handbook (Appendix N, eq. N-13) and the CARA RIC2ECI implementation. The rotating-frame form

\[ \begin{bmatrix} R & 0 \\ R[\boldsymbol{\omega}\times] & R \end{bmatrix} \]

(Vallado, AAS 03-526) is available by passing OrbitRelativeFrameVariant.ROTATING to to_trajectory_with_covariance_variant. A covariance frame of EME2000 or ITRF skips RTN entirely: it is rotated directly between that frame and the state frame with the state-transform Jacobian, so a message can carry covariance in either frame regardless of which one is the state frame. from_trajectory and from_trajectory_with_covariance_variant invert the same rotations to build a message from a trajectory; both reject a trajectory that is not six-dimensional Cartesian, so a Keplerian-element trajectory must be converted first.

Converting to a Trajectory

import numpy as np

import brahe as bh

bh.initialize_eop()

PATH = "test_assets/starlink/MEME_100001_STARLINK-38128_2540142_Operational_1473385380_UNCLASSIFIED.txt"

itc = bh.ITC.from_file(PATH)
trajectory = itc.to_trajectory()
start = trajectory.start_epoch()
end = trajectory.end_epoch()
print(f"Trajectory: {len(trajectory)} samples from {start} to {end}")

mid = start + 30.0
state = trajectory.interpolate(mid)
print(f"State at {mid} [m, m/s]: {np.array2string(state, precision=3)}")
covariance = trajectory.covariance(mid)
print(
    f"Position 1-sigma at {mid} [m]: {np.array2string(np.sqrt(np.diag(covariance))[:3], precision=3)}"
)

itrf = trajectory.to_itrf()
ecef = itrf.interpolate(mid)
print(f"ITRF position at {mid} [m]: {np.array2string(ecef[:3], precision=3)}")

station = bh.PointLocation(-122.4194, 37.7749, 0.0).with_name("San Francisco")
constraint = bh.ElevationConstraint(min_elevation_deg=10.0)
windows = bh.location_accesses(station, trajectory, start, end, constraint)
print(f"Access windows above 10 deg: {len(windows)}")
for window in windows[:3]:
    print(f"  {window.window_open} to {window.window_close} ({window.duration:.1f} s)")
use brahe as bh;
use brahe::traits::*;

const PATH: &str = "test_assets/starlink/MEME_100001_STARLINK-38128_2540142_Operational_1473385380_UNCLASSIFIED.txt";

fn main() {
    bh::initialize_eop().unwrap();

    let itc = bh::itc::ITC::from_file(PATH).unwrap();
    let trajectory = itc.to_trajectory().unwrap();
    let start = trajectory.start_epoch().unwrap();
    let end = trajectory.end_epoch().unwrap();
    println!("Trajectory: {} samples from {} to {}", trajectory.len(), start, end);

    let mid = start + 30.0;
    let state = trajectory.interpolate(&mid).unwrap();
    println!("State at {} [m, m/s]: [{:.3}, {:.3}, {:.3}, {:.3}, {:.3}, {:.3}]", mid, state[0], state[1], state[2], state[3], state[4], state[5]);
    let covariance = trajectory.covariance_at(mid).unwrap().unwrap();
    println!("Position 1-sigma at {} [m]: [{:.3}, {:.3}, {:.3}]", mid, covariance[(0, 0)].sqrt(), covariance[(1, 1)].sqrt(), covariance[(2, 2)].sqrt());

    let itrf = trajectory.to_itrf().unwrap();
    let ecef = itrf.interpolate(&mid).unwrap();
    println!("ITRF position at {} [m]: [{:.3}, {:.3}, {:.3}]", mid, ecef[0], ecef[1], ecef[2]);

    let station = bh::PointLocation::new(-122.4194, 37.7749, 0.0).unwrap().with_name("San Francisco");
    let constraint = bh::ElevationConstraint::new(Some(10.0), None).unwrap();
    let windows = bh::location_accesses(&station, &trajectory, start, end, &constraint, None, None).unwrap();
    println!("Access windows above 10 deg: {}", windows.len());
    for window in windows.iter().take(3) {
        println!("  {} to {} ({:.1} s)", window.window_open, window.window_close, window.duration());
    }
}
Output
1
2
3
4
5
6
7
8
Trajectory: 4321 samples from 2026-09-11 01:42:42.000 UTC to 2026-09-14 01:42:42.000 UTC
State at 2026-09-11 01:43:12.000 UTC [m, m/s]: [ 4.350e+06  1.421e+06  4.911e+06  3.426e+03  5.206e+03 -4.532e+03]
Position 1-sigma at 2026-09-11 01:43:12.000 UTC [m]: [0.795 0.967 1.029]
ITRF position at 2026-09-11 01:43:12.000 UTC [m]: [4559101.925  205850.957 4922350.492]
Access windows above 10 deg: 7
  2026-09-11 10:51:51.560 UTC to 2026-09-11 10:56:43.739 UTC (292.2 s)
  2026-09-12 00:07:29.304 UTC to 2026-09-12 00:12:53.331 UTC (324.0 s)
  2026-09-12 09:40:16.366 UTC to 2026-09-12 09:45:21.106 UTC (304.7 s)
1
2
3
4
5
6
7
8
Trajectory: 4321 samples from 2026-09-11 01:42:42.000 UTC to 2026-09-14 01:42:42.000 UTC
State at 2026-09-11 01:43:12.000 UTC [m, m/s]: [4349669.220, 1421310.402, 4910964.606, 3425.677, 5205.836, -4531.940]
Position 1-sigma at 2026-09-11 01:43:12.000 UTC [m]: [0.795, 0.967, 1.029]
ITRF position at 2026-09-11 01:43:12.000 UTC [m]: [4559101.925, 205850.957, 4922350.492]
Access windows above 10 deg: 7
  2026-09-11 10:51:51.560 UTC to 2026-09-11 10:56:43.739 UTC (292.2 s)
  2026-09-12 00:07:29.304 UTC to 2026-09-12 00:12:53.331 UTC (324.0 s)
  2026-09-12 09:40:16.366 UTC to 2026-09-12 09:45:21.106 UTC (304.7 s)

The resulting OrbitTrajectory interpolates position, velocity and covariance at any epoch within its span, and to_frame -- including the frame-specific shortcuts such as to_eci and to_itrf -- carries a Cartesian covariance along with the states by rotating it with the state-transform Jacobian, so a conversion to any frame keeps it. Because it is an ordinary OrbitTrajectory, it can be used directly with location_accesses to compute ground-station access windows, as in the example above, without a separate conversion step.

Writing a File and Naming It

import tempfile
from pathlib import Path

import brahe as bh

bh.initialize_eop()

PATH = "test_assets/starlink/MEME_100002_STARLINK-37711_2540149_Operational_1473385800_UNCLASSIFIED.txt"

trajectory = bh.ITC.from_file(PATH).to_trajectory()

header = bh.ITCHeader(
    created=bh.Epoch(2026, 9, 11, 2, 0, 0.0, 0.0, time_system=bh.TimeSystem.UTC),
    ephemeris_source="brahe example",
)
itc = bh.ITC.from_trajectory(trajectory, header)
print(f"Records: {len(itc)}, covariance: {itc.has_covariance}")
print(f"Span: {itc.start_epoch} to {itc.end_epoch}")

name = itc.file_name(
    100002, "STARLINK-37711", bh.SpaceTrackEphemerisFileCategory.OPERATIONAL, "nomnvr"
)
print(f"File name: {name}")

with tempfile.TemporaryDirectory() as directory:
    path = Path(directory) / str(name)
    itc.to_file(str(path))
    reread = bh.ITC.from_file(str(path))
    print(f"Re-read {len(reread)} records; state frame {reread.header.state_frame}")
    print(f"First line: {path.read_text().splitlines()[0]}")
use brahe as bh;
use brahe::itc::{ITC, ITCHeader};
use brahe::spacetrack::SpaceTrackEphemerisFileCategory;
use brahe::time::{Epoch, TimeSystem};

const PATH: &str = "test_assets/starlink/MEME_100002_STARLINK-37711_2540149_Operational_1473385800_UNCLASSIFIED.txt";

fn main() {
    bh::initialize_eop().unwrap();

    let trajectory = ITC::from_file(PATH).unwrap().to_trajectory().unwrap();

    let header = ITCHeader::new()
        .with_created(Epoch::from_datetime(2026, 9, 11, 2, 0, 0.0, 0.0, TimeSystem::UTC))
        .with_ephemeris_source("brahe example");
    let itc = ITC::from_trajectory(&trajectory, header).unwrap();
    println!("Records: {}, covariance: {}", itc.len(), itc.has_covariance());
    println!("Span: {} to {}", itc.start_epoch().unwrap(), itc.end_epoch().unwrap());

    let name = itc.file_name(100002, "STARLINK-37711", SpaceTrackEphemerisFileCategory::Operational, "nomnvr").unwrap();
    println!("File name: {}", name);

    let directory = std::env::temp_dir();
    let path = directory.join(name.to_string());
    itc.to_file(&path).unwrap();
    let reread = ITC::from_file(&path).unwrap();
    println!("Re-read {} records; state frame {}", reread.len(), reread.header.state_frame);
    let text = std::fs::read_to_string(&path).unwrap();
    println!("First line: {}", text.lines().next().unwrap_or_default());
    std::fs::remove_file(&path).unwrap();
}
Output
1
2
3
4
5
Records: 50, covariance: True
Span: 2026-09-11 01:49:42.000 UTC to 2026-09-11 02:38:42.000 UTC
File name: MEME_100002_STARLINK-37711_2540149_Operational_nomnvr_UNCLASSIFIED.txt
Re-read 50 records; state frame EME2000
First line: created:2026-09-11 02:00:00 UTC
1
2
3
4
5
Records: 50, covariance: true
Span: 2026-09-11 01:49:42.000 UTC to 2026-09-11 02:38:42.000 UTC
File name: MEME_100002_STARLINK-37711_2540149_Operational_nomnvr_UNCLASSIFIED.txt
Re-read 50 records; state frame EME2000
First line: created:2026-09-11 02:00:00 UTC

from_trajectory requires a Cartesian, six-dimensional trajectory; it fills the header's ephemeris start, stop and step from the trajectory's samples and keeps the header's state frame, covariance frame, created and ephemeris_source fields. ITC.file_name then builds a Space-Track compliant name from the message: <DataType>_<NORAD>_<Name>_<DDDHHMM>_<Category>_<Metadata>_<Classification>.<ext>, where the DataType comes from the state frame, the day-time group from the ephemeris start, and the catalog number, name, category and metadata from the arguments passed to file_name, and the classification and extension from the defaults of SpaceTrackEphemerisFileName. That type is the one behind the convention: parse reads a compliant name, constructing a SpaceTrackEphemerisFileName gives the MEME data type, UNCLASSIFIED classification and txt extension, and the with_* methods replace individual fields. Every field is rejected if it contains a path separator (/ or \) or is . or .., so a name built from untrusted input can never address a file outside its directory; the data type, metadata, classification and extension fields are additionally rejected if they contain an underscore, since that is the field delimiter -- the object name is the exception, since parse recovers it by anchoring the other six fixed-position fields from either end and taking whatever remains between them.


See Also