missiontools 0.1.0__tar.gz

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.
Files changed (72) hide show
  1. missiontools-0.1.0/LICENSE +8 -0
  2. missiontools-0.1.0/PKG-INFO +79 -0
  3. missiontools-0.1.0/README.md +53 -0
  4. missiontools-0.1.0/missiontools/__init__.py +122 -0
  5. missiontools-0.1.0/missiontools/aoi.py +363 -0
  6. missiontools-0.1.0/missiontools/attitude/__init__.py +36 -0
  7. missiontools-0.1.0/missiontools/attitude/attitude_law.py +625 -0
  8. missiontools-0.1.0/missiontools/cache.py +197 -0
  9. missiontools-0.1.0/missiontools/comm/__init__.py +25 -0
  10. missiontools-0.1.0/missiontools/comm/antenna.py +623 -0
  11. missiontools-0.1.0/missiontools/comm/link.py +366 -0
  12. missiontools-0.1.0/missiontools/coverage/__init__.py +68 -0
  13. missiontools-0.1.0/missiontools/coverage/coverage.py +1506 -0
  14. missiontools-0.1.0/missiontools/coverage/geodata/ne_map_units/ne_50m_admin_0_map_units.VERSION.txt +1 -0
  15. missiontools-0.1.0/missiontools/coverage/geodata/ne_map_units/ne_50m_admin_0_map_units.cpg +1 -0
  16. missiontools-0.1.0/missiontools/coverage/geodata/ne_map_units/ne_50m_admin_0_map_units.dbf +0 -0
  17. missiontools-0.1.0/missiontools/coverage/geodata/ne_map_units/ne_50m_admin_0_map_units.prj +1 -0
  18. missiontools-0.1.0/missiontools/coverage/geodata/ne_map_units/ne_50m_admin_0_map_units.shp +0 -0
  19. missiontools-0.1.0/missiontools/coverage/geodata/ne_map_units/ne_50m_admin_0_map_units.shx +0 -0
  20. missiontools-0.1.0/missiontools/coverage/geodata/ne_states_provinces/ne_50m_admin_1_states_provinces.VERSION.txt +1 -0
  21. missiontools-0.1.0/missiontools/coverage/geodata/ne_states_provinces/ne_50m_admin_1_states_provinces.cpg +1 -0
  22. missiontools-0.1.0/missiontools/coverage/geodata/ne_states_provinces/ne_50m_admin_1_states_provinces.dbf +0 -0
  23. missiontools-0.1.0/missiontools/coverage/geodata/ne_states_provinces/ne_50m_admin_1_states_provinces.prj +1 -0
  24. missiontools-0.1.0/missiontools/coverage/geodata/ne_states_provinces/ne_50m_admin_1_states_provinces.shp +0 -0
  25. missiontools-0.1.0/missiontools/coverage/geodata/ne_states_provinces/ne_50m_admin_1_states_provinces.shx +0 -0
  26. missiontools-0.1.0/missiontools/coverage_analysis.py +446 -0
  27. missiontools-0.1.0/missiontools/ground_station.py +139 -0
  28. missiontools-0.1.0/missiontools/orbit/__init__.py +84 -0
  29. missiontools-0.1.0/missiontools/orbit/access.py +442 -0
  30. missiontools-0.1.0/missiontools/orbit/constants.py +29 -0
  31. missiontools-0.1.0/missiontools/orbit/frames.py +342 -0
  32. missiontools-0.1.0/missiontools/orbit/propagation.py +670 -0
  33. missiontools-0.1.0/missiontools/orbit/shadow.py +51 -0
  34. missiontools-0.1.0/missiontools/plotting/__init__.py +22 -0
  35. missiontools-0.1.0/missiontools/plotting/_map.py +103 -0
  36. missiontools-0.1.0/missiontools/plotting/coverage_map.py +155 -0
  37. missiontools-0.1.0/missiontools/plotting/ground_track.py +169 -0
  38. missiontools-0.1.0/missiontools/power/__init__.py +66 -0
  39. missiontools-0.1.0/missiontools/power/solar_config.py +341 -0
  40. missiontools-0.1.0/missiontools/radiation/__init__.py +13 -0
  41. missiontools-0.1.0/missiontools/sensor.py +297 -0
  42. missiontools-0.1.0/missiontools/spacecraft.py +481 -0
  43. missiontools-0.1.0/missiontools/thermal/__init__.py +34 -0
  44. missiontools-0.1.0/missiontools/thermal/thermal_circuit.py +586 -0
  45. missiontools-0.1.0/missiontools/thermal/thermal_config.py +518 -0
  46. missiontools-0.1.0/missiontools.egg-info/PKG-INFO +79 -0
  47. missiontools-0.1.0/missiontools.egg-info/SOURCES.txt +70 -0
  48. missiontools-0.1.0/missiontools.egg-info/dependency_links.txt +1 -0
  49. missiontools-0.1.0/missiontools.egg-info/requires.txt +16 -0
  50. missiontools-0.1.0/missiontools.egg-info/top_level.txt +1 -0
  51. missiontools-0.1.0/pyproject.toml +57 -0
  52. missiontools-0.1.0/setup.cfg +4 -0
  53. missiontools-0.1.0/tests/test_access.py +501 -0
  54. missiontools-0.1.0/tests/test_antenna.py +614 -0
  55. missiontools-0.1.0/tests/test_aoi.py +420 -0
  56. missiontools-0.1.0/tests/test_attitude.py +304 -0
  57. missiontools-0.1.0/tests/test_cache.py +194 -0
  58. missiontools-0.1.0/tests/test_comm.py +5 -0
  59. missiontools-0.1.0/tests/test_coverage.py +955 -0
  60. missiontools-0.1.0/tests/test_coverage_analysis.py +338 -0
  61. missiontools-0.1.0/tests/test_frames.py +603 -0
  62. missiontools-0.1.0/tests/test_ground_station.py +143 -0
  63. missiontools-0.1.0/tests/test_link.py +464 -0
  64. missiontools-0.1.0/tests/test_orbit.py +586 -0
  65. missiontools-0.1.0/tests/test_plotting.py +388 -0
  66. missiontools-0.1.0/tests/test_power.py +5 -0
  67. missiontools-0.1.0/tests/test_radiation.py +5 -0
  68. missiontools-0.1.0/tests/test_sensor.py +319 -0
  69. missiontools-0.1.0/tests/test_shadow.py +74 -0
  70. missiontools-0.1.0/tests/test_solar_config.py +338 -0
  71. missiontools-0.1.0/tests/test_spacecraft.py +402 -0
  72. missiontools-0.1.0/tests/test_thermal.py +1222 -0
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2026 Peter Kazakoff
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8
+
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: missiontools
3
+ Version: 0.1.0
4
+ Summary: Space mission analysis and design (SMAD) toolkit
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/neuroblaze/missiontools
7
+ Project-URL: Documentation, https://missiontools.readthedocs.io/
8
+ Requires-Python: >=3.13
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: numpy
12
+ Requires-Dist: scipy
13
+ Requires-Dist: matplotlib
14
+ Requires-Dist: skyfield
15
+ Requires-Dist: pyshp
16
+ Requires-Dist: shapely
17
+ Requires-Dist: itur
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8; extra == "dev"
20
+ Requires-Dist: pytest-cov; extra == "dev"
21
+ Requires-Dist: jupyterlab; extra == "dev"
22
+ Requires-Dist: ipykernel; extra == "dev"
23
+ Provides-Extra: plot
24
+ Requires-Dist: cartopy; extra == "plot"
25
+ Dynamic: license-file
26
+
27
+ # `missiontools`: Space Mission Analysis in Python
28
+
29
+ `missiontools` is an MIT-licensed framework for space mission analysis tasks in Python. It is currently focused on Earth-orbiting missions, earth observation (EO) in particular.
30
+
31
+ **If you are arriving here from a web search:** this package is under very active development and is probably not useful to you yet. APIs are subject to change without notice.
32
+
33
+ **Check out the [examples folder](examples/) to see what you can do with `missiontools`**
34
+
35
+ ## Features
36
+
37
+ `missiontools` currently supports:
38
+ - Analytical propagation using either Keplerian two-body or J2 with secular perturbations.
39
+ - Convenience functions for generating sun-synchronous, geostationary, and highly elliptical orbits.
40
+ - Access interval computation (spacecraft to ground station, spacecraft to spacecraft)
41
+ - Creation of areas of interest (AoIs) for coverage analysis: global, latitude/longitude bounding box, ESRI shapefile.
42
+ - Convenience functions for creation of AoIs by country or state/province (eg: `AoI.from_geography('Canada/British Columbia')`). Uses the [Natural Earth](https://www.naturalearthdata.com) 1:50m dataset.
43
+ - Computation of space-to-ground coverage including constraints on spacecraft elevation, solar zenith angle (SZA), and field-of-view.
44
+ - Selectable spacecraft attitude laws: fixed (choice of frame: LVLH, ECI, ECEF), targeted
45
+ - Solar panel definition from panel normals and areas
46
+ - Solar generation and orbit average power
47
+ - Yaw steering for maximum solar generation
48
+ - Thermal analysis: faces/normals connected to lumped element thermal model
49
+ - Antenna modeling (isotropic, radially symmetric), including antenna pointing modes
50
+ - Dynamic link budget computation including ITU-R P.618 weather effects (using [ITU-RPy](https://itu-rpy.readthedocs.io/en/latest/)).
51
+
52
+ Possible future features (open an issue if you'd like to see one of these implemented):
53
+ - CAD import for solar & thermal
54
+ - Self-shadowing for solar/thermal imported from CAD
55
+ - Radiative thermal coupling between surface elements imported from CAD
56
+ - Variable-rate communications
57
+ - Radiation environment definition
58
+
59
+ ## Dependencies
60
+ - Python 3.13 or later
61
+ - numpy
62
+ - scipy
63
+ - matplotlib
64
+ - skyfield
65
+ - pyshp
66
+ - shapely
67
+ - itur
68
+
69
+ ## Validation
70
+
71
+ This project is in early days and is not yet well validated. Do not depend on the outputs for any critical design decisions. If an output looks off, it probably is - please open an issue!
72
+
73
+ ## Vibe Coding Disclaimer
74
+
75
+ This project is my first foray into agentic development. I make the architecture decisions myself and use Claude Code to implement them - generating functions, classes, and tests from my specifications. I review all output and redirect when needed, but I do not generally write the implementation code directly.
76
+
77
+ ## Acknowledgements
78
+ - [Natural Earth](https://www.naturalearthdata.com) for the country/state/province shapefiles
79
+ - Claude Sonnet 4.6 & Opus 4.6 for doing the implementation gruntwork
@@ -0,0 +1,53 @@
1
+ # `missiontools`: Space Mission Analysis in Python
2
+
3
+ `missiontools` is an MIT-licensed framework for space mission analysis tasks in Python. It is currently focused on Earth-orbiting missions, earth observation (EO) in particular.
4
+
5
+ **If you are arriving here from a web search:** this package is under very active development and is probably not useful to you yet. APIs are subject to change without notice.
6
+
7
+ **Check out the [examples folder](examples/) to see what you can do with `missiontools`**
8
+
9
+ ## Features
10
+
11
+ `missiontools` currently supports:
12
+ - Analytical propagation using either Keplerian two-body or J2 with secular perturbations.
13
+ - Convenience functions for generating sun-synchronous, geostationary, and highly elliptical orbits.
14
+ - Access interval computation (spacecraft to ground station, spacecraft to spacecraft)
15
+ - Creation of areas of interest (AoIs) for coverage analysis: global, latitude/longitude bounding box, ESRI shapefile.
16
+ - Convenience functions for creation of AoIs by country or state/province (eg: `AoI.from_geography('Canada/British Columbia')`). Uses the [Natural Earth](https://www.naturalearthdata.com) 1:50m dataset.
17
+ - Computation of space-to-ground coverage including constraints on spacecraft elevation, solar zenith angle (SZA), and field-of-view.
18
+ - Selectable spacecraft attitude laws: fixed (choice of frame: LVLH, ECI, ECEF), targeted
19
+ - Solar panel definition from panel normals and areas
20
+ - Solar generation and orbit average power
21
+ - Yaw steering for maximum solar generation
22
+ - Thermal analysis: faces/normals connected to lumped element thermal model
23
+ - Antenna modeling (isotropic, radially symmetric), including antenna pointing modes
24
+ - Dynamic link budget computation including ITU-R P.618 weather effects (using [ITU-RPy](https://itu-rpy.readthedocs.io/en/latest/)).
25
+
26
+ Possible future features (open an issue if you'd like to see one of these implemented):
27
+ - CAD import for solar & thermal
28
+ - Self-shadowing for solar/thermal imported from CAD
29
+ - Radiative thermal coupling between surface elements imported from CAD
30
+ - Variable-rate communications
31
+ - Radiation environment definition
32
+
33
+ ## Dependencies
34
+ - Python 3.13 or later
35
+ - numpy
36
+ - scipy
37
+ - matplotlib
38
+ - skyfield
39
+ - pyshp
40
+ - shapely
41
+ - itur
42
+
43
+ ## Validation
44
+
45
+ This project is in early days and is not yet well validated. Do not depend on the outputs for any critical design decisions. If an output looks off, it probably is - please open an issue!
46
+
47
+ ## Vibe Coding Disclaimer
48
+
49
+ This project is my first foray into agentic development. I make the architecture decisions myself and use Claude Code to implement them - generating functions, classes, and tests from my specifications. I review all output and redirect when needed, but I do not generally write the implementation code directly.
50
+
51
+ ## Acknowledgements
52
+ - [Natural Earth](https://www.naturalearthdata.com) for the country/state/province shapefiles
53
+ - Claude Sonnet 4.6 & Opus 4.6 for doing the implementation gruntwork
@@ -0,0 +1,122 @@
1
+ """
2
+ missiontools
3
+ ============
4
+ Space mission analysis and design (SMAD) toolkit.
5
+
6
+ missiontools provides a high-level Python interface for spacecraft mission
7
+ analysis, covering orbital mechanics, coverage and access analysis, attitude
8
+ determination and control, and solar power modelling.
9
+
10
+ Quick start
11
+ -----------
12
+ Propagate a sun-synchronous LEO orbit for one day::
13
+
14
+ import numpy as np
15
+ from missiontools import Spacecraft
16
+
17
+ sc = Spacecraft.sunsync(altitude_km=550, node_solar_time='10:30')
18
+ state = sc.propagate(
19
+ np.datetime64('2025-01-01', 'us'),
20
+ np.datetime64('2025-01-02', 'us'),
21
+ np.timedelta64(60, 's'),
22
+ )
23
+ # state['r'] — (N, 3) ECI position array (m)
24
+ # state['v'] — (N, 3) ECI velocity array (m/s)
25
+
26
+ Compute ground coverage::
27
+
28
+ from missiontools import AoI, Coverage, Sensor
29
+
30
+ sensor = Sensor(30.0, body_vector=[0, 0, 1])
31
+ sc.add_sensor(sensor)
32
+
33
+ aoi = AoI.from_geography('Australia')
34
+ cov = Coverage(aoi, [sensor])
35
+ result = cov.coverage_fraction(
36
+ np.datetime64('2025-01-01', 'us'),
37
+ np.datetime64('2025-01-02', 'us'),
38
+ )
39
+ print(f"1-day coverage: {result['final_cumulative']:.1%}")
40
+
41
+ Classes
42
+ -------
43
+ :class:`Spacecraft`
44
+ Defines a satellite orbit via Keplerian elements. Factory methods
45
+ :meth:`~Spacecraft.sunsync`, :meth:`~Spacecraft.geostationary`, and
46
+ :meth:`~Spacecraft.heo` cover the most common orbit types.
47
+ :class:`Sensor`
48
+ An instrument with a conical field of view, attached to a spacecraft
49
+ via :meth:`~Spacecraft.add_sensor`.
50
+ :class:`AttitudeLaw`
51
+ Spacecraft or sensor pointing law. Supports nadir, fixed-frame, and
52
+ target-tracking modes with optional yaw steering.
53
+ :class:`GroundStation`
54
+ A ground station defined in WGS84 geodetic coordinates, with an
55
+ :meth:`~GroundStation.access` method for contact scheduling.
56
+ :class:`AoI`
57
+ Area of interest defined by a sampled point cloud. Factory methods
58
+ :meth:`~AoI.from_region`, :meth:`~AoI.from_shapefile`, and
59
+ :meth:`~AoI.from_geography` cover rectangular regions, ESRI shapefiles,
60
+ and Natural Earth geographies respectively.
61
+ :class:`Coverage`
62
+ Coverage and revisit analysis for one or more sensors over an AoI.
63
+ Supports single-satellite and constellation configurations.
64
+ :class:`AbstractSolarConfig`
65
+ Abstract base class for solar power models.
66
+ :class:`NormalVectorSolarConfig`
67
+ Concrete solar config defined by panel normal vectors and areas.
68
+ :class:`ThermalCircuit`
69
+ Lumped-parameter thermal network for transient and steady-state analysis.
70
+ :class:`NormalVectorThermalConfig`
71
+ Surface thermal config defined by face normal vectors, areas,
72
+ emissivities, and absorptivities.
73
+ :class:`ThermalResult`
74
+ Container for thermal simulation results (node temperature histories).
75
+ :class:`AbstractThermalConfig`
76
+ Base class for surface thermal configurations.
77
+
78
+ Conventions
79
+ -----------
80
+ - All physical quantities use SI base units (m, kg, s, A, K, ...) unless
81
+ explicitly stated otherwise in a function's docstring.
82
+ - All angles are in radians unless explicitly stated otherwise.
83
+
84
+ Submodules
85
+ ----------
86
+ :mod:`~missiontools.orbit`
87
+ Orbital mechanics, propagation, frame transformations, and access analysis.
88
+ :mod:`~missiontools.attitude`
89
+ Attitude law representations.
90
+ :mod:`~missiontools.coverage`
91
+ Coverage and access analysis, geographic area sampling.
92
+ :mod:`~missiontools.power`
93
+ Solar power generation modelling.
94
+ :mod:`~missiontools.comm`
95
+ Antenna gain and link budget analysis.
96
+ :mod:`~missiontools.thermal`
97
+ Thermal analysis: lumped-parameter networks and surface thermal models.
98
+ :mod:`~missiontools.radiation`
99
+ Radiation environment *(planned)*.
100
+ """
101
+
102
+ __version__ = "0.1.0"
103
+
104
+ __all__ = [
105
+ 'Spacecraft', 'Sensor', 'AttitudeLaw', 'GroundStation', 'AoI', 'Coverage',
106
+ 'AbstractSolarConfig', 'NormalVectorSolarConfig',
107
+ 'ThermalCircuit', 'ThermalResult', 'AbstractThermalConfig', 'NormalVectorThermalConfig',
108
+ 'IsotropicAntenna', 'SymmetricAntenna', 'Link',
109
+ 'clear_cache', 'set_cache_limit', 'cache_info',
110
+ ]
111
+
112
+ from .spacecraft import Spacecraft
113
+ from .attitude import AttitudeLaw
114
+ from .ground_station import GroundStation
115
+ from .aoi import AoI
116
+ from .sensor import Sensor
117
+ from .coverage_analysis import Coverage
118
+ from .power import AbstractSolarConfig, NormalVectorSolarConfig
119
+ from .thermal import (ThermalCircuit, ThermalResult,
120
+ AbstractThermalConfig, NormalVectorThermalConfig)
121
+ from .comm import IsotropicAntenna, SymmetricAntenna, Link
122
+ from .cache import clear_cache, set_cache_limit, cache_info
@@ -0,0 +1,363 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import numpy.typing as npt
5
+
6
+
7
+ def _geom_crosses_am(geom) -> bool:
8
+ """Return True if *geom*'s bounding box extends outside [-180, 180] longitude."""
9
+ if geom.is_empty:
10
+ return False
11
+ b = geom.bounds # (minx, miny, maxx, maxy)
12
+ return b[2] > 180.0 or b[0] < -180.0
13
+
14
+
15
+ class AoI:
16
+ """Area of interest defined by a sampled point cloud.
17
+
18
+ All angles are in degrees at the user-facing interface; internally stored
19
+ as radians. The ``lat_rad`` / ``lon_rad`` properties expose the radians
20
+ representation for direct use with coverage analysis functions.
21
+
22
+ AoIs created via :meth:`from_region`, :meth:`from_shapefile`,
23
+ :meth:`from_geography`, or a set operation are **geometry-backed** — they
24
+ store a Shapely geometry and generate their sample points **lazily** on
25
+ first access. This means composing complex AoIs with set operations
26
+ (``|``, ``&``, ``-``, ``^``) incurs no sampling cost until points are
27
+ actually needed.
28
+
29
+ Parameters
30
+ ----------
31
+ lat_deg : array-like
32
+ Sample latitudes (deg), shape ``(M,)``.
33
+ lon_deg : array-like
34
+ Sample longitudes (deg), shape ``(M,)``.
35
+
36
+ Notes
37
+ -----
38
+ Directly constructed AoIs (``AoI(lat, lon)``) have no associated geometry
39
+ and cannot participate in set operations.
40
+
41
+ **Antimeridian caveat**: set operations between a geometry from a Natural
42
+ Earth shapefile that uses unwrapped longitudes (> 180°, e.g. Russia) and
43
+ a :meth:`from_region` box in [-180, 180] will not behave correctly because
44
+ Shapely treats coordinates as Cartesian. For most geographies this is not
45
+ an issue.
46
+
47
+ Examples
48
+ --------
49
+ Direct construction from arrays::
50
+
51
+ import numpy as np
52
+ from missiontools import AoI
53
+
54
+ lat = np.linspace(-10, 10, 50)
55
+ lon = np.linspace(30, 60, 50)
56
+ aoi = AoI(lat, lon)
57
+
58
+ From a rectangular lat/lon band (lazy — no points generated yet)::
59
+
60
+ aoi = AoI.from_region(lat_min_deg=-10, lat_max_deg=10,
61
+ lon_min_deg=30, lon_max_deg=60)
62
+
63
+ From a Natural Earth geography::
64
+
65
+ aoi = AoI.from_geography('Australia')
66
+
67
+ Compound AoI via set operations::
68
+
69
+ conus = AoI.from_geography("US") - AoI.from_geography("US-AK") \\
70
+ - AoI.from_geography("US-HI")
71
+
72
+ can_arctic = AoI.from_geography("Canada") & AoI.from_region(lat_min_deg=66)
73
+ """
74
+
75
+ def __init__(self, lat_deg: npt.ArrayLike, lon_deg: npt.ArrayLike) -> None:
76
+ self._lat = np.radians(np.asarray(lat_deg, dtype=np.float64))
77
+ self._lon = np.radians(np.asarray(lon_deg, dtype=np.float64))
78
+ self._geometry = None
79
+ self._shapefile_path = None
80
+ self._point_density_m2 = None
81
+ self._crosses_am = False
82
+
83
+ # ------------------------------------------------------------------
84
+ # Private lazy-construction classmethod
85
+ # ------------------------------------------------------------------
86
+
87
+ @classmethod
88
+ def _from_geometry(
89
+ cls,
90
+ geom,
91
+ crosses_am: bool,
92
+ density_m2: float,
93
+ shapefile_path: str | None = None,
94
+ ) -> 'AoI':
95
+ """Construct a lazy AoI backed by a Shapely geometry.
96
+
97
+ Points are not sampled until first access via a point-returning property.
98
+ """
99
+ obj = object.__new__(cls)
100
+ obj._lat = None # lazy: computed on demand
101
+ obj._lon = None
102
+ obj._geometry = geom
103
+ obj._shapefile_path = shapefile_path
104
+ obj._point_density_m2 = density_m2
105
+ obj._crosses_am = crosses_am
106
+ return obj
107
+
108
+ # ------------------------------------------------------------------
109
+ # Lazy evaluation
110
+ # ------------------------------------------------------------------
111
+
112
+ def _ensure_points(self) -> None:
113
+ """Compute sample points from geometry if they have not been generated yet."""
114
+ if self._lat is not None:
115
+ return
116
+ from .coverage.coverage import sample_from_geometry
117
+ if self._geometry.is_empty:
118
+ self._lat = np.empty(0, dtype=np.float64)
119
+ self._lon = np.empty(0, dtype=np.float64)
120
+ else:
121
+ self._lat, self._lon = sample_from_geometry(
122
+ self._geometry, self._crosses_am, self._point_density_m2
123
+ )
124
+
125
+ # ------------------------------------------------------------------
126
+ # Properties
127
+ # ------------------------------------------------------------------
128
+
129
+ @property
130
+ def lat(self) -> npt.NDArray[np.float64]:
131
+ """Sample latitudes (deg), shape ``(M,)``."""
132
+ self._ensure_points()
133
+ return np.degrees(self._lat)
134
+
135
+ @property
136
+ def lon(self) -> npt.NDArray[np.float64]:
137
+ """Sample longitudes (deg), shape ``(M,)``."""
138
+ self._ensure_points()
139
+ return np.degrees(self._lon)
140
+
141
+ @property
142
+ def lat_rad(self) -> npt.NDArray[np.float64]:
143
+ """Sample latitudes (rad), shape ``(M,)`` — for coverage functions."""
144
+ self._ensure_points()
145
+ return self._lat
146
+
147
+ @property
148
+ def lon_rad(self) -> npt.NDArray[np.float64]:
149
+ """Sample longitudes (rad), shape ``(M,)`` — for coverage functions."""
150
+ self._ensure_points()
151
+ return self._lon
152
+
153
+ @property
154
+ def geometry(self):
155
+ """Shapely geometry describing the AoI, or ``None`` if unavailable."""
156
+ return self._geometry
157
+
158
+ @property
159
+ def shapefile_path(self) -> str | None:
160
+ """Path to the source shapefile, or ``None`` if not constructed from one."""
161
+ return self._shapefile_path
162
+
163
+ def __len__(self) -> int:
164
+ self._ensure_points()
165
+ return len(self._lat)
166
+
167
+ def __repr__(self) -> str:
168
+ pts = (f'{len(self._lat)} points'
169
+ if self._lat is not None else 'not yet sampled')
170
+ if self._shapefile_path:
171
+ return f'AoI({pts}, shapefile={self._shapefile_path!r})'
172
+ if self._geometry is not None:
173
+ return f'AoI({pts}, {type(self._geometry).__name__})'
174
+ return f'AoI({pts})'
175
+
176
+ # ------------------------------------------------------------------
177
+ # Set operations
178
+ # ------------------------------------------------------------------
179
+
180
+ def _require_geometry(self, op: str) -> None:
181
+ if self._geometry is None:
182
+ raise TypeError(
183
+ f"Cannot use '{op}' on an AoI without associated geometry. "
184
+ "Use from_region(), from_shapefile(), from_geography(), "
185
+ "or a set operation to create a geometry-backed AoI."
186
+ )
187
+
188
+ def __or__(self, other: 'AoI') -> 'AoI':
189
+ """Union — all area covered by either AoI."""
190
+ self._require_geometry('|')
191
+ other._require_geometry('|')
192
+ geom = self._geometry.union(other._geometry)
193
+ return AoI._from_geometry(geom, _geom_crosses_am(geom), self._point_density_m2)
194
+
195
+ def __and__(self, other: 'AoI') -> 'AoI':
196
+ """Intersection — area common to both AoIs."""
197
+ self._require_geometry('&')
198
+ other._require_geometry('&')
199
+ geom = self._geometry.intersection(other._geometry)
200
+ return AoI._from_geometry(geom, _geom_crosses_am(geom), self._point_density_m2)
201
+
202
+ def __sub__(self, other: 'AoI') -> 'AoI':
203
+ """Difference — area in this AoI that is not in *other*."""
204
+ self._require_geometry('-')
205
+ other._require_geometry('-')
206
+ geom = self._geometry.difference(other._geometry)
207
+ return AoI._from_geometry(geom, _geom_crosses_am(geom), self._point_density_m2)
208
+
209
+ def __xor__(self, other: 'AoI') -> 'AoI':
210
+ """Symmetric difference — area in either AoI but not both."""
211
+ self._require_geometry('^')
212
+ other._require_geometry('^')
213
+ geom = self._geometry.symmetric_difference(other._geometry)
214
+ return AoI._from_geometry(geom, _geom_crosses_am(geom), self._point_density_m2)
215
+
216
+ # ------------------------------------------------------------------
217
+ # Factory classmethods
218
+ # ------------------------------------------------------------------
219
+
220
+ @classmethod
221
+ def _from_radians(cls, lat_rad: npt.NDArray, lon_rad: npt.NDArray) -> 'AoI':
222
+ """Construct directly from radian arrays, skipping the deg→rad conversion."""
223
+ obj = object.__new__(cls)
224
+ obj._lat = np.asarray(lat_rad, dtype=np.float64)
225
+ obj._lon = np.asarray(lon_rad, dtype=np.float64)
226
+ obj._geometry = None
227
+ obj._shapefile_path = None
228
+ obj._point_density_m2 = None
229
+ obj._crosses_am = False
230
+ return obj
231
+
232
+ @classmethod
233
+ def from_region(
234
+ cls,
235
+ lat_min_deg: float | None = None,
236
+ lat_max_deg: float | None = None,
237
+ lon_min_deg: float | None = None,
238
+ lon_max_deg: float | None = None,
239
+ *,
240
+ point_density: float = 1e5,
241
+ ) -> 'AoI':
242
+ """Sample an AoI from a rectangular lat/lon region.
243
+
244
+ Points are generated lazily on first access.
245
+
246
+ Parameters
247
+ ----------
248
+ lat_min_deg : float | None, optional
249
+ Southern boundary (deg). ``None`` extends to the South Pole.
250
+ lat_max_deg : float | None, optional
251
+ Northern boundary (deg). ``None`` extends to the North Pole.
252
+ lon_min_deg : float | None, optional
253
+ Western boundary (deg). Must be paired with ``lon_max_deg``; ``None``
254
+ (together with ``lon_max_deg=None``) includes all longitudes.
255
+ lon_max_deg : float | None, optional
256
+ Eastern boundary (deg). May be less than ``lon_min_deg`` for
257
+ anti-meridian-crossing regions.
258
+ point_density : float, optional
259
+ Approximate area per sample point (km²). Defaults to 1×10⁵ km²
260
+ (~100 000 km² per point).
261
+
262
+ Returns
263
+ -------
264
+ AoI
265
+ Geometry-backed, lazily sampled.
266
+ """
267
+ from shapely.geometry import box
268
+ from shapely.ops import unary_union
269
+
270
+ density_m2 = point_density * 1e6
271
+
272
+ lat_min = -90.0 if lat_min_deg is None else float(lat_min_deg)
273
+ lat_max = 90.0 if lat_max_deg is None else float(lat_max_deg)
274
+
275
+ if lon_min_deg is None and lon_max_deg is None:
276
+ geom = box(-180.0, lat_min, 180.0, lat_max)
277
+ crosses_am = False
278
+ elif lon_min_deg is not None and lon_max_deg is not None:
279
+ lon_min = float(lon_min_deg)
280
+ lon_max = float(lon_max_deg)
281
+ if lon_min <= lon_max:
282
+ geom = box(lon_min, lat_min, lon_max, lat_max)
283
+ crosses_am = False
284
+ else:
285
+ # antimeridian-crossing: two boxes in normal [-180, 180] coordinates
286
+ geom = unary_union([
287
+ box(lon_min, lat_min, 180.0, lat_max),
288
+ box(-180.0, lat_min, lon_max, lat_max),
289
+ ])
290
+ crosses_am = False
291
+ else:
292
+ raise ValueError(
293
+ "lon_min_deg and lon_max_deg must both be None or both be specified."
294
+ )
295
+
296
+ return cls._from_geometry(geom, crosses_am, density_m2)
297
+
298
+ @classmethod
299
+ def from_shapefile(
300
+ cls,
301
+ path: str,
302
+ *,
303
+ feature_index: int | None = None,
304
+ point_density: float = 1e5,
305
+ ) -> 'AoI':
306
+ """Sample an AoI from an ESRI Shapefile polygon.
307
+
308
+ Stores the Shapely geometry; points are generated lazily on first access.
309
+
310
+ Parameters
311
+ ----------
312
+ path : str
313
+ Path to the ``.shp`` file.
314
+ feature_index : int | None, optional
315
+ Index of the feature to sample. ``None`` (default) unions all
316
+ features.
317
+ point_density : float, optional
318
+ Approximate area per sample point (km²). Defaults to 1×10⁵ km².
319
+
320
+ Returns
321
+ -------
322
+ AoI
323
+ With :attr:`geometry` and :attr:`shapefile_path` populated.
324
+ """
325
+ from .coverage import load_shapefile_geometry
326
+
327
+ geom, crosses_am = load_shapefile_geometry(path, feature_index)
328
+ return cls._from_geometry(geom, crosses_am, point_density * 1e6,
329
+ shapefile_path=str(path))
330
+
331
+ @classmethod
332
+ def from_geography(
333
+ cls,
334
+ geography: str,
335
+ *,
336
+ point_density: float = 1e5,
337
+ ) -> 'AoI':
338
+ """Sample an AoI from a Natural Earth geography by name or code.
339
+
340
+ Stores the Shapely geometry; points are generated lazily on first access.
341
+
342
+ Parameters
343
+ ----------
344
+ geography : str
345
+ One of:
346
+
347
+ - Country name: ``'Canada'`` (case-insensitive)
348
+ - ``'Country/Subdivision'``: ``'Canada/Quebec'``
349
+ - ISO 3166-1 alpha-2: ``'CA'``
350
+ - ISO 3166-1 alpha-3: ``'CAN'``
351
+ - ISO 3166-2: ``'CA-QC'``
352
+ point_density : float, optional
353
+ Approximate area per sample point (km²). Defaults to 1×10⁵ km².
354
+
355
+ Returns
356
+ -------
357
+ AoI
358
+ With :attr:`geometry` populated.
359
+ """
360
+ from .coverage import geography_geometry
361
+
362
+ geom, crosses_am = geography_geometry(geography)
363
+ return cls._from_geometry(geom, crosses_am, point_density * 1e6)
@@ -0,0 +1,36 @@
1
+ """
2
+ missiontools.attitude
3
+ =====================
4
+ Spacecraft and sensor pointing laws.
5
+
6
+ The primary class is :class:`AttitudeLaw`, which stores a full 3-DOF body
7
+ orientation as a unit quaternion ``[w, x, y, z]`` and supports several
8
+ pointing modes:
9
+
10
+ - **Nadir** — body-z toward the Earth centre (default for all
11
+ :class:`~missiontools.Spacecraft` instances).
12
+ - **Fixed** — constant body orientation in a chosen reference frame
13
+ (LVLH, ECI, or ECEF). The boresight direction and an optional roll
14
+ angle are specified at construction.
15
+ - **Track** — boresight pointing toward a target
16
+ :class:`~missiontools.Spacecraft` at every timestep.
17
+
18
+ All pointing methods (:meth:`~AttitudeLaw.pointing_eci`,
19
+ :meth:`~AttitudeLaw.pointing_lvlh`, :meth:`~AttitudeLaw.pointing_ecef`)
20
+ return the **body-z** unit vector expressed in the requested frame.
21
+
22
+ Optional yaw steering can be enabled via
23
+ :meth:`~AttitudeLaw.yaw_steering` to maximise solar power generation by
24
+ rotating the spacecraft about the boresight axis at each timestep.
25
+
26
+ Planned functionality
27
+ ---------------------
28
+ - Environmental disturbance torques
29
+ - Actuator sizing (reaction wheels, magnetorquers, thrusters)
30
+ - Pointing budget and error analysis
31
+ - Sensor modelling (star tracker, sun sensor, magnetometer)
32
+ """
33
+
34
+ from .attitude_law import AttitudeLaw
35
+
36
+ __all__ = ['AttitudeLaw']