Migrate IP placement to WaypointSolver.

This commit is contained in:
Dan Albert 2023-07-29 21:49:17 -07:00 committed by Raffson
parent 643dafd2c8
commit 8b04dd878d
No known key found for this signature in database
GPG Key ID: B0402B2C9B764D99
11 changed files with 193 additions and 346 deletions

View File

@ -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,

View File

@ -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 },

View File

@ -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 (
<>
<Polygon
positions={data.homeBubble}
color="#ffff00"
fillOpacity={0.1}
interactive={false}
/>
<Polygon
positions={data.ipBubble}
color="#bb89ff"
fillOpacity={0.1}
interactive={false}
/>
<Polygon
positions={data.permissibleZone}
color="#ffffff"
fillOpacity={0.1}
interactive={false}
/>
{data.safeZones.map((zone, idx) => {
return (
<Polygon
key={idx}
positions={zone}
color="#80BA80"
fillOpacity={0.1}
interactive={false}
/>
);
})}
{data.preferredThreatenedZones.map((zone, idx) => {
return (
<Polygon
key={idx}
positions={zone}
color="#fe7d0a"
fillOpacity={0.1}
interactive={false}
/>
);
})}
{data.tolerableThreatenedLines.map((line, idx) => {
return (
<Polyline
key={idx}
positions={line}
color="#80BA80"
interactive={false}
/>
);
})}
</>
);
}
interface IpZonesLayerProps {
flightId: string | null;
}
export function IpZonesLayer(props: IpZonesLayerProps) {
return (
<LayerGroup>
{props.flightId ? <IpZones flightId={props.flightId} /> : <></>}
</LayerGroup>
);
}

View File

@ -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 (
<>
<LayersControl.Overlay name="IP zones">
<IpZonesLayer flightId={selectedFlightId} />
</LayersControl.Overlay>
<LayersControl.Overlay name="Join zones">
<JoinZonesLayer flightId={selectedFlightId} />
</LayersControl.Overlay>

View File

@ -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
)

View File

@ -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

View File

@ -1,3 +1,2 @@
from .holdzonegeometry import HoldZoneGeometry
from .ipzonegeometry import IpZoneGeometry
from .joinzonegeometry import JoinZoneGeometry

173
game/flightplan/ipsolver.py Normal file
View File

@ -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

View File

@ -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)

View File

@ -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")

View File

@ -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
)