From 8b04dd878d8f77dd5598cb31e6ae7718f5491053 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 29 Jul 2023 21:49:17 -0700 Subject: [PATCH] Migrate IP placement to WaypointSolver. --- client/src/api/_liberationApi.ts | 22 --- client/src/api/liberationApi.ts | 5 - .../components/waypointdebugzones/IpZones.tsx | 96 ---------- .../WaypointDebugZonesControls.tsx | 4 - game/ato/flightplans/ibuilder.py | 6 +- game/ato/packagewaypoints.py | 20 +- game/flightplan/__init__.py | 1 - game/flightplan/ipsolver.py | 173 ++++++++++++++++++ game/flightplan/ipzonegeometry.py | 165 ----------------- game/server/debuggeometries/models.py | 38 +--- game/server/debuggeometries/routes.py | 9 +- 11 files changed, 193 insertions(+), 346 deletions(-) delete mode 100644 client/src/components/waypointdebugzones/IpZones.tsx create mode 100644 game/flightplan/ipsolver.py delete mode 100644 game/flightplan/ipzonegeometry.py diff --git a/client/src/api/_liberationApi.ts b/client/src/api/_liberationApi.ts index b7de3303..8f527a49 100644 --- a/client/src/api/_liberationApi.ts +++ b/client/src/api/_liberationApi.ts @@ -50,14 +50,6 @@ const injectedRtkApi = api.injectEndpoints({ url: `/debug/waypoint-geometries/hold/${queryArg.flightId}`, }), }), - getDebugIpZones: build.query< - GetDebugIpZonesApiResponse, - GetDebugIpZonesApiArg - >({ - query: (queryArg) => ({ - url: `/debug/waypoint-geometries/ip/${queryArg.flightId}`, - }), - }), getDebugJoinZones: build.query< GetDebugJoinZonesApiResponse, GetDebugJoinZonesApiArg @@ -245,11 +237,6 @@ export type GetDebugHoldZonesApiResponse = export type GetDebugHoldZonesApiArg = { flightId: string; }; -export type GetDebugIpZonesApiResponse = - /** status 200 Successful Response */ IpZones; -export type GetDebugIpZonesApiArg = { - flightId: string; -}; export type GetDebugJoinZonesApiResponse = /** status 200 Successful Response */ JoinZones; export type GetDebugJoinZonesApiArg = { @@ -379,14 +366,6 @@ export type HoldZones = { permissibleZones: LatLng[][][]; preferredLines: LatLng[][]; }; -export type IpZones = { - homeBubble: LatLng[][]; - ipBubble: LatLng[][]; - permissibleZone: LatLng[][]; - safeZones: LatLng[][][]; - preferredThreatenedZones: LatLng[][][]; - tolerableThreatenedLines: LatLng[][]; -}; export type JoinZones = { homeBubble: LatLng[][]; targetBubble: LatLng[][]; @@ -500,7 +479,6 @@ export const { useSetControlPointDestinationMutation, useClearControlPointDestinationMutation, useGetDebugHoldZonesQuery, - useGetDebugIpZonesQuery, useGetDebugJoinZonesQuery, useListFlightsQuery, useGetFlightByIdQuery, diff --git a/client/src/api/liberationApi.ts b/client/src/api/liberationApi.ts index 84a58377..165e0d2b 100644 --- a/client/src/api/liberationApi.ts +++ b/client/src/api/liberationApi.ts @@ -30,11 +30,6 @@ export const liberationApi = _liberationApi.enhanceEndpoints({ { type: Tags.FLIGHT_PLAN, id: arg.flightId }, ], }, - getDebugIpZones: { - providesTags: (result, error, arg) => [ - { type: Tags.FLIGHT_PLAN, id: arg.flightId }, - ], - }, getDebugJoinZones: { providesTags: (result, error, arg) => [ { type: Tags.FLIGHT_PLAN, id: arg.flightId }, diff --git a/client/src/components/waypointdebugzones/IpZones.tsx b/client/src/components/waypointdebugzones/IpZones.tsx deleted file mode 100644 index fe93e1b5..00000000 --- a/client/src/components/waypointdebugzones/IpZones.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useGetDebugIpZonesQuery } from "../../api/liberationApi"; -import { LayerGroup, Polygon, Polyline } from "react-leaflet"; - -interface IpZonesProps { - flightId: string; -} - -function IpZones(props: IpZonesProps) { - const { data, error, isLoading } = useGetDebugIpZonesQuery({ - flightId: props.flightId, - }); - - if (isLoading) { - return <>; - } - - if (error) { - console.error("Error while loading waypoint IP zone info", error); - return <>; - } - - if (!data) { - console.log("Waypoint IP zone returned empty response"); - return <>; - } - - return ( - <> - - - - - {data.safeZones.map((zone, idx) => { - return ( - - ); - })} - - {data.preferredThreatenedZones.map((zone, idx) => { - return ( - - ); - })} - - {data.tolerableThreatenedLines.map((line, idx) => { - return ( - - ); - })} - - ); -} - -interface IpZonesLayerProps { - flightId: string | null; -} - -export function IpZonesLayer(props: IpZonesLayerProps) { - return ( - - {props.flightId ? : <>} - - ); -} diff --git a/client/src/components/waypointdebugzones/WaypointDebugZonesControls.tsx b/client/src/components/waypointdebugzones/WaypointDebugZonesControls.tsx index 7982fda4..8c08f7f1 100644 --- a/client/src/components/waypointdebugzones/WaypointDebugZonesControls.tsx +++ b/client/src/components/waypointdebugzones/WaypointDebugZonesControls.tsx @@ -1,7 +1,6 @@ import { selectSelectedFlightId } from "../../api/flightsSlice"; import { useAppSelector } from "../../app/hooks"; import { HoldZonesLayer } from "./HoldZones"; -import { IpZonesLayer } from "./IpZones"; import { JoinZonesLayer } from "./JoinZones"; import { LayersControl } from "react-leaflet"; @@ -16,9 +15,6 @@ export function WaypointDebugZonesControls() { return ( <> - - - diff --git a/game/ato/flightplans/ibuilder.py b/game/ato/flightplans/ibuilder.py index 635cac93..a1b6c905 100644 --- a/game/ato/flightplans/ibuilder.py +++ b/game/ato/flightplans/ibuilder.py @@ -44,7 +44,11 @@ class IBuilder(ABC, Generic[FlightPlanT, LayoutT]): ) from ex def _generate_package_waypoints_if_needed(self) -> None: - if self.package.waypoints is None: + # Package waypoints are only valid for offensive missions. Skip this if the + # target is friendly. + if self.package.waypoints is None and not self.package.target.is_friendly( + self.is_player + ): self.package.waypoints = PackageWaypoints.create( self.package, self.coalition ) diff --git a/game/ato/packagewaypoints.py b/game/ato/packagewaypoints.py index fba3d2eb..c890bfc5 100644 --- a/game/ato/packagewaypoints.py +++ b/game/ato/packagewaypoints.py @@ -2,13 +2,15 @@ from __future__ import annotations import random from dataclasses import dataclass -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING 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 from game.utils import nautical_miles if TYPE_CHECKING: @@ -29,11 +31,15 @@ class 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_shapely = IpSolver( + dcs_to_shapely_point(origin.position), + dcs_to_shapely_point(package.target.position), + coalition.doctrine, + coalition.opponent.threat_zone.all, + ).solve() + ingress_point = origin.position.new_in_same_map( + ingress_point_shapely.x, ingress_point_shapely.y + ) hdg = package.target.position.heading_between_point(ingress_point) # Generate a waypoint randomly between 7 & 9 NM diff --git a/game/flightplan/__init__.py b/game/flightplan/__init__.py index 17a92708..4e57dff5 100644 --- a/game/flightplan/__init__.py +++ b/game/flightplan/__init__.py @@ -1,3 +1,2 @@ from .holdzonegeometry import HoldZoneGeometry -from .ipzonegeometry import IpZoneGeometry from .joinzonegeometry import JoinZoneGeometry diff --git a/game/flightplan/ipsolver.py b/game/flightplan/ipsolver.py new file mode 100644 index 00000000..e4cb943d --- /dev/null +++ b/game/flightplan/ipsolver.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from shapely.geometry import MultiPolygon, Point +from shapely.geometry.base import BaseGeometry + +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 + +MIN_DISTANCE_FROM_DEPARTURE = nautical_miles(5) + + +class ThreatTolerantIpStrategy(WaypointStrategy): + def __init__( + self, + departure: Point, + target: Point, + doctrine: Doctrine, + threat_zones: MultiPolygon, + ) -> None: + super().__init__(threat_zones) + self.prerequisite(target).min_distance_from( + departure, doctrine.min_ingress_distance + ) + self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).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) + max_ip_range = min( + doctrine.max_ingress_distance, meters(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, + doctrine: Doctrine, + threat_zones: MultiPolygon, + ) -> None: + super().__init__(threat_zones) + self.prerequisite(target).min_distance_from( + departure, doctrine.min_ingress_distance + ) + self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from( + departure, "departure" + ) + self.require().at_most(meters(departure.distance(target))).away_from( + departure, "departure" + ) + self.require().at_least(doctrine.min_ingress_distance).away_from( + target, "target" + ) + max_ip_range = min( + doctrine.max_ingress_distance, meters(departure.distance(target)) + ) + self.require().at_most(max_ip_range).away_from(target, "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).is_safe() + self.prerequisite(target).min_distance_from( + departure, doctrine.min_ingress_distance + ) + self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from( + departure, "departure" + ) + self.require().at_most(meters(departure.distance(target))).away_from( + departure, "departure" + ) + self.require().at_least(doctrine.min_ingress_distance).away_from( + target, "target" + ) + self.require().at_most( + min(doctrine.max_ingress_distance, meters(departure.distance(target))) + ).away_from(target, "target") + self.require().safe() + self.nearest(departure) + + +class SafeBackTrackingIpStrategy(WaypointStrategy): + def __init__( + self, + departure: Point, + target: Point, + doctrine: Doctrine, + threat_zones: MultiPolygon, + ) -> None: + super().__init__(threat_zones) + self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from( + departure, "departure" + ) + self.require().at_least(doctrine.min_ingress_distance).away_from( + target, "target" + ) + self.require().at_most(doctrine.max_ingress_distance).away_from( + target, "target" + ) + self.require().safe() + self.nearest(departure) + + +class UnsafeBackTrackingIpStrategy(WaypointStrategy): + def __init__( + self, + departure: Point, + target: Point, + doctrine: Doctrine, + threat_zones: MultiPolygon, + ) -> None: + super().__init__(threat_zones) + self.require().at_least(MIN_DISTANCE_FROM_DEPARTURE).away_from( + departure, "departure" + ) + self.require().at_least(doctrine.min_ingress_distance).away_from( + target, "target" + ) + self.require().at_most(doctrine.max_ingress_distance).away_from( + target, "target" + ) + self.nearest(departure) + + +class IpSolver(WaypointSolver): + def __init__( + self, + departure: Point, + target: Point, + doctrine: Doctrine, + threat_zones: MultiPolygon, + ) -> None: + super().__init__() + self.departure = departure + self.target = target + self.doctrine = doctrine + self.threat_zones = threat_zones + + self.add_strategy(SafeIpStrategy(departure, target, doctrine, threat_zones)) + self.add_strategy( + ThreatTolerantIpStrategy(departure, target, doctrine, threat_zones) + ) + self.add_strategy(UnsafeIpStrategy(departure, target, doctrine, threat_zones)) + self.add_strategy( + SafeBackTrackingIpStrategy(departure, target, doctrine, threat_zones) + ) + # TODO: The cases that require this are not covered by any tests. + self.add_strategy( + UnsafeBackTrackingIpStrategy(departure, target, doctrine, threat_zones) + ) + + def describe_metadata(self) -> dict[str, Any]: + return {"doctrine": self.doctrine.name} + + def describe_inputs(self) -> Iterator[tuple[str, BaseGeometry]]: + yield "departure", self.departure + yield "target", self.target + yield "threat_zones", self.threat_zones diff --git a/game/flightplan/ipzonegeometry.py b/game/flightplan/ipzonegeometry.py deleted file mode 100644 index 3d8ea923..00000000 --- a/game/flightplan/ipzonegeometry.py +++ /dev/null @@ -1,165 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import shapely.ops -from dcs import Point -from shapely.geometry import MultiPolygon, Point as ShapelyPoint, MultiLineString - -from game.utils import meters, nautical_miles - -if TYPE_CHECKING: - from game.coalition import Coalition - - -class IpZoneGeometry: - """Defines the zones used for finding optimal IP placement. - - The zones themselves are stored in the class rather than just the resulting IP so - that the zones can be drawn in the map for debugging purposes. - """ - - def __init__( - self, - target: Point, - home: Point, - coalition: Coalition, - ) -> None: - self._target = target - self.threat_zone = coalition.opponent.threat_zone.all - self.home = ShapelyPoint(home.x, home.y) - - max_ip_distance = coalition.doctrine.max_ingress_distance - min_ip_distance = coalition.doctrine.min_ingress_distance - - # The minimum distance between the home location and the IP. - min_distance_from_home = nautical_miles(5) - - # The distance that is expected to be needed between the beginning of the attack - # and weapon release. This buffers the threat zone to give a 5nm window between - # the edge of the "safe" zone and the actual threat so that "safe" IPs are less - # likely to end up with the attacker entering a threatened area. - attack_distance_buffer = nautical_miles(5) - - 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.home_bubble = self.home.buffer(home_to_target_distance.meters).difference( - self.home.buffer(min_distance_from_home.meters) - ) - - # 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) - ) - - 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 - ) - preferred_threatened_zones = self.threat_zone.difference( - preferred_threatened_zone_mask - ) - - 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 - ) - 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] - - 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 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) diff --git a/game/server/debuggeometries/models.py b/game/server/debuggeometries/models.py index 93ecd166..5d60c823 100644 --- a/game/server/debuggeometries/models.py +++ b/game/server/debuggeometries/models.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, Field from game import Game from game.ato import Flight -from game.flightplan import HoldZoneGeometry, IpZoneGeometry, JoinZoneGeometry +from game.flightplan import HoldZoneGeometry, JoinZoneGeometry from ..leaflet import LeafletLine, LeafletPoly, ShapelyUtil @@ -59,42 +59,6 @@ class HoldZonesJs(BaseModel): ) -class IpZonesJs(BaseModel): - home_bubble: LeafletPoly = Field(alias="homeBubble") - ipBubble: LeafletPoly = Field(alias="ipBubble") - permissibleZone: LeafletPoly = Field(alias="permissibleZone") - safeZones: list[LeafletPoly] = Field(alias="safeZones") - preferred_threatened_zones: list[LeafletPoly] = Field( - alias="preferredThreatenedZones" - ) - tolerable_threatened_lines: list[LeafletLine] = Field( - alias="tolerableThreatenedLines" - ) - - class Config: - title = "IpZones" - - @classmethod - def for_flight(cls, flight: Flight, game: Game) -> IpZonesJs: - target = flight.package.target - home = flight.departure - geometry = IpZoneGeometry(target.position, home.position, game.blue) - return IpZonesJs( - homeBubble=ShapelyUtil.poly_to_leaflet(geometry.home_bubble, game.theater), - ipBubble=ShapelyUtil.poly_to_leaflet(geometry.ip_bubble, game.theater), - permissibleZone=ShapelyUtil.poly_to_leaflet( - geometry.permissible_zone, game.theater - ), - safeZones=ShapelyUtil.polys_to_leaflet(geometry.safe_zones, game.theater), - preferredThreatenedZones=ShapelyUtil.polys_to_leaflet( - geometry.preferred_threatened_zones, game.theater - ), - tolerableThreatenedLines=ShapelyUtil.lines_to_leaflet( - geometry.tolerable_threatened_lines, game.theater - ), - ) - - class JoinZonesJs(BaseModel): home_bubble: LeafletPoly = Field(alias="homeBubble") target_bubble: LeafletPoly = Field(alias="targetBubble") diff --git a/game/server/debuggeometries/routes.py b/game/server/debuggeometries/routes.py index b85b4250..f379b995 100644 --- a/game/server/debuggeometries/routes.py +++ b/game/server/debuggeometries/routes.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends from game import Game from game.server import GameContext -from .models import HoldZonesJs, IpZonesJs, JoinZonesJs +from .models import HoldZonesJs, JoinZonesJs router: APIRouter = APIRouter(prefix="/debug/waypoint-geometries") @@ -18,13 +18,6 @@ def hold_zones( return HoldZonesJs.for_flight(game.db.flights.get(flight_id), game) -@router.get( - "/ip/{flight_id}", operation_id="get_debug_ip_zones", response_model=IpZonesJs -) -def ip_zones(flight_id: UUID, game: Game = Depends(GameContext.require)) -> IpZonesJs: - return IpZonesJs.for_flight(game.db.flights.get(flight_id), game) - - @router.get( "/join/{flight_id}", operation_id="get_debug_join_zones", response_model=JoinZonesJs )