starplot 0.15.8__py2.py3-none-any.whl → 0.16.1__py2.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 (43) hide show
  1. starplot/__init__.py +7 -2
  2. starplot/base.py +57 -60
  3. starplot/cli.py +3 -3
  4. starplot/config.py +56 -0
  5. starplot/data/__init__.py +5 -5
  6. starplot/data/bigsky.py +3 -3
  7. starplot/data/db.py +2 -2
  8. starplot/data/library/sky.db +0 -0
  9. starplot/geometry.py +48 -0
  10. starplot/horizon.py +194 -90
  11. starplot/map.py +71 -168
  12. starplot/mixins.py +0 -55
  13. starplot/models/dso.py +10 -2
  14. starplot/observer.py +71 -0
  15. starplot/optic.py +61 -26
  16. starplot/plotters/__init__.py +2 -0
  17. starplot/plotters/constellations.py +4 -6
  18. starplot/plotters/dsos.py +3 -2
  19. starplot/plotters/gradients.py +153 -0
  20. starplot/plotters/legend.py +247 -0
  21. starplot/plotters/milkyway.py +8 -5
  22. starplot/plotters/stars.py +5 -3
  23. starplot/projections.py +155 -55
  24. starplot/styles/base.py +98 -22
  25. starplot/styles/ext/antique.yml +0 -1
  26. starplot/styles/ext/blue_dark.yml +0 -1
  27. starplot/styles/ext/blue_gold.yml +60 -52
  28. starplot/styles/ext/blue_light.yml +0 -1
  29. starplot/styles/ext/blue_medium.yml +7 -7
  30. starplot/styles/ext/blue_night.yml +178 -0
  31. starplot/styles/ext/cb_wong.yml +0 -1
  32. starplot/styles/ext/gradient_presets.yml +158 -0
  33. starplot/styles/ext/grayscale.yml +0 -1
  34. starplot/styles/ext/grayscale_dark.yml +0 -1
  35. starplot/styles/ext/nord.yml +0 -1
  36. starplot/styles/extensions.py +90 -0
  37. starplot/zenith.py +174 -0
  38. {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/METADATA +18 -11
  39. {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/RECORD +42 -36
  40. starplot/settings.py +0 -26
  41. {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/WHEEL +0 -0
  42. {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/entry_points.txt +0 -0
  43. {starplot-0.15.8.dist-info → starplot-0.16.1.dist-info}/licenses/LICENSE +0 -0
starplot/map.py CHANGED
@@ -1,11 +1,10 @@
1
- import datetime
2
1
  import math
3
2
  from typing import Callable
4
3
  from functools import cache
5
4
 
6
5
  from cartopy import crs as ccrs
7
6
  from matplotlib import pyplot as plt
8
- from matplotlib import path, patches, ticker
7
+ from matplotlib import patches, ticker
9
8
  from matplotlib.ticker import FuncFormatter, FixedLocator
10
9
  from shapely import Polygon
11
10
  from skyfield.api import wgs84
@@ -15,18 +14,21 @@ from starplot.coordinates import CoordinateSystem
15
14
  from starplot import geod
16
15
  from starplot.base import BasePlot, DPI
17
16
  from starplot.mixins import ExtentMaskMixin
17
+ from starplot.observer import Observer
18
18
  from starplot.plotters import (
19
19
  ConstellationPlotterMixin,
20
20
  StarPlotterMixin,
21
21
  DsoPlotterMixin,
22
22
  MilkyWayPlotterMixin,
23
+ LegendPlotterMixin,
24
+ GradientBackgroundMixin,
23
25
  )
24
- from starplot.projections import Projection
26
+ from starplot.projections import StereoNorth, StereoSouth, ProjectionBase
25
27
  from starplot.styles import (
26
28
  ObjectStyle,
27
- LabelStyle,
28
29
  PlotStyle,
29
30
  PathStyle,
31
+ GradientDirection,
30
32
  )
31
33
  from starplot.styles.helpers import use_style
32
34
  from starplot.utils import lon_to_ra, ra_to_lon
@@ -42,21 +44,18 @@ class MapPlot(
42
44
  DsoPlotterMixin,
43
45
  MilkyWayPlotterMixin,
44
46
  ConstellationPlotterMixin,
47
+ LegendPlotterMixin,
48
+ GradientBackgroundMixin,
45
49
  ):
46
50
  """Creates a new map plot.
47
51
 
48
- !!! star "Note"
49
- **`lat`, `lon`, and `dt` are required for perspective projections (`Orthographic`, `Stereographic`, and `Zenith`)**
50
-
51
52
  Args:
52
- projection: Projection of the map
53
+ projection: [Projection](/reference-mapplot/#projections) of the map
53
54
  ra_min: Minimum right ascension of the map's extent, in degrees (0...360)
54
55
  ra_max: Maximum right ascension of the map's extent, in degrees (0...360)
55
56
  dec_min: Minimum declination of the map's extent, in degrees (-90...90)
56
57
  dec_max: Maximum declination of the map's extent, in degrees (-90...90)
57
- lat: Latitude for perspective projections: Orthographic, Stereographic, and Zenith
58
- lon: Longitude for perspective projections: Orthographic, Stereographic, and Zenith
59
- dt: Date/time to use for star/planet positions, (*must be timezone-aware*). Default = current UTC time.
58
+ observer: Observer instance which specifies a time and place
60
59
  ephemeris: Ephemeris to use for calculating planet positions (see [Skyfield's documentation](https://rhodesmill.org/skyfield/planets.html) for details)
61
60
  style: Styling for the plot (colors, sizes, fonts, etc)
62
61
  resolution: Size (in pixels) of largest dimension of the map
@@ -72,17 +71,16 @@ class MapPlot(
72
71
  """
73
72
 
74
73
  _coordinate_system = CoordinateSystem.RA_DEC
74
+ _gradient_direction = GradientDirection.LINEAR
75
75
 
76
76
  def __init__(
77
77
  self,
78
- projection: Projection,
78
+ projection: ProjectionBase,
79
79
  ra_min: float = 0,
80
80
  ra_max: float = 360,
81
81
  dec_min: float = -90,
82
82
  dec_max: float = 90,
83
- lat: float = None,
84
- lon: float = None,
85
- dt: datetime = None,
83
+ observer: Observer = Observer(),
86
84
  ephemeris: str = "de421_2001.bsp",
87
85
  style: PlotStyle = DEFAULT_MAP_STYLE,
88
86
  resolution: int = 4096,
@@ -95,7 +93,7 @@ class MapPlot(
95
93
  **kwargs,
96
94
  ) -> "MapPlot":
97
95
  super().__init__(
98
- dt,
96
+ observer,
99
97
  ephemeris,
100
98
  style,
101
99
  resolution,
@@ -120,24 +118,8 @@ class MapPlot(
120
118
  self.ra_max = ra_max
121
119
  self.dec_min = dec_min
122
120
  self.dec_max = dec_max
123
- self.lat = lat
124
- self.lon = lon
125
121
  self.clip_path = clip_path
126
122
 
127
- if self.projection in [
128
- Projection.ORTHOGRAPHIC,
129
- Projection.STEREOGRAPHIC,
130
- Projection.ZENITH,
131
- ] and (lat is None or lon is None):
132
- raise ValueError(
133
- f"lat and lon are required for the {self.projection.value.upper()} projection"
134
- )
135
-
136
- if self.projection == Projection.ZENITH and not self._is_global_extent():
137
- raise ValueError(
138
- "Zenith projection requires a global extent: ra_min=0, ra_max=360, dec_min=-90, dec_max=90"
139
- )
140
-
141
123
  self._geodetic = ccrs.Geodetic()
142
124
  self._plate_carree = ccrs.PlateCarree()
143
125
  self._crs = ccrs.CRS(
@@ -192,9 +174,10 @@ class MapPlot(
192
174
  self.dec_max = extent[3]
193
175
 
194
176
  # adjust the RA min/max if the DEC bounds is near the poles
195
- if self.projection in [Projection.STEREO_NORTH, Projection.STEREO_SOUTH] and (
196
- self.dec_max > 80 or self.dec_min < -80
197
- ):
177
+ if (
178
+ isinstance(self.projection, StereoNorth)
179
+ or isinstance(self.projection, StereoSouth)
180
+ ) and (self.dec_max > 80 or self.dec_min < -80):
198
181
  self.ra_min = 0
199
182
  self.ra_max = 360
200
183
 
@@ -233,11 +216,13 @@ class MapPlot(
233
216
  label: Label for the zenith
234
217
  legend_label: Label in the legend
235
218
  """
236
- if self.lat is None or self.lon is None or self.dt is None:
237
- raise ValueError("lat, lon, and dt are required for plotting the zenith")
219
+ if self.observer is None:
220
+ raise ValueError("observer is required for plotting the zenith")
238
221
 
239
- geographic = wgs84.latlon(latitude_degrees=self.lat, longitude_degrees=self.lon)
240
- observer = geographic.at(self.timescale)
222
+ geographic = wgs84.latlon(
223
+ latitude_degrees=self.observer.lat, longitude_degrees=self.observer.lon
224
+ )
225
+ observer = geographic.at(self.observer.timescale)
241
226
  zenith = observer.from_altaz(alt_degrees=90, az_degrees=0)
242
227
  ra, dec, _ = zenith.radec()
243
228
 
@@ -262,11 +247,13 @@ class MapPlot(
262
247
  style: Style of the horizon path. If None, then the plot's style definition will be used.
263
248
  labels: List of labels for cardinal directions. **NOTE: labels should be in the order: North, East, South, West.**
264
249
  """
265
- if self.lat is None or self.lon is None or self.dt is None:
266
- raise ValueError("lat, lon, and dt are required for plotting the horizon")
250
+ if self.observer is None:
251
+ raise ValueError("observer is required for plotting the horizon")
267
252
 
268
- geographic = wgs84.latlon(latitude_degrees=self.lat, longitude_degrees=self.lon)
269
- observer = geographic.at(self.timescale)
253
+ geographic = wgs84.latlon(
254
+ latitude_degrees=self.observer.lat, longitude_degrees=self.observer.lon
255
+ )
256
+ observer = geographic.at(self.observer.timescale)
270
257
  zenith = observer.from_altaz(alt_degrees=90, az_degrees=0)
271
258
  ra, dec, _ = zenith.radec()
272
259
 
@@ -285,58 +272,16 @@ class MapPlot(
285
272
  y.append(y0)
286
273
 
287
274
  style_kwargs = {}
288
- if self.projection == Projection.ZENITH:
289
- """
290
- For zenith projections, we plot the horizon as a patch to make a more perfect circle
291
- """
292
- style_kwargs = style.line.matplot_kwargs(self.scale)
293
- style_kwargs["clip_on"] = False
294
- style_kwargs["edgecolor"] = style_kwargs.pop("color")
295
- patch = patches.Circle(
296
- (0.50, 0.50),
297
- radius=0.454,
298
- facecolor=None,
299
- fill=False,
300
- transform=self.ax.transAxes,
301
- **style_kwargs,
302
- )
303
- self.ax.add_patch(patch)
304
- self._background_clip_path = patch
305
- self._update_clip_path_polygon(
306
- buffer=style.line.width / 2 + 2 * style.line.edge_width + 20
307
- )
308
-
309
- if not labels:
310
- return
311
-
312
- label_ax_coords = [
313
- (0.5, 0.95), # north
314
- (0.045, 0.5), # east
315
- (0.5, 0.045), # south
316
- (0.954, 0.5), # west
317
- ]
318
- for label, coords in zip(labels, label_ax_coords):
319
- self.ax.annotate(
320
- label,
321
- coords,
322
- xycoords=self.ax.transAxes,
323
- clip_on=False,
324
- **style.label.matplot_kwargs(self.scale),
325
- )
326
-
327
- return
328
-
329
- else:
330
- style_kwargs["clip_on"] = True
331
- style_kwargs["clip_path"] = self._background_clip_path
332
- self.ax.plot(
333
- x,
334
- y,
335
- dash_capstyle=style.line.dash_capstyle,
336
- **style.line.matplot_kwargs(self.scale),
337
- **style_kwargs,
338
- **self._plot_kwargs(),
339
- )
275
+ style_kwargs["clip_on"] = True
276
+ style_kwargs["clip_path"] = self._background_clip_path
277
+ self.ax.plot(
278
+ x,
279
+ y,
280
+ dash_capstyle=style.line.dash_capstyle,
281
+ **style.line.matplot_kwargs(self.scale),
282
+ **style_kwargs,
283
+ **self._plot_kwargs(),
284
+ )
340
285
 
341
286
  if not labels:
342
287
  return
@@ -420,6 +365,7 @@ class MapPlot(
420
365
  gid="gridlines",
421
366
  **line_style_kwargs,
422
367
  )
368
+ gridlines.set_zorder(style.line.zorder)
423
369
 
424
370
  if labels:
425
371
  self._axis_labels = True
@@ -482,6 +428,20 @@ class MapPlot(
482
428
  width, height = bbox.width, bbox.height
483
429
  self.fig.set_size_inches(width, height)
484
430
 
431
+ def _set_extent(self):
432
+ bounds = self._latlon_bounds()
433
+ # (bounds[2] + bounds[3]) / 2
434
+ # center_lon = (bounds[0] + bounds[1]) / 2
435
+
436
+ # if hasattr(self.projection, "center_ra"):
437
+ # self.projection.center_ra = -1 * center_lon
438
+
439
+ if self._is_global_extent():
440
+ # this cartopy function works better for setting global extents
441
+ self.ax.set_global()
442
+ else:
443
+ self.ax.set_extent(bounds, crs=self._plate_carree)
444
+
485
445
  def _init_plot(self):
486
446
  self.fig = plt.figure(
487
447
  figsize=(self.figure_size, self.figure_size),
@@ -489,74 +449,21 @@ class MapPlot(
489
449
  layout="constrained",
490
450
  dpi=DPI,
491
451
  )
492
- bounds = self._latlon_bounds()
493
- center_lat = (bounds[2] + bounds[3]) / 2
494
- center_lon = (bounds[0] + bounds[1]) / 2
495
- self._center_lat = center_lat
496
- self._center_lon = center_lon
497
-
498
- if self.projection in [
499
- Projection.ORTHOGRAPHIC,
500
- Projection.STEREOGRAPHIC,
501
- Projection.ZENITH,
502
- ]:
503
- # Calculate local sidereal time (LST) to shift RA DEC to be in line with current date and time
504
- lst = -(360.0 * self.timescale.gmst / 24.0 + self.lon) % 360.0
505
- self._proj = Projection.crs(self.projection, lon=lst, lat=self.lat)
506
- elif self.projection == Projection.LAMBERT_AZ_EQ_AREA:
507
- self._proj = Projection.crs(
508
- self.projection, center_lat=center_lat, center_lon=center_lon
509
- )
510
- else:
511
- self._proj = Projection.crs(self.projection, center_lon)
512
- self._proj.threshold = 1000
513
- self.ax = plt.axes(projection=self._proj)
514
452
 
515
- if self._is_global_extent():
516
- if self.projection == Projection.ZENITH:
517
- theta = np.linspace(0, 2 * np.pi, 100)
518
- center, radius = [0.5, 0.5], 0.45
519
- verts = np.vstack([np.sin(theta), np.cos(theta)]).T
520
- circle = path.Path(verts * radius + center)
521
- extent = self.ax.get_extent(crs=self._proj)
522
- self.ax.set_extent((p / 3.548 for p in extent), crs=self._proj)
523
- self.ax.set_boundary(circle, transform=self.ax.transAxes)
524
- else:
525
- # this cartopy function works better for setting global extents
526
- self.ax.set_global()
527
- else:
528
- self.ax.set_extent(bounds, crs=self._plate_carree)
453
+ self._proj = self.projection.crs
454
+ self.ax = self.fig.add_subplot(1, 1, 1, projection=self._proj)
529
455
 
530
- self.ax.set_facecolor(self.style.background_color.as_hex())
456
+ self._set_extent()
531
457
  self._adjust_radec_minmax()
532
458
 
533
- self.logger.debug(f"Projection = {self.projection.value.upper()}")
459
+ self.logger.debug(f"Projection = {self.projection.__class__.__name__.upper()}")
534
460
 
535
461
  self._fit_to_ax()
536
- self._plot_background_clip_path()
537
-
538
- @use_style(LabelStyle, "info_text")
539
- def info(self, style: LabelStyle = None):
540
- """
541
- Plots info text in the lower left corner, including date/time and lat/lon.
542
462
 
543
- _Only available for ZENITH projections_
463
+ # if self.gradient_preset:
464
+ # self.apply_gradient_background(self.gradient_preset)
544
465
 
545
- Args:
546
- style: Styling of the info text. If None, then the plot's style definition will be used.
547
- """
548
- if not self.projection == Projection.ZENITH:
549
- raise NotImplementedError("info text only available for zenith projections")
550
-
551
- dt_str = self.dt.strftime("%m/%d/%Y @ %H:%M:%S") + " " + self.dt.tzname()
552
- info = f"{str(self.lat)}, {str(self.lon)}\n{dt_str}"
553
- self.ax.text(
554
- 0.05,
555
- 0.05,
556
- info,
557
- transform=self.ax.transAxes,
558
- **style.matplot_kwargs(self.scale),
559
- )
466
+ self._plot_background_clip_path()
560
467
 
561
468
  def _ax_to_radec(self, x, y):
562
469
  trans = self.ax.transAxes + self.ax.transData.inverted()
@@ -565,6 +472,12 @@ class MapPlot(
565
472
  return (x_ra + 360), y_ra
566
473
 
567
474
  def _plot_background_clip_path(self):
475
+ if self.style.has_gradient_background():
476
+ background_color = "#ffffff00"
477
+ self._plot_gradient_background(self.style.background_color)
478
+ else:
479
+ background_color = self.style.background_color.as_hex()
480
+
568
481
  def to_axes(points):
569
482
  ax_points = []
570
483
 
@@ -579,22 +492,11 @@ class MapPlot(
579
492
  points = list(zip(*self.clip_path.exterior.coords.xy))
580
493
  self._background_clip_path = patches.Polygon(
581
494
  to_axes(points),
582
- facecolor=self.style.background_color.as_hex(),
495
+ facecolor=background_color,
583
496
  fill=True,
584
497
  zorder=-2_000,
585
498
  transform=self.ax.transAxes,
586
499
  )
587
- elif self.projection == Projection.ZENITH:
588
- self._background_clip_path = patches.Circle(
589
- (0.50, 0.50),
590
- radius=0.45,
591
- fill=True,
592
- facecolor=self.style.background_color.as_hex(),
593
- # edgecolor=self.style.border_line_color.as_hex(),
594
- linewidth=0,
595
- zorder=-2_000,
596
- transform=self.ax.transAxes,
597
- )
598
500
  else:
599
501
  # draw patch in axes coords, which are easier to work with
600
502
  # in cases like this cause they go from 0...1 in all plots
@@ -602,12 +504,13 @@ class MapPlot(
602
504
  (0, 0),
603
505
  width=1,
604
506
  height=1,
605
- facecolor=self.style.background_color.as_hex(),
507
+ facecolor=background_color,
606
508
  linewidth=0,
607
509
  fill=True,
608
510
  zorder=-2_000,
609
511
  transform=self.ax.transAxes,
610
512
  )
611
513
 
514
+ self.ax.set_facecolor(background_color)
612
515
  self.ax.add_patch(self._background_clip_path)
613
516
  self._update_clip_path_polygon()
starplot/mixins.py CHANGED
@@ -44,61 +44,6 @@ class ExtentMaskMixin:
44
44
  ]
45
45
  )
46
46
 
47
- @cache
48
- def _extent_mask_altaz(self):
49
- """
50
- Returns shapely geometry objects of the alt/az extent
51
-
52
- If the extent crosses North cardinal direction, then a MultiPolygon will be returned
53
- """
54
- extent = list(self.ax.get_extent(crs=self._plate_carree))
55
- alt_min, alt_max = extent[2], extent[3]
56
- az_min, az_max = extent[0], extent[1]
57
-
58
- if az_min < 0:
59
- az_min += 360
60
- if az_max < 0:
61
- az_max += 360
62
-
63
- if az_min >= az_max:
64
- az_max += 360
65
-
66
- self.az = (az_min, az_max)
67
- self.alt = (alt_min, alt_max)
68
-
69
- if az_max <= 360:
70
- coords = [
71
- [az_min, alt_min],
72
- [az_max, alt_min],
73
- [az_max, alt_max],
74
- [az_min, alt_max],
75
- [az_min, alt_min],
76
- ]
77
- return Polygon(coords)
78
-
79
- else:
80
- coords_1 = [
81
- [az_min, alt_min],
82
- [360, alt_min],
83
- [360, alt_max],
84
- [az_min, alt_max],
85
- [az_min, alt_min],
86
- ]
87
- coords_2 = [
88
- [0, alt_min],
89
- [az_max - 360, alt_min],
90
- [az_max - 360, alt_max],
91
- [0, alt_max],
92
- [0, alt_min],
93
- ]
94
-
95
- return MultiPolygon(
96
- [
97
- Polygon(coords_1),
98
- Polygon(coords_2),
99
- ]
100
- )
101
-
102
47
  def _is_global_extent(self):
103
48
  """Returns True if the plot's RA/DEC range is the entire celestial sphere"""
104
49
  return all(
starplot/models/dso.py CHANGED
@@ -89,6 +89,13 @@ class DSO(SkyObject, CreateMapMixin, CreateOpticMixin):
89
89
  type: DsoType
90
90
  """Type of DSO"""
91
91
 
92
+ common_names: list[str] = None
93
+ """
94
+ List of common names for the DSO (e.g. 'Andromeda Galaxy' for M31)
95
+
96
+ 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.
97
+ """
98
+
92
99
  magnitude: Optional[float] = None
93
100
  """Magnitude (if available)"""
94
101
 
@@ -186,6 +193,7 @@ class DSO(SkyObject, CreateMapMixin, CreateOpticMixin):
186
193
  def from_tuple(d: tuple) -> DSO:
187
194
  dso = DSO(
188
195
  name=d.name,
196
+ common_names=d.common_names.split(",") if d.common_names else [],
189
197
  ra=d.ra,
190
198
  dec=d.dec,
191
199
  type=d.type,
@@ -243,10 +251,11 @@ DSO_LEGEND_LABELS = {
243
251
  DsoType.GROUP_OF_GALAXIES: "Galaxy",
244
252
  # Nebulas ----------
245
253
  DsoType.NEBULA: "Nebula",
246
- DsoType.PLANETARY_NEBULA: "Nebula",
254
+ DsoType.PLANETARY_NEBULA: "Planetary Nebula",
247
255
  DsoType.EMISSION_NEBULA: "Nebula",
248
256
  DsoType.STAR_CLUSTER_NEBULA: "Nebula",
249
257
  DsoType.REFLECTION_NEBULA: "Nebula",
258
+ DsoType.HII_IONIZED_REGION: "Nebula",
250
259
  # Star Clusters ----------
251
260
  DsoType.OPEN_CLUSTER: "Open Cluster",
252
261
  DsoType.GLOBULAR_CLUSTER: "Globular Cluster",
@@ -255,7 +264,6 @@ DSO_LEGEND_LABELS = {
255
264
  DsoType.ASSOCIATION_OF_STARS: "Association of stars",
256
265
  DsoType.NOVA_STAR: "Nova Star",
257
266
  # Others
258
- DsoType.HII_IONIZED_REGION: "HII Ionized Region",
259
267
  DsoType.DARK_NEBULA: "Dark Nebula",
260
268
  DsoType.SUPERNOVA_REMNANT: "Supernova Remnant",
261
269
  }
starplot/observer.py ADDED
@@ -0,0 +1,71 @@
1
+ from datetime import datetime, timezone
2
+ from functools import cached_property
3
+
4
+ from pydantic import BaseModel, AwareDatetime, Field, computed_field
5
+ from skyfield.timelib import Timescale
6
+
7
+ from starplot.data import load
8
+
9
+
10
+ class Observer(BaseModel):
11
+ """
12
+ Represents an observer at a specific time and place.
13
+
14
+ Example:
15
+
16
+ ```python
17
+ obs = Observer(
18
+ dt=datetime(2025, 10, 13, 21, 0, 0, tzinfo=ZoneInfo('US/Pacific')),
19
+ lat=33.363484,
20
+ lon=-116.836394,
21
+ )
22
+
23
+ ```
24
+ """
25
+
26
+ dt: AwareDatetime = Field(default_factory=lambda: datetime.now(timezone.utc))
27
+ """
28
+ Date and time of observation (**must be timezone-aware**).
29
+
30
+ Defaults to current time in UTC.
31
+ """
32
+
33
+ lat: float = Field(default=0, ge=-90, le=90)
34
+ """Latitude of observer location"""
35
+
36
+ lon: float = Field(default=0, ge=-180, le=180)
37
+ """Longitude of observer location"""
38
+
39
+ class Config:
40
+ arbitrary_types_allowed = True
41
+
42
+ @computed_field
43
+ @cached_property
44
+ def timescale(self) -> Timescale:
45
+ """
46
+ **Read-only Property**
47
+
48
+ Timescale instance of the specified datetime (used by Skyfield)
49
+ """
50
+ return load.timescale().from_datetime(self.dt)
51
+
52
+ @computed_field
53
+ @cached_property
54
+ def lst(self) -> float:
55
+ """
56
+ **Read-only Property**
57
+
58
+ Local sidereal time (in degrees)
59
+ """
60
+ return float(360.0 * self.timescale.gmst / 24.0 + self.lon) % 360.0
61
+
62
+ # @computed_field
63
+ # @cached_property
64
+ # def location(self):
65
+ # earth = self.ephemeris["earth"]
66
+ # return earth + wgs84.latlon(self.lat, self.lon)
67
+
68
+ # def observe(self):
69
+ # earth = self.ephemeris["earth"]
70
+ # self.location = earth + wgs84.latlon(self.lat, self.lon)
71
+ # return self.location.at(self.timescale).observe