"""Flight pattern generators for atmospheric sampling.
Each generator returns a :class:`~hyplan.pattern.Pattern` object that
bundles the generated flight lines or waypoints with the parameters used
to produce them. Line-based patterns (``racetrack``, ``rosette``) carry
``FlightLine`` objects. Continuous-track patterns (``polygon``,
``sawtooth``, ``spiral``) carry ``Waypoint`` objects with
``segment_type="pattern"`` so ``compute_flight_plan()`` labels the
connecting legs accordingly. ``compute_flight_plan`` accepts
:class:`Pattern` in its flight sequence and expands it inline.
"""
from typing import List, Optional, Union
import numpy as np
import pymap3d.vincenty
from pint import Quantity
from .units import ureg
from .exceptions import HyPlanValueError, HyPlanTypeError
from .geometry import wrap_to_180, wrap_to_360
from .waypoint import Waypoint
from .flight_line import FlightLine
from .aircraft import Aircraft
from .pattern import Pattern
from .glint import GlintArc
__all__ = [
"racetrack",
"rosette",
"polygon",
"sawtooth",
"spiral",
"glint_arc",
"flight_lines_to_waypoint_path",
"coordinated_line",
]
def _to_length_quantity(value, label="value"):
"""Convert a length value to a pint Quantity."""
if isinstance(value, (int, float)):
return ureg.Quantity(float(value), "meter")
if hasattr(value, 'units') and value.check('[length]'):
return value.to(ureg.meter)
raise HyPlanTypeError(f"{label} must be a float (meters) or a pint Quantity with length units")
def _length_m(value, label="value") -> float:
return float(_to_length_quantity(value, label).m_as(ureg.meter))
def _lines_dict(lines: List[FlightLine]) -> dict:
"""Key a list of FlightLines with sequential leg_N ids."""
return {f"leg_{i+1}": fl for i, fl in enumerate(lines)}
[docs]
def racetrack(
center: tuple,
heading: float,
altitude: Union[float, "Quantity"],
leg_length: Union[float, "Quantity"],
n_legs: int = 1,
offset: Union[float, "Quantity", list] = 0,
altitudes: Optional[list] = None,
stack_altitudes: Optional[list] = None,
name: Optional[str] = None,
) -> Pattern:
"""Generate parallel out-and-back flight lines.
Handles racetracks, lawnmowers, bowling alleys, vertical walls,
and stacked patterns through parameter variation. Consecutive legs
alternate direction so end-to-start transitions are short turns.
Args:
center: (lat, lon) center point of the pattern.
heading: Bearing of the first leg in degrees from north.
altitude: Altitude MSL for all legs (overridden by altitudes if given).
leg_length: Length of each leg.
n_legs: Number of parallel legs (default 1).
offset: Crosstrack spacing between legs. Scalar = uniform spacing.
List = per-leg offsets from center (length must equal n_legs).
altitudes: Per-leg altitudes. Length must equal n_legs.
Overrides altitude for each leg. Enables vertical walls.
stack_altitudes: Repeat the entire pattern at each altitude in this list.
Produces n_legs * len(stack_altitudes) total legs.
name: Pattern display name (default "Racetrack").
Returns:
A line-based :class:`Pattern` (``kind="racetrack"``).
"""
center_lat, center_lon = center
leg_len_q = _to_length_quantity(leg_length, "leg_length")
if leg_len_q.magnitude <= 0:
raise HyPlanValueError("leg_length must be positive")
heading = float(heading)
# Per-leg crosstrack offsets (meters from pattern center)
if isinstance(offset, list):
if len(offset) != n_legs:
raise HyPlanValueError(f"offset list length ({len(offset)}) must equal n_legs ({n_legs})")
offsets_m = [_length_m(o, "offset") for o in offset]
else:
spacing_m = _length_m(offset, "offset")
offsets_m = [spacing_m * (i - (n_legs - 1) / 2.0) for i in range(n_legs)]
# Per-leg altitudes
if stack_altitudes is not None:
stack_alts = [_to_length_quantity(a, "stack_altitudes") for a in stack_altitudes]
all_offsets = []
all_alts = []
for sa in stack_alts:
all_offsets.extend(offsets_m)
if altitudes is not None:
all_alts.extend([_to_length_quantity(a, "altitudes") for a in altitudes])
else:
all_alts.extend([sa] * n_legs)
offsets_m = all_offsets
leg_alts = all_alts
elif altitudes is not None:
if len(altitudes) != n_legs:
raise HyPlanValueError(f"altitudes length ({len(altitudes)}) must equal n_legs ({n_legs})")
leg_alts = [_to_length_quantity(a, "altitudes") for a in altitudes]
else:
default_alt = _to_length_quantity(altitude, "altitude")
leg_alts = [default_alt] * n_legs
perp_az = heading + 90.0
fwd_heading = heading % 360.0
rev_heading = (heading + 180.0) % 360.0
lines = []
for i, (ct_offset, alt) in enumerate(zip(offsets_m, leg_alts)):
if ct_offset != 0:
leg_center_lat, leg_center_lon = pymap3d.vincenty.vreckon(
center_lat, center_lon, abs(ct_offset),
perp_az if ct_offset >= 0 else perp_az - 180,
)
leg_center_lon = wrap_to_180(leg_center_lon)
else:
leg_center_lat, leg_center_lon = center_lat, center_lon
leg_az = fwd_heading if i % 2 == 0 else rev_heading
lines.append(FlightLine.center_length_azimuth(
lat=float(leg_center_lat),
lon=float(leg_center_lon),
length=leg_len_q,
az=leg_az,
altitude_msl=alt,
site_name=f"Leg{i+1}",
))
params = {
"center_lat": float(center_lat),
"center_lon": float(center_lon),
"heading": heading,
"altitude_msl_m": _length_m(altitude, "altitude"),
"leg_length_m": leg_len_q.m_as(ureg.meter),
"n_legs": int(n_legs),
"offset_m": (
[_length_m(o, "offset") for o in offset]
if isinstance(offset, list) else _length_m(offset, "offset")
),
}
if altitudes is not None:
params["altitudes_m"] = [_length_m(a, "altitudes") for a in altitudes]
if stack_altitudes is not None:
params["stack_altitudes_m"] = [_length_m(a, "stack_altitudes") for a in stack_altitudes]
return Pattern(
kind="racetrack",
name=name or "Racetrack",
params=params,
lines=_lines_dict(lines),
)
[docs]
def rosette(
center: tuple,
heading: float,
altitude: Union[float, "Quantity"],
radius: Union[float, "Quantity"],
n_lines: int = 3,
angles: Optional[List[float]] = None,
name: Optional[str] = None,
) -> Pattern:
"""Generate radial flight lines through a center point.
Creates a FlightLine centered on the point along the first angle, then
rotates it for each subsequent line angle. Each line is a full diameter
crossing through center.
Args:
center: (lat, lon) center point.
heading: Bearing of the first line in degrees from north.
altitude: Altitude MSL.
radius: Half-length of each line (center to tip).
n_lines: Number of lines (default 3). Lines are spaced at
180/n_lines degree intervals.
angles: Explicit angles for each line (degrees from north).
Overrides n_lines and heading.
name: Pattern display name (default "Rosette").
Returns:
A line-based :class:`Pattern` (``kind="rosette"``).
"""
center_lat, center_lon = center
radius_q = _to_length_quantity(radius, "radius")
diameter = radius_q * 2
if diameter.magnitude <= 0:
raise HyPlanValueError("radius must be positive")
alt = _to_length_quantity(altitude, "altitude")
if angles is not None:
line_angles = [float(a) for a in angles]
else:
line_angles = [heading + i * (180.0 / n_lines) for i in range(n_lines)]
base_line = FlightLine.center_length_azimuth(
lat=center_lat, lon=center_lon,
length=diameter, az=line_angles[0],
altitude_msl=alt,
site_name="L1",
)
lines = []
for i, angle in enumerate(line_angles):
rotation = angle - line_angles[0]
if rotation == 0:
fl = base_line
else:
fl = base_line.rotate_around_midpoint(rotation)
fl.site_name = f"L{i+1}"
lines.append(fl)
params = {
"center_lat": float(center_lat),
"center_lon": float(center_lon),
"heading": float(heading),
"altitude_msl_m": alt.m_as(ureg.meter),
"radius_m": radius_q.m_as(ureg.meter),
"n_lines": int(n_lines),
}
if angles is not None:
params["angles"] = [float(a) for a in angles]
return Pattern(
kind="rosette",
name=name or "Rosette",
params=params,
lines=_lines_dict(lines),
)
[docs]
def polygon(
center: tuple,
heading: float,
altitude: Union[float, "Quantity"],
radius: Union[float, "Quantity"],
n_sides: int = 4,
aspect_ratio: float = 1.0,
closed: bool = True,
name: Optional[str] = None,
) -> Pattern:
"""Generate a regular polygon perimeter (or stretched polygon).
Replaces separate rectangle() and circle() generators.
Args:
center: (lat, lon) center point.
heading: Bearing to the first vertex in degrees from north.
altitude: Altitude MSL.
radius: Circumscribed circle radius (center to vertex).
n_sides: Number of sides (3=triangle, 4=square, 36=circle). Default 4.
aspect_ratio: Stretch factor along the heading axis. 1.0 = regular
polygon. 2.0 = twice as long as wide. Default 1.0.
closed: If True, last waypoint repeats the first to close the loop.
Default True.
name: Pattern display name (default "Polygon").
Returns:
A waypoint-based :class:`Pattern` (``kind="polygon"``).
"""
center_lat, center_lon = center
radius_q = _to_length_quantity(radius, "radius")
radius_m = radius_q.magnitude
if radius_m <= 0:
raise HyPlanValueError("radius must be positive")
alt = _to_length_quantity(altitude, "altitude")
heading_rad = np.radians(heading)
angles = np.linspace(0, 2 * np.pi, n_sides, endpoint=False)
waypoints = []
vertex_coords = []
for angle in angles:
x = np.sin(angle)
y = np.cos(angle)
y *= aspect_ratio
dx = x * np.cos(heading_rad) + y * np.sin(heading_rad)
dy = -x * np.sin(heading_rad) + y * np.cos(heading_rad)
dist = radius_m * np.sqrt(dx**2 + dy**2)
bearing = np.degrees(np.arctan2(dx, dy))
vlat, vlon = pymap3d.vincenty.vreckon(center_lat, center_lon, dist, bearing)
vlon = wrap_to_180(vlon)
vertex_coords.append((float(vlat), float(vlon)))
n = len(vertex_coords)
for i in range(n):
lat_i, lon_i = vertex_coords[i]
lat_next, lon_next = vertex_coords[(i + 1) % n]
_, az = pymap3d.vincenty.vdist(lat_i, lon_i, lat_next, lon_next)
tangent_heading = wrap_to_360(float(az))
waypoints.append(Waypoint(
latitude=lat_i,
longitude=lon_i,
heading=tangent_heading, # type: ignore[arg-type]
altitude_msl=alt,
name=f"V{i+1}",
segment_type="pattern",
))
if closed:
waypoints.append(Waypoint(
latitude=waypoints[0].latitude,
longitude=waypoints[0].longitude,
heading=waypoints[0].heading, # type: ignore[arg-type]
altitude_msl=alt,
name="V1",
segment_type="pattern",
))
params = {
"center_lat": float(center_lat),
"center_lon": float(center_lon),
"heading": float(heading),
"altitude_msl_m": alt.m_as(ureg.meter),
"radius_m": radius_q.m_as(ureg.meter),
"n_sides": int(n_sides),
"aspect_ratio": float(aspect_ratio),
"closed": bool(closed),
}
return Pattern(
kind="polygon",
name=name or "Polygon",
params=params,
waypoints=waypoints,
)
[docs]
def sawtooth(
center: tuple,
heading: float,
altitude_min: Union[float, "Quantity"],
altitude_max: Union[float, "Quantity"],
leg_length: Union[float, "Quantity"],
n_cycles: int = 1,
name: Optional[str] = None,
) -> Pattern:
"""Generate an oscillating altitude profile along a straight track.
Args:
center: (lat, lon) center point of the track.
heading: Bearing of the track in degrees from north.
altitude_min: Bottom of each oscillation.
altitude_max: Top of each oscillation.
leg_length: Total track length.
n_cycles: Number of up-down cycles.
name: Pattern display name (default "Sawtooth").
Returns:
A waypoint-based :class:`Pattern` (``kind="sawtooth"``).
"""
center_lat, center_lon = center
leg_len_q = _to_length_quantity(leg_length, "leg_length")
total_len_m = leg_len_q.magnitude
if total_len_m <= 0:
raise HyPlanValueError("leg_length must be positive")
alt_min = _to_length_quantity(altitude_min, "altitude_min")
alt_max = _to_length_quantity(altitude_max, "altitude_max")
heading = float(heading)
n_points = 2 * n_cycles + 1
half_len = total_len_m / 2.0
start_lat, start_lon = pymap3d.vincenty.vreckon(
center_lat, center_lon, half_len, (heading + 180.0) % 360.0
)
start_lon = wrap_to_180(start_lon)
spacing = total_len_m / (n_points - 1) if n_points > 1 else 0
wp_heading = wrap_to_360(heading)
waypoints = []
for i in range(n_points):
dist_from_start = spacing * i
if dist_from_start == 0:
lat, lon = float(start_lat), float(start_lon)
else:
lat, lon = pymap3d.vincenty.vreckon( # type: ignore[assignment]
start_lat, start_lon, dist_from_start, heading
)
lon = wrap_to_180(lon) # type: ignore[assignment]
alt = alt_max if i % 2 == 0 else alt_min
waypoints.append(Waypoint(
latitude=float(lat),
longitude=float(lon),
heading=wp_heading, # type: ignore[arg-type]
altitude_msl=alt,
name=f"ST{i+1}",
segment_type="pattern_turn",
))
params = {
"center_lat": float(center_lat),
"center_lon": float(center_lon),
"heading": heading,
"altitude_min_m": alt_min.m_as(ureg.meter),
"altitude_max_m": alt_max.m_as(ureg.meter),
"leg_length_m": leg_len_q.m_as(ureg.meter),
"n_cycles": int(n_cycles),
}
return Pattern(
kind="sawtooth",
name=name or "Sawtooth",
params=params,
waypoints=waypoints,
)
[docs]
def spiral(
center: tuple,
heading: float,
altitude_start: Union[float, "Quantity"],
altitude_end: Union[float, "Quantity"],
radius: Union[float, "Quantity"],
n_turns: float = 3.0,
direction: str = "right",
points_per_turn: int = 36,
name: Optional[str] = None,
) -> Pattern:
"""Generate a helical spiral pattern for vertical profiling.
The aircraft flies a constant-radius circle while ascending or
descending. Used for boundary layer profiling, aerosol vertical
structure, and thermodynamic soundings.
Args:
center: (lat, lon) ground target point.
heading: Bearing from center to the entry point (degrees from north).
The aircraft enters tangent to the circle at this point.
altitude_start: Altitude MSL at the start of the spiral.
altitude_end: Altitude MSL at the end of the spiral.
radius: Turn radius (distance from center to the flight path).
n_turns: Number of complete revolutions (fractional OK). Default 3.
direction: "right" (clockwise viewed from above) or "left"
(counterclockwise). Default "right".
points_per_turn: Number of waypoints per revolution. Default 36
(one every 10 degrees).
name: Pattern display name (default "Spiral").
Returns:
A waypoint-based :class:`Pattern` (``kind="spiral"``).
Raises:
HyPlanValueError: If n_turns <= 0, points_per_turn < 3, or direction
is not "right" or "left".
"""
if n_turns <= 0:
raise HyPlanValueError("n_turns must be positive")
if points_per_turn < 3:
raise HyPlanValueError("points_per_turn must be at least 3")
if direction not in ("right", "left"):
raise HyPlanValueError(f"direction must be 'right' or 'left', got '{direction}'")
center_lat, center_lon = center
radius_q = _to_length_quantity(radius, "radius")
radius_m = radius_q.magnitude
if radius_m <= 0:
raise HyPlanValueError("radius must be positive")
alt_start = _to_length_quantity(altitude_start, "altitude_start")
alt_end = _to_length_quantity(altitude_end, "altitude_end")
total_steps = int(n_turns * points_per_turn)
if total_steps < 1:
total_steps = 1
angle_step = 360.0 / points_per_turn
if direction == "left":
angle_step = -angle_step
alt_start_m = alt_start.m_as(ureg.meter)
alt_end_m = alt_end.m_as(ureg.meter)
waypoints = []
for i in range(total_steps + 1):
bearing = (heading + i * angle_step) % 360.0
lat, lon = pymap3d.vincenty.vreckon(
center_lat, center_lon, radius_m, bearing
)
lon = wrap_to_180(lon)
if direction == "right":
tangent = wrap_to_360(bearing + 90.0)
else:
tangent = wrap_to_360(bearing - 90.0)
frac = i / total_steps if total_steps > 0 else 0.0
alt_m = alt_start_m + (alt_end_m - alt_start_m) * frac
alt = ureg.Quantity(alt_m, "meter")
waypoints.append(Waypoint(
latitude=float(lat),
longitude=float(lon),
heading=tangent, # type: ignore[arg-type]
altitude_msl=alt,
name=f"SP{i+1}",
segment_type="pattern",
))
params = {
"center_lat": float(center_lat),
"center_lon": float(center_lon),
"heading": float(heading),
"altitude_start_m": alt_start_m,
"altitude_end_m": alt_end_m,
"radius_m": radius_q.m_as(ureg.meter),
"n_turns": float(n_turns),
"direction": direction,
"points_per_turn": int(points_per_turn),
}
return Pattern(
kind="spiral",
name=name or "Spiral",
params=params,
waypoints=waypoints,
)
[docs]
def glint_arc(
center: tuple,
observation_datetime,
altitude: Union[float, "Quantity"],
speed: Union[float, "Quantity"],
bank_angle: Optional[float] = None,
bank_direction: str = "right",
collection_length: Union[float, "Quantity", None] = None,
densify_m: float = 200.0,
name: Optional[str] = None,
) -> Pattern:
"""Generate a banked specular-glint arc as a waypoint pattern.
Wraps :class:`~hyplan.glint.GlintArc` (Ayasse et al. 2022) so the arc
can be carried inside a :class:`Pattern` and round-tripped through
:meth:`Pattern.regenerate` and Campaign persistence. At the arc
midpoint the bank angle tilts the sensor to view the target with a
glint angle of zero (perfect specular reflection); the rest of the
arc keeps glint within a narrow band around it.
Args:
center: ``(target_lat, target_lon)`` of the surface point being
observed.
observation_datetime: UTC datetime for solar position. Same
``takeoff_time`` value used elsewhere in the planner.
altitude: Aircraft altitude MSL.
speed: Aircraft true airspeed used for turn-radius computation
(typically ``aircraft.cruise_speed_at(altitude)``).
bank_angle: Bank angle in degrees. ``None`` (default) auto-selects
the solar zenith angle (valid only when SZA <= 60°).
bank_direction: ``"right"`` (default) or ``"left"``.
collection_length: Optional arc length to limit the collection
(Quantity or float meters). ``None`` uses the full 180° arc.
densify_m: Spacing of the densified arc waypoints, in meters.
name: Pattern display name (default ``"Glint Arc"``).
Returns:
A waypoint-based :class:`Pattern` (``kind="glint_arc"``) whose
waypoints trace the densified arc with per-segment headings.
Raises:
HyPlanValueError: If solar zenith is too small (<5°) or too large
(>60° without an explicit ``bank_angle``), or if
``bank_direction`` is not "left"/"right".
"""
target_lat, target_lon = center
alt_q = _to_length_quantity(altitude, "altitude")
if isinstance(speed, (int, float)):
speed_q = ureg.Quantity(float(speed), ureg.meter / ureg.second)
elif hasattr(speed, "units") and speed.check("[speed]"):
speed_q = speed.to(ureg.meter / ureg.second)
else:
raise HyPlanTypeError("speed must be a float (m/s) or a pint Quantity with speed units")
cl_q = None
if collection_length is not None:
cl_q = _to_length_quantity(collection_length, "collection_length")
arc = GlintArc(
target_lat=float(target_lat),
target_lon=float(target_lon),
observation_datetime=observation_datetime,
altitude_msl=alt_q,
speed=speed_q,
bank_angle=bank_angle,
bank_direction=bank_direction,
collection_length=cl_q,
)
track = arc.track(precision=float(densify_m))
coords = list(track.coords) # [(lon, lat), ...]
waypoints: List[Waypoint] = []
n = len(coords)
for i, (lon, lat) in enumerate(coords):
if i < n - 1:
lon_next, lat_next = coords[i + 1]
_, az = pymap3d.vincenty.vdist(float(lat), float(lon), float(lat_next), float(lon_next))
heading: float = float(wrap_to_360(float(az)))
else:
heading = waypoints[-1].heading if waypoints else 0.0
waypoints.append(Waypoint(
latitude=float(lat),
longitude=float(lon),
heading=heading, # type: ignore[arg-type]
altitude_msl=alt_q,
name=f"GA{i+1}",
segment_type="pattern",
))
obs_iso = observation_datetime.isoformat() if hasattr(observation_datetime, "isoformat") else str(observation_datetime)
params = {
"center_lat": float(target_lat),
"center_lon": float(target_lon),
"altitude_msl_m": alt_q.m_as(ureg.meter),
"speed_mps": speed_q.m_as(ureg.meter / ureg.second),
"observation_datetime": obs_iso,
# bank_angle: store the *input* (None = auto from SZA) so that
# regenerate() with a different observation_datetime still
# auto-derives correctly.
"bank_angle": (float(bank_angle) if bank_angle is not None else None),
"bank_direction": bank_direction,
"collection_length_m": (cl_q.m_as(ureg.meter) if cl_q is not None else None),
"densify_m": float(densify_m),
# Effective values from this generation, for display only:
"effective_bank_angle": float(arc.bank_angle),
"solar_azimuth": float(arc.solar_azimuth),
"solar_zenith": float(arc.solar_zenith),
}
return Pattern(
kind="glint_arc",
name=name or "Glint Arc",
params=params,
waypoints=waypoints,
)
[docs]
def flight_lines_to_waypoint_path(
flight_lines: List[FlightLine],
altitude: Union[float, "Quantity", None] = None,
) -> List[Waypoint]:
"""Convert a list of FlightLine objects into a connected waypoint path.
Each flight line contributes its waypoint1 and waypoint2. No turn
waypoints are inserted — compute_flight_plan() handles Dubins
transitions between consecutive waypoints.
Args:
flight_lines: List of FlightLine objects (e.g. from box_around_polygon()).
altitude: If provided, overrides the altitude on all waypoints.
Returns:
List of Waypoint objects with segment_type="pattern".
"""
alt_override = _to_length_quantity(altitude, "altitude") if altitude is not None else None
waypoints = []
for i, fl in enumerate(flight_lines):
for j, wp in enumerate([fl.waypoint1, fl.waypoint2]):
alt = alt_override if alt_override is not None else wp.altitude_msl
name = f"{fl.site_name or f'FL{i+1}'}_{['start', 'end'][j]}"
seg_type = "pattern_turn" if j == 1 else "pattern"
waypoints.append(Waypoint(
latitude=wp.latitude, longitude=wp.longitude,
heading=wp.heading, altitude_msl=alt, # type: ignore[arg-type]
speed=wp.speed, name=name, segment_type=seg_type,
))
return waypoints
[docs]
def coordinated_line(
center: tuple,
heading: float,
primary_leg_length: Union[float, "Quantity"],
primary_aircraft: Aircraft,
secondary_aircraft: Aircraft,
primary_altitude: Union[float, "Quantity"],
secondary_altitude: Union[float, "Quantity"],
ground_speed_ratio: Union[float, List[float], None] = None,
primary_name: str = "P3",
secondary_name: str = "ER2",
) -> dict:
"""Generate a coordinated dual-aircraft line pattern (five-point line).
Two aircraft fly vertically stacked legs centered on a coordination
point. The secondary (faster) aircraft's leg is extended symmetrically
so both aircraft pass over the center point simultaneously.
Based on the IMPACTS sampling strategy (Yorks et al. 2025, BAMS).
Args:
center: (lat, lon) coordination point where both aircraft overlap.
heading: Bearing of the line in degrees from north.
primary_leg_length: Leg length of the primary (slower) aircraft.
primary_aircraft: Aircraft object for the primary (slower/sampling) platform.
secondary_aircraft: Aircraft object for the secondary (faster/remote-sensing) platform.
primary_altitude: Altitude MSL for the primary aircraft.
secondary_altitude: Altitude MSL for the secondary aircraft.
ground_speed_ratio: Ratio of secondary to primary ground speed.
If None, computed from TAS at each altitude. Can be a list for
multiple speed-ratio lines (e.g. [1.2, 1.45] for high/low P-3).
primary_name: Name prefix for primary waypoints (default "P3").
secondary_name: Name prefix for secondary waypoints (default "ER2").
Returns:
Dict with keys ``"primary"`` (list of 2 Waypoints [start, end] for
the primary aircraft), ``"secondary"`` (list of 2 Waypoints
[start, end] if a single ratio, or list of such pairs if multiple
ratios), ``"center"`` (Waypoint at the coordination point), and
``"ground_speed_ratio"`` (the ratio(s) used).
"""
center_lat, center_lon = center
pri_len = _to_length_quantity(primary_leg_length, "primary_leg_length")
pri_alt = _to_length_quantity(primary_altitude, "primary_altitude")
sec_alt = _to_length_quantity(secondary_altitude, "secondary_altitude")
heading = float(heading)
fwd_az = wrap_to_360(heading)
if ground_speed_ratio is None:
sec_speed = secondary_aircraft.cruise_speed_at(sec_alt)
pri_speed = primary_aircraft.cruise_speed_at(pri_alt)
ratios = [float((sec_speed / pri_speed).magnitude)]
elif isinstance(ground_speed_ratio, (int, float)):
ratios = [float(ground_speed_ratio)]
else:
ratios = [float(r) for r in ground_speed_ratio]
for r in ratios:
if r <= 0:
raise HyPlanValueError("ground_speed_ratio must be positive")
pri_fl = FlightLine.center_length_azimuth(
center_lat, center_lon, pri_len, heading,
altitude_msl=pri_alt, site_name=primary_name,
)
primary_wps = [
Waypoint(pri_fl.lat1, pri_fl.lon1, pri_fl.waypoint1.heading, pri_alt, # type: ignore[arg-type]
name=f"{primary_name}_start", segment_type="pattern"),
Waypoint(pri_fl.lat2, pri_fl.lon2, pri_fl.waypoint1.heading, pri_alt, # type: ignore[arg-type]
name=f"{primary_name}_end", segment_type="pattern_turn"),
]
center_wp = Waypoint(center_lat, center_lon, fwd_az, sec_alt, # type: ignore[arg-type]
name="C1", segment_type="pattern")
secondary_pairs = []
for i, r in enumerate(ratios):
sec_len = pri_len * r
sec_fl = FlightLine.center_length_azimuth(
center_lat, center_lon, sec_len, heading,
altitude_msl=sec_alt, site_name=secondary_name,
)
suffix = f"_r{i+1}" if len(ratios) > 1 else ""
secondary_pairs.append([
Waypoint(sec_fl.lat1, sec_fl.lon1, sec_fl.waypoint1.heading, sec_alt, # type: ignore[arg-type]
name=f"{secondary_name}_start{suffix}", segment_type="pattern"),
Waypoint(sec_fl.lat2, sec_fl.lon2, sec_fl.waypoint1.heading, sec_alt, # type: ignore[arg-type]
name=f"{secondary_name}_end{suffix}", segment_type="pattern_turn"),
])
return {
"primary": primary_wps,
"secondary": secondary_pairs[0] if len(ratios) == 1 else secondary_pairs,
"center": center_wp,
"ground_speed_ratio": ratios[0] if len(ratios) == 1 else ratios,
}