01ffa67de6
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
173 lines
8.9 KiB
Python
173 lines
8.9 KiB
Python
"""Best-effort layout of cities on a plane (or in free 3D space) from measured lengths and limits.
|
|
|
|
Lengths (flights) must fall inside a band [L(1-lo), L(1+hi)]; limits (round trips, rail, road,
|
|
satellite passes) cap a distance from above. Nothing else is imposed: no latitude, longitude,
|
|
radius or projection enters the objective.
|
|
|
|
The search runs in two stages, mirrored by the page's live solver:
|
|
1. least squares on every flight's relative error (smooth, finds the overall shape);
|
|
2. reweighted pseudo-L1 on the amount by which each measurement falls outside its band, so the
|
|
solver gives up on as few measurements as it can rather than straining many (an approximation
|
|
to minimising the number broken).
|
|
"""
|
|
import numpy as np
|
|
from scipy.optimize import minimize
|
|
|
|
LIMIT_W = 3.0 # weight of a limit relative to a flight in stage 2
|
|
MARGIN = 0.005 # stage 2 aims this far inside each band
|
|
DELTA = 0.003 # pseudo-L1 smoothing
|
|
EPS = 0.02 # reweighting floor
|
|
ROUNDS = 8
|
|
|
|
|
|
class Problem:
|
|
def __init__(self, n, fa, fb, fl, la, lb, lu):
|
|
self.n = n
|
|
self.fa, self.fb, self.fl = np.asarray(fa), np.asarray(fb), np.asarray(fl, float)
|
|
self.la, self.lb, self.lu = np.asarray(la, int), np.asarray(lb, int), np.asarray(lu, float)
|
|
|
|
def _grad(self, dim, X, ga, gl):
|
|
G = np.empty((self.n, dim)); n = self.n
|
|
gv = ga[:, None] * (X[self.fa] - X[self.fb])
|
|
hv = gl[:, None] * (X[self.la] - X[self.lb]) if len(self.lu) else None
|
|
for q in range(dim):
|
|
G[:, q] = np.bincount(self.fa, gv[:, q], n) - np.bincount(self.fb, gv[:, q], n)
|
|
if hv is not None:
|
|
G[:, q] += np.bincount(self.la, hv[:, q], n) - np.bincount(self.lb, hv[:, q], n)
|
|
return G.ravel()
|
|
|
|
def dists(self, X):
|
|
dd = np.sqrt(((X[self.fa] - X[self.fb]) ** 2).sum(1)) + 1e-9
|
|
ld = np.sqrt(((X[self.la] - X[self.lb]) ** 2).sum(1)) + 1e-9 if len(self.lu) else np.zeros(0)
|
|
return dd, ld
|
|
|
|
def l2(self, dim, wl=100.0):
|
|
def f(x):
|
|
X = x.reshape(self.n, dim); dd, ld = self.dists(X)
|
|
e = dd / self.fl - 1; ex = np.maximum(0, ld / self.lu - 1)
|
|
return (e ** 2).sum() + wl * (ex ** 2).sum(), self._grad(dim, X, 2 * e / self.fl / dd, wl * 2 * ex / self.lu / ld)
|
|
return f
|
|
|
|
def sparse(self, dim, lo, hi, w, wl):
|
|
a = self.fl * (1 - lo + MARGIN); b = self.fl * (1 + hi - MARGIN); ub = self.lu * (1 - MARGIN)
|
|
def f(x):
|
|
X = x.reshape(self.n, dim); dd, ld = self.dists(X)
|
|
eh = np.maximum(0, dd / b - 1); el = np.maximum(0, 1 - dd / a)
|
|
sh = np.sqrt(eh ** 2 + DELTA ** 2); sl = np.sqrt(el ** 2 + DELTA ** 2)
|
|
ex = np.maximum(0, ld / ub - 1); sx = np.sqrt(ex ** 2 + DELTA ** 2)
|
|
F = (w * (sh + sl - 2 * DELTA)).sum() + (wl * (sx - DELTA)).sum()
|
|
ga = w * (eh / sh / b - el / sl / a) / dd
|
|
gl = wl * ex / sx / ub / ld
|
|
return F, self._grad(dim, X, ga, gl)
|
|
return f
|
|
|
|
def excess(self, X, lo, hi):
|
|
dd, ld = self.dists(X); r = dd / self.fl
|
|
return np.maximum(0, r / (1 + hi) - 1) + np.maximum(0, 1 - r / (1 - lo)), np.maximum(0, ld / self.lu - 1)
|
|
|
|
def broken(self, X, lo, hi):
|
|
e, l = self.excess(X, lo, hi)
|
|
return int((e > 0).sum() + (l > 5e-4).sum())
|
|
|
|
def best_effort(self, X0, lo, hi, rounds=ROUNDS, maxiter=30000, eps=EPS, limit_w=LIMIT_W):
|
|
dim = X0.shape[1]
|
|
run = lambda f, X: minimize(f, X.ravel(), jac=True, method="L-BFGS-B",
|
|
options={"maxiter": maxiter, "maxcor": 30}).x.reshape(self.n, dim)
|
|
X = run(self.l2(dim), X0)
|
|
best = (self.broken(X, lo, hi), X.copy())
|
|
w = np.ones(len(self.fl)); wl = np.full(len(self.lu), limit_w)
|
|
for _ in range(rounds):
|
|
X = run(self.sparse(dim, lo, hi, w, wl), X)
|
|
k = self.broken(X, lo, hi)
|
|
if k < best[0]:
|
|
best = (k, X.copy())
|
|
e, l = self.excess(X, lo, hi)
|
|
w = eps / (e + eps); wl = limit_w * eps / (l + eps)
|
|
return best
|
|
|
|
|
|
def sphere_fit(X):
|
|
"""Least-squares sphere through 3D points: radius, median offset (share of radius), and how far
|
|
round it the points reach (angle from their mean direction to the farthest point)."""
|
|
A = np.c_[2 * X, np.ones(len(X))]; s = np.linalg.lstsq(A, (X ** 2).sum(1), rcond=None)[0]
|
|
c = s[:3]; r = float(np.sqrt(s[3] + c @ c)); d = np.linalg.norm(X - c, axis=1)
|
|
u = (X - c) / d[:, None]; m = u.mean(0); m /= np.linalg.norm(m)
|
|
return dict(radius=r, offset=float(np.median(np.abs(d - r) / r)),
|
|
wrap=float(np.degrees(np.arccos(np.clip(u @ m, -1, 1))).max()), centre=c.tolist())
|
|
|
|
|
|
# ---- measured along a sphere: concedes the shape, leaves its size free ----
|
|
def _arcs(P, R, a, b):
|
|
nrm = np.linalg.norm(P, axis=1); U = P / nrm[:, None]
|
|
u, v = U[a], U[b]; c = np.clip((u * v).sum(1), -1, 1); s = np.linalg.norm(np.cross(u, v), axis=1)
|
|
th = np.arctan2(s, c); s = np.maximum(s, 1e-9)
|
|
gu = -(v - c[:, None] * u) / (s * nrm[a])[:, None]; gv = -(u - c[:, None] * v) / (s * nrm[b])[:, None]
|
|
return R * th, th, gu, gv
|
|
|
|
|
|
class SphereProblem(Problem):
|
|
"""Cities held on a sphere of free radius; each distance measured along its surface (the haversine
|
|
arc, R x central angle). Stage 1 fits the radius with the cities; later stages hold it."""
|
|
|
|
def _pairs(self, P, R):
|
|
return _arcs(P, R, self.fa, self.fb), _arcs(P, R, self.la, self.lb)
|
|
|
|
def _apply(self, ga, gl, fa_, la_, R):
|
|
(dd, th, gu, gv), (ld, lth, lgu, lgv) = fa_, la_; n = self.n; G = np.zeros((n, 3))
|
|
for q in range(3):
|
|
G[:, q] = (np.bincount(self.fa, ga * R * gu[:, q], n) + np.bincount(self.fb, ga * R * gv[:, q], n)
|
|
+ np.bincount(self.la, gl * R * lgu[:, q], n) + np.bincount(self.lb, gl * R * lgv[:, q], n))
|
|
return G.ravel(), float((ga * th).sum() + (gl * lth).sum())
|
|
|
|
def arc_l2(self, wl=100.0):
|
|
def f(x):
|
|
P = x[:-1].reshape(self.n, 3); R = x[-1]; F_, L_ = self._pairs(P, R); dd, ld = F_[0], L_[0]
|
|
e = dd / self.fl - 1; ex = np.maximum(0, ld / self.lu - 1)
|
|
G, gR = self._apply(2 * e / self.fl, wl * 2 * ex / self.lu, F_, L_, R)
|
|
return (e ** 2).sum() + wl * (ex ** 2).sum(), np.r_[G, gR]
|
|
return f
|
|
|
|
def arc_sparse(self, R, lo, hi, w, wl):
|
|
a = self.fl * (1 - lo + MARGIN); b = self.fl * (1 + hi - MARGIN); ub = self.lu * (1 - MARGIN)
|
|
def f(x):
|
|
P = x.reshape(self.n, 3); F_, L_ = self._pairs(P, R); dd, ld = F_[0], L_[0]
|
|
eh = np.maximum(0, dd / b - 1); el = np.maximum(0, 1 - dd / a)
|
|
sh = np.sqrt(eh ** 2 + DELTA ** 2); sl = np.sqrt(el ** 2 + DELTA ** 2)
|
|
ex = np.maximum(0, ld / ub - 1); sx = np.sqrt(ex ** 2 + DELTA ** 2)
|
|
G, _ = self._apply(w * (eh / sh / b - el / sl / a), wl * ex / sx / ub, F_, L_, R)
|
|
return (w * (sh + sl - 2 * DELTA)).sum() + (wl * (sx - DELTA)).sum(), G
|
|
return f
|
|
|
|
def arc_broken(self, P, R, lo, hi):
|
|
F_, L_ = self._pairs(P, R); r = F_[0] / self.fl
|
|
return dict(long=int((r > 1 + hi).sum()), short=int((r < 1 - lo).sum()), limits=int((L_[0] > self.lu * 1.0005).sum()))
|
|
|
|
def arc_best_effort(self, X2, lo, hi, rounds=ROUNDS, maxiter=30000, eps=EPS, limit_w=LIMIT_W):
|
|
"""From a flat layout laid on a spherical cap whose radius is a third of the layout's width."""
|
|
X2 = X2 - X2.mean(0); R0 = float(np.ptp(X2, axis=0).max()) / 3
|
|
x = np.r_[np.c_[X2, np.full(self.n, R0)].ravel(), R0]
|
|
x = minimize(self.arc_l2(), x, jac=True, method="L-BFGS-B", options={"maxiter": maxiter, "maxcor": 30}).x
|
|
P, R = x[:-1].reshape(self.n, 3), float(x[-1])
|
|
k = sum(self.arc_broken(P, R, lo, hi).values()); best = (k, P.copy())
|
|
w = np.ones(len(self.fl)); wl = np.full(len(self.lu), limit_w)
|
|
for _ in range(rounds):
|
|
P = minimize(self.arc_sparse(R, lo, hi, w, wl), P.ravel(), jac=True, method="L-BFGS-B",
|
|
options={"maxiter": maxiter, "maxcor": 30}).x.reshape(self.n, 3)
|
|
k = sum(self.arc_broken(P, R, lo, hi).values())
|
|
if k < best[0]:
|
|
best = (k, P.copy())
|
|
F_, L_ = self._pairs(P, R); r = F_[0] / self.fl
|
|
e = np.maximum(0, r / (1 + hi) - 1) + np.maximum(0, 1 - r / (1 - lo)); l = np.maximum(0, L_[0] / self.lu - 1)
|
|
w = eps / (e + eps); wl = limit_w * eps / (l + eps)
|
|
P = best[1]; U = P / np.linalg.norm(P, axis=1)[:, None]
|
|
return R, U, self.arc_broken(P, R, lo, hi)
|
|
|
|
|
|
def true_offsets(U, lat, lon, R):
|
|
"""Distance of each solved city from its true place, after the orthogonal transformation that best
|
|
lines the true positions up with the solved ones (used to place coastlines and to check the fit)."""
|
|
p, l = np.radians(lat), np.radians(lon)
|
|
T = np.c_[np.cos(p) * np.cos(l), np.cos(p) * np.sin(l), np.sin(p)]
|
|
Us, _, Vt = np.linalg.svd(U.T @ T); Q = Us @ Vt
|
|
return R * np.arccos(np.clip(((T @ Q.T) * U).sum(1), -1, 1))
|