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.
- missiontools-0.1.0/LICENSE +8 -0
- missiontools-0.1.0/PKG-INFO +79 -0
- missiontools-0.1.0/README.md +53 -0
- missiontools-0.1.0/missiontools/__init__.py +122 -0
- missiontools-0.1.0/missiontools/aoi.py +363 -0
- missiontools-0.1.0/missiontools/attitude/__init__.py +36 -0
- missiontools-0.1.0/missiontools/attitude/attitude_law.py +625 -0
- missiontools-0.1.0/missiontools/cache.py +197 -0
- missiontools-0.1.0/missiontools/comm/__init__.py +25 -0
- missiontools-0.1.0/missiontools/comm/antenna.py +623 -0
- missiontools-0.1.0/missiontools/comm/link.py +366 -0
- missiontools-0.1.0/missiontools/coverage/__init__.py +68 -0
- missiontools-0.1.0/missiontools/coverage/coverage.py +1506 -0
- missiontools-0.1.0/missiontools/coverage/geodata/ne_map_units/ne_50m_admin_0_map_units.VERSION.txt +1 -0
- missiontools-0.1.0/missiontools/coverage/geodata/ne_map_units/ne_50m_admin_0_map_units.cpg +1 -0
- missiontools-0.1.0/missiontools/coverage/geodata/ne_map_units/ne_50m_admin_0_map_units.dbf +0 -0
- missiontools-0.1.0/missiontools/coverage/geodata/ne_map_units/ne_50m_admin_0_map_units.prj +1 -0
- missiontools-0.1.0/missiontools/coverage/geodata/ne_map_units/ne_50m_admin_0_map_units.shp +0 -0
- missiontools-0.1.0/missiontools/coverage/geodata/ne_map_units/ne_50m_admin_0_map_units.shx +0 -0
- missiontools-0.1.0/missiontools/coverage/geodata/ne_states_provinces/ne_50m_admin_1_states_provinces.VERSION.txt +1 -0
- missiontools-0.1.0/missiontools/coverage/geodata/ne_states_provinces/ne_50m_admin_1_states_provinces.cpg +1 -0
- missiontools-0.1.0/missiontools/coverage/geodata/ne_states_provinces/ne_50m_admin_1_states_provinces.dbf +0 -0
- missiontools-0.1.0/missiontools/coverage/geodata/ne_states_provinces/ne_50m_admin_1_states_provinces.prj +1 -0
- missiontools-0.1.0/missiontools/coverage/geodata/ne_states_provinces/ne_50m_admin_1_states_provinces.shp +0 -0
- missiontools-0.1.0/missiontools/coverage/geodata/ne_states_provinces/ne_50m_admin_1_states_provinces.shx +0 -0
- missiontools-0.1.0/missiontools/coverage_analysis.py +446 -0
- missiontools-0.1.0/missiontools/ground_station.py +139 -0
- missiontools-0.1.0/missiontools/orbit/__init__.py +84 -0
- missiontools-0.1.0/missiontools/orbit/access.py +442 -0
- missiontools-0.1.0/missiontools/orbit/constants.py +29 -0
- missiontools-0.1.0/missiontools/orbit/frames.py +342 -0
- missiontools-0.1.0/missiontools/orbit/propagation.py +670 -0
- missiontools-0.1.0/missiontools/orbit/shadow.py +51 -0
- missiontools-0.1.0/missiontools/plotting/__init__.py +22 -0
- missiontools-0.1.0/missiontools/plotting/_map.py +103 -0
- missiontools-0.1.0/missiontools/plotting/coverage_map.py +155 -0
- missiontools-0.1.0/missiontools/plotting/ground_track.py +169 -0
- missiontools-0.1.0/missiontools/power/__init__.py +66 -0
- missiontools-0.1.0/missiontools/power/solar_config.py +341 -0
- missiontools-0.1.0/missiontools/radiation/__init__.py +13 -0
- missiontools-0.1.0/missiontools/sensor.py +297 -0
- missiontools-0.1.0/missiontools/spacecraft.py +481 -0
- missiontools-0.1.0/missiontools/thermal/__init__.py +34 -0
- missiontools-0.1.0/missiontools/thermal/thermal_circuit.py +586 -0
- missiontools-0.1.0/missiontools/thermal/thermal_config.py +518 -0
- missiontools-0.1.0/missiontools.egg-info/PKG-INFO +79 -0
- missiontools-0.1.0/missiontools.egg-info/SOURCES.txt +70 -0
- missiontools-0.1.0/missiontools.egg-info/dependency_links.txt +1 -0
- missiontools-0.1.0/missiontools.egg-info/requires.txt +16 -0
- missiontools-0.1.0/missiontools.egg-info/top_level.txt +1 -0
- missiontools-0.1.0/pyproject.toml +57 -0
- missiontools-0.1.0/setup.cfg +4 -0
- missiontools-0.1.0/tests/test_access.py +501 -0
- missiontools-0.1.0/tests/test_antenna.py +614 -0
- missiontools-0.1.0/tests/test_aoi.py +420 -0
- missiontools-0.1.0/tests/test_attitude.py +304 -0
- missiontools-0.1.0/tests/test_cache.py +194 -0
- missiontools-0.1.0/tests/test_comm.py +5 -0
- missiontools-0.1.0/tests/test_coverage.py +955 -0
- missiontools-0.1.0/tests/test_coverage_analysis.py +338 -0
- missiontools-0.1.0/tests/test_frames.py +603 -0
- missiontools-0.1.0/tests/test_ground_station.py +143 -0
- missiontools-0.1.0/tests/test_link.py +464 -0
- missiontools-0.1.0/tests/test_orbit.py +586 -0
- missiontools-0.1.0/tests/test_plotting.py +388 -0
- missiontools-0.1.0/tests/test_power.py +5 -0
- missiontools-0.1.0/tests/test_radiation.py +5 -0
- missiontools-0.1.0/tests/test_sensor.py +319 -0
- missiontools-0.1.0/tests/test_shadow.py +74 -0
- missiontools-0.1.0/tests/test_solar_config.py +338 -0
- missiontools-0.1.0/tests/test_spacecraft.py +402 -0
- 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']
|