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.
- gpconf/__init__.py +6 -0
- gpconf/__main__.py +203 -0
- gpconf/adapters/__init__.py +2 -0
- gpconf/adapters/naive.py +142 -0
- gpconf/adapters/node_preflight.mjs +29 -0
- gpconf/adapters/pyephem_adapter.py +55 -0
- gpconf/adapters/reference.py +75 -0
- gpconf/adapters/satellitejs.mjs +77 -0
- gpconf/adapters/sgp4_adapter.py +78 -0
- gpconf/adapters/tlejs.mjs +49 -0
- gpconf/adapters/tlejs_vectors.mjs +34 -0
- gpconf/corpus/derived/alpha5-tle/alpha5-A-100000-saramago-first.provenance.json +34 -0
- gpconf/corpus/derived/alpha5-tle/alpha5-A-100000-saramago-first.tle +3 -0
- gpconf/corpus/derived/alpha5-tle/alpha5-A-last-30-days-snapshot.provenance.json +289 -0
- gpconf/corpus/derived/alpha5-tle/alpha5-A-last-30-days-snapshot.tle +768 -0
- gpconf/corpus/derived/alpha5-tle/alpha5-T-270449-analyst-first.provenance.json +34 -0
- gpconf/corpus/derived/alpha5-tle/alpha5-T-270449-analyst-first.tle +3 -0
- gpconf/corpus/derived/alpha5-tle/alpha5-T-analyst-27xxxx-snapshot.provenance.json +379 -0
- gpconf/corpus/derived/alpha5-tle/alpha5-T-analyst-27xxxx-snapshot.tle +1038 -0
- gpconf/corpus/derived/kvn-variants/v01-baseline-reserialised.kvn +27 -0
- gpconf/corpus/derived/kvn-variants/v01-baseline-reserialised.provenance.json +19 -0
- gpconf/corpus/derived/kvn-variants/v02-day-of-year-epoch-Z.kvn +27 -0
- gpconf/corpus/derived/kvn-variants/v02-day-of-year-epoch-Z.provenance.json +19 -0
- gpconf/corpus/derived/kvn-variants/v03-units-brackets-leading-zeros.kvn +27 -0
- gpconf/corpus/derived/kvn-variants/v03-units-brackets-leading-zeros.provenance.json +21 -0
- gpconf/corpus/derived/kvn-variants/v04-comments-blank-lines-whitespace-LF.kvn +32 -0
- gpconf/corpus/derived/kvn-variants/v04-comments-blank-lines-whitespace-LF.provenance.json +24 -0
- gpconf/corpus/derived/kvn-variants/v05-omm-3.0-header-optional-keywords-omitted.kvn +23 -0
- gpconf/corpus/derived/kvn-variants/v05-omm-3.0-header-optional-keywords-omitted.provenance.json +21 -0
- gpconf/corpus/derived/kvn-variants/v06-signed-integers-lowercase-exponent.kvn +27 -0
- gpconf/corpus/derived/kvn-variants/v06-signed-integers-lowercase-exponent.provenance.json +20 -0
- gpconf/corpus/fixtures/alpha5-encoding-vectors/case.md +56 -0
- gpconf/corpus/fixtures/alpha5-encoding-vectors/expected.json +677 -0
- gpconf/corpus/fixtures/alpha5-tle-derived/case.md +58 -0
- gpconf/corpus/fixtures/alpha5-tle-derived/expected.json +41753 -0
- gpconf/corpus/fixtures/analyst-objects/case.md +91 -0
- gpconf/corpus/fixtures/analyst-objects/expected.json +42959 -0
- gpconf/corpus/fixtures/baseline-iss-five-formats/case.md +67 -0
- gpconf/corpus/fixtures/baseline-iss-five-formats/expected.json +618 -0
- gpconf/corpus/fixtures/bstar-and-derivative-forms/case.md +65 -0
- gpconf/corpus/fixtures/bstar-and-derivative-forms/expected.json +7225 -0
- gpconf/corpus/fixtures/csv-json-omitted-mandatory-fields/case.md +67 -0
- gpconf/corpus/fixtures/csv-json-omitted-mandatory-fields/expected.json +887 -0
- gpconf/corpus/fixtures/epoch-year-19xx/case.md +77 -0
- gpconf/corpus/fixtures/epoch-year-19xx/expected.json +643 -0
- gpconf/corpus/fixtures/kvn-syntax-variants/case.md +52 -0
- gpconf/corpus/fixtures/kvn-syntax-variants/expected.json +811 -0
- gpconf/corpus/fixtures/mean-motion-derivative-convention/case.md +69 -0
- gpconf/corpus/fixtures/mean-motion-derivative-convention/expected.json +3523 -0
- gpconf/corpus/fixtures/nine-digit-supgp-launch-nominals/case.md +69 -0
- gpconf/corpus/fixtures/nine-digit-supgp-launch-nominals/expected.json +402 -0
- gpconf/corpus/fixtures/omm-xml-schema/case.md +59 -0
- gpconf/corpus/fixtures/omm-xml-schema/expected.json +435 -0
- gpconf/corpus/fixtures/satcat-70000-cutoff/case.md +57 -0
- gpconf/corpus/fixtures/satcat-70000-cutoff/expected.json +327 -0
- gpconf/corpus/fixtures/six-digit-omm-saramago/case.md +76 -0
- gpconf/corpus/fixtures/six-digit-omm-saramago/expected.json +832 -0
- gpconf/corpus/fixtures/supgp-celestrak-classification-c/case.md +67 -0
- gpconf/corpus/fixtures/supgp-celestrak-classification-c/expected.json +347 -0
- gpconf/corpus/fixtures/tle-omits-six-digit-objects/case.md +56 -0
- gpconf/corpus/fixtures/tle-omits-six-digit-objects/expected.json +9105 -0
- gpconf/corpus/fixtures/tle-vs-omm-precision-loss/case.md +85 -0
- gpconf/corpus/fixtures/tle-vs-omm-precision-loss/expected.json +9581 -0
- gpconf/corpus/fixtures/tle-writer-alpha5/case.md +86 -0
- gpconf/corpus/fixtures/tle-writer-alpha5/expected.json +27062 -0
- gpconf/corpus/manifest.json +2423 -0
- gpconf/corpus/tools/fetchlist.json +478 -0
- gpconf/corpus/vectors/alpha5.json +400 -0
- gpconf/corpus/vectors/ccsds-epoch-strings.json +75 -0
- gpconf/corpus/vectors/norad-cat-id-text.json +65 -0
- gpconf/corpus/vectors/two-digit-epoch-year.json +54 -0
- gpconf/fetch.py +443 -0
- gpconf/gates.py +162 -0
- gpconf/locate.py +111 -0
- gpconf/presets.py +212 -0
- gpconf/reference.py +380 -0
- gpconf/runner.py +1304 -0
- gpconf/tle.py +201 -0
- gpconf/writer.py +363 -0
- gpconf-0.3.0.dist-info/METADATA +506 -0
- gpconf-0.3.0.dist-info/RECORD +85 -0
- gpconf-0.3.0.dist-info/WHEEL +5 -0
- gpconf-0.3.0.dist-info/entry_points.txt +2 -0
- gpconf-0.3.0.dist-info/licenses/LICENSE +30 -0
- gpconf-0.3.0.dist-info/top_level.txt +1 -0
gpconf/__init__.py
ADDED
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())
|
gpconf/adapters/naive.py
ADDED
|
@@ -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));
|