Fall back to a compromise point when two distance circles don't cross

Previously two distance-only clues whose circles didn't actually
intersect (real typewriter data isn't perfectly consistent, a
misplaced spotter or an off-by-a-bit distance reading is enough) just
gave up entirely, even when they were nearly touching.

solver.closest_compromise_point() picks the midpoint between each
circle's point facing the other, the standard notion of the closest
approach between two circles. It stays well-behaved even when the
centers are nearly coincident, unlike projecting along the center
line the way a real intersection's formula does, which diverges as
the centers get close while the radii stay far apart, exactly the
near-coincident case this is for.

The result is flagged rather than treated as a clean fix: Location
gained a  field, set whenever solve_location() had to use this
fallback, cleared by any subsequent coord.setter call (manual or
solver), shown in the entity's status label and toasted after a
manual clue edit. Persisted through save/load.
This commit is contained in:
Dominik Moritz Roth 2026-08-08 23:56:24 +02:00
parent 8c73442c03
commit 980be86908
3 changed files with 100 additions and 13 deletions

View File

@ -162,7 +162,8 @@ def _scout_flight_row(sf, *, on_replot, on_remove, on_toggle_hidden) -> Gtk.Widg
def _location_status(obj) -> str:
if obj.coord is not None:
return obj.coord.label()
suffix = " approximate" if obj.location.note else ""
return f"{obj.coord.label()}{suffix}"
if obj.location.potential_coords:
return f"ambiguous ({len(obj.location.potential_coords)} candidates)"
if obj.location.desc_raw:
@ -991,8 +992,12 @@ class MainWindow(Adw.ApplicationWindow):
_apply_location(obj, location)
self._refresh()
# A manual clue edit (typically the Description tab) can go in and
# come out unresolved with no other feedback, tell the user why
# instead of leaving it looking like nothing happened.
# come out unresolved, or resolved only approximately, with no
# other feedback, tell the user which instead of leaving it
# looking like nothing happened / a clean fix.
if obj.location.note:
self.toast(f"{obj.name}: {obj.location.note}.")
return
reason = solver.explain_unresolved(obj.location, self.board)
if reason is not None:
self.toast(f"{obj.name} not resolved: {reason}.")

View File

@ -140,12 +140,17 @@ class Location:
that resolution. `potential_coords` holds a solver result that was
genuinely ambiguous (e.g. a bearing ray crossing a distance circle
twice), shown on the map as candidates, never treated as resolved
and never used to resolve anything else."""
and never used to resolve anything else. `note` is set instead when
the solver had to fall back to an approximate compromise point
because the clues didn't quite geometrically agree (see
solver.closest_compromise_point()), unlike potential_coords this
*is* a single resolved coord, just flagged as not fully trustworthy."""
coord: Coord | None = None
desc_raw: str | None = None
clues: list[Clue] = field(default_factory=list)
potential_coords: list[Coord] = field(default_factory=list)
note: str | None = None
@property
def is_resolved(self) -> bool:
@ -170,6 +175,7 @@ class Location:
"desc_raw": self.desc_raw,
"clues": [c.to_dict() for c in self.clues],
"potential_coords": [c.to_dict() for c in self.potential_coords],
"note": self.note,
}
@classmethod
@ -181,6 +187,7 @@ class Location:
desc_raw=d.get("desc_raw"),
clues=[Clue.from_dict(c) for c in d.get("clues", [])],
potential_coords=[Coord.from_dict(c) for c in d.get("potential_coords", [])],
note=d.get("note"),
)
@ -206,8 +213,14 @@ class Nest:
# potential_coords is left alone too, not cleared. A coord takes
# priority over it everywhere it matters (placed_entities() /
# ambiguous_entities() / firing panel cards all check coord first),
# so it just goes inert rather than being deleted.
# so it just goes inert rather than being deleted. `note` DOES get
# cleared: it's the solver's "this was an approximate compromise,
# not a real fix" flag, and any new coord here, solver-derived or
# a manual override, invalidates whatever note was there before
# (resolve_board() re-attaches a fresh one right after, if this
# new coord is itself another approximate fix).
self.location.coord = value
self.location.note = None
@dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members
@ -232,8 +245,14 @@ class Spotter:
# potential_coords is left alone too, not cleared. A coord takes
# priority over it everywhere it matters (placed_entities() /
# ambiguous_entities() / firing panel cards all check coord first),
# so it just goes inert rather than being deleted.
# so it just goes inert rather than being deleted. `note` DOES get
# cleared: it's the solver's "this was an approximate compromise,
# not a real fix" flag, and any new coord here, solver-derived or
# a manual override, invalidates whatever note was there before
# (resolve_board() re-attaches a fresh one right after, if this
# new coord is itself another approximate fix).
self.location.coord = value
self.location.note = None
@dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members
@ -258,8 +277,14 @@ class ReferencePoint:
# potential_coords is left alone too, not cleared. A coord takes
# priority over it everywhere it matters (placed_entities() /
# ambiguous_entities() / firing panel cards all check coord first),
# so it just goes inert rather than being deleted.
# so it just goes inert rather than being deleted. `note` DOES get
# cleared: it's the solver's "this was an approximate compromise,
# not a real fix" flag, and any new coord here, solver-derived or
# a manual override, invalidates whatever note was there before
# (resolve_board() re-attaches a fresh one right after, if this
# new coord is itself another approximate fix).
self.location.coord = value
self.location.note = None
@dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members
@ -306,8 +331,14 @@ class Target:
# potential_coords is left alone too, not cleared. A coord takes
# priority over it everywhere it matters (placed_entities() /
# ambiguous_entities() / firing panel cards all check coord first),
# so it just goes inert rather than being deleted.
# so it just goes inert rather than being deleted. `note` DOES get
# cleared: it's the solver's "this was an approximate compromise,
# not a real fix" flag, and any new coord here, solver-derived or
# a manual override, invalidates whatever note was there before
# (resolve_board() re-attaches a fresh one right after, if this
# new coord is itself another approximate fix).
self.location.coord = value
self.location.note = None
@dataclass(eq=False) # identity equality/hash, these are mutable, used as dict keys/set members

View File

@ -38,6 +38,11 @@ Point = tuple[float, float] # (col, row) in km
class SolveResult:
coord: Coord | None = None
potential: list[Coord] = field(default_factory=list)
# Set when `coord` came from closest_compromise_point() rather than a
# real intersection, i.e. the underlying readings don't quite agree
# with each other. Explains itself, meant to be shown to the user
# (see resolve_board()/Location.note), never checked by code.
note: str | None = None
def bearing_distance_to_delta(bearing_deg: float, distance_km: float) -> Point:
@ -123,6 +128,34 @@ def circle_circle_intersections(
return [(px + h * perp_x, py + h * perp_y), (px - h * perp_x, py - h * perp_y)]
def closest_compromise_point(
center_a: Point, radius_a: float, center_b: Point, radius_b: float
) -> Point | None:
"""When two distance-only clues' circles don't actually cross (real
game data isn't perfectly consistent, a spotter's position or a
reported distance can be off by enough that the two circles end up
nested or just short of touching), the point that best reconciles
both readings anyway: the midpoint between circle A's point facing
circle B and circle B's point facing circle A, the standard notion of
"closest points between two circles" when they're genuinely apart,
and it degrades gracefully rather than blowing up when they're
nested or nearly concentric too (unlike projecting along the
center line the way a real intersection's `a` term does, which
diverges as the centers get close together while the radii stay far
apart, exactly the case this function exists for). None only for
coincident centers, where "the line through them" isn't defined."""
ax, ay = center_a
bx, by = center_b
dx, dy = bx - ax, by - ay
d = math.hypot(dx, dy)
if d < 1e-9:
return None
ux, uy = dx / d, dy / d
edge_a = (ax + ux * radius_a, ay + uy * radius_a) # on circle A, facing B
edge_b = (bx - ux * radius_b, by - uy * radius_b) # on circle B, facing A
return (edge_a[0] + edge_b[0]) / 2, (edge_a[1] + edge_b[1]) / 2
def ray_ray_intersection(
origin_a: Point, bearing_a: float, origin_b: Point, bearing_b: float
) -> Point | None:
@ -215,6 +248,19 @@ def solve_location(location: Location, board: Board) -> SolveResult:
return SolveResult(coord=coords[0])
if len(coords) >= 2:
return SolveResult(potential=coords) # genuinely ambiguous
# No real intersection, the circles are nested or just short of
# touching. Rather than give up, use the point that best splits
# the difference, flagged as approximate rather than treated as
# a clean fix.
point = closest_compromise_point(p1, c1.distance_km, p2, c2.distance_km)
if point is not None:
coord = point_to_coord(point)
if coord is not None:
return SolveResult(coord=coord, note=(
f"approximate: {c1.reference}'s {c1.distance_km}km and {c2.reference}'s "
f"{c2.distance_km}km circles don't actually cross, used the closest point "
"between them instead"
))
return SolveResult()
@ -248,11 +294,15 @@ def explain_unresolved(location: Location, board: Board) -> str | None:
f"circle around {cd.reference}, check those two readings against each other")
if len(distances) >= 2:
# circle_circle_intersections() not crossing isn't fatal by itself
# any more, solve_location() falls back to closest_compromise_point()
# for that, only reaching here if even that gave up.
(c1, p1), (c2, p2) = distances[0], distances[1]
if not circle_circle_intersections(p1, c1.distance_km, p2, c2.distance_km):
return (f"the {c1.distance_km}km circle around {c1.reference} and the {c2.distance_km}km "
f"circle around {c2.reference} don't cross, too far apart or one nested inside "
"the other given those references' actual positions, check the readings/positions")
point = closest_compromise_point(p1, c1.distance_km, p2, c2.distance_km)
if point is None:
return f"{c1.reference} and {c2.reference} are reported at the exact same position, can't triangulate from two coincident circles"
if point_to_coord(point) is None:
return f"the best-fit point for {c1.reference}'s and {c2.reference}'s distances falls off the map"
return None # e.g. two bearings that are (near-)parallel, or genuinely just needs more info
@ -274,7 +324,8 @@ def resolve_board(board: Board) -> list[str]:
continue # already resolved, or stuck ambiguous, don't reprocess
result = solve_location(obj.location, board)
if result.coord is not None:
obj.coord = result.coord
obj.coord = result.coord # clears any stale note (see the coord setters)
obj.location.note = result.note
newly_resolved.append(obj.name)
changed = True
elif result.potential: