# SPDX-License-Identifier: MIT
"""Build the figures for YS-EN-0002 from results.json and write them into the
note between markers.
Usage: python build_figures.py (after parallax_fix.py)
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))
import svgchart as sc # noqa: E402
PAGE = HERE.parent.parent / "ys-en-0002-navigating-by-parallax.html"
R = json.loads((HERE / "results.json").read_text())
AU_KM = 149_597_870.7
# colour follows the star in every figure
STAR = {"Proxima Centauri": 1, "Barnard's Star": 2, "Wolf 359": 3}
def arcsec(v):
return f"{v:.3g}″" if v < 100 else f"{v:,.0f}″"
# ---------------------------------------------------------------- figure 1
def fig_shift():
s = R["shift"]
xs = s["baseline_au"]
series = [{"slot": STAR[n], "name": n, "values": s[n]} for n in STAR]
fr = sc.Frame(720, 360, "Maximum parallax shift of Proxima Centauri, Barnard's Star and Wolf 359 versus baseline")
fr.x_scale(1, 1000, log=True)
fr.y_scale(0.3, 1000, log=True)
fr.grid_y([1, 10, 100, 1000], fmt=lambda v: f"{v:,.0f}″")
fr.grid_x([1, 10, 100, 1000], fmt=lambda v: f"{v:,.0f} au", lines=False)
fr.baseline()
for x, lab, dy in ((1, "Earth's orbit", 0), (47.1176, "New Horizons, 2020", 0), (171.93, "Voyager 1, 2026", 14)):
xx = fr.x(x)
fr.out.append(f'')
fr.note(xx + 5, fr.mt + 11 + dy, lab)
fr.titles("Baseline perpendicular to the line of sight (log)", "Apparent shift (log)")
fr.cursor()
sc.draw_lines(fr, xs, series)
# the measured shifts, Earth–New Horizons separation 46.85 au
for name, v in (("Proxima Centauri", 32.36), ("Wolf 359", 15.72)):
cx, cy = fr.x(46.85), fr.y(v)
fr.out.append(f'')
fr.note(fr.x(46.85) - 9, fr.y(32.36) - 9, "measured, 2020", anchor="end")
sc.column_hits(fr, xs, series, lambda i, x: f"Baseline {x:,.3g} au", arcsec)
rows = [[f"{x:,.4g} au"] + [arcsec(s[n][i]) for n in STAR] for i, x in enumerate(xs) if i % 5 == 0]
rows.append(["46.85 au, measured 2020", "32.36″", "—", "15.72″"])
return sc.figure(
code="FIG. 1 — PARALLAX SHIFT VS BASELINE",
fid="fig-shift",
plot=fr.svg(),
legend_html=sc.legend(series) .replace("", 'Measured by New Horizons (Lauer et al.)'),
caption=("Figure 1. The largest possible shift, when the baseline is perpendicular to the line of "
"sight, from Gaia DR3 distances. The diamonds are the two shifts measured between Earth and New "
"Horizons; they sit below the lines because the real baseline was at an angle to each star."),
data_html=sc.table("Maximum shift by baseline", ["Baseline", *STAR], rows),
)
# ---------------------------------------------------------------- figure 2 (diagram)
def fig_lines():
W, H = 720, 360
sun = (120, 300)
craft = (430, 250)
stars = {"Proxima Centauri": (60, 40, "1.30 pc"), "Wolf 359": (700, 30, "2.41 pc")}
o = [f'")
return sc.figure(
code="FIG. 2 — LINES OF POSITION",
fid="fig-lines",
plot="\n".join(o),
caption=("Figure 2. Each measured direction, drawn back through the star's catalogue position, is "
"a line the spacecraft must lie on. Pointing error σ turns each line into a band whose width at "
"the spacecraft is the star's distance times σ, so the farther star gives the wider band. "
"Schematic, not to scale: the stars are thousands of times farther away than drawn."),
)
# ---------------------------------------------------------------- figure 3
def fig_scatter():
mc = R["monte_carlo"]
a = R["nh_fix"]["axes_au"]
fx, fy = mc["lorri_fix_in_basis"][:2]
span_x, span_y = 1.5, 0.85
pw = 720 - 58 - 132
ph = round(pw * (2 * span_y) / (2 * span_x)) # equal scale on both axes
fr = sc.Frame(720, ph + 72, "Monte Carlo cloud of 4000 simulated fixes, with the actual LORRI fix and the true position",
margins=(58, 132, 26, 46))
fr.x_scale(-span_x, span_x)
fr.y_scale(-span_y, span_y)
fr.grid_y([-0.5, 0, 0.5], fmt=lambda v: f"{v:+.1f}" if v else "0")
fr.grid_x([-1, -0.5, 0, 0.5, 1], fmt=lambda v: f"{v:+.1f}" if v else "0")
fr.titles("Along the ellipsoid's long axis, roughly toward Proxima (au)", "Middle axis (au)")
for x, y in mc["sample"]:
fr.out.append(f'')
for k, op in ((1, 0.9), (2, 0.5)):
rx = (fr.x(a[0] * k) - fr.x(0))
ry = (fr.y(0) - fr.y(a[1] * k))
fr.out.append(f'')
fr.note(fr.x(0) + rx * 0.72 + 4, fr.y(0) - ry * 0.72 - 4, f"{k}σ")
tx, ty = fr.x(0), fr.y(0)
fr.out.append(f'')
fr.note(tx + 10, ty + 16, "JPL / DSN (truth)", cls="ink")
px, py = fr.x(fx), fr.y(fy)
fr.out.append(f'')
fr.note(px + 10, py - 8, "LORRI fix", cls="ink")
e = R["nh_fix"]["error_norm_au"]
sc.point_hit(fr, tx, ty, 0, "True position (JPL)", "3|Offset|0 au")
sc.point_hit(fr, px, py, 1, "Fix from LORRI images",
f"1|Long axis|{fx:+.3f} au;1|Middle axis|{fy:+.3f} au;1|Total error (3D)|{e:.3f} au")
std = mc["std_along_axes_au"]
rows = [["Long axis", f"{a[0]:.3f} au", f"{std[0]:.3f} au", f"{fx:+.3f} au"],
["Middle axis", f"{a[1]:.3f} au", f"{std[1]:.3f} au", f"{fy:+.3f} au"],
["Short axis", f"{a[2]:.3f} au", f"{std[2]:.3f} au", f"{mc['lorri_fix_in_basis'][2]:+.3f} au"]]
leg = ('
Simulated fixes (σ = 0.18″)'
'Actual LORRI fixTrue position
')
return sc.figure(
code="FIG. 3 — 4,000 FIXES THAT COULD HAVE BEEN",
fid="fig-scatter",
plot=fr.svg(),
legend_html=leg,
caption=(f"Figure 3. We re-observed the true directions {mc['n']:,} times with 0.18″ of random "
"error per star and solved each time; 700 of the fixes are plotted, projected onto the "
"ellipsoid's two longest axes (equal scale). Their spread matches the analytic covariance, "
f"and the real fix sits inside the 1σ ellipse in this view ({R['nh_fix']['error_sigma']:.2f}σ in 3D)."),
data_html=sc.table("Error ellipsoid: analytic vs Monte Carlo",
["Axis", "1σ (analytic)", "1σ (Monte Carlo)", "LORRI fix offset"], rows),
)
# ---------------------------------------------------------------- figure 4
def fig_dop():
d = R["dop"]
xs = d["sep_deg"]
series = [
{"slot": 2, "name": "2nd star at 1.83 pc (Barnard's)", "end": "at 1.83 pc", "values": d["with_barnard_distance"]},
{"slot": 3, "name": "2nd star at 2.41 pc (Wolf 359's)", "end": "at 2.41 pc", "values": d["with_wolf_distance"]},
]
fr = sc.Frame(720, 330, "Long axis of the error ellipsoid versus angle between the two stars")
fr.x_scale(0, 180)
fr.y_scale(1, 30, log=True)
fr.grid_y([1, 2, 3, 5, 10, 20, 30], fmt=lambda v: f"{v:g}")
fr.grid_x([0, 30, 60, 90, 120, 150, 180], fmt=lambda v: f"{v:.0f}°", lines=False)
fr.baseline()
fr.titles("Angle between the two stars, seen from the Sun", "Long semi-axis, au per arcsecond (log)")
fr.cursor()
sc.draw_lines(fr, xs, series)
pairs = {(p["a"], p["b"]): p for p in R["pairs"]}
for key, slot, lab, dy in ((("Proxima Centauri", "Barnard's Star"), 2, "Proxima + Barnard's", 16),
(("Proxima Centauri", "Wolf 359"), 3, "Proxima + Wolf 359 (used)", -10)):
p = pairs[key]
cx, cy = fr.x(p["sep_deg"]), fr.y(p["long"])
fr.out.append(f'')
fr.note(cx, cy + dy, f'{lab}: {p["long"]:.2f}', anchor="middle", cls="ink")
sc.column_hits(fr, xs, series, lambda i, x: f"Stars {x}° apart", lambda v: f"{v:.2f} au/″")
rows = [[f"{x}°", f"{a:.2f}", f"{b:.2f}"] for x, a, b in zip(xs, d["with_barnard_distance"], d["with_wolf_distance"]) if x % 15 == 0]
return sc.figure(
code="FIG. 4 — GEOMETRY: WHY THE ANGLE MATTERS",
fid="fig-dop",
plot=fr.svg(),
legend_html=sc.legend(series),
caption=("Figure 4. Proxima Centauri plus a second star at a fixed distance, swept around the sky. "
"The error blows up as the two lines of sight become parallel (0° or 180°). It is smallest "
"at 90°, where the long axis equals the farther star's distance in parsecs. The two real pairs are marked at "
"their Gaia DR3 separations."),
data_html=sc.table("Long semi-axis (au per arcsec) by separation", ["Separation", "2nd at 1.83 pc", "2nd at 2.41 pc"], rows),
)
# ---------------------------------------------------------------- figure 5 (table)
def fig_pairs():
paper = {("Proxima Centauri", "Barnard's Star"): "1.91 / 1.06", ("Proxima Centauri", "Wolf 359"): "2.45 / 1.15",
("Proxima Centauri", "Lalande 21185"): "2.70 / 1.16", ("Proxima Centauri", "Ross 154"): "3.64 / 1.19",
("Barnard's Star", "Wolf 359"): "2.58 / 1.46", ("Barnard's Star", "Lalande 21185"): "2.60 / 1.49",
("Barnard's Star", "Ross 154"): "6.54 / 1.56", ("Wolf 359", "Lalande 21185"): "7.00 / 1.75",
("Wolf 359", "Ross 154"): "3.80 / 1.87", ("Lalande 21185", "Ross 154"): "4.24 / 1.93"}
rows = []
for p in sorted(R["pairs"], key=lambda p: p["long"]):
rows.append([f'{p["a"]} + {p["b"]}', f'{p["sep_deg"]:.1f}°', f'{p["long"]:.2f} / {p["short"]:.2f}', paper[(p["a"], p["b"])]])
tbl = sc.table("Error semi-axes for every pair, au per arcsecond of pointing error",
["Pair", "Separation", "Long / short (this note)", "Lauer et al. Table 4"], rows)
return (f'
'
f'FIG. 5 — STAR PAIRS, RANKED
'
f'
{tbl}
Figure 5. Every pair of the five candidate stars within '
f"3 pc (Lauer et al. Table 4), best first. Multiply by the pointing error in arcseconds to get the error "
f"in au. Lalande 21185 is HD 95735 and Ross 154 is CD−23 14742 in the paper.")
# ---------------------------------------------------------------- figure 6
def fig_ladder():
y = R["ysnav"]
items = [ # label, km, kind (1 measured, 2 projected/simulated, 3 derived/specified), note
("YS·NAV 7 course hold: ±0.02 ly", y["fix_au"] * AU_KM, 3, "our specification, whole crossing"),
("1″ camera, best pair", y["fix_at_1arcsec_au"] * AU_KM, 3, "this note, pair solve"),
("LORRI 2020, 1σ long axis", 0.44 * AU_KM, 1, "Lauer et al., formal 1σ"),
("LORRI 2020, actual error", 0.351 * AU_KM, 1, "Lauer et al."),
("LORRI, 4× images + Barnard's", 0.17 * AU_KM, 2, "Lauer et al., projected"),
("Few-mas camera, nearest pair", 0.01 * AU_KM, 2, "Lauer et al., projected"),
("YS·NAV 7 live fix: ±0.01 au", 0.01 * AU_KM, 3, "our specification"),
("20 stars at 1 mas, light-years out", 0.003 * AU_KM, 2, "Bailer-Jones, simulated"),
("DSN ΔDOR, plane of sky, 47 au", 0.281 * 47.1, 3, "DSN handbook, 281 m at 1 au, scaled"),
("X-ray pulsars, low Earth orbit", 10, 1, "Mitchell et al., < 10 km"),
("DSN ranging, line of sight", 0.001, 3, "≈ 1 m, handbook figure"),
]
row_h = 30
fr = sc.Frame(720, 50 + row_h * len(items) + 40, "Position accuracy of navigation methods, log scale from 1 metre to 0.02 light-years",
margins=(232, 24, 30, 46))
fr.x_scale(1e-3, 1e12, log=True)
fr.y_scale(0, 1)
ticks = [1e-3, 1, 1e3, 1e6, 1e9, 1e12]
names = {1e-3: "1 m", 1: "1 km", 1e3: "1,000 km", 1e6: "10⁶ km", 1e9: "10⁹ km", 1e12: "10¹² km"}
fr.out.append('')
for t in ticks:
fr.out.append(f'')
fr.out.append("")
for t in ticks:
fr.out.append(f'{names[t]}')
au = fr.x(AU_KM)
fr.out.append(f'')
fr.note(au + 5, fr.mt - 8, "1 au")
fr.titles("Position uncertainty (log scale)")
for i, (lab, km, kind, note) in enumerate(items):
cy = fr.mt + row_h * (i + 0.5) + 6
fr.out.append(f'{sc.esc(lab)}')
fr.out.append(f'')
cx = fr.x(km)
fr.out.append(f'')
val = f"{km / AU_KM:.3g} au" if km > 1e5 else (f"{km * 1000:.0f} m" if km < 1 else f"{km:,.0f} km")
sc.point_hit(fr, cx, cy, i, lab, f"{kind}|{note}|{val}", r=13)
rows = []
for lab, km, kind, note in items:
val = f"{km / AU_KM:.3g} au" if km > 1e5 else (f"{km * 1000:.0f} m" if km < 1 else f"{km:,.0f} km")
rows.append([lab, val, f"{km:.3g} km", {1: "Measured", 2: "Projected / simulated", 3: "Derived / specified"}[kind], note])
leg = ('
MeasuredProjected or simulated'
'Derived or specified
')
return sc.figure(
code="FIG. 6 — ACCURACY LADDER",
fid="fig-ladder",
plot=fr.svg(),
legend_html=leg,
caption=("Figure 6. About fourteen orders of magnitude of position accuracy. Stellar parallax sits around an "
"au today and could reach a hundredth of an au; radio tracking is millions of times better while a "
"ground network can see the craft. Methods differ in what they measure (range, plane of sky, full "
"3D) and where they were tested; read the table view before comparing rows."),
data_html=sc.table("Position accuracy by method", ["Method", "Accuracy", "In km", "Kind", "Source"], rows, numeric_from=1),
)
if __name__ == "__main__":
for name, fn in [("shift", fig_shift), ("lines", fig_lines), ("scatter", fig_scatter), ("dop", fig_dop),
("pairs", fig_pairs), ("ladder", fig_ladder)]:
sc.inject(PAGE, name, fn())
print("built", name)