How GPKit works
Four steps, each a small set of types.K-11
The same, as text
bytes ──ElementSets.read──▶ ElementSetFile ──▶ ElementSet ──▶ Propagator ─┬─state(at:)─────────────▶ StateVector (TEME)
│ ├─look(from:at:)─────────▶ Look
└─▶ Refusal (per bad record) └─passes(over:from:for:)─▶ Passes
1. Reading: bytes to element sets
ElementSets.read(_:as:) takes bytes, Data or a String, and a format: .csv, .json, .xml, .kvn or .tle.
GPKit doesn't guess the format; you say which it is.K-10K-22A-46
It returns an ElementSetFile: one entry per record, each either an ElementSet or a Refusal.K-10K-25
let file = try ElementSets.read(bytes, as: .json)
for set in file.elementSets { set.catalogNumber } // 25544: an Int, whatever its width
for refusal in file.refusals { print(refusal) } // NORAD_CAT_ID "A0000" is not an integer of up to nine digits (record 2 of the file)
One record, whatever the format. An ElementSet's properties are the OMM's keywords in the OMM's units, and a TLE
is read into the same record. The TLE is a way in, not the model.K-12
TLE
Strict: both lines 69 printable ASCII characters, both checksums right, the two catalog fields the same, every separator column a space, and every field what its columns are for. A letter in a numeric field, a short line, a line 1 with no line 2, and a line that belongs to no set are each refused with that reason.K-20
- Name lines are optional. A name line starting
0, as in a three-line set, is read without the0.K-22 - Years: a two-digit year of 57–99 is 1957–1999, and 00–56 is 2000–2056.K-20
- The epoch day
YYDDD.DDDDDDDDis an exact number of microseconds.K-28 - The checksum is verified by default.
checksum: .ignorereads lines whose check digit is wrong or blank, and makes every other check. Without the checksum, an altered line gives no sign of it.K-21
Go deeper: the checksum
Sum the first 68 characters, counting each digit as its value and each minus sign as 1; everything else counts 0. The last digit of that sum is the check digit in column 69.K-21OMM
OMM (CCSDS 502.0-B-3) in four encodings: CSV with a header row, JSON (an array of objects, or one object), XML (NDM/XML, CCSDS 505.0-B-3) and KVN.K-22
What GPKit fills in, and what it lets pass, is written down, because each is a decision made for the readerK-27:
| when | GPKit |
|---|---|
CENTER_NAME, REF_FRAME, TIME_SYSTEM, MEAN_ELEMENT_THEORY are absent (as in CelesTrak's CSV and JSON, and every TLE) |
fills in EARTH, TEME, UTC, SGP4, and lists each in defaulted |
OBJECT_ID or OBJECT_NAME is empty, null or absent |
nil; nothing is put in its place |
| a keyword GPKit has no property for | keeps it as text in otherKeywords |
| a byte-order mark; LF or CRLF | passes over it |
| JSON numbers written as strings | reads them |
| XML namespace prefixes, CDATA, entities | reads them; a document type declaration is refused |
Reading checks the form of a record, not its physics: an eccentricity of 2 is a number, and it's read.K-22
Catalog numbers and Alpha-5
The catalog assigned number 100000 on 11 July 2026. The usable five-digit range had already run out at 69,999 (CelesTrak).G-10G-11
- In GPKit a catalog number is an
Int, from 0 to 999,999,999, whatever format it came in.K-24 - In a TLE, numbers from 100000 to 339999 are written in Alpha-5, as Space-Track defines it: the first two digits
become one letter,
A= 10 toZ= 33, withoutIorO; the last four digits follow. 100000 isA0000, 339999 isZ9999. GPKit decodes the field on the way in and never keeps it. No number is ever routed through five characters.K-23K-24 - Above 339999, no TLE can carry the number. In OMM,
NORAD_CAT_IDis an integer of up to nine digits. GPKit reads it as one, and refuses Alpha-5 there, since Alpha-5 belongs to TLEs.K-24
Go deeper: decoding Alpha-5
`value = (index of the letter in "ABCDEFGHJKLMNPQRSTUVWXYZ" + 10) × 10,000 + the last four digits`. Refused: lowercase, `I` or `O`, a space or sign, a letter anywhere but first, and any length but five.K-23 | number | field | | number | field | |---|---|---|---|---| | 100000 | `A0000` | | 229999 | `N9999` | | 179999 | `H9999` | | 230000 | `P0000` | | 180000 | `J0000` | | 339999 | `Z9999` | These are among the test vectors in `ValueTests.swift`.K-23Refusals
GPKit drops nothing without a word, and guesses nothing.K-25 A Refusal says what was wrong (one of 19 kinds), the
field, the text that was there, and the line or record. One bad record costs one record: the ones around it are read.K-25
ElementSets.read throws only when the input as a whole can't be what it's said to be: bytes that aren't UTF-8, empty
input, JSON or XML that doesn't parse, or a file that ends inside a record. A file cut short is refused whole, so part
of a catalog is never loaded as if it were all of it.K-25K-26
Writing a TLE
TLE.write(set) brings each value to its field's resolution, exactly, in decimal, a half rounding up.
eccentricity: .truncated cuts it off instead, as CelesTrak's own lines do. What a TLE can't carry is refused, never
approximated: a catalog number that's missing, negative or above 339999, an epoch outside 1957–2056, or a value wider
than its columns.K-29
2. Values: exact decimals and integer epochs
ExactDecimalkeeps the source's digits..15975118E-3from a CSV,0.00015975118from JSON and15975-3from a TLE's column compare equal, without passing through binary..doublegives the nearestDoublefor computing.K-27Epochis integers: a date, a time of day, and the fraction of a second in attoseconds. Second 60 is kept. An epoch is UTC.K-28K-34
3. SGP4
Propagator is SGP4: a port to Swift of the propagation routines of David Vallado's SGP4.cpp, as CelesTrak publishes
it.K-30
let propagator = try Propagator(set) // refuses a set that isn't for SGP4
let state = try propagator.state(at: time) // TEME: kilometres and kilometres per second
- A transcription. Names are the C++'s, statements are in its order, and every expression keeps the C++'s order of operations. The C++'s own TLE reader is not ported: GPKit has its own.K-30
- Constants: WGS-72 by default, since element sets are fitted with it. WGS-84 and WGS-72-old are there for comparison.K-31
- Deep space: at a period of 225 minutes or more, the lunar and solar terms and the half-day and one-day resonance terms.K-32
- Failures: a time it can't propagate to (a decayed satellite, say) throws a
PropagationFailurenaming which of SGP4's conditions stopped it.K-33 - A value, safe to share. Making one does the initialisation. Every call after that is independent of the calls before it, so one can be shared between tasks.K-36
- The epoch, exactly. GPKit starts from the element set's own epoch. Vallado's reader takes a TLE's epoch through a calendar date and back, and can land up to 20 microseconds away.K-37
Go deeper: frames, and how it's checked
- `state` is in TEME. `position(at:)` turns it with the Earth by Greenwich mean sidereal time and gives the point on the WGS-84 ellipsoid beneath the satellite. UT1 is taken to be UTC (they're within 0.9 s), and the motion of the pole is left out.K-34K-35 - The tests compile Vallado's SGP4.cpp, unmodified, and run it beside the port through the 33 verification cases: 2,349 steps, 24 of the cases deep space, with each set of constants and in both of the C++'s operation modes. The port **agrees with the reference C++ within 1 micrometre**, with the same error code at every step.K-40K-40b - The C++ is compiled with floating-point contraction off, because Clang fuses multiply-adds by default on arm64 and Swift never does.K-41b - [How it's tested →](/testing/)4. Passes, look angles and Doppler
let home = Observer(latitude: 40.0, longitude: -75.0, height: 100) // degrees, degrees, metres
let passes = propagator.passes(over: home, from: now, for: .seconds(86_400))
for pass in passes { pass.rise?.time; pass.culmination.look.elevation; pass.set?.look.azimuth }
- The search samples the elevation every 30 seconds, narrows each horizon crossing to a millisecond by bisection, and finds the highest point by golden-section search.K-50K-51
- Short passes: a peak between two samples is looked into, so a pass shorter than the step is found too.K-52
- Edges:
riseis nil when the satellite is already up at the start, andsetis nil when it's still up at the end. There is one culmination per pass, the highest.K-54b - Failures: the search stops at the first time it can't propagate to, and keeps what it found.
failuresays why. K-54 - A minimum elevation in degrees, the horizon by default. Angles are geometric, with no refraction.K-53
Look: azimuth (degrees clockwise from north), elevation (degrees), range (km) and range rate (km/s, positive while receding).K-16- Doppler:
look.received(from: f)= f × (1 − ṙ/c) for a downlink, andlook.transmit(toBeReceivedAt: f)= f / (1 − ṙ/c) for an uplink, with c = 299,792.458 km/s. At 437 MHz and 7 km/s approaching, that's about 10.2 kHz high.K-60K-61
What GPKit 1.0.0 doesn't do: it has no sun model, so nothing about whether a pass can be seen, and no horizon that
varies by direction. GP Passes adds both on top.K-55 GPKit's main, not yet in a release, adds a sun model and
when in a pass the satellite can be seen.K-90K-96
Sources
K-11Data flow:ElementSets.read→ElementSet→Propagator→ TEME state → Earth-fixed → look → passes. Source: GPKit:ElementSets.swift:54-69;OMM.swift:43-104;Propagator.swift:94-128;Observer.swift:102-154;Passes.swift:58-177.K-10The public API (signatures). Source: GPKit:docs/public-api.swift(generated bytools/public-api.sh).K-22OMM in CSV, JSON, XML (505.0-B-3) and KVN (502.0-B-3 §7). Quirks handled as listed. DOCTYPE refused. Source: GPKit:ElementSets.swift:1-13;XML.swift:1-7;README.md:105-119.A-46Import reads OMM in CSV, JSON, XML or KVN, and TLE (including .2le and .3le), detected by content. Source: Passes:FormatDetector.swift:4-44.K-25Refusals carry a kind (19 kinds), a message, a field and a line or record. One bad record costs one record. Source: GPKit:Refusal.swift:7-89;README.md:81-83.K-71bThe Reading example (ElementSets.read,elementSets,refusals, the printed refusal). Source: GPKit:README.md:42-62.K-12One record type whatever the format; "the TLE is a way in, not the model". Source: GPKit:ElementSet.swift:3-4;README.md:64-65.K-20TLE reading is strict: 69 printable ASCII characters, both checksums, agreeing catalog fields, space separators. Year pivot 57. Source: GPKit:TLE.swift:3-5,19-24,51-80.K-28Epochs are integers to the attosecond; a leap second at 23:59:60 is kept. Source: GPKit:Epoch.swift:1-7.K-21The checksum is verified by default;.ignoreskips only the checksum. Source: GPKit:TLE.swift:8-34;README.md:93-96; testTLETests.swift:77-99.K-27Values are ExactDecimal. Applied defaults are recorded indefaulted. Source: GPKit:ExactDecimal.swift:1-6;ElementSet.swift:54-76.G-10On 11 July 2026 the catalog assigned number 100000, to SARAMAGO. Source: gpconf:README.md:98-99;docs/BRAND.md:59.G-11The five-digit range ended at 69,999 (CelesTrak). Source: gpconf:docs/BRAND.md:59;docs/RESEARCH.md:32-36.K-24Catalog numbers are an Int; OMM NORAD_CAT_ID takes up to nine digits; Alpha-5 is refused in OMM. Source: GPKit:OMM.swift:4-17;ElementSet.swift:9-10;OMMTests.swift:58-70.K-23Alpha-5 as Space-Track defines it: A–Z without I and O = 10–33, ceiling 339999. Test vectors. Source: GPKit:Alpha5.swift:1-46;ValueTests.swift:85-101.K-26A file cut short is refused whole. Source: GPKit:README.md:85-87; testOMMTests.swift:120.K-29The TLE writer uses exact decimal rounding, Alpha-5 from 100000, and refuses numbers above 339999. Source: GPKit:TLE.swift:242-354;README.md:121-133.K-34Positions are TEME; UT1 is taken as UTC (within 0.9 s); polar motion is left out. Source: GPKit:Observer.swift:102-104;README.md:218-219.K-30SGP4 is a port of Vallado's SGP4.cpp (CelesTrak/fundamentals-of-astrodynamics 49df0479c950), with names and order of operations kept. Source: GPKit:NOTICE:9-17;SGP4Core.swift:1-15.K-31Gravity constants WGS-72 (default), WGS-84 and WGS-72-old. Source: GPKit:SGP4Core.swift:78-108;Propagator.swift:76-94.K-32Deep-space terms apply at a period of 225 min or more. Source: GPKit:SGP4Core.swift:44-45.K-33Error codes map toPropagationFailure.Kind. Source: GPKit:Propagator.swift:33-48,115-122.K-36A Propagator is a value and can be shared between tasks. Source: GPKit:Propagator.swift:71-73; testSGP4Tests.swift:170.K-37The TLE epoch is kept exactly; the C++ reader's round trip moves it by up to 20 µs. Source: GPKit:Propagator.swift:91-93;README.md:237-243.K-35WGS-84 ellipsoid for the observer. Source: GPKit:Observer.swift:95-100.K-40SGP4 vs the unmodified C++: 33 cases (24 deep space), 2,349 steps, equal error codes, agreeing within 1 micrometre (asserted). Source: GPKit:SGP4Tests.swift:74-75,102-126.K-40bThe same comparison with WGS-72-old, WGS-72 and WGS-84, each in modesianda: equal error codes, within 1 micrometre. Source: GPKit:SGP4Tests.swift:188-209.K-41bThe oracle compiles SGP4.cpp with#pragma STDC FP_CONTRACT OFF: Clang fuses multiply-adds by default on arm64, and Swift never does. Source: GPKit:Tests/SGP4Oracle/oracle.cpp:1-9.K-72The Passes example (Observerwith height in metres,passes(over:from:for:), rise/culmination/set). Source: GPKit:README.md:190-207.K-50Elevation is sampled every 30 s. Source: GPKit:Passes.swift:110.K-51Crossings are found by bisection to 0.5 ms; the highest point by golden-section search. Source: GPKit:Passes.swift:77-108.K-52Passes shorter than a step are found. Source: GPKit:Passes.swift:147-156; testPassTests.swift:290-303.K-54briseis nil when already up at the start andsetnil when still up at the end; one culmination a pass, the highest. Source: GPKit:Passes.swift:44-49;README.md:220-222.K-54A search stops at the first failure and keeps what it found. Source: GPKit:Passes.swift:29-31,164-175; testsPassTests.swift:313-367.K-53No refraction; angles are geometric. Source: GPKit:README.md:216-217.K-16Azimuth is degrees clockwise from north (90 is east); elevation is degrees above the horizon, negative below it; range is in km; range rate is in km/s, positive while receding. Source: GPKit:Observer.swift:64-73.K-60Doppler: received = f(1 − ṙ/c), transmit = f/(1 − ṙ/c), c = 299,792.458 km/s. Source: GPKit:Observer.swift:75-88.K-61437 MHz at 7 km/s approaching comes to about 10.2 kHz. Source: GPKit: testPassTests.swift:125-138.K-55GPKit has no sun or visibility model and no azimuth-dependent horizon. Source: GPKit: a grep of Sources for sun/eclipse/sunlit/twilight found nothing; signaturepasses(over:from:for:above:).K-90Merged into GPKit's main since 1.0.0 and not in a release: v1.0.0 is the only tag; each item below is under "Unreleased", as additions ("nothing is removed or changed"); within 1.x, code written for 1.0 keeps compiling. Source: GPKit main 11b7ab4 (PR #25, 2026-11-07):CHANGELOG.md:7-9,:92;README.md(Stability);git tag(v1.0.0 only); GitHub releases (v1.0.0 only).K-96Sun(the sun's position and height at a place, after the NOAA calculators' formulas),Sunlight(the sky at a place: daylight, twilight or dark; whether the satellite is sunlit; phase angle; a brightness estimate) andPassVisibility(when in a pass the satellite is sunlit and when it can be seen with the eye). Source: GPKit main 11b7ab4 (PR #25, 2026-11-07):CHANGELOG.md:24-29;docs/public-api.swift:288,:301,:317,:331.