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.
Files changed (98) hide show
  1. starplot/__init__.py +59 -0
  2. starplot/callables.py +176 -0
  3. starplot/cli.py +29 -0
  4. starplot/config.py +80 -0
  5. starplot/coordinates.py +7 -0
  6. starplot/data/__init__.py +22 -0
  7. starplot/data/catalogs.py +356 -0
  8. starplot/data/constellations.py +71 -0
  9. starplot/data/db.py +33 -0
  10. starplot/data/dsos.py +85 -0
  11. starplot/data/ecliptic.py +201 -0
  12. starplot/data/library/constellation_names.parquet +0 -0
  13. starplot/data/library/dso_names.parquet +0 -0
  14. starplot/data/library/readme.md +1 -0
  15. starplot/data/library/star_designations.parquet +0 -0
  16. starplot/data/stars.py +79 -0
  17. starplot/data/translations.py +408 -0
  18. starplot/data/utils.py +35 -0
  19. starplot/geometry.py +319 -0
  20. starplot/mixins.py +360 -0
  21. starplot/models/__init__.py +21 -0
  22. starplot/models/base.py +77 -0
  23. starplot/models/comet.py +302 -0
  24. starplot/models/constellation.py +151 -0
  25. starplot/models/dso.py +317 -0
  26. starplot/models/milky_way.py +30 -0
  27. starplot/models/moon.py +130 -0
  28. starplot/models/objects.py +29 -0
  29. starplot/models/observer.py +125 -0
  30. starplot/models/optics.py +342 -0
  31. starplot/models/planet.py +137 -0
  32. starplot/models/satellite.py +138 -0
  33. starplot/models/star.py +253 -0
  34. starplot/models/sun.py +62 -0
  35. starplot/plots/__init__.py +7 -0
  36. starplot/plots/base.py +1023 -0
  37. starplot/plots/galaxy.py +372 -0
  38. starplot/plots/horizon.py +543 -0
  39. starplot/plots/map.py +515 -0
  40. starplot/plots/optic.py +469 -0
  41. starplot/plots/zenith.py +217 -0
  42. starplot/plotters/__init__.py +9 -0
  43. starplot/plotters/arrow.py +174 -0
  44. starplot/plotters/constellations.py +298 -0
  45. starplot/plotters/debug.py +21 -0
  46. starplot/plotters/dsos.py +294 -0
  47. starplot/plotters/experimental.py +722 -0
  48. starplot/plotters/gradients.py +153 -0
  49. starplot/plotters/legend.py +253 -0
  50. starplot/plotters/milkyway.py +51 -0
  51. starplot/plotters/stars.py +319 -0
  52. starplot/plotters/text.py +802 -0
  53. starplot/profile.py +16 -0
  54. starplot/projections.py +184 -0
  55. starplot/styles/__init__.py +6 -0
  56. starplot/styles/base.py +1344 -0
  57. starplot/styles/ext/antique.yml +175 -0
  58. starplot/styles/ext/blue_dark.yml +163 -0
  59. starplot/styles/ext/blue_gold.yml +147 -0
  60. starplot/styles/ext/blue_light.yml +123 -0
  61. starplot/styles/ext/blue_medium.yml +142 -0
  62. starplot/styles/ext/blue_night.yml +185 -0
  63. starplot/styles/ext/cb_wong.yml +124 -0
  64. starplot/styles/ext/color_print.yml +111 -0
  65. starplot/styles/ext/gradient_presets.yml +158 -0
  66. starplot/styles/ext/grayscale.yml +94 -0
  67. starplot/styles/ext/grayscale_dark.yml +136 -0
  68. starplot/styles/ext/map.yml +12 -0
  69. starplot/styles/ext/nord.yml +158 -0
  70. starplot/styles/ext/optic.yml +20 -0
  71. starplot/styles/ext/publication.yml +8 -0
  72. starplot/styles/extensions.py +129 -0
  73. starplot/styles/fonts-library/gfs-didot/DESCRIPTION.en_us.html +9 -0
  74. starplot/styles/fonts-library/gfs-didot/GFSDidot-Regular.ttf +0 -0
  75. starplot/styles/fonts-library/gfs-didot/METADATA.pb +16 -0
  76. starplot/styles/fonts-library/gfs-didot/OFL.txt +94 -0
  77. starplot/styles/fonts-library/inter/Inter-Bold.ttf +0 -0
  78. starplot/styles/fonts-library/inter/Inter-BoldItalic.ttf +0 -0
  79. starplot/styles/fonts-library/inter/Inter-ExtraBold.ttf +0 -0
  80. starplot/styles/fonts-library/inter/Inter-ExtraLight.ttf +0 -0
  81. starplot/styles/fonts-library/inter/Inter-ExtraLightItalic.ttf +0 -0
  82. starplot/styles/fonts-library/inter/Inter-Italic.ttf +0 -0
  83. starplot/styles/fonts-library/inter/Inter-Light.ttf +0 -0
  84. starplot/styles/fonts-library/inter/Inter-LightItalic.ttf +0 -0
  85. starplot/styles/fonts-library/inter/Inter-Regular.ttf +0 -0
  86. starplot/styles/fonts-library/inter/Inter-SemiBold.ttf +0 -0
  87. starplot/styles/fonts-library/inter/Inter-SemiBoldItalic.ttf +0 -0
  88. starplot/styles/fonts-library/inter/LICENSE.txt +92 -0
  89. starplot/styles/fonts.py +15 -0
  90. starplot/styles/helpers.py +93 -0
  91. starplot/styles/markers.py +308 -0
  92. starplot/utils.py +169 -0
  93. starplot/warnings.py +21 -0
  94. starplot-0.20.5.dist-info/METADATA +146 -0
  95. starplot-0.20.5.dist-info/RECORD +98 -0
  96. starplot-0.20.5.dist-info/WHEEL +4 -0
  97. starplot-0.20.5.dist-info/entry_points.txt +3 -0
  98. 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)
@@ -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