Skip to content

Commit

Permalink
WIP: Build common interface for waypoint geometry constraints.
Browse files Browse the repository at this point in the history
  • Loading branch information
DanAlbert committed Jul 28, 2023
1 parent 159120b commit ab2cc54
Show file tree
Hide file tree
Showing 9 changed files with 583 additions and 118 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { IpZonesLayer } from "./IpZones";
import { JoinZonesLayer } from "./JoinZones";
import { LayersControl } from "react-leaflet";

const ENABLE_EXPENSIVE_DEBUG_TOOLS = false;
const ENABLE_EXPENSIVE_DEBUG_TOOLS = true;

export function WaypointDebugZonesControls() {
const selectedFlightId = useAppSelector(selectSelectedFlightId);
Expand Down
15 changes: 9 additions & 6 deletions game/ato/packagewaypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
from dcs import Point

from game.ato.flightplans.waypointbuilder import WaypointBuilder
from game.flightplan import IpZoneGeometry, JoinZoneGeometry
from game.flightplan import JoinZoneGeometry
from game.flightplan.ipsolver import IpSolver
from game.flightplan.refuelzonegeometry import RefuelZoneGeometry
from game.utils import dcs_to_shapely_point

if TYPE_CHECKING:
from game.ato import Package
Expand All @@ -26,11 +28,12 @@ def create(package: Package, coalition: Coalition) -> PackageWaypoints:
origin = package.departure_closest_to_target()

# Start by picking the best IP for the attack.
ingress_point = IpZoneGeometry(
package.target.position,
origin.position,
coalition,
).find_best_ip()
ingress_point = IpSolver(
dcs_to_shapely_point(package.target.position),
dcs_to_shapely_point(origin.position),
coalition.doctrine,
coalition.threat_zone.all,
).solve()

join_point = JoinZoneGeometry(
package.target.position,
Expand Down
78 changes: 78 additions & 0 deletions game/flightplan/ipsolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from shapely.geometry import MultiPolygon, Point

from game.data.doctrine import Doctrine
from game.flightplan.waypointsolver import WaypointSolver
from game.flightplan.waypointstrategy import WaypointStrategy
from game.utils import meters, nautical_miles


class ThreatTolerantIpStrategy(WaypointStrategy):
def __init__(
self,
departure: Point,
target: Point,
threat_zones: MultiPolygon,
) -> None:
super().__init__(threat_zones)
self.prerequisite(target).min_distance_from(departure, nautical_miles(10))
self.require().at_least(nautical_miles(5)).away_from(departure)
self.require().at_most(meters(departure.distance(target))).away_from(departure)
self.require().at_least(nautical_miles(10)).away_from(target)
max_ip_range = min(nautical_miles(45), departure.distance(target))
self.require().at_most(max_ip_range).away_from(target)
self.threat_tolerance(target, max_ip_range, nautical_miles(5))
self.nearest(departure)


class UnsafeIpStrategy(WaypointStrategy):
def __init__(
self,
departure: Point,
target: Point,
threat_zones: MultiPolygon,
) -> None:
super().__init__(threat_zones)
self.prerequisite(target).min_distance_from(departure, nautical_miles(10))
self.require().at_least(nautical_miles(5)).away_from(departure)
self.require().at_most(meters(departure.distance(target))).away_from(departure)
self.require().at_least(nautical_miles(10)).away_from(target)
max_ip_range = min(nautical_miles(45), departure.distance(target))
self.require().at_most(max_ip_range).away_from(target)
self.nearest(departure)


class SafeIpStrategy(WaypointStrategy):
def __init__(
self,
departure: Point,
target: Point,
doctrine: Doctrine,
threat_zones: MultiPolygon,
) -> None:
super().__init__(threat_zones)
self.prerequisite(departure).safe()
self.prerequisite(target).min_distance_from(
departure, doctrine.min_ingress_distance
)
self.require().at_least(nautical_miles(5)).away_from(departure)
self.require().at_most(meters(departure.distance(target))).away_from(departure)
self.require().at_least(doctrine.min_ingress_distance).away_from(target)
self.require().at_most(
min(doctrine.max_ingress_distance, meters(departure.distance(target)))
).away_from(target)
self.require().safe()
self.nearest(departure)


class IpSolver(WaypointSolver):
def __init__(
self,
departure: Point,
target: Point,
doctrine: Doctrine,
threat_zones: MultiPolygon,
) -> None:
super().__init__()
self.add_strategy(SafeIpStrategy(departure, target, doctrine, threat_zones))
self.add_strategy(ThreatTolerantIpStrategy(departure, target, threat_zones))
self.add_strategy(UnsafeIpStrategy(departure, target, threat_zones))
166 changes: 55 additions & 111 deletions game/flightplan/ipzonegeometry.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from __future__ import annotations

from collections.abc import Iterator
from typing import TYPE_CHECKING

import shapely.ops
from dcs import Point
from shapely.geometry import MultiPolygon, Point as ShapelyPoint, MultiLineString
from shapely.geometry import Point as ShapelyPoint

from game.flightplan.waypointstrategy import WaypointStrategy
from game.utils import meters, nautical_miles

if TYPE_CHECKING:
Expand All @@ -28,6 +29,7 @@ def __init__(
self._target = target
self.threat_zone = coalition.opponent.threat_zone.all
self.home = ShapelyPoint(home.x, home.y)
self.doctrine = coalition.doctrine

max_ip_distance = coalition.doctrine.max_ingress_distance
min_ip_distance = coalition.doctrine.min_ingress_distance
Expand All @@ -43,123 +45,65 @@ def __init__(

home_threatened = coalition.opponent.threat_zone.threatened(home)

shapely_target = ShapelyPoint(target.x, target.y)
home_to_target_distance = meters(home.distance_to_point(target))
self.target = ShapelyPoint(target.x, target.y)
self.home_to_target_distance = meters(home.distance_to_point(target))

self.home_bubble = self.home.buffer(home_to_target_distance.meters).difference(
self.home.buffer(min_distance_from_home.meters)
def _unsafeish_ip(self) -> ShapelyPoint:
solver = WaypointStrategy(self.threat_zone)
solver.prerequisite(self.target).min_distance_from(
self.home, nautical_miles(10)
)

# If the home zone is not threatened and home is within LAR, constrain the max
# range to the home-to-target distance to prevent excessive backtracking.
#
# If the home zone *is* threatened, we need to back out of the zone to
# rendezvous anyway.
if not home_threatened and (
min_ip_distance < home_to_target_distance < max_ip_distance
):
max_ip_distance = home_to_target_distance
max_ip_bubble = shapely_target.buffer(max_ip_distance.meters)
min_ip_bubble = shapely_target.buffer(min_ip_distance.meters)
self.ip_bubble = max_ip_bubble.difference(min_ip_bubble)

# The intersection of the home bubble and IP bubble will be all the points that
# are within the valid IP range that are not farther from home than the target
# is. However, if the origin airfield is threatened but there are safe
# placements for the IP, we should not constrain to the home zone. In this case
# we'll either end up with a safe zone outside the home zone and pick the
# closest point in to to home (minimizing backtracking), or we'll have no safe
# IP anywhere within range of the target, and we'll later pick the IP nearest
# the edge of the threat zone.
if home_threatened:
self.permissible_zone = self.ip_bubble
else:
self.permissible_zone = self.ip_bubble.intersection(self.home_bubble)

if self.permissible_zone.is_empty:
# If home is closer to the target than the min range, there will not be an
# IP solution that's close enough to home, in which case we need to ignore
# the home bubble.
self.permissible_zone = self.ip_bubble

safe_zones = self.permissible_zone.difference(
self.threat_zone.buffer(attack_distance_buffer.meters)
solver.require().at_least(nautical_miles(5)).away_from(self.home)
solver.require().at_most(meters(self.home.distance(self.target))).away_from(
self.home
)
solver.require().at_least(nautical_miles(10)).away_from(self.target)
max_ip_range = min(nautical_miles(45), self.home_to_target_distance)
solver.require().at_most(max_ip_range).away_from(self.target)
solver.threat_tolerance(self.target, max_ip_range, nautical_miles(5))
return solver.nearest(self.home)

if not isinstance(safe_zones, MultiPolygon):
safe_zones = MultiPolygon([safe_zones])
self.safe_zones = safe_zones

# See explanation where this is used in _unsafe_ip.
# https://github.com/dcs-liberation/dcs_liberation/issues/2754
preferred_threatened_zone_wiggle_room = nautical_miles(5)
threat_buffer_distance = self.permissible_zone.distance(
self.threat_zone.boundary
)
preferred_threatened_zone_mask = self.threat_zone.buffer(
-threat_buffer_distance - preferred_threatened_zone_wiggle_room.meters
def _unsafe_ip(self) -> ShapelyPoint:
solver = WaypointStrategy(self.threat_zone)
solver.prerequisite(self.target).min_distance_from(
self.home, nautical_miles(10)
)
preferred_threatened_zones = self.threat_zone.difference(
preferred_threatened_zone_mask
solver.require().at_least(nautical_miles(5)).away_from(self.home)
solver.require().at_most(meters(self.home.distance(self.target))).away_from(
self.home
)
solver.require().at_least(nautical_miles(10)).away_from(self.target)
max_ip_range = min(nautical_miles(45), self.home_to_target_distance)
solver.require().at_most(max_ip_range).away_from(self.target)
return solver.nearest(self.home)

if not isinstance(preferred_threatened_zones, MultiPolygon):
preferred_threatened_zones = MultiPolygon([preferred_threatened_zones])
self.preferred_threatened_zones = preferred_threatened_zones

tolerable_threatened_lines = self.preferred_threatened_zones.intersection(
self.permissible_zone.boundary
def _safe_ip(self) -> ShapelyPoint:
solver = WaypointStrategy(self.threat_zone)
solver.prerequisite(self.home).safe()
solver.prerequisite(self.target).min_distance_from(
self.home, self.doctrine.min_ingress_distance
)
if tolerable_threatened_lines.is_empty:
tolerable_threatened_lines = MultiLineString([])
elif not isinstance(tolerable_threatened_lines, MultiLineString):
tolerable_threatened_lines = MultiLineString([tolerable_threatened_lines])
self.tolerable_threatened_lines = tolerable_threatened_lines

def _unsafe_ip(self) -> ShapelyPoint:
unthreatened_home_zone = self.home_bubble.difference(self.threat_zone)
if unthreatened_home_zone.is_empty:
# Nowhere in our home zone is safe. The package will need to exit the
# threatened area to hold and rendezvous. Pick the IP closest to the
# edge of the threat zone.
return shapely.ops.nearest_points(
self.permissible_zone, self.threat_zone.boundary
)[0]

# No safe point in the IP zone, but the home zone is safe. Pick an IP within
# both the permissible zone and preferred threatened zone that's as close to the
# unthreatened home zone as possible. This should get us a max-range IP that
# is roughly as safe as possible without unjustifiably long routes.
#
# If we do the obvious thing and pick the IP that minimizes threatened travel
# time (the IP closest to the threat boundary) and the objective is near the
# center of the threat zone (common when there is an airbase covered only by air
# defenses with shorter range than the BARCAP zone, and the target is a TGO near
# the CP), the IP could be placed such that the flight would fly all the way
# around the threat zone just to avoid a few more threatened miles of travel. To
# avoid that, we generate a set of preferred threatened areas that offer a
# trade-off between travel time and safety.
#
# https://github.com/dcs-liberation/dcs_liberation/issues/2754
if not self.tolerable_threatened_lines.is_empty:
return shapely.ops.nearest_points(
self.tolerable_threatened_lines, self.home
)[0]

# But if no part of the permissible zone is tolerably threatened, fall back to
# the old safety maximizing approach.
return shapely.ops.nearest_points(
self.permissible_zone, unthreatened_home_zone
)[0]
solver.require().at_least(nautical_miles(5)).away_from(self.home)
solver.require().at_most(meters(self.home.distance(self.target))).away_from(
self.home
)
solver.require().at_least(self.doctrine.min_ingress_distance).away_from(
self.target
)
solver.require().at_most(
min(self.doctrine.max_ingress_distance, self.home_to_target_distance)
).away_from(self.target)
solver.require().safe()
return solver.nearest(self.home)

def _safe_ip(self) -> ShapelyPoint:
# We have a zone of possible IPs that are safe, close enough, and in range. Pick
# the IP in the zone that's closest to the target.
return shapely.ops.nearest_points(self.safe_zones, self.home)[0]
def _try_each_solution(self) -> Iterator[ShapelyPoint | None]:
yield self._safe_ip()
yield self._unsafeish_ip()
yield self._unsafe_ip()

def find_best_ip(self) -> Point:
if self.safe_zones.is_empty:
ip = self._unsafe_ip()
else:
ip = self._safe_ip()
return self._target.new_in_same_map(ip.x, ip.y)
for ip in self._try_each_solution():
if ip is None:
continue
return self._target.new_in_same_map(ip.x, ip.y)
raise RuntimeError
2 changes: 2 additions & 0 deletions game/flightplan/joinzonegeometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ def __init__(
self.preferred_lines = preferred_lines

def find_best_join_point(self) -> Point:
# TODO: afaict the permissible_lines case is entirely unnecessary. The two
# definitions appear equivalent.
if self.preferred_lines.is_empty:
join, _ = shapely.ops.nearest_points(self.permissible_zones, self.ip)
else:
Expand Down
42 changes: 42 additions & 0 deletions game/flightplan/waypointsolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

from dcs import Point

if TYPE_CHECKING:
from .waypointstrategy import WaypointStrategy


class WaypointSolver:
def __init__(self) -> None:
self.strategies: list[WaypointStrategy] = []
self.debug_output_directory: Path | None = None

def add_strategy(self, strategy: WaypointStrategy) -> None:
self.strategies.append(strategy)

def set_debug_output_directory(self, path: Path) -> None:
self.debug_output_directory = path

def dump_debug_info(self) -> None:
path = self.debug_output_directory
if path is None:
return

def solve(self) -> Point:
if not self.strategies:
raise ValueError(
"WaypointSolver.solve() called before any strategies were added"
)

for strategy in self.strategies:
if (point := strategy.find()) is not None:
return point

self.dump_debug_info()
debug_details = "No debug output directory set"
if (debug_path := self.debug_output_directory) is not None:
debug_details = f"Debug details written to {debug_path}"
raise RuntimeError(f"No solutions found for waypoint. {debug_details}")
Loading

0 comments on commit ab2cc54

Please sign in to comment.