Files
Christopher Van Hoose 6cc0c1e56e Make the WMM block generated, and the tests reviewable
The declination model landed as 4.7 KB of constants pasted into a Lua app,
generated by a script that lived in out/ -- which is gitignored, holds firmware
bins, and is where the app's own "Regenerate:" comment pointed. So the pointer
dangled for anyone who cloned the repo, and nobody but me could answer the
first fair question a reviewer would ask about that block of magic numbers:
where did it come from, and how do I know it is right.

  scripts/wmm/       WMM.COF + NOAA's 100 official test values (both upstream
                     and unmodified), the float64 reference, the generator,
                     verify.py, and a README covering provenance, regeneration
                     and how to move to WMM2030.
  scripts/lua-harness/  the host harness, with run.sh so it is one command.

Neither goes in test/: that is PlatformIO's directory and a harness with a
main.c would be swept into `pio test`. scripts/ already holds this repo's dev
tooling, test_companion_serial.py included.

The block in the app is now genuinely generated rather than hand-pasted:

    scripts/wmm/gen_lua.py --update <app>    rewrite it
    scripts/wmm/gen_lua.py --check  <app>    fail, with a diff, if it drifted

--check catches coefficients updated without regenerating, or a block edited by
hand. The generator owns the `local declination / do ... end` wrapper too, and
that is the point: the tables are named G/H/GD/HD, gpscompass uses a global H
for the screen height, and an unscoped `local H` silently ate it. Hand-wrapping
is how that happened, so hand-wrapping is now not a step.

Verification, all reproducible from a clean clone:
  scripts/wmm/verify.py            100 NOAA values, worst D error 0.005 deg
  scripts/lua-harness/run.sh       10 scenarios, incl. the generated Lua in
                                   the device's own LUA_32BITS interpreter --
                                   0.0002 deg vs NOAA, worst tick 12k of 100k

Also refreshes the LUA_APPS.md paragraph, which still advertised the O and F
keys that were removed and quoted harness numbers from before tilt
compensation.
2026-08-22 13:30:36 -04:00

147 lines
5.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate the WMM declination block that lives inside a Lua Store app.
The block is generated rather than hand-pasted for two reasons. It is four
kilobytes of coefficients that nobody can eyeball, and the wrapper around it
matters: the coefficient tables are named G/H/GD/HD, and an app that happens
to use a global named H (gpscompass uses it for the screen height) has that
global silently eaten by a module-scope `local H`. That actually happened.
Owning the `do ... end` here means it cannot come unwrapped again.
gen_lua.py --emit print the block
gen_lua.py --update <app.lua> rewrite the block in place
gen_lua.py --check <app.lua> fail if the app has drifted
--check is the one to wire into CI: it regenerates from WMM.COF and compares,
so a coefficient file that gets updated without regenerating, or a block that
gets edited by hand, is caught rather than shipped.
Verify the maths itself with verify.py (NOAA's own 100 test values), and the
generated Lua with the `declination` scenario in scripts/lua-harness.
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from wmm import load_cof
HERE = os.path.dirname(os.path.abspath(__file__))
COF = os.path.join(HERE, "WMM.COF")
BEGIN = "-- WMM-GEN BEGIN -- generated by scripts/wmm/gen_lua.py; do not edit by hand"
END = "end -- WMM-GEN END"
def fmt(v):
"""Shortest round-trip-safe spelling: the coefficients are one decimal."""
s = f"{v:.1f}"
if s.endswith(".0"): s = s[:-2]
if s == "-0": s = "0"
if s.startswith("0."): s = s[1:] # 0.2 -> .2
if s.startswith("-0."): s = "-" + s[2:] # -0.2 -> -.2
return s
def _arr(name, vals):
body, line = [], ""
for v in vals:
if len(line) + len(v) + 1 > 96:
body.append(line)
line = ""
line += v + ","
if line:
body.append(line)
return f"local {name}={{" + "\n".join(body) + "}\n"
def gen_block(cof=COF):
"""The complete Lua block, markers included, ready to sit in an app."""
epoch, N, g, h, gd, hd = load_cof(cof)
G, H, GD, HD = [], [], [], []
for n in range(1, N + 1):
for m in range(0, n + 1):
G.append(fmt(g[(n, m)]))
GD.append(fmt(gd[(n, m)]))
if m >= 1:
H.append(fmt(h[(n, m)]))
HD.append(fmt(hd[(n, m)]))
with open(os.path.join(HERE, "wmm_block.lua"), encoding="utf-8") as f:
maths = f.read()
head = (
f"{BEGIN}\n"
f"-- WMM{int(epoch)} magnetic declination, degree {N}. East positive:\n"
f"-- true bearing = magnetic bearing + declination\n"
f"-- Data: NOAA/NCEI WMM.COF epoch {epoch}, valid {int(epoch)}.0-{int(epoch) + 5}.0.\n"
f"-- Source: https://www.ncei.noaa.gov/sites/default/files/2024-12/WMM2025COF.zip\n"
f"-- Everything below is scoped: the tables are named G/H/GD/HD and would\n"
f"-- otherwise shadow an app's own globals of those names.\n"
f"local declination\n"
f"do\n"
f"local EPOCH,NMAX={epoch},{N}\n"
)
arrays = _arr("G", G) + _arr("H", H) + _arr("GD", GD) + _arr("HD", HD)
return head + arrays + maths + END + "\n"
def _split(path):
"""(before, block, after) around the markers in `path`."""
src = open(path, encoding="utf-8").read()
a = src.find(BEGIN)
if a < 0:
sys.exit(f"{path}: no '{BEGIN}' marker")
b = src.find(END, a)
if b < 0:
sys.exit(f"{path}: BEGIN marker has no matching '{END}'")
return src[:a], src[a:b + len(END) + 1], src[b + len(END) + 1:]
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--emit", action="store_true", help="print the block to stdout")
g.add_argument("--update", metavar="APP", help="rewrite the block inside APP")
g.add_argument("--check", metavar="APP", help="exit 1 if APP has drifted")
ap.add_argument("--cof", default=COF, help="coefficient file (default: WMM.COF)")
args = ap.parse_args()
block = gen_block(args.cof)
if args.emit:
sys.stdout.write(block)
return 0
path = args.update or args.check
before, current, after = _split(path)
if args.check:
if current == block:
print(f"{os.path.basename(path)}: up to date ({len(block)} bytes)")
return 0
print(f"{os.path.basename(path)}: the generated block has DRIFTED from "
f"{os.path.basename(args.cof)}", file=sys.stderr)
import difflib
diff = difflib.unified_diff(current.splitlines(), block.splitlines(),
"in app", "generated", lineterm="", n=1)
for i, line in enumerate(diff):
if i > 40:
print(" ...", file=sys.stderr)
break
print(" " + line, file=sys.stderr)
print(" run: scripts/wmm/gen_lua.py --update " + path, file=sys.stderr)
return 1
if current == block:
print(f"{os.path.basename(path)}: already up to date")
return 0
open(path, "w", encoding="utf-8").write(before + block + after)
print(f"{os.path.basename(path)}: block updated ({len(block)} bytes)")
return 0
if __name__ == "__main__":
sys.exit(main())