opencoverage 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
opencoverage/io/qgc.py ADDED
@@ -0,0 +1,130 @@
1
+ """QGroundControl waypoint file export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from opencoverage.io.coords import path_to_geodetics
8
+ from opencoverage.models import GeodeticCoordinate
9
+
10
+
11
+ def _write_command(
12
+ waypoint_id: int,
13
+ is_current: int,
14
+ coordinate_frame: int,
15
+ command: int,
16
+ param1: float,
17
+ param2: float,
18
+ param3: float,
19
+ param4: float,
20
+ latitude: float,
21
+ longitude: float,
22
+ altitude: float,
23
+ auto_continue: int,
24
+ ) -> str:
25
+ return (
26
+ f"{waypoint_id}\t{is_current}\t{coordinate_frame}\t{command}\t"
27
+ f"{param1}\t{param2}\t{param3}\t{param4}\t"
28
+ f"{latitude:.15f}\t{longitude:.15f}\t{altitude:.6f}\t{auto_continue}"
29
+ )
30
+
31
+
32
+ def write_qgc_file(
33
+ filename: str | Path,
34
+ *,
35
+ path: list[tuple[float, float]],
36
+ flight_height: float,
37
+ reference: GeodeticCoordinate,
38
+ home: GeodeticCoordinate | None = None,
39
+ takeoff: GeodeticCoordinate | None = None,
40
+ auto_takeoff: bool = False,
41
+ ) -> None:
42
+ """
43
+ Write a QGroundControl waypoint file from a local-meter path.
44
+
45
+ Format follows the legacy QGC WPL 120 tab-separated layout.
46
+ """
47
+ geodetics = path_to_geodetics(path, reference, flight_height)
48
+ lines = ["QGC WPL 120"]
49
+ waypoint_id = 0
50
+ current_id = 0
51
+ auto_continue = 1
52
+
53
+ if auto_takeoff and takeoff is not None:
54
+ lines.append(
55
+ _write_command(
56
+ waypoint_id,
57
+ 1 if waypoint_id == current_id else 0,
58
+ 3,
59
+ 22,
60
+ 15,
61
+ 0,
62
+ 0,
63
+ 0,
64
+ takeoff.latitude,
65
+ takeoff.longitude,
66
+ takeoff.altitude,
67
+ auto_continue,
68
+ )
69
+ )
70
+ waypoint_id += 1
71
+
72
+ if geodetics:
73
+ first = geodetics[0]
74
+ lines.append(
75
+ _write_command(
76
+ waypoint_id,
77
+ 1 if waypoint_id == current_id else 0,
78
+ 3,
79
+ 16,
80
+ 0,
81
+ 0,
82
+ 0,
83
+ 0,
84
+ first.latitude,
85
+ first.longitude,
86
+ first.altitude,
87
+ auto_continue,
88
+ )
89
+ )
90
+ waypoint_id += 1
91
+
92
+ for geo in geodetics:
93
+ lines.append(
94
+ _write_command(
95
+ waypoint_id,
96
+ 1 if waypoint_id == current_id else 0,
97
+ 3,
98
+ 16,
99
+ 0,
100
+ 0,
101
+ 0,
102
+ 0,
103
+ geo.latitude,
104
+ geo.longitude,
105
+ geo.altitude,
106
+ auto_continue,
107
+ )
108
+ )
109
+ waypoint_id += 1
110
+
111
+ if geodetics:
112
+ first = geodetics[0]
113
+ lines.append(
114
+ _write_command(
115
+ waypoint_id,
116
+ 1 if waypoint_id == current_id else 0,
117
+ 3,
118
+ 17,
119
+ 0,
120
+ 0,
121
+ 15,
122
+ 0,
123
+ first.latitude,
124
+ first.longitude,
125
+ first.altitude,
126
+ auto_continue,
127
+ )
128
+ )
129
+
130
+ Path(filename).write_text("\r\n".join(lines) + "\r\n", encoding="utf-8")
@@ -0,0 +1,21 @@
1
+ """Survey polygon input readers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from opencoverage.geometry.map import SurveyMap
8
+ from opencoverage.io.kml import read_polygon_from_kml
9
+ from opencoverage.io.mission_planner import read_polygon_from_mission_planner
10
+
11
+
12
+ def read_survey_map(path: str | Path) -> SurveyMap:
13
+ """
14
+ Load a survey map from KML or Mission Planner polygon file.
15
+
16
+ Supported extensions: ``.kml``, ``.kmz`` (KML only), ``.poly``, ``.txt``.
17
+ """
18
+ suffix = Path(path).suffix.lower()
19
+ if suffix in {".kml", ".kmz"}:
20
+ return read_polygon_from_kml(path)
21
+ return read_polygon_from_mission_planner(path)
@@ -0,0 +1,83 @@
1
+ """Split long missions according to UAV flight-time limits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from opencoverage.models import FlightMission, GeodeticCoordinate, UAV
8
+
9
+
10
+ class MissionSplitter:
11
+ """Divide a mission into segments that fit within UAV flight time."""
12
+
13
+ def __init__(self, uav: UAV) -> None:
14
+ self.uav = uav
15
+
16
+ def split(self, mission: FlightMission) -> list[FlightMission]:
17
+ """
18
+ Split a mission into sub-missions when flight_time_s is configured.
19
+
20
+ When flight_time_s is zero, the original mission is returned unchanged.
21
+ """
22
+ if self.uav.flight_time_s <= 0 or not mission.path:
23
+ return [mission]
24
+
25
+ segments: list[FlightMission] = []
26
+ partial_path: list[tuple[float, float]] = []
27
+ last_point: tuple[float, float] | None = None
28
+
29
+ for point in mission.path:
30
+ partial_path.append(point)
31
+ elapsed = self.uav.estimate_flight_time(partial_path)
32
+ if elapsed > self.uav.flight_time_s:
33
+ partial_path.pop()
34
+ if partial_path:
35
+ segments.append(self._clone_mission(mission, partial_path))
36
+ partial_path = []
37
+ if last_point is not None:
38
+ partial_path.append(last_point)
39
+ partial_path.append(point)
40
+
41
+ last_point = point
42
+
43
+ if partial_path:
44
+ segments.append(self._clone_mission(mission, partial_path))
45
+
46
+ return segments or [mission]
47
+
48
+ def save_qgc_files(
49
+ self,
50
+ missions: list[FlightMission],
51
+ base_filename: str | Path,
52
+ reference: GeodeticCoordinate,
53
+ ) -> list[Path]:
54
+ """Save each mission segment as a numbered QGroundControl file."""
55
+ base_path = Path(base_filename)
56
+ stem = base_path.stem
57
+ suffix = base_path.suffix or ".txt"
58
+ parent = base_path.parent
59
+
60
+ saved: list[Path] = []
61
+ for index, mission in enumerate(missions, start=1):
62
+ output = parent / f"{stem}{index}{suffix}"
63
+ mission.save_qgc(output, reference=reference)
64
+ saved.append(output)
65
+ return saved
66
+
67
+ def _clone_mission(
68
+ self,
69
+ mission: FlightMission,
70
+ path: list[tuple[float, float]],
71
+ ) -> FlightMission:
72
+ return FlightMission(
73
+ path=list(path),
74
+ flight_height=mission.flight_height,
75
+ home=mission.home,
76
+ takeoff=mission.takeoff,
77
+ reference=mission.reference,
78
+ auto_takeoff=mission.auto_takeoff,
79
+ forward_distance=mission.forward_distance,
80
+ lateral_distance=mission.lateral_distance,
81
+ capture_rate=mission.capture_rate,
82
+ flight_time_s=self.uav.estimate_flight_time(path),
83
+ )
opencoverage/models.py ADDED
@@ -0,0 +1,134 @@
1
+ """Domain models for UAV survey planning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import IntEnum
7
+ from typing import Sequence
8
+
9
+ import numpy as np
10
+
11
+
12
+ class TargetPlanning(IntEnum):
13
+ """Planning objective used to derive flight height and capture rate."""
14
+
15
+ RESOLUTION = 1
16
+ VELOCITY = 2
17
+ ALTITUDE = 3
18
+
19
+
20
+ class SearchPatternType(IntEnum):
21
+ """Available coverage sweep patterns."""
22
+
23
+ BACK_AND_FORTH = 1
24
+ LONG_EDGE = 2
25
+ OPTIMAL_SWEEP = 3
26
+ FOLLOWING_WIND = 4
27
+
28
+
29
+ @dataclass
30
+ class GeodeticCoordinate:
31
+ """WGS84 geodetic coordinate."""
32
+
33
+ latitude: float
34
+ longitude: float
35
+ altitude: float = 0.0
36
+
37
+
38
+ @dataclass
39
+ class CartesianCoordinate:
40
+ """Local Cartesian coordinate in meters (north-east-down style x/y)."""
41
+
42
+ x: float
43
+ y: float
44
+ z: float = 0.0
45
+
46
+
47
+ @dataclass
48
+ class PinholeCamera:
49
+ """Pinhole camera model. All geometric parameters are in millimeters."""
50
+
51
+ pixel_size_mm: float
52
+ focal_length_mm: float
53
+ sensor_width_mm: float
54
+ sensor_height_mm: float
55
+ capture_rate_s: float = 3.0
56
+
57
+ @classmethod
58
+ def from_config(cls, config: dict[str, float]) -> PinholeCamera:
59
+ """Build a camera from parsed INI camera section values."""
60
+ return cls(
61
+ pixel_size_mm=config["pixel_size_mm"],
62
+ focal_length_mm=config["focal_length_mm"],
63
+ sensor_width_mm=config["sensor_width_mm"],
64
+ sensor_height_mm=config["sensor_height_mm"],
65
+ capture_rate_s=config.get("capture_rate_s", 3.0),
66
+ )
67
+
68
+
69
+ @dataclass
70
+ class UAV:
71
+ """Multirotor UAV flight constraints."""
72
+
73
+ min_height: float = 70.0
74
+ max_height: float = 500.0
75
+ min_velocity: float = 5.0
76
+ max_velocity: float = 20.0
77
+ survey_velocity: float = 17.0
78
+ flight_time_s: float = 0.0
79
+
80
+ @classmethod
81
+ def from_config(cls, config: dict[str, float]) -> UAV:
82
+ """Build a UAV from parsed INI UAV section values."""
83
+ return cls(
84
+ min_height=config["min_height"],
85
+ max_height=config["max_height"],
86
+ min_velocity=config.get("min_velocity", 5.0),
87
+ max_velocity=config.get("max_velocity", 20.0),
88
+ survey_velocity=config.get("survey_velocity", config.get("velocity", 17.0)),
89
+ flight_time_s=config.get("flight_time_s", 0.0),
90
+ )
91
+
92
+ def estimate_path_distance(self, path: Sequence[tuple[float, float]]) -> float:
93
+ """Return total path length in meters."""
94
+ if len(path) < 2:
95
+ return 0.0
96
+ points = np.asarray(path, dtype=float)
97
+ deltas = np.diff(points, axis=0)
98
+ return float(np.sum(np.hypot(deltas[:, 0], deltas[:, 1])))
99
+
100
+ def estimate_flight_time(self, path: Sequence[tuple[float, float]]) -> float:
101
+ """Return estimated flight duration in seconds."""
102
+ if self.survey_velocity <= 0:
103
+ return 0.0
104
+ return self.estimate_path_distance(path) / self.survey_velocity
105
+
106
+
107
+ @dataclass
108
+ class FlightMission:
109
+ """Planned survey mission with waypoint path in local coordinates."""
110
+
111
+ path: list[tuple[float, float]] = field(default_factory=list)
112
+ flight_height: float = 0.0
113
+ home: GeodeticCoordinate | None = None
114
+ takeoff: GeodeticCoordinate | None = None
115
+ reference: GeodeticCoordinate | None = None
116
+ auto_takeoff: bool = False
117
+ forward_distance: float = 0.0
118
+ lateral_distance: float = 0.0
119
+ capture_rate: float = 0.0
120
+ flight_time_s: float = 0.0
121
+
122
+ def save_qgc(self, filename: str, reference: GeodeticCoordinate) -> None:
123
+ """Export the mission as a QGroundControl waypoint file."""
124
+ from opencoverage.io.qgc import write_qgc_file
125
+
126
+ write_qgc_file(
127
+ filename,
128
+ path=self.path,
129
+ flight_height=self.flight_height,
130
+ reference=reference,
131
+ home=self.home,
132
+ takeoff=self.takeoff,
133
+ auto_takeoff=self.auto_takeoff,
134
+ )
@@ -0,0 +1,132 @@
1
+ """Survey parameter calculations derived from camera and UAV models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from opencoverage.models import PinholeCamera, TargetPlanning, UAV
6
+
7
+
8
+ def flight_height_from_resolution(spatial_resolution_mm: float, camera: PinholeCamera) -> float:
9
+ """Compute flight height in meters for a target ground resolution."""
10
+ height_m = (spatial_resolution_mm * camera.focal_length_mm) / camera.pixel_size_mm
11
+ return height_m / 1000.0
12
+
13
+
14
+ def spatial_resolution_from_height(flight_height_m: float, camera: PinholeCamera) -> float:
15
+ """Compute ground resolution in mm/pixel for a given flight height."""
16
+ resolution_m = camera.pixel_size_mm * flight_height_m / camera.focal_length_mm
17
+ return resolution_m * 1000.0
18
+
19
+
20
+ def lateral_line_spacing(
21
+ flight_height_m: float,
22
+ lateral_overlap: float,
23
+ camera: PinholeCamera,
24
+ ) -> float:
25
+ """Distance between adjacent sweep lines in meters."""
26
+ return (
27
+ (flight_height_m / camera.focal_length_mm)
28
+ * camera.sensor_width_mm
29
+ * (1.0 - lateral_overlap)
30
+ )
31
+
32
+
33
+ def forward_trigger_distance(
34
+ flight_height_m: float,
35
+ forward_overlap: float,
36
+ camera: PinholeCamera,
37
+ ) -> float:
38
+ """Forward camera trigger distance in meters."""
39
+ return (
40
+ (flight_height_m / camera.focal_length_mm)
41
+ * camera.sensor_height_mm
42
+ * (1.0 - forward_overlap)
43
+ )
44
+
45
+
46
+ def cruise_height_for_velocity(
47
+ velocity_m_s: float,
48
+ forward_overlap: float,
49
+ camera: PinholeCamera,
50
+ ) -> float:
51
+ """Compute flight height that matches velocity and camera capture rate."""
52
+ return (
53
+ velocity_m_s
54
+ * camera.capture_rate_s
55
+ * camera.focal_length_mm
56
+ / (camera.sensor_height_mm * (1.0 - forward_overlap))
57
+ )
58
+
59
+
60
+ def capture_rate_for_height(
61
+ flight_height_m: float,
62
+ forward_overlap: float,
63
+ velocity_m_s: float,
64
+ camera: PinholeCamera,
65
+ ) -> float:
66
+ """Required capture interval in seconds for the given flight parameters."""
67
+ trigger_distance = forward_trigger_distance(flight_height_m, forward_overlap, camera)
68
+ return trigger_distance / velocity_m_s
69
+
70
+
71
+ def minimum_survey_height(
72
+ forward_overlap: float,
73
+ camera: PinholeCamera,
74
+ uav: UAV,
75
+ ) -> float:
76
+ """Minimum feasible survey height given UAV minimum velocity."""
77
+ min_trigger = camera.capture_rate_s * uav.min_velocity
78
+ return (min_trigger * camera.focal_length_mm) / (
79
+ camera.sensor_height_mm * (1.0 - forward_overlap)
80
+ )
81
+
82
+
83
+ def resolve_survey_parameters(
84
+ *,
85
+ uav: UAV,
86
+ camera: PinholeCamera,
87
+ lateral_overlap: float,
88
+ forward_overlap: float,
89
+ target: TargetPlanning,
90
+ target_value: float,
91
+ ) -> dict[str, float]:
92
+ """
93
+ Resolve flight height, spatial resolution, velocity, and trigger distance.
94
+
95
+ Mirrors the parameter selection logic from the original C++ SearchPattern class.
96
+ """
97
+ if target == TargetPlanning.RESOLUTION:
98
+ flight_height = flight_height_from_resolution(target_value, camera)
99
+ spatial_resolution = target_value
100
+ velocity = uav.survey_velocity
101
+ elif target == TargetPlanning.ALTITUDE:
102
+ flight_height = target_value
103
+ spatial_resolution = spatial_resolution_from_height(flight_height, camera)
104
+ velocity = uav.survey_velocity
105
+ elif target == TargetPlanning.VELOCITY:
106
+ velocity = target_value
107
+ flight_height = cruise_height_for_velocity(velocity, forward_overlap, camera)
108
+ spatial_resolution = spatial_resolution_from_height(flight_height, camera)
109
+ else:
110
+ raise ValueError(f"Unsupported planning target: {target}")
111
+
112
+ if flight_height < uav.min_height:
113
+ flight_height = uav.min_height
114
+ spatial_resolution = spatial_resolution_from_height(flight_height, camera)
115
+
116
+ if flight_height > uav.max_height:
117
+ flight_height = uav.max_height
118
+ spatial_resolution = spatial_resolution_from_height(flight_height, camera)
119
+
120
+ trigger_distance = forward_trigger_distance(flight_height, forward_overlap, camera)
121
+ required_capture_rate = capture_rate_for_height(
122
+ flight_height, forward_overlap, velocity, camera
123
+ )
124
+
125
+ return {
126
+ "flight_height": flight_height,
127
+ "spatial_resolution": spatial_resolution,
128
+ "velocity": velocity,
129
+ "trigger_distance": trigger_distance,
130
+ "required_capture_rate": required_capture_rate,
131
+ "lateral_spacing": lateral_line_spacing(flight_height, lateral_overlap, camera),
132
+ }
@@ -0,0 +1,15 @@
1
+ """Coverage search pattern implementations."""
2
+
3
+ from opencoverage.patterns.back_forth import BackAndForthPattern
4
+ from opencoverage.patterns.base import SearchPattern
5
+ from opencoverage.patterns.following_wind import FollowingWindPattern
6
+ from opencoverage.patterns.long_edge import LongEdgePattern
7
+ from opencoverage.patterns.optimal_sweep import OptimalSweepPattern
8
+
9
+ __all__ = [
10
+ "BackAndForthPattern",
11
+ "FollowingWindPattern",
12
+ "LongEdgePattern",
13
+ "OptimalSweepPattern",
14
+ "SearchPattern",
15
+ ]
@@ -0,0 +1,57 @@
1
+ """Back-and-forth (boustrophedon) coverage sweep pattern."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from opencoverage.geometry.map import SurveyMap
6
+ from opencoverage.geometry.transforms import rotate_points
7
+ from opencoverage.models import FlightMission
8
+ from opencoverage.patterns.base import SearchPattern
9
+
10
+
11
+ class BackAndForthPattern(SearchPattern):
12
+ """Generate parallel sweep lines across the polygon bounding width."""
13
+
14
+ LEFT_TO_RIGHT = 1
15
+ RIGHT_TO_LEFT = -1
16
+ BOTTOM_UP = 1
17
+ TOP_DOWN = -1
18
+
19
+ def calculate(self) -> FlightMission:
20
+ waypoints = self.generate_pattern(self.survey_map)
21
+ return self.build_mission(waypoints)
22
+
23
+ def generate_rotated_pattern(self, angle_rad: float) -> list[tuple[float, float]]:
24
+ """Generate a back-and-forth path after rotating the map by angle_rad."""
25
+ rotated_map = self.survey_map.rotated_copy(angle_rad)
26
+ waypoints = self.generate_pattern(rotated_map)
27
+ return rotate_points(waypoints, -angle_rad)
28
+
29
+ def generate_pattern(self, survey_map: SurveyMap) -> list[tuple[float, float]]:
30
+ """Generate waypoint path for the given map."""
31
+ extremes = survey_map.extreme_vertices()
32
+ left_point = extremes["left"]
33
+ right_point = extremes["right"]
34
+
35
+ horizontal_order = self.LEFT_TO_RIGHT
36
+ vertical_order = self.BOTTOM_UP
37
+ lateral_displacement = self.lateral_spacing * horizontal_order
38
+
39
+ if horizontal_order == self.LEFT_TO_RIGHT:
40
+ line_x = left_point[0]
41
+ min_x = left_point[0]
42
+ max_x = right_point[0]
43
+ else:
44
+ line_x = right_point[0]
45
+ min_x = left_point[0]
46
+ max_x = right_point[0]
47
+
48
+ waypoints: list[tuple[float, float]] = []
49
+
50
+ while min_x <= line_x <= max_x:
51
+ points = survey_map.intersect_vertical_line(line_x)
52
+ points.sort(key=lambda p: p[1], reverse=vertical_order == self.TOP_DOWN)
53
+ vertical_order *= -1
54
+ waypoints.extend(points)
55
+ line_x += lateral_displacement
56
+
57
+ return waypoints
@@ -0,0 +1,74 @@
1
+ """Base class for coverage search patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from opencoverage.geometry.map import SurveyMap
8
+ from opencoverage.models import FlightMission, PinholeCamera, TargetPlanning, UAV
9
+ from opencoverage.parameters import resolve_survey_parameters
10
+
11
+
12
+ class SearchPattern(ABC):
13
+ """Abstract coverage path planner."""
14
+
15
+ def __init__(self, uav: UAV, camera: PinholeCamera, survey_map: SurveyMap) -> None:
16
+ self.uav = uav
17
+ self.camera = camera
18
+ self.survey_map = survey_map
19
+ self.lateral_overlap = 0.0
20
+ self.forward_overlap = 0.0
21
+ self.target = TargetPlanning.VELOCITY
22
+ self.target_value = 0.0
23
+ self.flight_height = 0.0
24
+ self.spatial_resolution = 0.0
25
+ self.velocity = 0.0
26
+ self.trigger_distance = 0.0
27
+ self.lateral_spacing = 0.0
28
+ self.flight_time_s = 0.0
29
+
30
+ def set_search_parameters(
31
+ self,
32
+ lateral_overlap: float,
33
+ forward_overlap: float,
34
+ target_value: float,
35
+ target: TargetPlanning,
36
+ ) -> None:
37
+ """Configure overlap ratios and the planning objective."""
38
+ self.lateral_overlap = lateral_overlap
39
+ self.forward_overlap = forward_overlap
40
+ self.target = target
41
+ self.target_value = target_value
42
+
43
+ params = resolve_survey_parameters(
44
+ uav=self.uav,
45
+ camera=self.camera,
46
+ lateral_overlap=lateral_overlap,
47
+ forward_overlap=forward_overlap,
48
+ target=target,
49
+ target_value=target_value,
50
+ )
51
+ self.flight_height = params["flight_height"]
52
+ self.spatial_resolution = params["spatial_resolution"]
53
+ self.velocity = params["velocity"]
54
+ self.trigger_distance = params["trigger_distance"]
55
+ self.lateral_spacing = params["lateral_spacing"]
56
+
57
+ def build_mission(self, waypoints: list[tuple[float, float]]) -> FlightMission:
58
+ """Create a FlightMission from a local waypoint path."""
59
+ capture_rate = self.trigger_distance / self.velocity if self.velocity else 0.0
60
+ return FlightMission(
61
+ path=waypoints,
62
+ flight_height=self.flight_height,
63
+ home=self.survey_map.home,
64
+ takeoff=self.survey_map.takeoff,
65
+ reference=self.survey_map.reference,
66
+ forward_distance=self.trigger_distance,
67
+ lateral_distance=self.lateral_spacing,
68
+ capture_rate=capture_rate,
69
+ flight_time_s=self.uav.estimate_flight_time(waypoints),
70
+ )
71
+
72
+ @abstractmethod
73
+ def calculate(self) -> FlightMission:
74
+ """Compute the coverage mission."""
@@ -0,0 +1,33 @@
1
+ """Wind-aligned coverage sweep pattern."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from opencoverage.models import FlightMission
8
+ from opencoverage.patterns.back_forth import BackAndForthPattern
9
+
10
+
11
+ class FollowingWindPattern(BackAndForthPattern):
12
+ """
13
+ Align sweep lines with a wind direction.
14
+
15
+ Wind direction uses meteorological convention in degrees:
16
+ 0 = from north, 90 = from east, 180 = from south, 270 = from west.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ uav,
22
+ camera,
23
+ survey_map,
24
+ *,
25
+ wind_direction_deg: float = 0.0,
26
+ ) -> None:
27
+ super().__init__(uav, camera, survey_map)
28
+ self.wind_direction_deg = wind_direction_deg
29
+
30
+ def calculate(self) -> FlightMission:
31
+ rotation = math.radians(self.wind_direction_deg)
32
+ waypoints = self.generate_rotated_pattern(rotation)
33
+ return self.build_mission(waypoints)