7 August 2026 · 🔭 Astronomy
When the N.I.N.A. Orbitals Plugin Stops Loading MPC Comet Data – at Starfront and Other Remote-Hosting Sites
On my first attempt at a comet I was 19 degrees off. Half of that came down to the Orbitals plugin in N.I.N.A. being unable to load its comet data from the Minor Planet Center.
The error has been coming reliably ever since:
Failed to update comet elements. A connection attempt failed because the connected party did not properly respond after a period of time… (www.minorplanetcenter.net:443)
Two reflexes came to me immediately – and both lead nowhere. One: just switch to JPL. The other: download the file by hand and drop it into the folder. Here is why neither works, and what does help instead.
Reflex 1: “I will just use JPL, then”
The plugin can fetch comet elements from two sources. With JPL the download runs reliably, the file is smaller, the parser more robust. Tempting.
Except that this is exactly the source that cost me the first night. JPL publishes its orbital elements with the epoch of the orbit solution – for 10P/Tempel 2 that is 2016. For JPL this is no problem, because Horizons integrates those values numerically, with planetary perturbations and an outgassing model. The plugin, however, computes a two-body solution, and over ten years that adds up to 9.3 degrees.
The MPC, by contrast, keeps the epoch up to date. For comets it is the right source – which is why the detour is worth taking.
With asteroids the question does not arise: those always come from JPL; there is no choice.
Diagnosis first: is it me or the MPC?
Before you repair anything, here is the test that gave me the decisive piece of information – two minutes, no tools. Open the URL
https://www.minorplanetcenter.net/iau/MPCORB/CometEls.txt
in a browser on both machines: once at home, once on the machine at the rig in Starfront.
| Finding | Meaning |
|---|---|
| Nothing at home, nothing on the rig either | The MPC itself is having a problem right now. Try again later. |
| It arrives at home, and on the rig straight away | It was the twelve-hour block. Click Update once and let it run. |
| It arrives at home, and on the rig after more than 100 seconds | The plugin will never manage it – it aborts hard at 100 s. |
| It arrives at home, and on the rig not at all | The site is blocked. That is my case. |
In my case the MacBook in Denmark gets the file immediately, while the Beelink in Texas runs into the timeout. So it is neither a plugin bug nor a server outage. It comes down to the address the machine in Texas presents to the outside world.
Why an entire site shares one block
The MPC rule has been in place since 25 February 2015:
“…only one download of each file per 12 hours is permitted per IP address. Subsequent attempts to download the same file within 12 hours will return a blank file… Due to the way that the scheme works, an aborted download will trigger the 12-hour block.”
Per IP address. Not per user, not per machine.
By its own account, Starfront is the largest remote observatory in the world: over 900 telescopes in operation, spread across 14 observatory buildings, with more than 900 customers. All of it is connected through one shared fibre link – Starfront itself calls it the “fiber backbone”, and every tenant gets an Ethernet socket from it at their pier. The FAQ expressly describes that bandwidth as a “communal resource”.
And whoever shares a line generally shares the public IPv4 address as well. To the MPC we are then not 900 observatories but a single user.
And CometEls.txt is precisely the file everyone pulls: Orbitals, Stellarium, Astrometrica, SkySafari, TheSkyX, every comet script there is. With over 900 rigs on the site, one single neighbour whose software updates automatically in the morning is enough – and the whole site's allowance is gone before I have got up. And because even an aborted attempt triggers the block, the rigs keep it alive for one another.
Which yields a rule that applies to a lone telescope at home just as much: after a timeout, do not click again. Every attempt restarts the twelve hours. That is exactly the trap I walked into on the first evening.
One detail that does not quite fit
For all its plausibility, one thing does not add up. What the MPC describes as the consequence of the block is a blank file, not a timeout. A blank file would look different in the plugin: the download would run through and the parser would find zero comets.
A connection timeout points to something harder. The MPC also blocks conspicuous addresses outright; the status history says so several times, along the lines of “the offending IP addresses have been blocked”. Over 900 machines behind one address look, from outside, exactly like a single user hammering the server – precisely what the block was built against.
That remains a supposition. Equally possible: a firewall at the site, a routing problem, an IPv6 problem. All that is certain is that my MacBook gets the same file and the machine in Texas does not.
If you want to know for sure: open ifconfig.me on the rig and compare the address with a neighbour's. The same address for everyone means a shared allowance. You can report that to the MPC – but it is the host's IP, so it is a case for the operator, not for an individual tenant.
Reflex 2: “Then I will just copy the file across by hand”
Obvious enough, but it does not work. I checked that in the plugin's source code – it is open under MPL-2.0, and I had Claude AI analyse it.
The result in one sentence: the downloaded text file is never saved. It is parsed straight out of the HTTP stream, and only the result lands on disk – as a GZip-compressed binary file in protobuf-net format. The text parser runs exclusively in the download path; on start-up the plugin only reads that binary file.
So putting a CometEls.txt into the folder achieves precisely nothing. Anyone wanting to feed the data in by hand has to produce the binary file themselves.
It lives here:
%LOCALAPPDATA%\NINA\OrbitalElements\
| File | read when |
|---|---|
MPC_CometElements.bin.gz | source = MPC |
CometElements.bin.gz | source = JPL |
NumberedAsteroidsElements.bin.gz | always (asteroids) |
UnnumberedAsteroidsElements.bin.gz | always (asteroids) |
Two side findings that matter in practice:
The timeout is hard-wired at 100 seconds – the plugin sets no timeout of its own, the .NET default applies, and there is no setting for it. If the MPC needs longer, it is hopeless.
“Last Updated” comes from the file date, not from a database. Before every download the plugin compares that date with the server's Last-Modified and reports “already up to date” if the local file is newer. So a self-made file also holds off future update attempts – which here is exactly what you want.
The script
mpc_to_orbitals.py downloads CometEls.txt (or reads a local copy), parses the columns and writes the binary file in the expected format. It needs no third-party libraries, only Python 3, and runs on Windows, macOS and Linux.
⬇️ Download mpc_to_orbitals.py · 8.6 kB, Python 3, no dependencies – identical to the listing at the end of this article.
What matters is which step you carry out on which machine:
| Step | where |
|---|---|
| Download | at home – on the rig it is precisely what does not work |
| Convert | either machine will do |
| Drop it in and restart N.I.N.A. | on the rig |
So the machine at home is needed solely for the download. That gives two variants; the only difference is what you transfer.
Route A: transfer the finished binary file
Recommended, because then no Python has to be installed on the rig at all. The binary file is platform-neutral; it makes no difference which operating system produced it.
At home:
curl -O https://www.minorplanetcenter.net/iau/MPCORB/CometEls.txt python3 mpc_to_orbitals.py --input CometEls.txt --out ~/Desktop/MPC_CometElements.bin.gz python3 mpc_to_orbitals.py --verify --out ~/Desktop/MPC_CometElements.bin.gz
On the rig: copy the MPC_CometElements.bin.gz across – via the remote desktop clipboard or a network drive – to
C:\Users\<user>\AppData\Local\NINA\OrbitalElements\
then restart N.I.N.A.
Route B: transfer the text file
Needs Python on the rig, but then you do not have to know the target path – the script finds it itself.
At home: download only.
curl -O https://www.minorplanetcenter.net/iau/MPCORB/CometEls.txt
On the rig: copy the CometEls.txt over and have it converted, then restart N.I.N.A.
python mpc_to_orbitals.py --input CometEls.txt python mpc_to_orbitals.py --verify
Instead of restarting N.I.N.A. you can also toggle the source in the plugin, MPC → JPL → MPC; that triggers a reload as well.
One pitfall when transferring: the .bin.gz must not be unpacked on the way. Safari likes to expand downloaded .gz files automatically. Copying over RDP or a network drive does not do that, but if you send the file by email or through the cloud, check afterwards: it has to end in .bin.gz and be only a few hundred kilobytes in size.
A bug that will catch everyone sooner or later
While rebuilding the parser I came across issue #12, open since March 2026. The MPC occasionally ships a record with nothing but asterisks where a number should be:
CJ54O010 2085 07 20.7056 ********* 9.999999 76.9423 ... C/1954 O1 (Vozarova)
The plugin's parser throws an exception on that, and the entire update fails – a single broken line among thousands takes everything with it. The reporter suggested simply skipping such records. Nothing has happened so far.
My script skips them and says afterwards how many there were.
The complete script
⬇️ Download mpc_to_orbitals.py · 8.6 kB, Python 3, no dependencies – identical to the listing at the end of this article.
#!/usr/bin/env python3
"""
mpc_to_orbitals.py — fetch comet orbital elements from the Minor Planet Center
and write them in the binary format used by the N.I.N.A. "Orbitals" plugin.
Background: the plugin downloads CometEls.txt from the MPC, parses it in memory
and stores the result as a GZip-compressed protobuf-net stream. The text file
itself is never saved and never read back from disk. So if the built-in download
does not work for you, you have to produce that binary file yourself.
Target: %LOCALAPPDATA%\\NINA\\OrbitalElements\\MPC_CometElements.bin.gz
(only read when the plugin's comet source is set to MPC)
Usage:
python mpc_to_orbitals.py # download from MPC and write
python mpc_to_orbitals.py --input C.txt # use a local CometEls.txt
python mpc_to_orbitals.py --verify # read the written file back
python mpc_to_orbitals.py --out X.bin.gz # different target path
Runs on Windows, macOS and Linux. Standard library only, no dependencies.
"""
import argparse, gzip, math, os, struct, sys, urllib.request
MPC_URL = "https://www.minorplanetcenter.net/iau/MPCORB/CometEls.txt"
GM_SUN = 1.32712440018e20 # m^3/s^2, same as GravitationalParameter.Sun
RAD = math.pi / 180.0
# ---------------------------------------------------------------- protobuf ---
def _varint(n):
out = bytearray()
while True:
b = n & 0x7F
n >>= 7
out.append(b | (0x80 if n else 0))
if not n:
return bytes(out)
def _f64(field, value):
return bytes([(field << 3) | 1]) + struct.pack("<d", value)
def _bytes(field, payload):
return bytes([(field << 3) | 2]) + _varint(len(payload)) + payload
def _gm(field, value):
inner = _f64(1, value) if value != 0.0 else b"" # protobuf-net omits defaults
return _bytes(field, inner)
def encode_element(name, epoch_jd, q_au, ecc, i_rad, w_rad, node_rad, tp_jd):
"""One OrbitalElements record as declared in Kepler.cs."""
m = _bytes(1, name.encode("utf-8"))
m += _gm(2, GM_SUN) # PrimaryGravitationalParameter
m += _gm(3, 0.0) # SecondaryGravitationalParameter
m += _f64(4, epoch_jd)
m += _f64(5, q_au)
m += _f64(6, ecc)
m += _f64(7, i_rad)
m += _f64(8, w_rad)
m += _f64(9, node_rad)
m += _f64(10, tp_jd)
# Fields 11 (M_MeanAnomalyAtEpoch) and 12 (a_SemiMajorAxis_au) are left out
# (null), exactly like MPCCometElements.ToOrbitalElements() does.
return _bytes(1, m) # SerializeWithLengthPrefix(Base128, field number 1)
# -------------------------------------------------------------------- date ---
def julian_date(year, month, day, hour=0.0):
"""NOVAS julian_date(): integer day number plus fraction of day.
Careful: C integer division truncates toward zero, Python's // floors.
For (month-14)/12 that differs for months 1..13 and puts the result
two days off.
"""
a = math.trunc((month - 14) / 12)
jd = (day - 32075
+ 1461 * (year + 4800 + a) // 4
+ 367 * (month - 2 - a * 12) // 12
- 3 * ((year + 4900 + a) // 100) // 4)
return jd - 0.5 + hour / 24.0
# ------------------------------------------------------------------ parser ---
COLS = dict(number=(0,4), otype=(4,5), prov=(5,14), tp_y=(14,19), tp_m=(19,22),
tp_d=(22,30), q=(30,41), e=(41,51), w=(51,61), node=(61,71),
i=(71,81), epoch=(81,89), name=(100,159))
def _num(s):
s = s.strip()
if not s or "*" in s:
# The MPC occasionally ships asterisks instead of a value. The plugin
# aborts the whole update on those (issue #12); we skip the record.
raise ValueError(f"unusable value {s!r}")
return float(s)
def parse_line(line):
g = lambda k: line[COLS[k][0]:COLS[k][1]]
name = g("name").strip()
if not name:
raise ValueError("no name")
tp_y, tp_m = int(g("tp_y")), int(g("tp_m"))
tp_d = _num(g("tp_d"))
day = int(tp_d)
tp_jd = julian_date(tp_y, tp_m, day, (tp_d - day) * 24.0)
ep = g("epoch").strip()
epoch_jd = (julian_date(int(ep[0:4]), int(ep[4:6]), int(ep[6:8]))
if len(ep) == 8 else float("nan"))
# Angles go in as radians; a is derived from q and e by the plugin at runtime.
return dict(name=name, epoch_jd=epoch_jd, q_au=_num(g("q")), ecc=_num(g("e")),
i_rad=_num(g("i")) * RAD, w_rad=_num(g("w")) * RAD,
node_rad=_num(g("node")) * RAD, tp_jd=tp_jd)
# ------------------------------------------------------------------ reader ---
def read_back(path, limit=5):
"""Minimal decoder, used by --verify to sanity-check what was written."""
data = gzip.open(path, "rb").read()
pos, n, shown = 0, 0, []
while pos < len(data):
assert data[pos] == 0x0A, f"unexpected tag at {pos}"
pos += 1
ln, shift = 0, 0
while True:
b = data[pos]; pos += 1
ln |= (b & 0x7F) << shift; shift += 7
if not b & 0x80: break
msg, pos = data[pos:pos+ln], pos + ln
n += 1
if len(shown) < limit:
p, rec = 0, {}
while p < len(msg):
tag = msg[p]; p += 1
fld, wt = tag >> 3, tag & 7
if wt == 1:
rec[fld] = struct.unpack("<d", msg[p:p+8])[0]; p += 8
elif wt == 2:
l2, sh = 0, 0
while True:
b = msg[p]; p += 1
l2 |= (b & 0x7F) << sh; sh += 7
if not b & 0x80: break
val, p = msg[p:p+l2], p + l2
rec[fld] = val.decode("utf-8") if fld == 1 else val
shown.append(rec)
return n, shown
# -------------------------------------------------------------------- main ---
def default_out():
"""Default target on Windows only; elsewhere --out is required."""
base = os.environ.get("LOCALAPPDATA")
if not base:
sys.exit("No %LOCALAPPDATA% found (not Windows?).\n"
"Please give a target, e.g.:\n"
" --out ~/Desktop/MPC_CometElements.bin.gz\n"
"then copy the finished file to\n"
" C:\\Users\\<user>\\AppData\\Local\\NINA\\OrbitalElements\\\n"
"without unpacking it.")
return os.path.join(base, "NINA", "OrbitalElements", "MPC_CometElements.bin.gz")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--input", help="local CometEls.txt instead of downloading")
ap.add_argument("--out", default=None, help="target path for the .bin.gz")
ap.add_argument("--timeout", type=int, default=600,
help="download timeout in seconds (default 600; "
"the plugin itself is stuck at 100)")
ap.add_argument("--verify", action="store_true",
help="read the written file back and show the first records")
a = ap.parse_args()
out = a.out or default_out()
if a.verify:
n, recs = read_back(out)
print(f"{out}\n{n} records\n")
for r in recs:
print(f" {r[1]:<34} q={r[5]:.6f} e={r[6]:.6f} "
f"i={math.degrees(r[7]):8.4f}deg tp={r[10]:.4f}")
return
if a.input:
text = open(a.input, encoding="utf-8", errors="replace").read()
else:
print(f"Downloading {MPC_URL} (timeout {a.timeout}s) ...")
req = urllib.request.Request(MPC_URL, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"Accept": "text/plain,*/*"})
with urllib.request.urlopen(req, timeout=a.timeout) as r:
text = r.read().decode("utf-8", errors="replace")
print(f" {len(text):,} characters received")
ok, bad = [], 0
for line in text.splitlines():
if len(line) < 100:
continue
try:
ok.append(parse_line(line))
except Exception:
bad += 1
if not ok:
sys.exit("No valid records found - is this really CometEls.txt?")
# Write to a temp file first, then swap it in, so a crash cannot leave
# the plugin with a half-written file.
d = os.path.dirname(out)
if d:
os.makedirs(d, exist_ok=True)
tmp = out + ".tmp"
with gzip.open(tmp, "wb", compresslevel=9) as f:
for e in ok:
f.write(encode_element(e["name"], e["epoch_jd"], e["q_au"], e["ecc"],
e["i_rad"], e["w_rad"], e["node_rad"], e["tp_jd"]))
os.replace(tmp, out)
print(f"\n{len(ok)} comets written, {bad} bad lines skipped")
print(f"-> {out}")
print("\nRestart N.I.N.A. (or toggle the source MPC->JPL->MPC), then check")
print("that 'Comets (...)' shows the expected count.")
if __name__ == "__main__":
main()
Sources
ghilios/NINA.Joko.Plugin.Orbitals – source code, MPL-2.0
Issue #12: “Orbitals is failing with a bad record from MPC on comet updates”
MPC status page – download block per IP address, since 25 Feb 2015
MPC – Contacting the Minor Planet Center – reporting blocked IP addresses
Starfront Observatories – About Us and FAQ – number of telescopes, buildings, network connection (as of August 2026)
JPL Horizons – comparison ephemeris
🔭 How it came to this: My first comet – and why I was 19 degrees off → · The rig behind it: My rig @ Starfront in Texas