starplot 0.20.5__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.
- starplot/__init__.py +59 -0
- starplot/callables.py +176 -0
- starplot/cli.py +29 -0
- starplot/config.py +80 -0
- starplot/coordinates.py +7 -0
- starplot/data/__init__.py +22 -0
- starplot/data/catalogs.py +356 -0
- starplot/data/constellations.py +71 -0
- starplot/data/db.py +33 -0
- starplot/data/dsos.py +85 -0
- starplot/data/ecliptic.py +201 -0
- starplot/data/library/constellation_names.parquet +0 -0
- starplot/data/library/dso_names.parquet +0 -0
- starplot/data/library/readme.md +1 -0
- starplot/data/library/star_designations.parquet +0 -0
- starplot/data/stars.py +79 -0
- starplot/data/translations.py +408 -0
- starplot/data/utils.py +35 -0
- starplot/geometry.py +319 -0
- starplot/mixins.py +360 -0
- starplot/models/__init__.py +21 -0
- starplot/models/base.py +77 -0
- starplot/models/comet.py +302 -0
- starplot/models/constellation.py +151 -0
- starplot/models/dso.py +317 -0
- starplot/models/milky_way.py +30 -0
- starplot/models/moon.py +130 -0
- starplot/models/objects.py +29 -0
- starplot/models/observer.py +125 -0
- starplot/models/optics.py +342 -0
- starplot/models/planet.py +137 -0
- starplot/models/satellite.py +138 -0
- starplot/models/star.py +253 -0
- starplot/models/sun.py +62 -0
- starplot/plots/__init__.py +7 -0
- starplot/plots/base.py +1023 -0
- starplot/plots/galaxy.py +372 -0
- starplot/plots/horizon.py +543 -0
- starplot/plots/map.py +515 -0
- starplot/plots/optic.py +469 -0
- starplot/plots/zenith.py +217 -0
- starplot/plotters/__init__.py +9 -0
- starplot/plotters/arrow.py +174 -0
- starplot/plotters/constellations.py +298 -0
- starplot/plotters/debug.py +21 -0
- starplot/plotters/dsos.py +294 -0
- starplot/plotters/experimental.py +722 -0
- starplot/plotters/gradients.py +153 -0
- starplot/plotters/legend.py +253 -0
- starplot/plotters/milkyway.py +51 -0
- starplot/plotters/stars.py +319 -0
- starplot/plotters/text.py +802 -0
- starplot/profile.py +16 -0
- starplot/projections.py +184 -0
- starplot/styles/__init__.py +6 -0
- starplot/styles/base.py +1344 -0
- starplot/styles/ext/antique.yml +175 -0
- starplot/styles/ext/blue_dark.yml +163 -0
- starplot/styles/ext/blue_gold.yml +147 -0
- starplot/styles/ext/blue_light.yml +123 -0
- starplot/styles/ext/blue_medium.yml +142 -0
- starplot/styles/ext/blue_night.yml +185 -0
- starplot/styles/ext/cb_wong.yml +124 -0
- starplot/styles/ext/color_print.yml +111 -0
- starplot/styles/ext/gradient_presets.yml +158 -0
- starplot/styles/ext/grayscale.yml +94 -0
- starplot/styles/ext/grayscale_dark.yml +136 -0
- starplot/styles/ext/map.yml +12 -0
- starplot/styles/ext/nord.yml +158 -0
- starplot/styles/ext/optic.yml +20 -0
- starplot/styles/ext/publication.yml +8 -0
- starplot/styles/extensions.py +129 -0
- starplot/styles/fonts-library/gfs-didot/DESCRIPTION.en_us.html +9 -0
- starplot/styles/fonts-library/gfs-didot/GFSDidot-Regular.ttf +0 -0
- starplot/styles/fonts-library/gfs-didot/METADATA.pb +16 -0
- starplot/styles/fonts-library/gfs-didot/OFL.txt +94 -0
- starplot/styles/fonts-library/inter/Inter-Bold.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-BoldItalic.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-ExtraBold.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-ExtraLight.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-ExtraLightItalic.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-Italic.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-Light.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-LightItalic.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-Regular.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-SemiBold.ttf +0 -0
- starplot/styles/fonts-library/inter/Inter-SemiBoldItalic.ttf +0 -0
- starplot/styles/fonts-library/inter/LICENSE.txt +92 -0
- starplot/styles/fonts.py +15 -0
- starplot/styles/helpers.py +93 -0
- starplot/styles/markers.py +308 -0
- starplot/utils.py +169 -0
- starplot/warnings.py +21 -0
- starplot-0.20.5.dist-info/METADATA +146 -0
- starplot-0.20.5.dist-info/RECORD +98 -0
- starplot-0.20.5.dist-info/WHEEL +4 -0
- starplot-0.20.5.dist-info/entry_points.txt +3 -0
- starplot-0.20.5.dist-info/licenses/LICENSE +21 -0
starplot/models/dso.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional, Iterator
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
from ibis import _
|
|
6
|
+
import pyarrow as pa
|
|
7
|
+
from shapely import Polygon, MultiPolygon
|
|
8
|
+
|
|
9
|
+
from starplot.data.utils import to_pandas
|
|
10
|
+
from starplot.data.catalogs import Catalog, OPEN_NGC
|
|
11
|
+
from starplot.data.dsos import load
|
|
12
|
+
from starplot.models.base import SkyObject, CatalogObject
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DsoType(str, Enum):
|
|
16
|
+
"""
|
|
17
|
+
Type of deep sky object (DSO), as designated in OpenNGC
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
STAR = "*"
|
|
21
|
+
"""Star"""
|
|
22
|
+
|
|
23
|
+
DOUBLE_STAR = "**"
|
|
24
|
+
"""Double star or multiple star system"""
|
|
25
|
+
|
|
26
|
+
ASSOCIATION_OF_STARS = "*Ass"
|
|
27
|
+
"""Association of stars"""
|
|
28
|
+
|
|
29
|
+
OPEN_CLUSTER = "OCl"
|
|
30
|
+
"""Open cluster of stars"""
|
|
31
|
+
|
|
32
|
+
GLOBULAR_CLUSTER = "GCl"
|
|
33
|
+
"""Globular cluster of stars"""
|
|
34
|
+
|
|
35
|
+
GALAXY = "G"
|
|
36
|
+
"""Galaxy"""
|
|
37
|
+
|
|
38
|
+
GALAXY_PAIR = "GPair"
|
|
39
|
+
"""Group of two galaxies"""
|
|
40
|
+
|
|
41
|
+
GALAXY_TRIPLET = "GTrpl"
|
|
42
|
+
"""Group of three galaxies"""
|
|
43
|
+
|
|
44
|
+
GROUP_OF_GALAXIES = "GGroup"
|
|
45
|
+
"""Group of more than three galaxies"""
|
|
46
|
+
|
|
47
|
+
NEBULA = "Neb"
|
|
48
|
+
"""Nebula"""
|
|
49
|
+
|
|
50
|
+
PLANETARY_NEBULA = "PN"
|
|
51
|
+
"""Planetary nebula"""
|
|
52
|
+
|
|
53
|
+
EMISSION_NEBULA = "EmN"
|
|
54
|
+
"""Emission Nebula"""
|
|
55
|
+
|
|
56
|
+
STAR_CLUSTER_NEBULA = "Cl+N"
|
|
57
|
+
"""Star cluster with nebulosity"""
|
|
58
|
+
|
|
59
|
+
REFLECTION_NEBULA = "RfN"
|
|
60
|
+
"""Reflection nebula"""
|
|
61
|
+
|
|
62
|
+
DARK_NEBULA = "DrkN"
|
|
63
|
+
"""Dark nebula"""
|
|
64
|
+
|
|
65
|
+
HII_IONIZED_REGION = "HII"
|
|
66
|
+
"""Hydrogen ionized region"""
|
|
67
|
+
|
|
68
|
+
SUPERNOVA_REMNANT = "SNR"
|
|
69
|
+
"""Supernova remnant"""
|
|
70
|
+
|
|
71
|
+
NOVA_STAR = "Nova"
|
|
72
|
+
"""Nova star"""
|
|
73
|
+
|
|
74
|
+
NONEXISTENT = "NonEx"
|
|
75
|
+
"""Non-existant object"""
|
|
76
|
+
|
|
77
|
+
UNKNOWN = "Other"
|
|
78
|
+
"""Unknown type of object"""
|
|
79
|
+
|
|
80
|
+
DUPLICATE_RECORD = "Dup"
|
|
81
|
+
"""Duplicate record of another object"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(slots=True, kw_only=True)
|
|
85
|
+
class DSO(CatalogObject, SkyObject):
|
|
86
|
+
"""
|
|
87
|
+
Deep Sky Object (DSO) model. An instance of this model is passed to any [callables](/reference-callables) you define when plotting DSOs.
|
|
88
|
+
So, you can use any attributes of this model in your callables. Note that some may be null.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
geometry: Polygon | MultiPolygon
|
|
92
|
+
"""Shapely Polygon of the DSO's extent. Right ascension coordinates are in degrees (0...360)."""
|
|
93
|
+
|
|
94
|
+
name: str = None
|
|
95
|
+
"""Name of the DSO (as specified in OpenNGC)"""
|
|
96
|
+
|
|
97
|
+
type: DsoType = DsoType.UNKNOWN
|
|
98
|
+
"""Type of DSO"""
|
|
99
|
+
|
|
100
|
+
common_names: list[str] = None
|
|
101
|
+
"""
|
|
102
|
+
List of common names for the DSO (e.g. 'Andromeda Galaxy' for M31)
|
|
103
|
+
|
|
104
|
+
Note: this field is parsed into a list of strings _after_ querying DSOs, so if you want to query on this field, you should treat it as a comma-separated list.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
magnitude: Optional[float] = None
|
|
108
|
+
"""Magnitude (if available)"""
|
|
109
|
+
|
|
110
|
+
maj_ax: Optional[float] = None
|
|
111
|
+
"""Major axis of the DSO, in arcmin (if available)"""
|
|
112
|
+
|
|
113
|
+
min_ax: Optional[float] = None
|
|
114
|
+
"""Minor axis of the DSO, in arcmin (if available)"""
|
|
115
|
+
|
|
116
|
+
angle: Optional[float] = None
|
|
117
|
+
"""Angle of the DSO, in degrees (if available)"""
|
|
118
|
+
|
|
119
|
+
size: Optional[float] = None
|
|
120
|
+
"""Size of the DSO calculated as the area of the minimum bounding rectangle of the DSO, in degrees squared (if available)"""
|
|
121
|
+
|
|
122
|
+
m: Optional[str] = None
|
|
123
|
+
"""
|
|
124
|
+
Messier number. *Note that this field is a string, to be consistent with the other identifier fields (`ngc` and `ic`).*
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
ngc: Optional[str] = None
|
|
128
|
+
"""
|
|
129
|
+
New General Catalogue (NGC) identifier. *Note that this field is a string, to support objects like '3537 NED01'.*
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
ic: Optional[str] = None
|
|
133
|
+
"""
|
|
134
|
+
Index Catalogue (IC) identifier. *Note that this field is a string, to support objects like '4974 NED01'.*
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def _pyarrow_schema(cls):
|
|
139
|
+
base_schema = super(DSO, cls)._pyarrow_schema()
|
|
140
|
+
dso_fields = [
|
|
141
|
+
pa.field("pk", pa.int64(), nullable=False),
|
|
142
|
+
pa.field("geometry", pa.binary(), nullable=False),
|
|
143
|
+
pa.field("name", pa.string()),
|
|
144
|
+
pa.field("type", pa.string()),
|
|
145
|
+
pa.field("common_names", pa.string()),
|
|
146
|
+
pa.field("magnitude", pa.float64()),
|
|
147
|
+
pa.field("maj_ax", pa.float64()),
|
|
148
|
+
pa.field("min_ax", pa.float64()),
|
|
149
|
+
pa.field("angle", pa.float64()),
|
|
150
|
+
pa.field("size", pa.float64()),
|
|
151
|
+
pa.field("m", pa.string()),
|
|
152
|
+
pa.field("ngc", pa.string()),
|
|
153
|
+
pa.field("ic", pa.string()),
|
|
154
|
+
]
|
|
155
|
+
return pa.schema(list(base_schema) + dso_fields)
|
|
156
|
+
|
|
157
|
+
def __repr__(self) -> str:
|
|
158
|
+
return f"DSO(name={self.name}, magnitude={self.magnitude})"
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def all(cls, catalog: Catalog = OPEN_NGC) -> Iterator["DSO"]:
|
|
162
|
+
"""
|
|
163
|
+
Get all DSOs from a catalog
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
catalog: Catalog you want to get DSO objects from
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Iterator of DSO instances
|
|
170
|
+
"""
|
|
171
|
+
df = to_pandas(load(catalog=catalog))
|
|
172
|
+
|
|
173
|
+
for d in df.itertuples():
|
|
174
|
+
yield from_tuple(d)
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def get(cls, catalog: Catalog = OPEN_NGC, sql: str = None, **kwargs) -> "DSO":
|
|
178
|
+
"""
|
|
179
|
+
Get a DSO, by matching its attributes.
|
|
180
|
+
|
|
181
|
+
Example:
|
|
182
|
+
|
|
183
|
+
d = DSO.get(m=13)
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
catalog: Catalog you want to search
|
|
187
|
+
sql: SQL query for selecting DSO (table name is "_")
|
|
188
|
+
**kwargs: Attributes on the DSO you want to match
|
|
189
|
+
|
|
190
|
+
Raises: `ValueError` if more than one DSO is matched
|
|
191
|
+
"""
|
|
192
|
+
filters = []
|
|
193
|
+
|
|
194
|
+
for k, v in kwargs.items():
|
|
195
|
+
filters.append(getattr(_, k) == v)
|
|
196
|
+
|
|
197
|
+
df = to_pandas(load(catalog=catalog, filters=filters, sql=sql))
|
|
198
|
+
|
|
199
|
+
results = [from_tuple(d) for d in df.itertuples()]
|
|
200
|
+
|
|
201
|
+
if len(results) == 1:
|
|
202
|
+
return results[0]
|
|
203
|
+
|
|
204
|
+
if len(results) > 1:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
"More than one match. Use find() instead or narrow your search."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def find(
|
|
213
|
+
cls, catalog: Catalog = OPEN_NGC, where: list = None, sql: str = None
|
|
214
|
+
) -> list["DSO"]:
|
|
215
|
+
"""
|
|
216
|
+
Find DSOs
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
catalog: Catalog you want to search
|
|
220
|
+
where: A list of expressions that determine which DSOs to find. See [Selecting Objects](/reference-selecting-objects/) for details.
|
|
221
|
+
sql: SQL query for selecting DSOs (table name is "_")
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
List of DSOs that match all `where` expressions
|
|
225
|
+
|
|
226
|
+
"""
|
|
227
|
+
df = to_pandas(load(catalog=catalog, filters=where, sql=sql))
|
|
228
|
+
return [from_tuple(d) for d in df.itertuples()]
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
def get_label(cls, dso) -> str:
|
|
232
|
+
"""
|
|
233
|
+
Default function for determining the plotted label for a DSO.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
|
|
237
|
+
1. `"M13"` if DSO is a Messier object
|
|
238
|
+
2. `"6456"` if DSO is an NGC object
|
|
239
|
+
3. `"IC1920"` if DSO is an IC object
|
|
240
|
+
4. Empty string otherwise
|
|
241
|
+
|
|
242
|
+
"""
|
|
243
|
+
if dso.m:
|
|
244
|
+
return f"M{dso.m}"
|
|
245
|
+
|
|
246
|
+
if dso.ngc:
|
|
247
|
+
return f"{dso.ngc}"
|
|
248
|
+
|
|
249
|
+
if dso.ic:
|
|
250
|
+
return f"IC{dso.ic}"
|
|
251
|
+
|
|
252
|
+
return ""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def from_tuple(d: tuple) -> DSO:
|
|
256
|
+
kwargs = {f: getattr(d, f) for f in DSO._fields() if hasattr(d, f)}
|
|
257
|
+
if "common_names" in kwargs and kwargs["common_names"] is not None:
|
|
258
|
+
kwargs["common_names"] = kwargs["common_names"].split(",")
|
|
259
|
+
|
|
260
|
+
return DSO(**kwargs)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
ONGC_TYPE = {
|
|
264
|
+
# Star Clusters ----------
|
|
265
|
+
DsoType.OPEN_CLUSTER: "OCl",
|
|
266
|
+
DsoType.GLOBULAR_CLUSTER: "GCl",
|
|
267
|
+
# Galaxies ----------
|
|
268
|
+
DsoType.GALAXY: "G",
|
|
269
|
+
DsoType.GALAXY_PAIR: "GPair",
|
|
270
|
+
DsoType.GALAXY_TRIPLET: "GTrpl",
|
|
271
|
+
DsoType.GROUP_OF_GALAXIES: "GGroup",
|
|
272
|
+
# Nebulas ----------
|
|
273
|
+
DsoType.NEBULA: "Neb",
|
|
274
|
+
DsoType.PLANETARY_NEBULA: "PN",
|
|
275
|
+
DsoType.EMISSION_NEBULA: "EmN",
|
|
276
|
+
DsoType.STAR_CLUSTER_NEBULA: "Cl+N",
|
|
277
|
+
DsoType.REFLECTION_NEBULA: "RfN",
|
|
278
|
+
# Stars ----------
|
|
279
|
+
DsoType.STAR: "*",
|
|
280
|
+
DsoType.DOUBLE_STAR: "**",
|
|
281
|
+
DsoType.ASSOCIATION_OF_STARS: "*Ass",
|
|
282
|
+
# Others
|
|
283
|
+
DsoType.HII_IONIZED_REGION: "HII",
|
|
284
|
+
DsoType.DARK_NEBULA: "DrkN",
|
|
285
|
+
DsoType.SUPERNOVA_REMNANT: "SNR",
|
|
286
|
+
DsoType.NOVA_STAR: "Nova",
|
|
287
|
+
DsoType.NONEXISTENT: "NonEx",
|
|
288
|
+
DsoType.UNKNOWN: "Other",
|
|
289
|
+
DsoType.DUPLICATE_RECORD: "Dup",
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
ONGC_TYPE_MAP = {v: k for k, v in ONGC_TYPE.items()}
|
|
293
|
+
|
|
294
|
+
DSO_LEGEND_LABELS = {
|
|
295
|
+
# Galaxies ----------
|
|
296
|
+
DsoType.GALAXY: "Galaxy",
|
|
297
|
+
DsoType.GALAXY_PAIR: "Galaxy",
|
|
298
|
+
DsoType.GALAXY_TRIPLET: "Galaxy",
|
|
299
|
+
DsoType.GROUP_OF_GALAXIES: "Galaxy",
|
|
300
|
+
# Nebulas ----------
|
|
301
|
+
DsoType.NEBULA: "Nebula",
|
|
302
|
+
DsoType.PLANETARY_NEBULA: "Planetary Nebula",
|
|
303
|
+
DsoType.EMISSION_NEBULA: "Nebula",
|
|
304
|
+
DsoType.STAR_CLUSTER_NEBULA: "Nebula",
|
|
305
|
+
DsoType.REFLECTION_NEBULA: "Nebula",
|
|
306
|
+
DsoType.HII_IONIZED_REGION: "Nebula",
|
|
307
|
+
# Star Clusters ----------
|
|
308
|
+
DsoType.OPEN_CLUSTER: "Open Cluster",
|
|
309
|
+
DsoType.GLOBULAR_CLUSTER: "Globular Cluster",
|
|
310
|
+
# Stars ----------
|
|
311
|
+
DsoType.DOUBLE_STAR: "Double Star",
|
|
312
|
+
DsoType.ASSOCIATION_OF_STARS: "Association of stars",
|
|
313
|
+
DsoType.NOVA_STAR: "Nova Star",
|
|
314
|
+
# Others
|
|
315
|
+
DsoType.DARK_NEBULA: "Dark Nebula",
|
|
316
|
+
DsoType.SUPERNOVA_REMNANT: "Supernova Remnant",
|
|
317
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import pyarrow as pa
|
|
4
|
+
from shapely import Polygon, MultiPolygon
|
|
5
|
+
|
|
6
|
+
from starplot.models.base import SkyObject, CatalogObject
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True, kw_only=True)
|
|
10
|
+
class MilkyWay(CatalogObject, SkyObject):
|
|
11
|
+
"""
|
|
12
|
+
Milky Way model.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
geometry: Polygon | MultiPolygon
|
|
16
|
+
"""Shapely Polygon of the Milky Way's extent. Right ascension coordinates are in degrees (0...360)."""
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def _pyarrow_schema(cls):
|
|
20
|
+
base_schema = super(MilkyWay, cls)._pyarrow_schema()
|
|
21
|
+
extra_fields = [
|
|
22
|
+
pa.field("pk", pa.int64(), nullable=False),
|
|
23
|
+
pa.field("geometry", pa.binary(), nullable=False),
|
|
24
|
+
]
|
|
25
|
+
return pa.schema(list(base_schema) + extra_fields)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def from_tuple(d: tuple) -> MilkyWay:
|
|
29
|
+
kwargs = {f: getattr(d, f) for f in MilkyWay._fields() if hasattr(d, f)}
|
|
30
|
+
return MilkyWay(**kwargs)
|
starplot/models/moon.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from shapely import Polygon
|
|
7
|
+
from skyfield.api import Angle
|
|
8
|
+
from skyfield import almanac
|
|
9
|
+
|
|
10
|
+
from starplot.data import load
|
|
11
|
+
from starplot.models.observer import Observer
|
|
12
|
+
from starplot.models.base import SkyObject
|
|
13
|
+
from starplot.geometry import circle
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MoonPhase(str, Enum):
|
|
17
|
+
"""Phases of Earth's moon"""
|
|
18
|
+
|
|
19
|
+
NEW_MOON = "New Moon"
|
|
20
|
+
WAXING_CRESCENT = "Waxing Crescent"
|
|
21
|
+
FIRST_QUARTER = "First Quarter"
|
|
22
|
+
WAXING_GIBBOUS = "Waxing Gibbous"
|
|
23
|
+
FULL_MOON = "Full Moon"
|
|
24
|
+
WANING_GIBBOUS = "Waning Gibbous"
|
|
25
|
+
LAST_QUARTER = "Last Quarter"
|
|
26
|
+
WANING_CRESCENT = "Waning Crescent"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True, kw_only=True)
|
|
30
|
+
class Moon(SkyObject):
|
|
31
|
+
"""Moon model. Only used for Earth's moon right now, but will potentially represent other planets' moons in future versions."""
|
|
32
|
+
|
|
33
|
+
name: str = "Moon"
|
|
34
|
+
"""Name of the moon"""
|
|
35
|
+
|
|
36
|
+
dt: datetime = None
|
|
37
|
+
"""Date/time of moon's position"""
|
|
38
|
+
|
|
39
|
+
apparent_size: float
|
|
40
|
+
"""Apparent diameter in the sky (degrees)"""
|
|
41
|
+
|
|
42
|
+
phase_angle: float
|
|
43
|
+
"""Angle of the moon from the Sun (degrees)"""
|
|
44
|
+
|
|
45
|
+
phase_description: str
|
|
46
|
+
"""Description of the moon's phase. The Moon will be considered New/Full/Quarter if it's within 12 hours of that precise phase."""
|
|
47
|
+
|
|
48
|
+
illumination: float
|
|
49
|
+
"""Percent of illumination (0 to 1)"""
|
|
50
|
+
|
|
51
|
+
geometry: Polygon = None
|
|
52
|
+
"""Shapely Polygon of the moon's extent. Right ascension coordinates are in degrees (0 to 360)."""
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def get(
|
|
56
|
+
cls,
|
|
57
|
+
observer: Observer = None,
|
|
58
|
+
ephemeris: str = "de421.bsp",
|
|
59
|
+
) -> "Moon":
|
|
60
|
+
"""
|
|
61
|
+
Get the Moon for a specific date/time and observing location.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
observer: Observer instance that specifies a time and location
|
|
65
|
+
ephemeris: Ephemeris to use for calculating moon positions (see [Skyfield's documentation](https://rhodesmill.org/skyfield/planets.html) for details)
|
|
66
|
+
"""
|
|
67
|
+
RADIUS_KM = 1_740
|
|
68
|
+
|
|
69
|
+
observer = observer or Observer(lat=None, lon=None)
|
|
70
|
+
timescale = observer.timescale
|
|
71
|
+
|
|
72
|
+
eph = load(ephemeris)
|
|
73
|
+
moon = eph["moon"]
|
|
74
|
+
|
|
75
|
+
ra, dec, distance = observer._astrometric(moon, ephemeris=ephemeris)
|
|
76
|
+
|
|
77
|
+
apparent_diameter_degrees = Angle(
|
|
78
|
+
radians=np.arcsin(RADIUS_KM / distance.km) * 2.0
|
|
79
|
+
).degrees
|
|
80
|
+
|
|
81
|
+
phase_angle = almanac.moon_phase(eph, timescale).degrees
|
|
82
|
+
|
|
83
|
+
if phase_angle <= 180:
|
|
84
|
+
illumination = phase_angle / 180
|
|
85
|
+
else:
|
|
86
|
+
illumination = 2 - (phase_angle / 180)
|
|
87
|
+
|
|
88
|
+
# phase angle 12 hours BEFORE dt
|
|
89
|
+
phase_angle_0 = almanac.moon_phase(eph, timescale - timedelta(hours=12)).degrees
|
|
90
|
+
|
|
91
|
+
# phase angle 12 hours AFTER dt
|
|
92
|
+
phase_angle_1 = almanac.moon_phase(eph, timescale + timedelta(hours=12)).degrees
|
|
93
|
+
|
|
94
|
+
phase = None
|
|
95
|
+
|
|
96
|
+
if phase_angle_1 < phase_angle_0:
|
|
97
|
+
phase = MoonPhase.NEW_MOON
|
|
98
|
+
|
|
99
|
+
elif phase_angle_0 < 90 < phase_angle_1:
|
|
100
|
+
phase = MoonPhase.FIRST_QUARTER
|
|
101
|
+
|
|
102
|
+
elif phase_angle_0 < 180 < phase_angle_1:
|
|
103
|
+
phase = MoonPhase.FULL_MOON
|
|
104
|
+
|
|
105
|
+
elif phase_angle_0 < 270 < phase_angle_1:
|
|
106
|
+
phase = MoonPhase.LAST_QUARTER
|
|
107
|
+
|
|
108
|
+
elif 0 < phase_angle < 90:
|
|
109
|
+
phase = MoonPhase.WAXING_CRESCENT
|
|
110
|
+
|
|
111
|
+
elif 90 < phase_angle < 180:
|
|
112
|
+
phase = MoonPhase.WAXING_GIBBOUS
|
|
113
|
+
|
|
114
|
+
elif 180 < phase_angle < 270:
|
|
115
|
+
phase = MoonPhase.WANING_GIBBOUS
|
|
116
|
+
|
|
117
|
+
elif 270 < phase_angle < 360:
|
|
118
|
+
phase = MoonPhase.WANING_CRESCENT
|
|
119
|
+
|
|
120
|
+
return Moon(
|
|
121
|
+
ra=ra.hours * 15,
|
|
122
|
+
dec=dec.degrees,
|
|
123
|
+
name="Moon",
|
|
124
|
+
dt=observer.dt,
|
|
125
|
+
apparent_size=apparent_diameter_degrees,
|
|
126
|
+
phase_angle=phase_angle,
|
|
127
|
+
phase_description=phase.value,
|
|
128
|
+
illumination=illumination,
|
|
129
|
+
geometry=circle((ra.hours * 15, dec.degrees), apparent_diameter_degrees),
|
|
130
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from starplot.models import Star, DSO, Moon, Sun, Planet, Constellation
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ObjectList(object):
|
|
5
|
+
"""Lists of objects that have been plotted. An instance of this model is returned by a plot's `objects` property."""
|
|
6
|
+
|
|
7
|
+
stars: list[Star] = None
|
|
8
|
+
"""Stars"""
|
|
9
|
+
|
|
10
|
+
constellations: list[Constellation] = None
|
|
11
|
+
"""Constellations"""
|
|
12
|
+
|
|
13
|
+
dsos: list[DSO] = None
|
|
14
|
+
"""Deep Sky Objects (DSOs)"""
|
|
15
|
+
|
|
16
|
+
planets: list[Planet] = None
|
|
17
|
+
"""Planets"""
|
|
18
|
+
|
|
19
|
+
moon: Moon = None
|
|
20
|
+
"""Moon"""
|
|
21
|
+
|
|
22
|
+
sun: Sun = None
|
|
23
|
+
"""Sun"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
26
|
+
self.stars = []
|
|
27
|
+
self.dsos = []
|
|
28
|
+
self.planets = []
|
|
29
|
+
self.constellations = []
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from functools import cached_property, cache
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, AwareDatetime, Field, computed_field
|
|
7
|
+
from skyfield.timelib import Timescale
|
|
8
|
+
from skyfield.api import wgs84, Star as SkyfieldStar
|
|
9
|
+
|
|
10
|
+
from starplot.data import load
|
|
11
|
+
|
|
12
|
+
ts = load.timescale()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Observer(BaseModel):
|
|
16
|
+
"""
|
|
17
|
+
Represents an observer at a specific time and place.
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
obs = Observer(
|
|
23
|
+
dt=datetime(2025, 10, 13, 21, 0, 0, tzinfo=ZoneInfo('US/Pacific')),
|
|
24
|
+
lat=33.363484,
|
|
25
|
+
lon=-116.836394,
|
|
26
|
+
)
|
|
27
|
+
```
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
dt: AwareDatetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
31
|
+
"""
|
|
32
|
+
Date and time of observation (**must be timezone-aware**).
|
|
33
|
+
|
|
34
|
+
Defaults to current time in UTC.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
lat: float | None = Field(default=0, ge=-90, le=90)
|
|
38
|
+
"""Latitude of observer location"""
|
|
39
|
+
|
|
40
|
+
lon: float | None = Field(default=0, ge=-180, le=180)
|
|
41
|
+
"""Longitude of observer location"""
|
|
42
|
+
|
|
43
|
+
elevation: float = 0
|
|
44
|
+
"""Elevation of observer, in meters"""
|
|
45
|
+
|
|
46
|
+
temperature: float = 10
|
|
47
|
+
"""Temperature in degrees Celsius. This is only used for determining atmospheric refraction for apparent positions."""
|
|
48
|
+
|
|
49
|
+
pressure: float | None = None
|
|
50
|
+
"""Atmospheric pressure in millibars. If `None`, then it'll be estimated based on the observer's elevation. This is only used for determining atmospheric refraction for apparent positions."""
|
|
51
|
+
|
|
52
|
+
class Config:
|
|
53
|
+
frozen = True
|
|
54
|
+
arbitrary_types_allowed = True
|
|
55
|
+
|
|
56
|
+
@computed_field
|
|
57
|
+
@cached_property
|
|
58
|
+
def timescale(self) -> Timescale:
|
|
59
|
+
"""
|
|
60
|
+
**Read-only Property**
|
|
61
|
+
|
|
62
|
+
Timescale instance of the specified datetime (used by Skyfield)
|
|
63
|
+
"""
|
|
64
|
+
return ts.from_datetime(self.dt)
|
|
65
|
+
|
|
66
|
+
@computed_field
|
|
67
|
+
@cached_property
|
|
68
|
+
def lst(self) -> float:
|
|
69
|
+
"""
|
|
70
|
+
**Read-only Property**
|
|
71
|
+
|
|
72
|
+
Local sidereal time (in degrees)
|
|
73
|
+
"""
|
|
74
|
+
return float(360.0 * self.timescale.gmst / 24.0 + self.lon) % 360.0
|
|
75
|
+
|
|
76
|
+
@computed_field
|
|
77
|
+
@cached_property
|
|
78
|
+
def has_location(self) -> bool:
|
|
79
|
+
return self.lat is not None and self.lon is not None
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def at_epoch(cls, epoch: float) -> "Observer":
|
|
83
|
+
"""
|
|
84
|
+
Returns an Observer for the specified epoch (Julian year)
|
|
85
|
+
"""
|
|
86
|
+
return Observer(dt=ts.J(epoch).utc_datetime())
|
|
87
|
+
|
|
88
|
+
@cache
|
|
89
|
+
def position(self, ephemeris: str = "de421.bsp"):
|
|
90
|
+
"""
|
|
91
|
+
Returns a Skyfield position for this observer.
|
|
92
|
+
|
|
93
|
+
If the observer has no lat/lon, then the Earth's center will be returned.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
ephemeris: Ephemeris to use
|
|
97
|
+
"""
|
|
98
|
+
eph = load(ephemeris)
|
|
99
|
+
earth = eph["earth"]
|
|
100
|
+
|
|
101
|
+
if self.lat is None and self.lon is None:
|
|
102
|
+
return earth
|
|
103
|
+
|
|
104
|
+
return earth + wgs84.latlon(self.lat, self.lon, self.elevation)
|
|
105
|
+
|
|
106
|
+
@cache
|
|
107
|
+
def observe(self, ephemeris: str = "de421.bsp") -> Callable:
|
|
108
|
+
return self.position(ephemeris).at(self.timescale).observe
|
|
109
|
+
|
|
110
|
+
def _astrometric(self, obj: SkyfieldStar, ephemeris: str = "de421.bsp"):
|
|
111
|
+
ra, dec, distance = self.observe(ephemeris)(obj).radec()
|
|
112
|
+
return ra, dec, distance
|
|
113
|
+
|
|
114
|
+
def _apparent(self, obj: SkyfieldStar, ephemeris: str = "de421.bsp"):
|
|
115
|
+
"""Returns apparent AZ, ALT of object"""
|
|
116
|
+
pressure_mbar = self.pressure if self.pressure is not None else "standard"
|
|
117
|
+
pos_alt, pos_az, _ = (
|
|
118
|
+
self.observe(ephemeris)(obj)
|
|
119
|
+
.apparent()
|
|
120
|
+
.altaz(
|
|
121
|
+
temperature_C=self.temperature,
|
|
122
|
+
pressure_mbar=pressure_mbar,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return pos_az.degrees, pos_alt.degrees
|