compass-lib 0.0.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- compass_lib/__init__.py +117 -0
- compass_lib/commands/__init__.py +2 -0
- compass_lib/commands/convert.py +251 -0
- compass_lib/commands/encrypt.py +115 -0
- compass_lib/commands/geojson.py +161 -0
- compass_lib/commands/main.py +33 -0
- compass_lib/constants.py +98 -0
- compass_lib/enums.py +394 -0
- compass_lib/errors.py +86 -0
- compass_lib/geo_utils.py +47 -0
- compass_lib/geojson.py +2216 -0
- compass_lib/interface.py +332 -0
- compass_lib/io.py +246 -0
- compass_lib/models.py +251 -0
- compass_lib/plot/__init__.py +28 -0
- compass_lib/plot/models.py +265 -0
- compass_lib/plot/parser.py +610 -0
- compass_lib/project/__init__.py +36 -0
- compass_lib/project/format.py +158 -0
- compass_lib/project/models.py +496 -0
- compass_lib/project/parser.py +639 -0
- compass_lib/solver/__init__.py +43 -0
- compass_lib/solver/ariane.py +488 -0
- compass_lib/solver/base.py +54 -0
- compass_lib/solver/lse.py +304 -0
- compass_lib/solver/models.py +229 -0
- compass_lib/solver/noop.py +28 -0
- compass_lib/solver/proportional.py +201 -0
- compass_lib/solver/sparse.py +249 -0
- compass_lib/survey/__init__.py +24 -0
- compass_lib/survey/format.py +291 -0
- compass_lib/survey/models.py +126 -0
- compass_lib/survey/parser.py +701 -0
- compass_lib/validation.py +241 -0
- compass_lib-0.0.8.dist-info/METADATA +63 -0
- compass_lib-0.0.8.dist-info/RECORD +39 -0
- compass_lib-0.0.8.dist-info/WHEEL +4 -0
- compass_lib-0.0.8.dist-info/entry_points.txt +8 -0
- compass_lib-0.0.8.dist-info/licenses/LICENSE +660 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Sparse CG-based network adjustment with traverse-quality weighting.
|
|
3
|
+
|
|
4
|
+
Combines Ariane's performance strategy (sparse normal equations solved
|
|
5
|
+
via Conjugate Gradient) with our traverse-quality weighting (Larry Fish
|
|
6
|
+
method) and full 3D adjustment.
|
|
7
|
+
|
|
8
|
+
**From Ariane:**
|
|
9
|
+
|
|
10
|
+
- Builds the Normal Equations ``N x = n`` directly in sparse format
|
|
11
|
+
(weighted graph Laplacian), instead of assembling a dense design
|
|
12
|
+
matrix and calling ``lstsq``. Memory drops from O(stations²) to
|
|
13
|
+
O(edges); solve time from O(n³) to O(nnz * iterations).
|
|
14
|
+
- Uses Conjugate Gradient (CG) to solve the symmetric positive-definite
|
|
15
|
+
system, with a direct-solver fallback for robustness.
|
|
16
|
+
- Warm-starts CG from BFS-propagated positions.
|
|
17
|
+
|
|
18
|
+
**From our approach:**
|
|
19
|
+
|
|
20
|
+
- Full 3D adjustment (X, Y, Z) — Ariane only adjusts the horizontal
|
|
21
|
+
plane.
|
|
22
|
+
- Traverse-quality weighting: shots on good traverses (low misclosure
|
|
23
|
+
per shot) are protected with high weight; shots on bad traverses
|
|
24
|
+
absorb the error. Blunders stay localised (Larry Fish insight).
|
|
25
|
+
- ``1/L²`` base weight (percentage-equalising) rather than Ariane's
|
|
26
|
+
``1/L``.
|
|
27
|
+
- Post-solve validation and detailed logging.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
from collections import deque
|
|
34
|
+
from itertools import combinations
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
from scipy.sparse import coo_matrix
|
|
38
|
+
from scipy.sparse.linalg import cg as sparse_cg
|
|
39
|
+
from scipy.sparse.linalg import spsolve
|
|
40
|
+
|
|
41
|
+
from compass_lib.constants import SOLVER_MAX_HEADING_CHANGE
|
|
42
|
+
from compass_lib.constants import SOLVER_MAX_LENGTH_CHANGE
|
|
43
|
+
from compass_lib.solver.base import SurveyAdjuster
|
|
44
|
+
from compass_lib.solver.models import NetworkShot
|
|
45
|
+
from compass_lib.solver.models import SurveyNetwork
|
|
46
|
+
from compass_lib.solver.models import Vector3D
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Traverse quality helpers
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _find_path(
|
|
57
|
+
start: str,
|
|
58
|
+
end: str,
|
|
59
|
+
adj: dict[str, list[NetworkShot]],
|
|
60
|
+
blocked: frozenset[str] = frozenset(),
|
|
61
|
+
) -> list[str] | None:
|
|
62
|
+
"""BFS shortest path, avoiding *blocked* intermediate stations."""
|
|
63
|
+
if start == end:
|
|
64
|
+
return [start]
|
|
65
|
+
parent: dict[str, str | None] = {start: None}
|
|
66
|
+
queue: deque[str] = deque([start])
|
|
67
|
+
while queue:
|
|
68
|
+
current = queue.popleft()
|
|
69
|
+
for shot in adj.get(current, []):
|
|
70
|
+
n = shot.to_name
|
|
71
|
+
if n in parent or (n in blocked and n != end):
|
|
72
|
+
continue
|
|
73
|
+
parent[n] = current
|
|
74
|
+
if n == end:
|
|
75
|
+
path: list[str] = []
|
|
76
|
+
node: str | None = end
|
|
77
|
+
while node is not None:
|
|
78
|
+
path.append(node)
|
|
79
|
+
node = parent[node]
|
|
80
|
+
path.reverse()
|
|
81
|
+
return path
|
|
82
|
+
queue.append(n)
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _compute_traverse_quality(
|
|
87
|
+
network: SurveyNetwork,
|
|
88
|
+
) -> dict[tuple[str, str], float]:
|
|
89
|
+
"""Compute per-shot traverse quality scores.
|
|
90
|
+
|
|
91
|
+
For each pair of adjacent anchors, find the traverse path and
|
|
92
|
+
compute misclosure per shot. Each shot gets the quality score
|
|
93
|
+
of the **best** traverse it belongs to (lowest misclosure/shot).
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
dict
|
|
98
|
+
Mapping ``(min(a, b), max(a, b)) -> quality`` where *quality*
|
|
99
|
+
is misclosure-per-shot in metres.
|
|
100
|
+
"""
|
|
101
|
+
anchors = network.anchors
|
|
102
|
+
adj = network.adjacency
|
|
103
|
+
shot_key_to_quality: dict[tuple[str, str], float] = {}
|
|
104
|
+
|
|
105
|
+
# Build a directed edge lookup for O(1) path-walk instead of
|
|
106
|
+
# scanning adjacency lists per step.
|
|
107
|
+
edge_delta: dict[tuple[str, str], Vector3D] = {}
|
|
108
|
+
for shot in network.shots:
|
|
109
|
+
edge_delta[(shot.from_name, shot.to_name)] = shot.delta
|
|
110
|
+
edge_delta[(shot.to_name, shot.from_name)] = -shot.delta
|
|
111
|
+
|
|
112
|
+
anchor_list = sorted(anchors)
|
|
113
|
+
all_anchors_frozen = frozenset(anchors)
|
|
114
|
+
|
|
115
|
+
for a, b in combinations(anchor_list, 2):
|
|
116
|
+
# Derive blocked set efficiently from the pre-computed full set.
|
|
117
|
+
other = all_anchors_frozen - {a, b}
|
|
118
|
+
path = _find_path(a, b, adj, blocked=other)
|
|
119
|
+
if path is None or len(path) < 2:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
# Forward-propagate to compute misclosure — O(path_length)
|
|
123
|
+
# using the edge lookup instead of scanning adjacency lists.
|
|
124
|
+
pos = network.stations[a]
|
|
125
|
+
n_shots = 0
|
|
126
|
+
for i in range(len(path) - 1):
|
|
127
|
+
delta = edge_delta.get((path[i], path[i + 1]))
|
|
128
|
+
if delta is not None:
|
|
129
|
+
pos = pos + delta
|
|
130
|
+
n_shots += 1
|
|
131
|
+
|
|
132
|
+
if n_shots == 0:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
misclosure = pos - network.stations[b]
|
|
136
|
+
quality = misclosure.length / n_shots # metres per shot
|
|
137
|
+
|
|
138
|
+
# Assign quality to each shot on this traverse (keep best).
|
|
139
|
+
for i in range(len(path) - 1):
|
|
140
|
+
p, q = path[i], path[i + 1]
|
|
141
|
+
key = (p, q) if p < q else (q, p)
|
|
142
|
+
if key not in shot_key_to_quality or quality < shot_key_to_quality[key]:
|
|
143
|
+
shot_key_to_quality[key] = quality
|
|
144
|
+
|
|
145
|
+
logger.debug(
|
|
146
|
+
"Traverse %s -> %s: %d shots, misclosure=%.1f m, quality=%.3f m/shot",
|
|
147
|
+
a,
|
|
148
|
+
b,
|
|
149
|
+
n_shots,
|
|
150
|
+
misclosure.length,
|
|
151
|
+
quality,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return shot_key_to_quality
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# Sparse Normal Equations solver
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _solve_ariane(
|
|
163
|
+
network: SurveyNetwork,
|
|
164
|
+
max_length_frac: float,
|
|
165
|
+
cg_maxiter: int = 60_000,
|
|
166
|
+
cg_tol: float = 1e-8,
|
|
167
|
+
) -> dict[str, Vector3D]:
|
|
168
|
+
"""Sparse CG solver with traverse-quality weighting.
|
|
169
|
+
|
|
170
|
+
Assembles the Normal Equations (weighted graph Laplacian) directly
|
|
171
|
+
in sparse COO format and solves via Conjugate Gradient, following
|
|
172
|
+
Ariane's approach but extended to 3D with Larry Fish weighting.
|
|
173
|
+
|
|
174
|
+
The observation equation per edge ``(u → v)`` is::
|
|
175
|
+
|
|
176
|
+
x_v - x_u = dx_observed (and same for y, z)
|
|
177
|
+
|
|
178
|
+
Minimising ``Σ w_e · (x_v - x_u - dx)²`` over all edges yields
|
|
179
|
+
the Normal Equations ``N · x = rhs`` where *N* is the weighted
|
|
180
|
+
graph Laplacian over free vertices (symmetric positive-definite
|
|
181
|
+
when at least one anchor exists).
|
|
182
|
+
"""
|
|
183
|
+
# Filter out anchors that don't exist in stations (orphan link
|
|
184
|
+
# stations, already warned about upstream).
|
|
185
|
+
anchors = network.anchors & set(network.stations.keys())
|
|
186
|
+
non_anchors = sorted(set(network.stations.keys()) - anchors)
|
|
187
|
+
|
|
188
|
+
if not non_anchors:
|
|
189
|
+
return dict(network.stations)
|
|
190
|
+
|
|
191
|
+
n = len(non_anchors)
|
|
192
|
+
station_to_idx = {name: i for i, name in enumerate(non_anchors)}
|
|
193
|
+
anchor_pos: dict[str, Vector3D] = {name: network.stations[name] for name in anchors}
|
|
194
|
+
|
|
195
|
+
# -- Centre coordinates for numerical stability ---------------------------
|
|
196
|
+
_all_pos = list(anchor_pos.values())
|
|
197
|
+
origin = Vector3D(
|
|
198
|
+
sum(p.x for p in _all_pos) / len(_all_pos),
|
|
199
|
+
sum(p.y for p in _all_pos) / len(_all_pos),
|
|
200
|
+
sum(p.z for p in _all_pos) / len(_all_pos),
|
|
201
|
+
)
|
|
202
|
+
anchor_pos_c = {n: p - origin for n, p in anchor_pos.items()}
|
|
203
|
+
|
|
204
|
+
# -- Traverse quality (Larry Fish) --------------------------------------
|
|
205
|
+
shot_quality = _compute_traverse_quality(network)
|
|
206
|
+
|
|
207
|
+
# -- Assemble sparse Normal Equations directly --------------------------
|
|
208
|
+
#
|
|
209
|
+
# Following Ariane's approach: build N = A^T W A and rhs = A^T W b
|
|
210
|
+
# edge-by-edge in COO format. The matrix N is the weighted graph
|
|
211
|
+
# Laplacian over free vertices — symmetric positive-definite when at
|
|
212
|
+
# least one anchor exists.
|
|
213
|
+
|
|
214
|
+
coo_rows: list[int] = []
|
|
215
|
+
coo_cols: list[int] = []
|
|
216
|
+
coo_vals: list[float] = []
|
|
217
|
+
|
|
218
|
+
rhs_x = np.zeros(n, dtype=np.float64)
|
|
219
|
+
rhs_y = np.zeros(n, dtype=np.float64)
|
|
220
|
+
rhs_z = np.zeros(n, dtype=np.float64)
|
|
221
|
+
|
|
222
|
+
# Initial guess from BFS-propagated positions (warm start for CG),
|
|
223
|
+
# centred around the anchor centroid for numerical stability.
|
|
224
|
+
x0 = np.zeros(n, dtype=np.float64)
|
|
225
|
+
y0 = np.zeros(n, dtype=np.float64)
|
|
226
|
+
z0 = np.zeros(n, dtype=np.float64)
|
|
227
|
+
for name, idx in station_to_idx.items():
|
|
228
|
+
pos = network.stations[name] - origin
|
|
229
|
+
x0[idx] = pos.x
|
|
230
|
+
y0[idx] = pos.y
|
|
231
|
+
z0[idx] = pos.z
|
|
232
|
+
|
|
233
|
+
n_edges = 0
|
|
234
|
+
for shot in network.shots:
|
|
235
|
+
from_anc = shot.from_name in anchors
|
|
236
|
+
to_anc = shot.to_name in anchors
|
|
237
|
+
if from_anc and to_anc:
|
|
238
|
+
continue # Both fixed — nothing to solve.
|
|
239
|
+
|
|
240
|
+
dx, dy, dz = shot.delta.x, shot.delta.y, shot.delta.z
|
|
241
|
+
L = max(shot.distance, 0.1)
|
|
242
|
+
|
|
243
|
+
# Base weight: 1/L² (percentage-equalising).
|
|
244
|
+
base_w = 1.0 / (L * L)
|
|
245
|
+
|
|
246
|
+
# Traverse-quality factor (Larry Fish).
|
|
247
|
+
key = (
|
|
248
|
+
min(shot.from_name, shot.to_name),
|
|
249
|
+
max(shot.from_name, shot.to_name),
|
|
250
|
+
)
|
|
251
|
+
quality = shot_quality.get(key)
|
|
252
|
+
if quality is not None and quality > 1e-6:
|
|
253
|
+
quality_factor = 1.0 / (quality * quality)
|
|
254
|
+
else:
|
|
255
|
+
quality_factor = 1.0
|
|
256
|
+
|
|
257
|
+
w = base_w * quality_factor
|
|
258
|
+
n_edges += 1
|
|
259
|
+
|
|
260
|
+
u_idx = station_to_idx.get(shot.from_name) # None if anchor
|
|
261
|
+
v_idx = station_to_idx.get(shot.to_name) # None if anchor
|
|
262
|
+
|
|
263
|
+
if u_idx is not None and v_idx is not None:
|
|
264
|
+
# Case 1: both free — full Laplacian contribution.
|
|
265
|
+
# N[u,u] += w, N[v,v] += w
|
|
266
|
+
# N[u,v] -= w, N[v,u] -= w
|
|
267
|
+
coo_rows.extend([u_idx, v_idx, u_idx, v_idx])
|
|
268
|
+
coo_cols.extend([u_idx, v_idx, v_idx, u_idx])
|
|
269
|
+
coo_vals.extend([w, w, -w, -w])
|
|
270
|
+
|
|
271
|
+
rhs_x[u_idx] -= w * dx
|
|
272
|
+
rhs_x[v_idx] += w * dx
|
|
273
|
+
rhs_y[u_idx] -= w * dy
|
|
274
|
+
rhs_y[v_idx] += w * dy
|
|
275
|
+
rhs_z[u_idx] -= w * dz
|
|
276
|
+
rhs_z[v_idx] += w * dz
|
|
277
|
+
|
|
278
|
+
elif u_idx is not None:
|
|
279
|
+
# Case 2: u free, v fixed (anchor).
|
|
280
|
+
# N[u,u] += w
|
|
281
|
+
# rhs[u] += w · (x_v_fixed - dx)
|
|
282
|
+
coo_rows.append(u_idx)
|
|
283
|
+
coo_cols.append(u_idx)
|
|
284
|
+
coo_vals.append(w)
|
|
285
|
+
|
|
286
|
+
v_pos = anchor_pos_c[shot.to_name]
|
|
287
|
+
rhs_x[u_idx] += w * (v_pos.x - dx)
|
|
288
|
+
rhs_y[u_idx] += w * (v_pos.y - dy)
|
|
289
|
+
rhs_z[u_idx] += w * (v_pos.z - dz)
|
|
290
|
+
|
|
291
|
+
elif v_idx is not None:
|
|
292
|
+
# Case 3: u fixed (anchor), v free.
|
|
293
|
+
# N[v,v] += w
|
|
294
|
+
# rhs[v] += w · (x_u_fixed + dx)
|
|
295
|
+
coo_rows.append(v_idx)
|
|
296
|
+
coo_cols.append(v_idx)
|
|
297
|
+
coo_vals.append(w)
|
|
298
|
+
|
|
299
|
+
u_pos = anchor_pos_c[shot.from_name]
|
|
300
|
+
rhs_x[v_idx] += w * (u_pos.x + dx)
|
|
301
|
+
rhs_y[v_idx] += w * (u_pos.y + dy)
|
|
302
|
+
rhs_z[v_idx] += w * (u_pos.z + dz)
|
|
303
|
+
|
|
304
|
+
# Case 4 (both fixed) already filtered above.
|
|
305
|
+
|
|
306
|
+
if not coo_rows:
|
|
307
|
+
return dict(network.stations)
|
|
308
|
+
|
|
309
|
+
# Build sparse CSR matrix (COO → CSR; duplicate entries are summed).
|
|
310
|
+
N = coo_matrix(
|
|
311
|
+
(
|
|
312
|
+
np.array(coo_vals, dtype=np.float64),
|
|
313
|
+
(
|
|
314
|
+
np.array(coo_rows, dtype=np.int32),
|
|
315
|
+
np.array(coo_cols, dtype=np.int32),
|
|
316
|
+
),
|
|
317
|
+
),
|
|
318
|
+
shape=(n, n),
|
|
319
|
+
).tocsr()
|
|
320
|
+
|
|
321
|
+
# -- Solve via Conjugate Gradient for each axis -------------------------
|
|
322
|
+
#
|
|
323
|
+
# N is symmetric positive-definite (weighted graph Laplacian with at
|
|
324
|
+
# least one anchor removed), so CG is guaranteed to converge.
|
|
325
|
+
# X, Y, Z are independent and can be solved separately (Ariane solves
|
|
326
|
+
# X and Y in parallel threads; here we solve sequentially for
|
|
327
|
+
# simplicity but the structure allows trivial parallelisation).
|
|
328
|
+
|
|
329
|
+
sol_x, info_x = sparse_cg(
|
|
330
|
+
N,
|
|
331
|
+
rhs_x,
|
|
332
|
+
x0=x0,
|
|
333
|
+
atol=cg_tol,
|
|
334
|
+
maxiter=cg_maxiter,
|
|
335
|
+
)
|
|
336
|
+
sol_y, info_y = sparse_cg(
|
|
337
|
+
N,
|
|
338
|
+
rhs_y,
|
|
339
|
+
x0=y0,
|
|
340
|
+
atol=cg_tol,
|
|
341
|
+
maxiter=cg_maxiter,
|
|
342
|
+
)
|
|
343
|
+
sol_z, info_z = sparse_cg(
|
|
344
|
+
N,
|
|
345
|
+
rhs_z,
|
|
346
|
+
x0=z0,
|
|
347
|
+
atol=cg_tol,
|
|
348
|
+
maxiter=cg_maxiter,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Fallback to direct solver if CG did not converge.
|
|
352
|
+
if info_x != 0:
|
|
353
|
+
logger.warning(
|
|
354
|
+
"CG did not converge for X (info=%d), falling back to direct solver",
|
|
355
|
+
info_x,
|
|
356
|
+
)
|
|
357
|
+
sol_x = spsolve(N, rhs_x)
|
|
358
|
+
if info_y != 0:
|
|
359
|
+
logger.warning(
|
|
360
|
+
"CG did not converge for Y (info=%d), falling back to direct solver",
|
|
361
|
+
info_y,
|
|
362
|
+
)
|
|
363
|
+
sol_y = spsolve(N, rhs_y)
|
|
364
|
+
if info_z != 0:
|
|
365
|
+
logger.warning(
|
|
366
|
+
"CG did not converge for Z (info=%d), falling back to direct solver",
|
|
367
|
+
info_z,
|
|
368
|
+
)
|
|
369
|
+
sol_z = spsolve(N, rhs_z)
|
|
370
|
+
|
|
371
|
+
# -- Build result dict (translate back from centred coords) ---------------
|
|
372
|
+
|
|
373
|
+
result: dict[str, Vector3D] = {}
|
|
374
|
+
for name in anchors:
|
|
375
|
+
result[name] = network.stations[name]
|
|
376
|
+
for name, idx in station_to_idx.items():
|
|
377
|
+
result[name] = Vector3D(
|
|
378
|
+
float(sol_x[idx]) + origin.x,
|
|
379
|
+
float(sol_y[idx]) + origin.y,
|
|
380
|
+
float(sol_z[idx]) + origin.z,
|
|
381
|
+
)
|
|
382
|
+
for name in network.stations:
|
|
383
|
+
if name not in result:
|
|
384
|
+
result[name] = network.stations[name]
|
|
385
|
+
|
|
386
|
+
# -- Validate (vectorised) ----------------------------------------------
|
|
387
|
+
|
|
388
|
+
if network.shots:
|
|
389
|
+
n_s = len(network.shots)
|
|
390
|
+
_dx = np.empty(n_s, dtype=np.float64)
|
|
391
|
+
_dy = np.empty(n_s, dtype=np.float64)
|
|
392
|
+
_dz = np.empty(n_s, dtype=np.float64)
|
|
393
|
+
_sl = np.empty(n_s, dtype=np.float64)
|
|
394
|
+
for _i, _sh in enumerate(network.shots):
|
|
395
|
+
_f = result[_sh.from_name]
|
|
396
|
+
_t = result[_sh.to_name]
|
|
397
|
+
_dx[_i] = _t.x - _f.x
|
|
398
|
+
_dy[_i] = _t.y - _f.y
|
|
399
|
+
_dz[_i] = _t.z - _f.z
|
|
400
|
+
_sl[_i] = _sh.delta.length
|
|
401
|
+
_el = np.sqrt(_dx * _dx + _dy * _dy + _dz * _dz)
|
|
402
|
+
_mask = _sl > 1e-9
|
|
403
|
+
_ratio = np.abs(_el[_mask] - _sl[_mask]) / _sl[_mask]
|
|
404
|
+
violation_count = int(np.sum(_ratio > max_length_frac))
|
|
405
|
+
|
|
406
|
+
if violation_count:
|
|
407
|
+
logger.warning(
|
|
408
|
+
"%d shot(s) exceed length tolerance (%.0f%%)",
|
|
409
|
+
violation_count,
|
|
410
|
+
max_length_frac * 100,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
logger.info(
|
|
414
|
+
"Adjusted %d station(s) via Ariane solver "
|
|
415
|
+
"(%d shots, %d anchors, %d traverses, sparse CG)",
|
|
416
|
+
len(non_anchors),
|
|
417
|
+
n_edges,
|
|
418
|
+
len(anchors),
|
|
419
|
+
len(shot_quality),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
return result
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ---------------------------------------------------------------------------
|
|
426
|
+
# Solver class
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class ArianeSolver(SurveyAdjuster):
|
|
431
|
+
"""Sparse CG solver with traverse-quality weighting.
|
|
432
|
+
|
|
433
|
+
Combines Ariane's sparse normal-equations / Conjugate Gradient
|
|
434
|
+
approach with Larry Fish's traverse-quality weighting and full
|
|
435
|
+
3D adjustment.
|
|
436
|
+
|
|
437
|
+
Key properties:
|
|
438
|
+
|
|
439
|
+
- **O(edges) memory** instead of O(stations²) — scales to
|
|
440
|
+
100k+ station networks.
|
|
441
|
+
- **O(nnz * iterations) solve time** instead of O(n³).
|
|
442
|
+
- **Traverse-quality weights** protect good surveys from
|
|
443
|
+
blunder contamination.
|
|
444
|
+
- **Full 3D** (X, Y, Z) — Ariane only adjusts the horizontal
|
|
445
|
+
plane.
|
|
446
|
+
|
|
447
|
+
Parameters
|
|
448
|
+
----------
|
|
449
|
+
max_length_change : float
|
|
450
|
+
Maximum relative change to a shot's tape length during
|
|
451
|
+
adjustment. Used for post-solve validation warnings.
|
|
452
|
+
max_angle_change : float
|
|
453
|
+
Maximum relative change to a shot's heading during
|
|
454
|
+
adjustment. Used for post-solve validation warnings.
|
|
455
|
+
cg_maxiter : int
|
|
456
|
+
Maximum iterations for the Conjugate Gradient solver.
|
|
457
|
+
Default 60 000 (matching Ariane).
|
|
458
|
+
cg_tol : float
|
|
459
|
+
Absolute residual tolerance for CG convergence.
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
def __init__(
|
|
463
|
+
self,
|
|
464
|
+
max_length_change: float = SOLVER_MAX_LENGTH_CHANGE,
|
|
465
|
+
max_angle_change: float = SOLVER_MAX_HEADING_CHANGE,
|
|
466
|
+
cg_maxiter: int = 60_000,
|
|
467
|
+
cg_tol: float = 1e-8,
|
|
468
|
+
) -> None:
|
|
469
|
+
self._max_length_change = max_length_change
|
|
470
|
+
self._max_angle_change = max_angle_change
|
|
471
|
+
self._cg_maxiter = cg_maxiter
|
|
472
|
+
self._cg_tol = cg_tol
|
|
473
|
+
|
|
474
|
+
@property
|
|
475
|
+
def name(self) -> str:
|
|
476
|
+
return "ArianeSolver"
|
|
477
|
+
|
|
478
|
+
def adjust(self, network: SurveyNetwork) -> dict[str, Vector3D]:
|
|
479
|
+
if len(network.anchors) < 2:
|
|
480
|
+
logger.info("Fewer than 2 anchors -- nothing to adjust")
|
|
481
|
+
return dict(network.stations)
|
|
482
|
+
|
|
483
|
+
return _solve_ariane(
|
|
484
|
+
network,
|
|
485
|
+
self._max_length_change,
|
|
486
|
+
cg_maxiter=self._cg_maxiter,
|
|
487
|
+
cg_tol=self._cg_tol,
|
|
488
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Abstract base class for survey adjustment solvers.
|
|
3
|
+
|
|
4
|
+
To implement a new solver algorithm:
|
|
5
|
+
|
|
6
|
+
1. Subclass ``SurveyAdjuster``.
|
|
7
|
+
2. Implement the ``adjust`` method.
|
|
8
|
+
3. Optionally override ``name`` for logging / UI labels.
|
|
9
|
+
|
|
10
|
+
The solver receives a :class:`SurveyNetwork` (station positions, shots,
|
|
11
|
+
anchor list) and returns a dictionary mapping station names to adjusted
|
|
12
|
+
3-D coordinates. Anchor stations must **not** be moved.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from abc import ABC
|
|
18
|
+
from abc import abstractmethod
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from compass_lib.solver.models import SurveyNetwork
|
|
23
|
+
from compass_lib.solver.models import Vector3D
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SurveyAdjuster(ABC):
|
|
27
|
+
"""Abstract base class for survey adjustment algorithms.
|
|
28
|
+
|
|
29
|
+
Subclasses must implement :meth:`adjust`. The contract is:
|
|
30
|
+
|
|
31
|
+
* Input: a :class:`SurveyNetwork` with raw BFS-propagated positions.
|
|
32
|
+
* Output: a ``dict[str, Vector3D]`` of *adjusted* station positions.
|
|
33
|
+
* Anchor stations (``network.anchors``) must keep their original
|
|
34
|
+
coordinates.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def name(self) -> str:
|
|
39
|
+
"""Human-readable name of the solver (for logging / UI)."""
|
|
40
|
+
return self.__class__.__name__
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def adjust(self, network: SurveyNetwork) -> dict[str, Vector3D]:
|
|
44
|
+
"""Adjust station coordinates.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
network: The survey network with raw BFS coordinates.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Dictionary of ``station_name -> adjusted (x, y, z)``
|
|
51
|
+
coordinates. Every station in ``network.stations`` must
|
|
52
|
+
appear in the result.
|
|
53
|
+
"""
|
|
54
|
+
...
|