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:
parent
8c73442c03
commit
980be86908
@ -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}.")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user