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.
@@ -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
+ ...