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

84 lines
2.6 KiB
Python

import math
A_WGS84 = 6378.137
F_WGS84 = 1.0/298.257223563
B_WGS84 = A_WGS84*(1.0-F_WGS84)
E2 = F_WGS84*(2.0-F_WGS84)
RE = 6371.2 # geomagnetic reference radius, km
def load_cof(path):
g={}; h={}; gd={}; hd={}; epoch=None
with open(path) as f:
for line in f:
t=line.split()
if len(t)==3:
epoch=float(t[0]); continue
if len(t)!=6: continue
n=int(t[0]); m=int(t[1])
g[(n,m)]=float(t[2]); h[(n,m)]=float(t[3])
gd[(n,m)]=float(t[4]); hd[(n,m)]=float(t[5])
N=max(k[0] for k in g)
return epoch,N,g,h,gd,hd
def schmidt(N, theta):
"""Schmidt semi-normalized P[n][m](cos theta) and dP/dtheta."""
ct=math.cos(theta); st=math.sin(theta)
P=[[0.0]*(N+1) for _ in range(N+1)]
dP=[[0.0]*(N+1) for _ in range(N+1)]
P[0][0]=1.0; dP[0][0]=0.0
for n in range(1,N+1):
# sectorial
k=math.sqrt((2*n-1)/(2.0*n))
if n==1: k*=math.sqrt(2.0) # Schmidt m=0 -> m=1 boundary
P[n][n]=k*st*P[n-1][n-1]
dP[n][n]=k*(st*dP[n-1][n-1]+ct*P[n-1][n-1])
for m in range(0,n):
d=math.sqrt(n*n-m*m)
c=(2*n-1)/d
P[n][m]=c*ct*P[n-1][m]
dP[n][m]=c*(ct*dP[n-1][m]-st*P[n-1][m])
if n-2>=m:
e=math.sqrt((n-1)**2-m*m)/d
P[n][m]-=e*P[n-2][m]
dP[n][m]-=e*dP[n-2][m]
return P,dP
def wmm(model, lat, lon, h_km, year):
epoch,N,gc,hc,gd,hd = model
dt = year-epoch
phi=math.radians(lat); lam=math.radians(lon)
sp=math.sin(phi); cp=math.cos(phi)
Rc=A_WGS84/math.sqrt(1.0-E2*sp*sp)
p=(Rc+h_km)*cp
z=(Rc*(1.0-E2)+h_km)*sp
r=math.sqrt(p*p+z*z)
phip=math.asin(z/r) # geocentric latitude
theta=math.pi/2.0-phip
P,dP=schmidt(N,theta)
cml=[math.cos(m*lam) for m in range(N+1)]
sml=[math.sin(m*lam) for m in range(N+1)]
Xp=Yp=Zp=0.0
ratio=RE/r
for n in range(1,N+1):
f=ratio**(n+2)
for m in range(0,n+1):
g=gc[(n,m)]+dt*gd[(n,m)]
hh=hc[(n,m)]+dt*hd[(n,m)]
a=g*cml[m]+hh*sml[m]
b=g*sml[m]-hh*cml[m]
Xp += f*a*dP[n][m] # note: dP/dphi' = -dP/dtheta -> handled by sign below
Yp += f*m*b*P[n][m]
Zp += -(n+1)*f*a*P[n][m]
pass
cpp=math.cos(phip)
Yp = Yp/cpp
d=phip-phi
X=Xp*math.cos(d)-Zp*math.sin(d)
Z=Xp*math.sin(d)+Zp*math.cos(d)
Y=Yp
H=math.hypot(X,Y)
F=math.sqrt(H*H+Z*Z)
D=math.degrees(math.atan2(Y,X))
I=math.degrees(math.atan2(Z,H))
return dict(X=X,Y=Y,Z=Z,H=H,F=F,D=D,I=I)