gpconf 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. gpconf/__init__.py +6 -0
  2. gpconf/__main__.py +203 -0
  3. gpconf/adapters/__init__.py +2 -0
  4. gpconf/adapters/naive.py +142 -0
  5. gpconf/adapters/node_preflight.mjs +29 -0
  6. gpconf/adapters/pyephem_adapter.py +55 -0
  7. gpconf/adapters/reference.py +75 -0
  8. gpconf/adapters/satellitejs.mjs +77 -0
  9. gpconf/adapters/sgp4_adapter.py +78 -0
  10. gpconf/adapters/tlejs.mjs +49 -0
  11. gpconf/adapters/tlejs_vectors.mjs +34 -0
  12. gpconf/corpus/derived/alpha5-tle/alpha5-A-100000-saramago-first.provenance.json +34 -0
  13. gpconf/corpus/derived/alpha5-tle/alpha5-A-100000-saramago-first.tle +3 -0
  14. gpconf/corpus/derived/alpha5-tle/alpha5-A-last-30-days-snapshot.provenance.json +289 -0
  15. gpconf/corpus/derived/alpha5-tle/alpha5-A-last-30-days-snapshot.tle +768 -0
  16. gpconf/corpus/derived/alpha5-tle/alpha5-T-270449-analyst-first.provenance.json +34 -0
  17. gpconf/corpus/derived/alpha5-tle/alpha5-T-270449-analyst-first.tle +3 -0
  18. gpconf/corpus/derived/alpha5-tle/alpha5-T-analyst-27xxxx-snapshot.provenance.json +379 -0
  19. gpconf/corpus/derived/alpha5-tle/alpha5-T-analyst-27xxxx-snapshot.tle +1038 -0
  20. gpconf/corpus/derived/kvn-variants/v01-baseline-reserialised.kvn +27 -0
  21. gpconf/corpus/derived/kvn-variants/v01-baseline-reserialised.provenance.json +19 -0
  22. gpconf/corpus/derived/kvn-variants/v02-day-of-year-epoch-Z.kvn +27 -0
  23. gpconf/corpus/derived/kvn-variants/v02-day-of-year-epoch-Z.provenance.json +19 -0
  24. gpconf/corpus/derived/kvn-variants/v03-units-brackets-leading-zeros.kvn +27 -0
  25. gpconf/corpus/derived/kvn-variants/v03-units-brackets-leading-zeros.provenance.json +21 -0
  26. gpconf/corpus/derived/kvn-variants/v04-comments-blank-lines-whitespace-LF.kvn +32 -0
  27. gpconf/corpus/derived/kvn-variants/v04-comments-blank-lines-whitespace-LF.provenance.json +24 -0
  28. gpconf/corpus/derived/kvn-variants/v05-omm-3.0-header-optional-keywords-omitted.kvn +23 -0
  29. gpconf/corpus/derived/kvn-variants/v05-omm-3.0-header-optional-keywords-omitted.provenance.json +21 -0
  30. gpconf/corpus/derived/kvn-variants/v06-signed-integers-lowercase-exponent.kvn +27 -0
  31. gpconf/corpus/derived/kvn-variants/v06-signed-integers-lowercase-exponent.provenance.json +20 -0
  32. gpconf/corpus/fixtures/alpha5-encoding-vectors/case.md +56 -0
  33. gpconf/corpus/fixtures/alpha5-encoding-vectors/expected.json +677 -0
  34. gpconf/corpus/fixtures/alpha5-tle-derived/case.md +58 -0
  35. gpconf/corpus/fixtures/alpha5-tle-derived/expected.json +41753 -0
  36. gpconf/corpus/fixtures/analyst-objects/case.md +91 -0
  37. gpconf/corpus/fixtures/analyst-objects/expected.json +42959 -0
  38. gpconf/corpus/fixtures/baseline-iss-five-formats/case.md +67 -0
  39. gpconf/corpus/fixtures/baseline-iss-five-formats/expected.json +618 -0
  40. gpconf/corpus/fixtures/bstar-and-derivative-forms/case.md +65 -0
  41. gpconf/corpus/fixtures/bstar-and-derivative-forms/expected.json +7225 -0
  42. gpconf/corpus/fixtures/csv-json-omitted-mandatory-fields/case.md +67 -0
  43. gpconf/corpus/fixtures/csv-json-omitted-mandatory-fields/expected.json +887 -0
  44. gpconf/corpus/fixtures/epoch-year-19xx/case.md +77 -0
  45. gpconf/corpus/fixtures/epoch-year-19xx/expected.json +643 -0
  46. gpconf/corpus/fixtures/kvn-syntax-variants/case.md +52 -0
  47. gpconf/corpus/fixtures/kvn-syntax-variants/expected.json +811 -0
  48. gpconf/corpus/fixtures/mean-motion-derivative-convention/case.md +69 -0
  49. gpconf/corpus/fixtures/mean-motion-derivative-convention/expected.json +3523 -0
  50. gpconf/corpus/fixtures/nine-digit-supgp-launch-nominals/case.md +69 -0
  51. gpconf/corpus/fixtures/nine-digit-supgp-launch-nominals/expected.json +402 -0
  52. gpconf/corpus/fixtures/omm-xml-schema/case.md +59 -0
  53. gpconf/corpus/fixtures/omm-xml-schema/expected.json +435 -0
  54. gpconf/corpus/fixtures/satcat-70000-cutoff/case.md +57 -0
  55. gpconf/corpus/fixtures/satcat-70000-cutoff/expected.json +327 -0
  56. gpconf/corpus/fixtures/six-digit-omm-saramago/case.md +76 -0
  57. gpconf/corpus/fixtures/six-digit-omm-saramago/expected.json +832 -0
  58. gpconf/corpus/fixtures/supgp-celestrak-classification-c/case.md +67 -0
  59. gpconf/corpus/fixtures/supgp-celestrak-classification-c/expected.json +347 -0
  60. gpconf/corpus/fixtures/tle-omits-six-digit-objects/case.md +56 -0
  61. gpconf/corpus/fixtures/tle-omits-six-digit-objects/expected.json +9105 -0
  62. gpconf/corpus/fixtures/tle-vs-omm-precision-loss/case.md +85 -0
  63. gpconf/corpus/fixtures/tle-vs-omm-precision-loss/expected.json +9581 -0
  64. gpconf/corpus/fixtures/tle-writer-alpha5/case.md +86 -0
  65. gpconf/corpus/fixtures/tle-writer-alpha5/expected.json +27062 -0
  66. gpconf/corpus/manifest.json +2423 -0
  67. gpconf/corpus/tools/fetchlist.json +478 -0
  68. gpconf/corpus/vectors/alpha5.json +400 -0
  69. gpconf/corpus/vectors/ccsds-epoch-strings.json +75 -0
  70. gpconf/corpus/vectors/norad-cat-id-text.json +65 -0
  71. gpconf/corpus/vectors/two-digit-epoch-year.json +54 -0
  72. gpconf/fetch.py +443 -0
  73. gpconf/gates.py +162 -0
  74. gpconf/locate.py +111 -0
  75. gpconf/presets.py +212 -0
  76. gpconf/reference.py +380 -0
  77. gpconf/runner.py +1304 -0
  78. gpconf/tle.py +201 -0
  79. gpconf/writer.py +363 -0
  80. gpconf-0.3.0.dist-info/METADATA +506 -0
  81. gpconf-0.3.0.dist-info/RECORD +85 -0
  82. gpconf-0.3.0.dist-info/WHEEL +5 -0
  83. gpconf-0.3.0.dist-info/entry_points.txt +2 -0
  84. gpconf-0.3.0.dist-info/licenses/LICENSE +30 -0
  85. gpconf-0.3.0.dist-info/top_level.txt +1 -0
gpconf/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """gpconf -- runner for the GP/OMM conformance corpus (standard library only, Python 3.9+)."""
2
+
3
+ __version__ = "0.3.0"
4
+
5
+ from .tle import to_alpha5, from_alpha5, checksum # noqa: F401
6
+ from .reference import read_file # noqa: F401
gpconf/__main__.py ADDED
@@ -0,0 +1,203 @@
1
+ """python -m gpconf: run a parser against the corpus and print a report; fetch: fetch the provider data the corpus
2
+ does not ship; list: the cases; check-tle: check a TLE file a tool wrote."""
3
+ import argparse
4
+ import importlib
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ from . import __version__
10
+ from . import locate
11
+ from .runner import Runner, CommandParser, CorpusIncomplete
12
+
13
+
14
+ def load_adapter(spec):
15
+ mod, _, attr = spec.partition(":")
16
+ sys.path.insert(0, os.getcwd())
17
+ m = importlib.import_module(mod)
18
+ obj = getattr(m, attr or "Parser")
19
+ return obj() if isinstance(obj, type) else obj
20
+
21
+
22
+ def print_missing(results, unfetched, runner):
23
+ """Below the count line: what was not fetched, so that neither a case with no data nor a case that ran on part
24
+ of its data reads as a result it has not earned (D-148)."""
25
+ partial = [r for r in results if r.status != "not-fetched" and r.missing()]
26
+ if not unfetched and not partial:
27
+ return
28
+ if unfetched:
29
+ print(f"{len(unfetched)} case(s) have none of their provider files on disk and report not-fetched; "
30
+ "skip is reserved for a parser with no reader for a format or no hook for a check.")
31
+ if partial:
32
+ files = [f for r in partial for f in r.missing()]
33
+ print(f"{len(partial)} case(s) ran without {len(files)} of their provider files, so each result covers only the files on disk "
34
+ f"(column n/f): {', '.join(r.case_id for r in partial)}.")
35
+ if any("recapture" in os.path.basename(f) for f in files):
36
+ # corrected by D-150: a normal fetch never requests a re-capture, so a later run does not bring it
37
+ print(" A re-capture file is the same endpoint requested a second time when the corpus was built; the fetch requests it "
38
+ "only with --include-recaptures, once its original is at least two hours old, so a normal fetch leaves these cases without it.")
39
+ # the fetch command only where a normal fetch would bring something: a missing re-capture it never requests (D-150)
40
+ if runner.fetch_hints and any("recapture" not in os.path.basename(f) for r in results for f in r.missing()):
41
+ print(f"Provider data is not shipped with the corpus; fetch it with {runner.hint()}.")
42
+
43
+
44
+ def check_tle(args):
45
+ """check-tle FILE... [--against RECORDS] [--json OUT]: the writer checks applied to files a tool wrote."""
46
+ from . import writer as W
47
+ if not args.files:
48
+ print("check-tle needs at least one TLE file", file=sys.stderr)
49
+ return 2
50
+ against = W.load_against(args.against) if args.against else None
51
+ print(f"gpconf {__version__} | check-tle | {len(args.files)} file(s)" + (f" | round trip against {args.against}" if args.against else ""))
52
+ report, n_pass, n_fail = [], 0, 0
53
+ for path in args.files:
54
+ with open(path, "rb") as f:
55
+ text = f.read().decode("utf-8", "replace")
56
+ results = W.check_file(text, against)
57
+ print(f"\n{path}: {len(results)} record(s)" + ("" if results else " (no element line found)"))
58
+ for r in results:
59
+ ident = repr(r["catalog_field"]) + (f" -> {r['norad_cat_id']}" if r["norad_cat_id"] is not None else "")
60
+ head = "unpaired element line" if r.get("unpaired") else (r["name"] or "(no name line)")
61
+ print(f" [{r['status']}] line {r['line_number']}: {head} | catalog field {ident}")
62
+ for p in r["problems"]:
63
+ print(f" {p}")
64
+ for n in r["notes"]:
65
+ print(f" note: {n}")
66
+ if r.get("round_trip"):
67
+ print(" round trip against the source record: " + ", ".join(f"{k} {v}" for k, v in r["round_trip"]["conventions"].items()))
68
+ if r.get("secondary_fields"):
69
+ print(" secondary fields (information): " + ", ".join(f"{k} {v}" for k, v in r["secondary_fields"].items() if k != "zero_ddot_sign"))
70
+ n_pass += r["status"] == "pass"
71
+ n_fail += r["status"] == "fail"
72
+ report.append({"file": path, "records": results})
73
+ print(f"\n{n_pass + n_fail} record(s): {n_pass} pass, {n_fail} fail"
74
+ + ("" if n_pass + n_fail else "; nothing to check"))
75
+ if args.json:
76
+ import datetime as _dt
77
+ with open(args.json, "w") as f:
78
+ json.dump({"gpconf": __version__, "command": "check-tle", "against": args.against,
79
+ "generated_at": _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "files": report}, f, indent=1, default=str)
80
+ return 1 if n_fail or not (n_pass + n_fail) else 0
81
+
82
+
83
+ def main(argv=None):
84
+ argv = sys.argv[1:] if argv is None else list(argv)
85
+ if argv[:1] == ["fetch"]: # its own options (tools/fetch.py's), parsed by gpconf/fetch.py (D-150)
86
+ from .fetch import run as fetch_run
87
+ return fetch_run(argv[1:], prog="gpconf fetch")
88
+ ap = argparse.ArgumentParser(prog="gpconf", description="GP/OMM conformance corpus runner",
89
+ epilog="gpconf fetch --help: the fetch subcommand's own options")
90
+ ap.add_argument("command", choices=["run", "list", "presets", "check-tle", "fetch"])
91
+ ap.add_argument("files", nargs="*", help="check-tle: TLE file(s) written by the tool under test")
92
+ ap.add_argument("--against", help="check-tle: the source records the lines were written from (CSV/JSON/XML/KVN/TLE); enables the round-trip check")
93
+ ap.add_argument("--preset", help="a shipped adapter by name: reference, naive, sgp4, pyephem, satellite.js, tle.js or tle.js-api (gpconf presets lists them)")
94
+ ap.add_argument("--module", help="Node presets: the library's entry file, for a checkout the working directory cannot import by name")
95
+ ap.add_argument("--adapter", help="python import path module:attr of a parser object or class")
96
+ ap.add_argument("--cmd", help="external command; raw bytes on stdin, JSON array on stdout; {fmt} substituted; exit 3 = unsupported format")
97
+ ap.add_argument("--vectors-cmd", help="external command for vector hooks (JSON {op,input} on stdin -> {result}|{error})")
98
+ ap.add_argument("--write-cmd", help="external TLE writer for the writer case: one JSON record on stdin, 2-3 TLE lines on stdout; exit 3 = unsupported, other non-zero = refused")
99
+ ap.add_argument("--root", help="corpus root (default: the clone this package sits in, else the corpus installed with it)")
100
+ ap.add_argument("--data", help=f"folder holding the fetched provider files (default: ${locate.DATA_ENV}, else the clone, else a per-user cache folder)")
101
+ ap.add_argument("--no-fetch-hint", action="store_true", help="do not name the command that fetches provider data, for a run that is "
102
+ "offline by design, such as the GitHub Action's, whose data folder is empty and discarded with the job")
103
+ ap.add_argument("--case", action="append", help="case id (repeatable)")
104
+ ap.add_argument("--tag", action="append", help="test tag (repeatable)")
105
+ ap.add_argument("--json", help="write the full report to this file")
106
+ ap.add_argument("--verbose", "-v", action="store_true", help="print every item, not only failures and within-tolerance passes")
107
+ args = ap.parse_args(argv)
108
+
109
+ if args.command == "check-tle": # needs no corpus: it checks the user's own file
110
+ return check_tle(args)
111
+ if args.command == "presets":
112
+ from .presets import listing
113
+ print("\n".join(listing()))
114
+ return 0
115
+ try:
116
+ loc = locate.resolve(args.root, args.data)
117
+ except locate.CorpusNotFound as e:
118
+ print(f"gpconf: {e}", file=sys.stderr)
119
+ return 2
120
+ root = loc["corpus"]
121
+ manifest = json.load(open(os.path.join(root, "manifest.json")))
122
+ if args.command == "list":
123
+ for c in manifest["cases"]:
124
+ print(f"{c['id']:40s} {c['kind']:12s} {', '.join(c['tests'])}")
125
+ return 0
126
+ chosen = [f for f in ("preset", "adapter", "cmd") if getattr(args, f)] + (["write-cmd"] if args.write_cmd and not args.cmd else [])
127
+ if not chosen:
128
+ ap.error("run needs --preset, --adapter, --cmd or --write-cmd")
129
+ if args.preset and len(chosen) > 1:
130
+ ap.error("--preset names the parser; it cannot be combined with --adapter, --cmd or --write-cmd")
131
+ if args.module and not args.preset:
132
+ ap.error("--module applies to the Node presets")
133
+ preset = None
134
+ if args.preset:
135
+ from .presets import load as load_preset, PresetUnavailable
136
+ try:
137
+ parser, preset = load_preset(args.preset, module=args.module)
138
+ except PresetUnavailable as e:
139
+ print(f"gpconf: {e}", file=sys.stderr)
140
+ return 2
141
+ elif args.cmd or args.write_cmd:
142
+ parser = CommandParser(args.cmd, args.vectors_cmd, write_cmd=args.write_cmd)
143
+ else:
144
+ parser = load_adapter(args.adapter)
145
+ parser_name = preset["label"] if preset else (args.cmd or args.write_cmd or args.adapter)
146
+ runner = Runner(parser, root=root, verbose=args.verbose, data=loc["data"], data_why=loc["data_why"], fetch_hints=not args.no_fetch_hint)
147
+ try:
148
+ results = runner.run(case_ids=args.case, tags=args.tag)
149
+ except CorpusIncomplete as e:
150
+ print(f"gpconf: {e}", file=sys.stderr)
151
+ return 2
152
+ print(f"gpconf {__version__} | corpus {manifest['corpus_version']} | parser: {parser_name}")
153
+ if runner.data != root: # a clone's output is unchanged; elsewhere the reader is told where the provider files were looked for
154
+ print(f"provider data: {runner.data} ({runner.data_why})")
155
+ print()
156
+ print(f"{'case':40s} {'status':15s} exact tol fail skip n/e n/f")
157
+ for r in results:
158
+ c = r.counts()
159
+ print(f"{r.case_id:40s} {r.status:15s} {c['pass']:5d} {c['pass-tolerance']:4d} {c['fail']:4d} {c['skip']:4d} {c['not-exercised']:3d} {c['not-fetched']:3d}")
160
+ print()
161
+ from .gates import compute_gates
162
+ gates = compute_gates(results, root, runner.data)
163
+ for g in gates:
164
+ print(f"gate {g['name']}: {g['headline']}")
165
+ print()
166
+ for r in results:
167
+ shown = [i for i in r.items if args.verbose or i.status in ("fail", "pass-tolerance")]
168
+ if shown:
169
+ print(f"== {r.case_id}")
170
+ for i in shown:
171
+ print(f" [{i.status}] {i.check} ({i.file or '-'}): {i.detail}")
172
+ drifted = [(r.case_id, p) for r in results for p in r.drift]
173
+ if drifted:
174
+ print("\nSTABLE SOURCE DRIFT: " + ", ".join(f"{c}: {p}" for c, p in drifted)
175
+ + "\n These stable-tier files differ from the tested snapshot; the frozen expected values were not applied to them and the parser was compared against the reference reader instead."
176
+ + (" Run " + runner.hint("--check-drift") + "." if runner.fetch_hints else ""))
177
+ modes = {m for r in results for m in r.modes.values()}
178
+ if "live" in modes:
179
+ print("\nnote: some sources hash differently from the tested snapshot; for those, values were compared against the corpus's own reference reader, not the human-verified snapshot.")
180
+ if args.json:
181
+ import datetime as _dt
182
+ with open(args.json, "w") as f:
183
+ json.dump({"gpconf": __version__, "corpus_version": manifest["corpus_version"], "parser": parser_name,
184
+ **({"preset": {k: preset.get(k) for k in ("name", "library", "found", "tested_with", "runtime") if k != "runtime" or preset.get(k)}} if preset else {}),
185
+ "generated_at": _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
186
+ "results": [r.as_dict() for r in results], "gates": gates}, f, indent=1, default=str)
187
+ failed = sum(1 for r in results if r.status == "fail")
188
+ tol = sum(1 for r in results if r.status == "pass-tolerance")
189
+ unfetched = [r for r in results if r.status == "not-fetched"]
190
+ print(f"\n{len(results)} case(s): {sum(1 for r in results if r.status == 'pass')} pass (exact), {tol} pass within tolerance, {failed} fail, "
191
+ f"{sum(1 for r in results if r.status == 'skip')} skip, {len(unfetched)} need fetched data, {sum(1 for r in results if r.status == 'not-exercised')} not exercised")
192
+ print_missing(results, unfetched, runner)
193
+ reused = {p: v for r in results for p, v in r.reused.items()}
194
+ if reused: # D-157: the report says which provider files were copied from an earlier version's cache, not fetched
195
+ print(f"{len(reused)} provider file(s) were reused from corpus {', '.join(sorted(set(map(str, reused.values()))))}'s cache "
196
+ "rather than fetched: stable tier, bytes matching this version's recorded SHA-256.")
197
+ if tol:
198
+ print("'pass within tolerance' items list per-field count, mean signed difference and maximum, so a systematic bias is visible (see README, Tolerances).")
199
+ return 1 if failed else 0
200
+
201
+
202
+ if __name__ == "__main__":
203
+ sys.exit(main())
@@ -0,0 +1,2 @@
1
+ """The adapters behind `gpconf run --preset` (D-151): reference, naive, sgp4 (python-sgp4) and pyephem (PyEphem).
2
+ gpconf/presets.py names them, the library each exposes and the version each was tested against."""
@@ -0,0 +1,142 @@
1
+ """
2
+ Preset `naive`: a demonstration of failure, not a parser anyone should use. It is the parser many projects
3
+ actually have, written deliberately with the common shortcuts so that the runner's failures document what breaks
4
+ in the migration:
5
+ * catalog number read with int() from five columns / assumed five characters wide
6
+ * two-digit year pivot present, but OBJECT_ID's year sliced unconditionally
7
+ * OMM epoch parsed with the single format '%Y-%m-%dT%H:%M:%S.%f'
8
+ * classification assumed to be 'U'
9
+ * KVN parsed as strict 'KEY = value' with float() on the value
10
+ * every CelesTrak CSV/JSON column assumed present
11
+ * TLE written with the catalog number through an integer format (five digits assumed, never refused)
12
+ """
13
+ import csv
14
+ import datetime as dt
15
+ import io
16
+ import json
17
+ import xml.etree.ElementTree as ET
18
+
19
+
20
+ def _year(yy):
21
+ yy = int(yy)
22
+ return 1900 + yy if yy >= 57 else 2000 + yy
23
+
24
+
25
+ def _tle_epoch(field):
26
+ year = _year(field[:2])
27
+ day = float(field[2:])
28
+ return (dt.datetime(year, 1, 1) + dt.timedelta(days=day - 1)).strftime("%Y-%m-%dT%H:%M:%S.%f")
29
+
30
+
31
+ def _exp(f):
32
+ f = f.strip()
33
+ return float(f"{f[:-2]}e{f[-2:]}".replace("-", "-0.", 1) if f.startswith("-") else f"0.{f[:-2]}e{f[-2:]}")
34
+
35
+
36
+ class Parser:
37
+ def parse(self, raw, fmt):
38
+ text = raw.decode("utf-8")
39
+ if fmt in ("tle", "2le"):
40
+ return self._tle(text)
41
+ if fmt == "csv":
42
+ return [self._omm(r) for r in csv.DictReader(io.StringIO(text))]
43
+ if fmt == "json":
44
+ return [self._omm(r) for r in json.loads(text)]
45
+ if fmt == "xml":
46
+ root = ET.fromstring(text)
47
+ out = []
48
+ for seg in root.iter("segment"):
49
+ f = {}
50
+ for blk in ("metadata", "data/meanElements", "data/tleParameters"):
51
+ for e in seg.find(blk):
52
+ f[e.tag] = e.text
53
+ out.append(self._omm(f))
54
+ return out
55
+ if fmt == "kvn":
56
+ out, cur = [], None
57
+ for line in text.splitlines():
58
+ if not line.strip():
59
+ continue
60
+ k, v = [x.strip() for x in line.split("=", 1)]
61
+ if k == "CCSDS_OMM_VERS":
62
+ cur = {}
63
+ out.append(cur)
64
+ cur[k] = v
65
+ return [self._omm(f) for f in out]
66
+ raise ValueError(fmt)
67
+
68
+ def _tle(self, text):
69
+ lines = text.splitlines()
70
+ out = []
71
+ for i, l in enumerate(lines):
72
+ if l.startswith("1 ") and lines[i + 1].startswith("2 "):
73
+ l1, l2 = l, lines[i + 1]
74
+ cat = int(l1[2:7]) # breaks on Alpha-5
75
+ assert l1[7] == "U", "classification" # breaks on 'C'
76
+ out.append({
77
+ "norad_cat_id": cat, "object_name": (lines[i - 1].strip() if i and not lines[i - 1].startswith(("1 ", "2 ")) else None),
78
+ "object_id": f"{_year(l1[9:11])}-{l1[11:17].strip()}", # invents '1900-' + blanks for empty designator? no: _year(' ') raises
79
+ "epoch": _tle_epoch(l1[18:32]), "mean_motion": float(l2[52:63]), "eccentricity": float("0." + l2[26:33]),
80
+ "inclination": float(l2[8:16]), "ra_of_asc_node": float(l2[17:25]), "arg_of_pericenter": float(l2[34:42]),
81
+ "mean_anomaly": float(l2[43:51]), "bstar": _exp(l1[53:61]), "mean_motion_dot": float(l1[33:43]),
82
+ "mean_motion_ddot": _exp(l1[44:52]), "ephemeris_type": int(l1[62]), "classification_type": l1[7],
83
+ "element_set_no": int(l1[64:68]), "rev_at_epoch": int(l2[63:68]),
84
+ })
85
+ return out
86
+
87
+ def _omm(self, f):
88
+ cat = int(f["NORAD_CAT_ID"])
89
+ assert len(f"{cat:05d}") == 5, "catalog number wider than five digits" # breaks on 6- and 9-digit ids
90
+ epoch = dt.datetime.strptime(f["EPOCH"], "%Y-%m-%dT%H:%M:%S.%f") # breaks on day-of-year / Z / no fraction
91
+ launch_year = int(f["OBJECT_ID"][:4]) # breaks on empty OBJECT_ID (and None)
92
+ assert f["CLASSIFICATION_TYPE"] == "U" # breaks on 'C'
93
+ return {
94
+ "norad_cat_id": cat, "object_name": f["OBJECT_NAME"], "object_id": f["OBJECT_ID"], "epoch": epoch,
95
+ "mean_motion": float(f["MEAN_MOTION"]), "eccentricity": float(f["ECCENTRICITY"]), "inclination": float(f["INCLINATION"]),
96
+ "ra_of_asc_node": float(f["RA_OF_ASC_NODE"]), "arg_of_pericenter": float(f["ARG_OF_PERICENTER"]), "mean_anomaly": float(f["MEAN_ANOMALY"]),
97
+ "bstar": float(f["BSTAR"]), "mean_motion_dot": float(f["MEAN_MOTION_DOT"]), "mean_motion_ddot": float(f["MEAN_MOTION_DDOT"]),
98
+ "ephemeris_type": int(f["EPHEMERIS_TYPE"]), "classification_type": f["CLASSIFICATION_TYPE"],
99
+ "element_set_no": int(f["ELEMENT_SET_NO"]), "rev_at_epoch": int(f["REV_AT_EPOCH"]),
100
+ }
101
+
102
+ def write_tle(self, record):
103
+ """The writer many projects have: printf-style fields, the catalog number as an integer."""
104
+ r = record
105
+ sat = f"{int(r['norad_cat_id']):05d}" # six digits above 99999; '-0001' for -1; never refuses
106
+ t = dt.datetime.strptime(str(r["epoch"])[:26], "%Y-%m-%dT%H:%M:%S.%f")
107
+ doy = (t - dt.datetime(t.year, 1, 1)).total_seconds() / 86400.0 + 1
108
+ epoch = f"{t.year % 100:02d}{doy:012.8f}"
109
+ oid = r.get("object_id") or ""
110
+ desig = (oid[2:4] + oid[5:]) if oid else ""
111
+
112
+ def exp(v):
113
+ v = float(v or 0)
114
+ if v == 0:
115
+ return " 00000-0"
116
+ m, e = f"{abs(v):.4e}".split("e")
117
+ return ("-" if v < 0 else " ") + m.replace(".", "") + f"{int(e) + 1:+d}"
118
+
119
+ ndot = float(r.get("mean_motion_dot") or 0)
120
+ nd = ("-" if ndot < 0 else " ") + f"{abs(ndot):.8f}"[1:] # ' .00004770' / '-.00003657'
121
+ l1 = f"1 {sat}U {desig:<8} {epoch} {nd} {exp(r.get('mean_motion_ddot'))} {exp(r.get('bstar'))} 0 {int(r.get('element_set_no') or 0):4d}"
122
+ l2 = (f"2 {sat} {float(r['inclination']):8.4f} {float(r['ra_of_asc_node']):8.4f} {float(r['eccentricity']) * 1e7:07.0f} "
123
+ f"{float(r['arg_of_pericenter']):8.4f} {float(r['mean_anomaly']):8.4f} {float(r['mean_motion']):11.8f}{int(r.get('rev_at_epoch') or 0):5d}")
124
+
125
+ def cs(l):
126
+ return str(sum(int(c) if c.isdigit() else (c == "-") for c in l) % 10)
127
+ return l1 + cs(l1), l2 + cs(l2)
128
+
129
+ def alpha5_decode(self, field):
130
+ return int(field) # breaks on any letter
131
+
132
+ def alpha5_encode(self, n):
133
+ return f"{n:05d}" # produces six digits above 99999, never a letter
134
+
135
+ def two_digit_year(self, yy):
136
+ return _year(yy)
137
+
138
+ def parse_catalog_id(self, text):
139
+ return int(text) # accepts ten digits; no width check
140
+
141
+ def parse_epoch(self, text):
142
+ return dt.datetime.strptime(text, "%Y-%m-%dT%H:%M:%S.%f")
@@ -0,0 +1,29 @@
1
+ // Preflight for a Node preset (D-153, D-154): can the working directory import the library? Run by gpconf as
2
+ // `node --input-type=module --eval <this source> -- <package>` before any case, so that a missing Node library is a
3
+ // setup error, never a parse failure charged to the library. Prints one JSON object: ok, node, entry, version, error.
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+
8
+ const [name] = process.argv.slice(1);
9
+ const target = process.env.GPCONF_NODE_MODULE;
10
+ const out = { ok: false, node: process.versions.node };
11
+ try {
12
+ await import(target ? pathToFileURL(target).href : name);
13
+ let entry = target || null;
14
+ if (!entry) {
15
+ try { entry = fileURLToPath(import.meta.resolve(name)); } catch { entry = null; }
16
+ }
17
+ out.entry = entry;
18
+ // the version: the nearest package.json above the entry that names the library (a maintainer's checkout included)
19
+ for (let d = entry ? dirname(entry) : null; d && d !== dirname(d); d = dirname(d)) {
20
+ const p = join(d, 'package.json');
21
+ if (!existsSync(p)) continue;
22
+ const pj = JSON.parse(readFileSync(p, 'utf8'));
23
+ if (pj.name === name) { out.version = pj.version; break; }
24
+ }
25
+ out.ok = true;
26
+ } catch (e) {
27
+ out.error = `${e.code || e.name}: ${e.message}`.slice(0, 300);
28
+ }
29
+ process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,55 @@
1
+ """Preset `pyephem`: PyEphem (the `ephem` package, an optional dependency, tested with 4.2.1). TLE and 2LE only;
2
+ every other format is Unsupported.
3
+
4
+ Exposes the library as-is: `ephem.readtle()` per element set. An element set `readtle()` refuses is dropped, as in
5
+ the corpus's hand run of 2026-09-24 (corpus D-128; reported upstream as pyephem#296); this adapter does not yet report
6
+ refusals through the runner's refusal channel (D-144), so the runner counts them as dropped. Moved into the package
7
+ from the hand run's harness (D-151) with its parsing unchanged.
8
+ """
9
+ import math
10
+
11
+ try:
12
+ import ephem
13
+ except ImportError as e: # pragma: no cover
14
+ raise ImportError("this adapter needs PyEphem: pip install ephem") from e
15
+
16
+ from gpconf.runner import Unsupported
17
+
18
+
19
+ def _rec(s, name):
20
+ d = s._epoch.datetime() # ephem.Date -> naive UTC datetime, microsecond resolution
21
+ return {
22
+ "norad_cat_id": s.catalog_number,
23
+ "object_name": name or None,
24
+ "epoch": d,
25
+ "mean_motion": s._n,
26
+ "eccentricity": s._e,
27
+ "inclination": math.degrees(s._inc),
28
+ "ra_of_asc_node": math.degrees(s._raan),
29
+ "arg_of_pericenter": math.degrees(s._ap),
30
+ "mean_anomaly": math.degrees(s._M),
31
+ "bstar": s._drag,
32
+ "mean_motion_dot": s._decay,
33
+ # mean_motion_ddot: PyEphem keeps no second derivative (no attribute on EarthSatellite); omitted on purpose
34
+ "rev_at_epoch": s._orbit,
35
+ # object_id, classification_type, element_set_no, ephemeris_type: not exposed by PyEphem
36
+ }
37
+
38
+
39
+ class Parser:
40
+ def parse(self, raw, fmt):
41
+ if fmt not in ("tle", "2le"):
42
+ raise Unsupported(fmt)
43
+ lines = raw.decode("utf-8").splitlines()
44
+ out = []
45
+ for i, l in enumerate(lines):
46
+ if l.startswith("1 ") and i + 1 < len(lines) and lines[i + 1].startswith("2 "):
47
+ name = lines[i - 1].strip() if i and not lines[i - 1].startswith(("1 ", "2 ")) else ""
48
+ # readtle() rejects an empty name (db_tle returns -1 when the stripped name is empty), so a
49
+ # name-less 2LE pair gets the catalog field as its name; object_name is then not reported
50
+ call_name = name or l[2:7]
51
+ try:
52
+ out.append(_rec(ephem.readtle(call_name, l, lines[i + 1]), name))
53
+ except Exception: # noqa: BLE001 -- the library refused the element set: dropped, as in the hand run
54
+ pass
55
+ return out
@@ -0,0 +1,75 @@
1
+ """Preset `reference`: the corpus's own readers, the control. It passes every case that exercises a parser, since it
2
+ produced the expected values. Standard library only."""
3
+ import datetime as dt
4
+
5
+ from gpconf import reference as ref
6
+ from gpconf import tle as tlemod
7
+ from gpconf.runner import CORE_FIELDS, OPTIONAL_FIELDS
8
+
9
+ FMT = {"tle": ref.read_tle_text, "2le": ref.read_tle_text}
10
+
11
+
12
+ class Parser:
13
+ def parse(self, raw, fmt):
14
+ text = raw.decode("utf-8")
15
+ if fmt in ("tle", "2le"):
16
+ recs = ref.read_tle_text(text)
17
+ elif fmt == "csv":
18
+ recs, _ = ref.read_csv_text(text)
19
+ elif fmt == "json":
20
+ recs, _ = ref.read_json_text(text)
21
+ elif fmt == "xml":
22
+ recs, _ = ref.read_xml_text(text)
23
+ elif fmt == "kvn":
24
+ recs, _ = ref.read_kvn_text(text)
25
+ else:
26
+ from gpconf.runner import Unsupported
27
+ raise Unsupported(fmt)
28
+ out = []
29
+ for r in recs:
30
+ d = {k: r.get(k) for k in CORE_FIELDS + OPTIONAL_FIELDS if r.get(k) is not None or k in CORE_FIELDS}
31
+ out.append(d)
32
+ return out
33
+
34
+ def write_tle(self, record):
35
+ # the corpus's own renderer (CelesTrak conventions: eccentricity truncated, mantissas rounded half up);
36
+ # to_alpha5 raises for a catalog number the TLE field cannot carry, which is the correct answer
37
+ return tlemod.render(tlemod.omm_fields_from_record(record), mantissa_mode="round", ecc_mode="truncate")
38
+
39
+ def alpha5_decode(self, field):
40
+ if len(field) != 5 or field != field.strip():
41
+ raise ValueError("field must be five characters")
42
+ return tlemod.from_alpha5(field)
43
+
44
+ def alpha5_encode(self, n):
45
+ return tlemod.to_alpha5(n)
46
+
47
+ def two_digit_year(self, yy):
48
+ return ref.two_digit_year(yy)
49
+
50
+ def parse_catalog_id(self, text):
51
+ import re
52
+ s = text.strip()
53
+ if not re.fullmatch(r"\+?\d{1,9}", s): # CCSDS: integer of up to nine digits; KVN allows a leading sign and zeros
54
+ raise ValueError(f"not a NORAD_CAT_ID: {text!r}")
55
+ return int(s)
56
+
57
+ def parse_epoch(self, text):
58
+ import re
59
+ m = re.fullmatch(r"(\d{4})-(?:(\d{2})-(\d{2})|(\d{3}))T(\d{2}):(\d{2}):(\d{2})(\.\d+)?Z?", text)
60
+ if not m:
61
+ raise ValueError(text)
62
+ s = text.rstrip("Z")
63
+ frac = m.group(8) or ""
64
+ base = s[: len(s) - len(frac)]
65
+ fmt = "%Y-%jT%H:%M:%S" if m.group(4) else "%Y-%m-%dT%H:%M:%S"
66
+ try:
67
+ t = dt.datetime.strptime(base, fmt)
68
+ except ValueError:
69
+ if base.endswith(":60"): # leap second: valid per CCSDS, not representable in datetime
70
+ t = dt.datetime.strptime(base[:-3] + ":59", fmt)
71
+ else:
72
+ raise
73
+ if frac:
74
+ t = t + dt.timedelta(microseconds=int(round(float("0" + frac) * 1e6)))
75
+ return t
@@ -0,0 +1,77 @@
1
+ // Preset `satellite.js` (tested with 7.1.0): satellite.js as-is through the runner's command protocol. Raw bytes on
2
+ // stdin, a JSON array of records on stdout, exit 3 for a format the library has no reader for. twoline2satrec for
3
+ // tle and 2le, json2satrec for json; csv, xml and kvn are unsupported. Moved into the package from the corpus's
4
+ // hand run (D-132), its parsing unchanged.
5
+ // gpconf runs this file as `node --input-type=module --eval <this source> -- <arguments>` from the working directory,
6
+ // so the library resolves as it would for a script in the user's project; a file outside the project could not import
7
+ // it (D-154). GPCONF_NODE_MODULE, set by `--module PATH`, names the library's entry file instead.
8
+ import { readFileSync } from 'node:fs';
9
+ import { pathToFileURL } from 'node:url';
10
+
11
+ const target = process.env.GPCONF_NODE_MODULE;
12
+ const { twoline2satrec, json2satrec } = await import(target ? pathToFileURL(target).href : 'satellite.js');
13
+
14
+ const [fmt] = process.argv.slice(1);
15
+ const XPDOTP = 1440.0 / (2.0 * Math.PI); // satellite.js constants.xpdotp
16
+ const RAD2DEG = 180.0 / Math.PI;
17
+
18
+ function isoEpoch(epochyr, epochdays) {
19
+ // the library's own pivot (io.js line 102); day-of-year form with microseconds, from the double the library kept
20
+ const year = epochyr < 57 ? epochyr + 2000 : epochyr + 1900;
21
+ let doy = Math.floor(epochdays);
22
+ let us = Math.round((epochdays - doy) * 86400e6);
23
+ if (us >= 86400e6) { us -= 86400e6; doy += 1; }
24
+ const h = Math.floor(us / 3600e6), m = Math.floor((us % 3600e6) / 60e6), s = Math.floor((us % 60e6) / 1e6), f = us % 1e6;
25
+ const p = (n, w) => String(n).padStart(w, '0');
26
+ return `${year}-${p(doy, 3)}T${p(h, 2)}:${p(m, 2)}:${p(s, 2)}.${p(f, 6)}`;
27
+ }
28
+
29
+ function record(s, name) {
30
+ const raw = s.satnum; // a string in this library, never decoded
31
+ // digits -> int (what any consumer must do); anything else (an Alpha-5 field) is passed through as the string the
32
+ // library returns, and the runner reports the value and its type (corpus D-131)
33
+ const id = /^\s*\d+\s*$/.test(raw) ? Number(raw) : raw;
34
+ const mm = s.nokozai ?? s.no_kozai ?? s.no; // the Kozai mean motion as parsed, if the library keeps it apart
35
+ return {
36
+ norad_cat_id: id,
37
+ _catalog_field: raw,
38
+ object_name: name || null,
39
+ epoch: isoEpoch(s.epochyr, s.epochdays),
40
+ mean_motion: mm * XPDOTP,
41
+ eccentricity: s.ecco,
42
+ inclination: s.inclo * RAD2DEG,
43
+ ra_of_asc_node: s.nodeo * RAD2DEG,
44
+ arg_of_pericenter: s.argpo * RAD2DEG,
45
+ mean_anomaly: s.mo * RAD2DEG,
46
+ bstar: s.bstar,
47
+ mean_motion_dot: s.ndot * XPDOTP * 1440.0,
48
+ mean_motion_ddot: s.nddot * XPDOTP * 1440.0 * 1440.0,
49
+ _mm_field: s.nokozai !== undefined ? 'nokozai' : (s.no_kozai !== undefined ? 'no_kozai' : 'no'),
50
+ };
51
+ }
52
+
53
+ const raw = readFileSync(0, 'utf8');
54
+ const out = [];
55
+ if (fmt === 'tle' || fmt === '2le') {
56
+ const lines = raw.split(/\r?\n/);
57
+ for (let i = 0; i < lines.length; i++) {
58
+ const l = lines[i];
59
+ if (l.startsWith('1 ') && i + 1 < lines.length && lines[i + 1].startsWith('2 ')) {
60
+ const name = i > 0 && !/^[12] /.test(lines[i - 1]) ? lines[i - 1].trim() : '';
61
+ try { out.push(record(twoline2satrec(l, lines[i + 1]), name)); }
62
+ catch (e) { process.stderr.write(`satellite.js refused line ${i + 1}: ${e && e.message}\n`); }
63
+ }
64
+ }
65
+ } else if (fmt === 'json') {
66
+ let data;
67
+ try { data = JSON.parse(raw); } catch (e) { process.stderr.write(`not JSON: ${e.message}\n`); process.exit(1); }
68
+ for (const obj of Array.isArray(data) ? data : [data]) {
69
+ try { out.push(record(json2satrec(obj), obj.OBJECT_NAME)); }
70
+ catch (e) { process.stderr.write(`satellite.js refused record ${obj && obj.NORAD_CAT_ID}: ${e && e.message}\n`); }
71
+ }
72
+ } else {
73
+ process.exit(3);
74
+ }
75
+ // NaN and Infinity have no JSON form: JSON.stringify writes null, which the runner reads as "absent"; say so on stderr
76
+ for (const r of out) for (const [k, v] of Object.entries(r)) if (typeof v === 'number' && !Number.isFinite(v)) process.stderr.write(`non-finite ${k} for ${r.norad_cat_id}: ${v} -> null\n`);
77
+ process.stdout.write(JSON.stringify(out));