Starlink publishes the Modified ITC ephemerides it submits to Space-Track at https://api.starlink.com/public-files/ephemerides/, one file per satellite, refreshed about every eight hours. The directory has no index page; its contents are listed in a plain-text MANIFEST.txt. Brahe's StarlinkClient retrieves them with the same caching, rate limiting and BRAHE_NETWORK_MODE behavior as the other ephemeris clients, and returns either the parsed ITC message or an OrbitTrajectory built from it. See Modified ITC Ephemeris Format for the file format itself.
The following example looks up a satellite by name in the manifest and loads its ephemeris as a trajectory. It reads a cached manifest and ephemeris file, using a week-long cache_max_age so the cached manifest is not refreshed while it is under a week old.
get_manifest serves the cached MANIFEST.txt while it is younger than cache_max_age (3600 seconds by default) and asks the server for a newer listing otherwise, keeping the cached copy when nothing has changed. refresh_manifest asks the server regardless of the cache's age, while cached_manifest and previous_manifest never touch the network: the former returns whatever is on disk, and the latter returns the listing that was current before the last change, kept so a caller can see what moved.
Each entry carries the NORAD catalog ID, the object name, the operational or special category, the ephemeris start epoch at minute resolution decoded from the file name's day-time group, the ephemeris stop epoch decoded from the file name's metadata field when it holds GPS seconds, and the exact file name as listed. find_by_norad_id and find_by_object_name look up a single entry; changed_since compares two manifests and returns the entries whose file name is new or has changed, which answers "which satellites have new ephemerides since my last refresh". to_dataframe returns the listing as a table with columns norad_cat_id (UInt32), object_name and category (strings), ephemeris_start and ephemeris_stop (millisecond-resolution naive UTC datetimes, the stop column null where the file name carries no stop), and file_name.
importpolarsasplimportbraheasbhbh.initialize_eop()client=bh.StarlinkClient(cache_max_age=7*86400.0)manifest=client.get_manifest()print(f"Manifest lists {len(manifest)} satellites")by_id=manifest.find_by_norad_id(100001)print(f"NORAD 100001 is {by_id.object_name}")by_name=manifest.find_by_object_name("STARLINK-37711")print(f"STARLINK-37711 is NORAD {by_name.norad_cat_id}")forentryinlist(manifest)[:3]:print(f" {entry.norad_cat_id}{entry.object_name}: "f"{entry.ephemeris_start} to {entry.ephemeris_stop}")previous=client.previous_manifest()ifpreviousisnotNone:changed=manifest.changed_since(previous)print(f"{len(changed)} satellites changed since the previous manifest")else:print("No previous manifest cached; nothing to diff against")df=manifest.to_dataframe()print(f"DataFrame shape: {df.shape}")print(f"Columns: {df.columns}")threshold="2026-09-11T01:42:30"later=df.filter(pl.col("ephemeris_start")>pl.lit(threshold).str.to_datetime(time_unit="ms"))print(f"Entries starting after {threshold}: {later['object_name'].to_list()}")
usebraheasbh;usebrahe::starlink::StarlinkClient;usebrahe::time::{Epoch,TimeSystem};fnmain(){bh::initialize_eop().unwrap();letclient=StarlinkClient::with_cache_age(7.0*86400.0);letmanifest=client.get_manifest().unwrap();println!("Manifest lists {} satellites",manifest.len());letby_id=manifest.find_by_norad_id(100001).unwrap();println!("NORAD 100001 is {}",by_id.object_name);letby_name=manifest.find_by_object_name("STARLINK-37711").unwrap();println!("STARLINK-37711 is NORAD {}",by_name.norad_cat_id);forentryinmanifest.iter().take(3){println!(" {} {}: {} to {}",entry.norad_cat_id,entry.object_name,entry.ephemeris_start,entry.ephemeris_stop.map(|e|e.to_string()).unwrap_or_else(||"None".to_string()));}matchclient.previous_manifest().unwrap(){Some(previous)=>{letchanged=manifest.changed_since(&previous);println!("{} satellites changed since the previous manifest",changed.len());}None=>println!("No previous manifest cached; nothing to diff against"),}letdf=manifest.to_dataframe().unwrap();println!("DataFrame shape: {:?}",df.shape());println!("Columns: {:?}",df.get_column_names());// The `polars` dependency here is built with only the `lazy` feature and// isn't itself a nameable crate for this example, so filter the manifest// entries directly instead of the Python example's Polars-level// `.filter()` expression.letthreshold_text="2026-09-11T01:42:30";letthreshold=Epoch::from_datetime(2026,9,11,1,42,30.0,0.0,TimeSystem::UTC);letlater:Vec<&str>=manifest.iter().filter(|e|e.ephemeris_start>threshold).map(|e|e.object_name.as_str()).collect();println!("Entries starting after {}: {:?}",threshold_text,later);}
download_ephemeris fetches a satellite's file only when the manifest names one that is not already cached, validating the downloaded body as a Modified ITC message before writing it. get_ephemeris downloads (if needed) and returns the parsed ITC message. get_trajectory does the same and converts it to an OrbitTrajectory in the file's state frame (EME2000), with the RTN covariance rotated into that frame using the block-diagonal convention; get_trajectory accepts an optional covariance_variant, and get_trajectory_with_covariance_variant takes it as a required variant, including OrbitRelativeFrameVariant.ROTATING for the alternative rotation.
Note
Downloading a satellite's new ephemeris deletes the other cached files for that NORAD ID; saved copies are unaffected.
importnumpyasnpimportbraheasbhbh.initialize_eop()client=bh.StarlinkClient(cache_max_age=7*86400.0)manifest=client.get_manifest()entry=manifest.find_by_object_name("STARLINK-38128")trajectory=client.get_trajectory(entry.norad_cat_id)start=trajectory.start_epoch()end=trajectory.end_epoch()print(f"Trajectory: {len(trajectory)} samples from {start} to {end} in {trajectory.frame}")mid=start+30.0state=trajectory.interpolate(mid)print(f"State at {mid} [m, m/s]: {np.array2string(state,precision=3)}")covariance=trajectory.covariance_in_frame(bh.CelestialFrame.ITRF,mid)print(f"ITRF position 1-sigma at {mid} [m]: "f"{np.array2string(np.sqrt(np.diag(covariance))[: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)}")forwindowinwindows[:2]:print(f" {window.window_open} to {window.window_close} ({window.duration:.1f} s)")
usebraheasbh;usebrahe::frames::CelestialFrame;usebrahe::starlink::StarlinkClient;usebrahe::traits::*;fnmain(){bh::initialize_eop().unwrap();letclient=StarlinkClient::with_cache_age(7.0*86400.0);letmanifest=client.get_manifest().unwrap();letentry=manifest.find_by_object_name("STARLINK-38128").unwrap();lettrajectory=client.get_trajectory(entry.norad_cat_id).unwrap();letstart=trajectory.start_epoch().unwrap();letend=trajectory.end_epoch().unwrap();println!("Trajectory: {} samples from {} to {} in {}",trajectory.len(),start,end,trajectory.frame);letmid=start+30.0;letstate=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]);letcovariance=trajectory.covariance_in_frame(CelestialFrame::ITRF,mid).unwrap();println!("ITRF position 1-sigma at {} [m]: [{:.3}, {:.3}, {:.3}]",mid,covariance[(0,0)].sqrt(),covariance[(1,1)].sqrt(),covariance[(2,2)].sqrt());letstation=bh::PointLocation::new(-122.4194,37.7749,0.0).unwrap().with_name("San Francisco");letconstraint=bh::ElevationConstraint::new(Some(10.0),None).unwrap();letwindows=bh::location_accesses(&station,&trajectory,start,end,&constraint,None,None).unwrap();println!("Access windows above 10 deg: {}",windows.len());forwindowinwindows.iter().take(2){println!(" {} to {} ({:.1} s)",window.window_open,window.window_close,window.duration());}}
Trajectory: 4321 samples from 2026-09-11 01:42:42.000 UTC to 2026-09-14 01:42:42.000 UTC in EME2000
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]
ITRF position 1-sigma at 2026-09-11 01:43:12.000 UTC [m]: [0.64 1.076 1.028]
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)
Trajectory: 4321 samples from 2026-09-11 01:42:42.000 UTC to 2026-09-14 01:42:42.000 UTC in EME2000
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]
ITRF position 1-sigma at 2026-09-11 01:43:12.000 UTC [m]: [0.640, 1.076, 1.028]
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)
save_ephemeris downloads a satellite's file and writes it to a destination outside the cache: an existing directory, or a path whose last component has no extension, is treated as a directory and the original file name is kept, while a path with an extension is used as the file name directly, and a missing directory is created. A destination inside the cache directory is an error, checked before anything is downloaded. The keep_cached argument chooses how the file reaches the destination: false moves the downloaded file, so the destination becomes the only copy and a later download_ephemeris or get_ephemeris call for the same satellite downloads it again; true copies the file instead, so the cache keeps its copy alongside the one written to the destination.
download_all(concurrency) downloads every file the manifest lists that is not already cached, one file per NORAD ID, using up to concurrency worker threads that share the client's rate limiter. As of writing the full listing is about 11,000 files and approximately 22 GB, so a first run against the public mirror is a long transfer; the call stops at the first failure and returns it, and it never prunes files no longer listed. save_all runs download_all and writes every downloaded file into a destination directory, created if missing; a destination inside the cache directory is an error, checked before anything is downloaded. With keep_cached false each file is moved, so the destination holds every listed ephemeris and the cache holds none of them, and a later download_ephemeris or download_all call downloads them again; with keep_cached true each file is copied, so the cache keeps its copies alongside the ones written to the destination. Files for satellites no longer listed stay until prune_cache removes them. prune_cache removes cached ephemeris files the current manifest no longer lists, without touching MANIFEST.txt or MANIFEST.previous.txt. cached_files lists the ephemeris files currently in the cache. The cache directory itself is not coordinated across processes or across clients sharing it, so only one bulk download should run against a given cache directory at a time.
StarlinkClient rate limits requests to 1000 per minute and 30000 per hour by default, configured through the same RateLimitConfig used by the other clients (see Rate Limiting); max_retries controls how many times a transient failure is retried. BRAHE_NETWORK_MODE applies as it does for the other clients: offline serves a cached manifest or file of any age, offline-strict serves only a fresh manifest and errors on one that is stale or missing, and in online mode a refresh that fails is an error rather than a silent fall back to a stale cached copy.
The manifest and ephemeris files are cached under $BRAHE_CACHE/starlink/ for the default public endpoint. A client constructed with any other base_url caches under $BRAHE_CACHE/starlink/mirrors/<host>-<hash>/ instead, so a client pointed at a different mirror never reads or writes the public endpoint's cached files.