Build common interface for waypoint geometry constraints.

This is a replacement for the existing "zone geometry" classes that are
currently used for choosing locations for IP, hold, and join points.
The older approach required the author to define the methods for
choosing locations at a rather low level using shapely APIs to merge or
mask geometries. Debug UIs had to be defined manually which was a great
deal of work. Worse, those debug UIs were only useable for *successful*
waypoint placement. If there was a bug in the solver (which was pretty
much unavoidable during development or tuning), it wasn't possible to
use the debug UI.

This new system adds a (very simple) geometric constraint solver to
allow the author to describe the requirements for a waypoint at a high
level. Each waypoint type will define a waypoint solver that defines one
or more waypoint strategies which will be tried in order. For example,
the IP solver might have the following strategies:

1. Safe IP
2. Threat tolerant IP
3. Unsafe IP
4. Safe backtracking IP
5. Unsafe backtracking IP

We prefer those in the order defined, but the preferred strategies won't
always have a valid solution. When that happens, the next one is tried.

The strategies define the constraints for the waypoint location. For
example, the safe IP strategy could be defined as (in pseudo code):

* At least 5 NM away from the departure airfield
* Not farther from the departure airfield than the target is
* Within 10 NM and 45 NM of the target (doctrine dependent)
* Safe
* Within the permissible region, select the point nearest the departure
  airfield

When a solver fails to find a solution using any strategy, debug
information is automatically written in a GeoJSON format which can be
viewed on geojson.io.

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/3085.
This commit is contained in:
Dan Albert
2023-07-17 23:35:21 -07:00
committed by Raffson
parent bf879a6141
commit 643dafd2c8
4 changed files with 830 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
import json
from collections.abc import Iterator
from pathlib import Path
import pytest
from dcs.terrain import Caucasus
from shapely.geometry import Point, MultiPolygon
from shapely.geometry.base import BaseGeometry
from game.flightplan.waypointsolver import WaypointSolver, NoSolutionsError
from game.flightplan.waypointstrategy import WaypointStrategy
class NoSolutionsStrategy(WaypointStrategy):
def __init__(self) -> None:
super().__init__(MultiPolygon([]))
def find(self) -> Point | None:
return None
class PointStrategy(WaypointStrategy):
def __init__(self, x: float, y: float) -> None:
super().__init__(MultiPolygon([]))
self.point = Point(x, y)
def find(self) -> Point | None:
return self.point
class OriginStrategy(PointStrategy):
def __init__(self) -> None:
super().__init__(0, 0)
class DebuggableStrategy(NoSolutionsStrategy):
def __init__(self, distance_factor: int) -> None:
super().__init__()
center = Point(0, 0)
self.exclude("foo", center.buffer(1 * distance_factor))
self.exclude(
"bar",
center.buffer(3 * distance_factor).difference(
center.buffer(2 * distance_factor)
),
)
class SolverWithInputs(WaypointSolver):
def describe_inputs(self) -> Iterator[tuple[str, BaseGeometry]]:
yield "foo", Point(0, 0)
yield "bar", Point(1, 1)
def test_solver_tries_strategies_in_order() -> None:
solver = WaypointSolver()
solver.add_strategy(OriginStrategy())
solver.add_strategy(PointStrategy(1, 1))
assert solver.solve() == Point(0, 0)
def test_individual_failed_strategies_do_not_fail_solver() -> None:
solver = WaypointSolver()
solver.add_strategy(NoSolutionsStrategy())
solver.add_strategy(OriginStrategy())
assert solver.solve() == Point(0, 0)
def test_no_solutions_raises() -> None:
solver = WaypointSolver()
solver.add_strategy(NoSolutionsStrategy())
with pytest.raises(NoSolutionsError):
solver.solve()
def test_no_strategies_raises() -> None:
solver = WaypointSolver()
with pytest.raises(ValueError):
solver.solve()
def test_success_does_not_dump_debug_info(tmp_path: Path) -> None:
solver = WaypointSolver()
solver.set_debug_properties(tmp_path, Caucasus())
solver.add_strategy(OriginStrategy())
solver.solve()
assert not list(tmp_path.iterdir())
def test_no_solutions_dumps_debug_info(tmp_path: Path) -> None:
center = Point(0, 0)
solver = WaypointSolver()
solver.set_debug_properties(tmp_path, Caucasus())
strategy_0 = DebuggableStrategy(distance_factor=1)
strategy_1 = DebuggableStrategy(distance_factor=2)
strategy_1.prerequisite(center).is_safe()
solver.add_strategy(strategy_0)
solver.add_strategy(strategy_1)
with pytest.raises(NoSolutionsError):
solver.solve()
strategy_0_path = tmp_path / "0.json"
strategy_1_path = tmp_path / "1.json"
assert set(tmp_path.iterdir()) == {
tmp_path / "solver.json",
strategy_0_path,
strategy_1_path,
}
with strategy_0_path.open("r", encoding="utf-8") as metadata_file:
data = json.load(metadata_file)
assert data["type"] == "FeatureCollection"
assert data["metadata"]["name"] == "DebuggableStrategy"
assert data["metadata"]["prerequisites"] == []
assert len(data.keys()) == 3
features = data["features"]
assert len(features) == 2
for debug_info, feature in zip(strategy_0.iter_debug_info(), features):
assert debug_info.to_geojson(solver.to_geojson) == feature
with strategy_1_path.open("r", encoding="utf-8") as metadata_file:
data = json.load(metadata_file)
assert data["type"] == "FeatureCollection"
assert data["metadata"]["name"] == "DebuggableStrategy"
assert data["metadata"]["prerequisites"] == [
{
"requirement": "is safe",
"satisfied": True,
"subject": solver.to_geojson(center),
}
]
assert len(data.keys()) == 3
features = data["features"]
assert len(features) == 2
for debug_info, feature in zip(strategy_1.iter_debug_info(), features):
assert debug_info.to_geojson(solver.to_geojson) == feature
def test_no_solutions_dumps_inputs(tmp_path: Path) -> None:
solver = SolverWithInputs()
solver.set_debug_properties(tmp_path, Caucasus())
solver.add_strategy(NoSolutionsStrategy())
with pytest.raises(NoSolutionsError):
solver.solve()
inputs_path = tmp_path / "solver.json"
with inputs_path.open(encoding="utf-8") as inputs_file:
data = json.load(inputs_file)
assert data == {
"type": "FeatureCollection",
"metadata": {
"name": "SolverWithInputs",
"terrain": "Caucasus",
},
"features": [
{
"type": "Feature",
"properties": {"description": "foo"},
"geometry": {
"type": "Point",
"coordinates": [34.265515188456, 45.129497060328966],
},
},
{
"type": "Feature",
"properties": {"description": "bar"},
"geometry": {
"type": "Point",
"coordinates": [34.265528100962584, 45.1295059189547],
},
},
],
}
def test_solver_inputs_appear_in_strategy_features(tmp_path: Path) -> None:
solver = SolverWithInputs()
solver.set_debug_properties(tmp_path, Caucasus())
solver.add_strategy(PointStrategy(2, 2))
solver.dump_debug_info()
strategy_path = tmp_path / "0.json"
with strategy_path.open(encoding="utf-8") as inputs_file:
data = json.load(inputs_file)
assert data == {
"type": "FeatureCollection",
"metadata": {
"name": "PointStrategy",
"prerequisites": [],
},
"features": [
{
"type": "Feature",
"properties": {"description": "foo"},
"geometry": {
"type": "Point",
"coordinates": [34.265515188456, 45.129497060328966],
},
},
{
"type": "Feature",
"properties": {"description": "bar"},
"geometry": {
"type": "Point",
"coordinates": [34.265528100962584, 45.1295059189547],
},
},
{
"type": "Feature",
"properties": {"description": "solution"},
"geometry": {
"coordinates": [34.265541013473154, 45.12951477757893],
"type": "Point",
},
},
],
}
def test_to_geojson(tmp_path: Path) -> None:
solver = WaypointSolver()
solver.set_debug_properties(tmp_path, Caucasus())
assert solver.to_geojson(Point(0, 0)) == {
"coordinates": [34.265515188456, 45.129497060328966],
"type": "Point",
}
assert solver.to_geojson(MultiPolygon([])) == {
"type": "MultiPolygon",
"coordinates": [],
}

View File

@@ -0,0 +1,190 @@
from __future__ import annotations
from pathlib import Path
import pytest
from pytest import approx
from shapely.geometry import Point, MultiPolygon
from game.flightplan.waypointstrategy import WaypointStrategy, angle_between_points
from game.utils import meters, Heading
def test_safe_prerequisite_safe_point() -> None:
strategy = WaypointStrategy(MultiPolygon([]))
strategy.prerequisite(Point(0, 0)).is_safe()
assert strategy.prerequisites_are_satisfied()
def test_safe_prerequisite_unsafe_point() -> None:
strategy = WaypointStrategy(MultiPolygon([Point(0, 0).buffer(1)]))
strategy.prerequisite(Point(0, 0)).is_safe()
assert not strategy.prerequisites_are_satisfied()
def test_no_solution_if_prerequisites_failed() -> None:
"""Verify that no solution is found if prerequisites are not satisfied.
This test has a 1-meter radius threat zone about the center of the plane. It has a
prerequisite for a safe center, which will fail. The test verifies that even if
there are no .require() constraints that would prevent finding a solution, failed
prerequisites still prevent it (prerequisites differ from constraints in that they
will prevent any of the other operations from happening without needing to location
constraints, which is important because it allows strategies to avoid defending
against invalid cases).
"""
strategy = WaypointStrategy(MultiPolygon([Point(0, 0).buffer(1)]))
strategy.prerequisite(Point(0, 0)).is_safe()
# This constraint won't actually apply, but it's required before calling find() so
# we need to set it even though it's not actually relevant to the test.
strategy.nearest(Point(0, 0))
assert strategy.find() is None
def test_has_solution_if_prerequisites_satisfied() -> None:
strategy = WaypointStrategy(MultiPolygon([]))
strategy.prerequisite(Point(0, 0)).is_safe()
strategy.nearest(Point(0, 0))
assert strategy.find() is not None
def test_require_nearest() -> None:
strategy = WaypointStrategy(MultiPolygon([]))
center = Point(0, 0)
strategy.nearest(center)
assert strategy.find() == center
def test_find_without_nearest_raises() -> None:
with pytest.raises(RuntimeError):
WaypointStrategy(MultiPolygon([])).find()
def test_multiple_nearest_raises() -> None:
strategy = WaypointStrategy(MultiPolygon([]))
strategy.nearest(Point(0, 0))
with pytest.raises(RuntimeError):
strategy.nearest(Point(0, 0))
def test_require_at_least() -> None:
strategy = WaypointStrategy(MultiPolygon([]))
center = Point(0, 0)
strategy.require().at_least(meters(10)).away_from(center)
strategy.nearest(center)
solution = strategy.find()
assert solution is not None
assert solution.distance(center) == approx(10, 0.1)
def test_require_at_most() -> None:
strategy = WaypointStrategy(MultiPolygon([]))
center = Point(0, 0)
strategy.require().at_most(meters(1)).away_from(center)
strategy.nearest(Point(10, 0))
solution = strategy.find()
assert solution is not None
assert solution.distance(center) <= 1
def test_require_safe() -> None:
threat = MultiPolygon([Point(0, 0).buffer(10)])
strategy = WaypointStrategy(threat)
strategy.require().safe()
strategy.nearest(Point(0, 0))
solution = strategy.find()
assert solution is not None
assert not solution.intersects(threat)
def test_require_maximum_turn_to() -> None:
strategy = WaypointStrategy(MultiPolygon([]))
turn_point = Point(1, 0)
turn_target = Point(0, 0)
strategy.require().maximum_turn_to(turn_point, turn_target, Heading(90))
strategy.nearest(Point(0, 1))
pre_turn_heading = Heading.from_degrees(
angle_between_points(strategy.find(), turn_point)
)
post_turn_heading = Heading.from_degrees(
angle_between_points(turn_point, turn_target)
)
assert pre_turn_heading.angle_between(post_turn_heading) <= Heading(90)
def test_combined_constraints() -> None:
strategy = WaypointStrategy(MultiPolygon([]))
center = Point(0, 0)
offset = Point(1, 0)
midpoint = Point(0.5, 0)
strategy.require().at_least(meters(1)).away_from(center)
strategy.require().at_least(meters(1)).away_from(offset)
strategy.nearest(midpoint)
solution = strategy.find()
assert solution is not None
assert solution.distance(center) == approx(1, rel=0.1, abs=0.1)
assert solution.distance(offset) == approx(1, rel=0.1, abs=0.1)
assert solution.distance(midpoint) < 1
def test_threat_tolerance(tmp_path: Path) -> None:
home = Point(20, 0)
target = Point(-1, 0)
max_distance = meters(5)
threat = MultiPolygon([Point(0, 0).buffer(10)])
strategy = WaypointStrategy(threat)
strategy.require().at_most(max_distance).away_from(target)
strategy.threat_tolerance(target, max_distance, meters(1))
strategy.require().safe()
strategy.nearest(home)
solution = strategy.find()
assert solution is not None
# Max distance of 5 from -1, so the point should be at 4. Home is at 20.
assert solution.distance(home) == 16
def test_threat_tolerance_does_nothing_if_no_threats(tmp_path: Path) -> None:
strategy = WaypointStrategy(MultiPolygon([]))
strategy.threat_tolerance(Point(0, 0), meters(1), meters(1))
assert strategy._threat_tolerance is None
def test_no_solutions() -> None:
strategy = WaypointStrategy(MultiPolygon([]))
strategy.require().at_most(meters(1)).away_from(Point(0, 0))
strategy.require().at_least(meters(2)).away_from(Point(0, 0))
strategy.nearest(Point(0, 0))
assert strategy.find() is None
def test_debug() -> None:
center = Point(0, 0)
threat = MultiPolygon([center.buffer(5)])
strategy = WaypointStrategy(threat)
strategy.require().at_most(meters(10)).away_from(center, "center")
strategy.require().at_least(meters(2)).away_from(center)
strategy.require().safe()
strategy.nearest(center)
solution = strategy.find()
assert solution is not None
debug_info = list(strategy.iter_debug_info())
assert len(debug_info) == 4
max_distance_debug, min_distance_debug, safe_debug, solution_debug = debug_info
assert max_distance_debug.description == "at most 10 meters away from center"
assert max_distance_debug.geometry.distance(center) == approx(10, 0.1)
assert min_distance_debug.description == "at least 2 meters away from POINT (0 0)"
assert max_distance_debug.geometry.boundary.distance(center) == approx(10, 0.1)
assert safe_debug.description == "safe"
assert safe_debug.geometry == threat
assert solution_debug.description == "solution"
assert solution_debug.geometry == solution
def test_debug_info_omits_solution_if_none() -> None:
center = Point(0, 0)
strategy = WaypointStrategy(MultiPolygon([]))
strategy.require().at_most(meters(1)).away_from(center)
strategy.require().at_least(meters(2)).away_from(center)
strategy.nearest(center)
debug_infos = list(strategy.iter_debug_info())
assert len(debug_infos) == 2