starplot 0.12.5__py2.py3-none-any.whl → 0.14.0__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.

Potentially problematic release.


This version of starplot might be problematic. Click here for more details.

Files changed (73) hide show
  1. starplot/__init__.py +3 -2
  2. starplot/base.py +408 -95
  3. starplot/callables.py +61 -7
  4. starplot/coordinates.py +6 -0
  5. starplot/data/bayer.py +1532 -3
  6. starplot/data/constellations.py +564 -2
  7. starplot/data/flamsteed.py +2682 -0
  8. starplot/data/library/constellation_borders_inv.gpkg +0 -0
  9. starplot/data/library/constellation_lines_hips.json +3 -1
  10. starplot/data/stars.py +408 -87
  11. starplot/geometry.py +82 -0
  12. starplot/horizon.py +458 -0
  13. starplot/map.py +97 -284
  14. starplot/models/base.py +9 -2
  15. starplot/models/constellation.py +1 -1
  16. starplot/optic.py +32 -14
  17. starplot/plotters/__init__.py +2 -0
  18. starplot/plotters/constellations.py +339 -0
  19. starplot/plotters/dsos.py +5 -1
  20. starplot/plotters/experimental.py +171 -0
  21. starplot/plotters/milkyway.py +41 -0
  22. starplot/plotters/stars.py +143 -13
  23. starplot/styles/base.py +308 -169
  24. starplot/styles/ext/antique.yml +54 -46
  25. starplot/styles/ext/blue_dark.yml +39 -45
  26. starplot/styles/ext/blue_light.yml +49 -30
  27. starplot/styles/ext/blue_medium.yml +53 -50
  28. starplot/styles/ext/cb_wong.yml +16 -7
  29. starplot/styles/ext/grayscale.yml +17 -10
  30. starplot/styles/ext/grayscale_dark.yml +18 -8
  31. starplot/styles/ext/map.yml +10 -7
  32. starplot/styles/ext/nord.yml +38 -38
  33. starplot/styles/ext/optic.yml +7 -5
  34. starplot/styles/fonts-library/gfs-didot/DESCRIPTION.en_us.html +9 -0
  35. starplot/styles/fonts-library/gfs-didot/GFSDidot-Regular.ttf +0 -0
  36. starplot/styles/fonts-library/gfs-didot/METADATA.pb +16 -0
  37. starplot/styles/fonts-library/gfs-didot/OFL.txt +94 -0
  38. starplot/styles/fonts-library/hind/DESCRIPTION.en_us.html +28 -0
  39. starplot/styles/fonts-library/hind/Hind-Bold.ttf +0 -0
  40. starplot/styles/fonts-library/hind/Hind-Light.ttf +0 -0
  41. starplot/styles/fonts-library/hind/Hind-Medium.ttf +0 -0
  42. starplot/styles/fonts-library/hind/Hind-Regular.ttf +0 -0
  43. starplot/styles/fonts-library/hind/Hind-SemiBold.ttf +0 -0
  44. starplot/styles/fonts-library/hind/METADATA.pb +58 -0
  45. starplot/styles/fonts-library/hind/OFL.txt +93 -0
  46. starplot/styles/fonts-library/inter/Inter-Black.ttf +0 -0
  47. starplot/styles/fonts-library/inter/Inter-BlackItalic.ttf +0 -0
  48. starplot/styles/fonts-library/inter/Inter-Bold.ttf +0 -0
  49. starplot/styles/fonts-library/inter/Inter-BoldItalic.ttf +0 -0
  50. starplot/styles/fonts-library/inter/Inter-ExtraBold.ttf +0 -0
  51. starplot/styles/fonts-library/inter/Inter-ExtraBoldItalic.ttf +0 -0
  52. starplot/styles/fonts-library/inter/Inter-ExtraLight.ttf +0 -0
  53. starplot/styles/fonts-library/inter/Inter-ExtraLightItalic.ttf +0 -0
  54. starplot/styles/fonts-library/inter/Inter-Italic.ttf +0 -0
  55. starplot/styles/fonts-library/inter/Inter-Light.ttf +0 -0
  56. starplot/styles/fonts-library/inter/Inter-LightItalic.ttf +0 -0
  57. starplot/styles/fonts-library/inter/Inter-Medium.ttf +0 -0
  58. starplot/styles/fonts-library/inter/Inter-MediumItalic.ttf +0 -0
  59. starplot/styles/fonts-library/inter/Inter-Regular.ttf +0 -0
  60. starplot/styles/fonts-library/inter/Inter-SemiBold.ttf +0 -0
  61. starplot/styles/fonts-library/inter/Inter-SemiBoldItalic.ttf +0 -0
  62. starplot/styles/fonts-library/inter/Inter-Thin.ttf +0 -0
  63. starplot/styles/fonts-library/inter/Inter-ThinItalic.ttf +0 -0
  64. starplot/styles/fonts-library/inter/LICENSE.txt +92 -0
  65. starplot/styles/fonts.py +15 -0
  66. starplot/styles/markers.py +207 -6
  67. starplot/utils.py +19 -0
  68. starplot/warnings.py +16 -0
  69. {starplot-0.12.5.dist-info → starplot-0.14.0.dist-info}/METADATA +12 -12
  70. starplot-0.14.0.dist-info/RECORD +107 -0
  71. starplot-0.12.5.dist-info/RECORD +0 -67
  72. {starplot-0.12.5.dist-info → starplot-0.14.0.dist-info}/LICENSE +0 -0
  73. {starplot-0.12.5.dist-info → starplot-0.14.0.dist-info}/WHEEL +0 -0
starplot/horizon.py ADDED
@@ -0,0 +1,458 @@
1
+ from datetime import datetime
2
+ from functools import cache
3
+
4
+ import pandas as pd
5
+ import geopandas as gpd
6
+
7
+ from cartopy import crs as ccrs
8
+ from matplotlib import pyplot as plt, patches
9
+ from matplotlib.ticker import FixedLocator
10
+ from skyfield.api import wgs84, Star as SkyfieldStar
11
+
12
+ from starplot.coordinates import CoordinateSystem
13
+ from starplot.base import BasePlot, DPI
14
+ from starplot.mixins import ExtentMaskMixin
15
+ from starplot.plotters import (
16
+ ConstellationPlotterMixin,
17
+ StarPlotterMixin,
18
+ DsoPlotterMixin,
19
+ MilkyWayPlotterMixin,
20
+ )
21
+ from starplot.styles import (
22
+ PlotStyle,
23
+ extensions,
24
+ use_style,
25
+ PathStyle,
26
+ )
27
+
28
+ pd.options.mode.chained_assignment = None # default='warn'
29
+
30
+ DEFAULT_HORIZON_STYLE = PlotStyle().extend(extensions.MAP)
31
+
32
+ DEFAULT_HORIZON_LABELS = {
33
+ 0: "N",
34
+ 45: "NE",
35
+ 90: "E",
36
+ 135: "SE",
37
+ 180: "S",
38
+ 225: "SW",
39
+ 270: "W",
40
+ 315: "NW",
41
+ }
42
+
43
+
44
+ class HorizonPlot(
45
+ BasePlot,
46
+ ExtentMaskMixin,
47
+ ConstellationPlotterMixin,
48
+ StarPlotterMixin,
49
+ DsoPlotterMixin,
50
+ MilkyWayPlotterMixin,
51
+ ):
52
+ """Creates a new horizon plot.
53
+
54
+ Args:
55
+ lat: Latitude of observer's location
56
+ lon: Longitude of observer's location
57
+ altitude: Tuple of altitude range to plot (min, max)
58
+ azimuth: Tuple of azimuth range to plot (min, max)
59
+ dt: Date/time of observation (*must be timezone-aware*). Default = current UTC time.
60
+ ephemeris: Ephemeris to use for calculating planet positions (see [Skyfield's documentation](https://rhodesmill.org/skyfield/planets.html) for details)
61
+ style: Styling for the plot (colors, sizes, fonts, etc)
62
+ resolution: Size (in pixels) of largest dimension of the map
63
+ hide_colliding_labels: If True, then labels will not be plotted if they collide with another existing label
64
+ scale: Scaling factor that will be applied to all relevant sizes in styles (e.g. font size, marker size, line widths, etc). For example, if you want to make everything 2x bigger, then set scale to 2.
65
+ autoscale: If True, then the scale will be automatically set based on resolution
66
+ suppress_warnings: If True (the default), then all warnings will be suppressed
67
+
68
+ Returns:
69
+ HorizonPlot: A new instance of an HorizonPlot
70
+
71
+ """
72
+
73
+ _coordinate_system = CoordinateSystem.AZ_ALT
74
+
75
+ FIELD_OF_VIEW_MAX = 9.0
76
+
77
+ def __init__(
78
+ self,
79
+ lat: float,
80
+ lon: float,
81
+ altitude: tuple[float, float],
82
+ azimuth: tuple[float, float],
83
+ dt: datetime = None,
84
+ ephemeris: str = "de421_2001.bsp",
85
+ style: PlotStyle = DEFAULT_HORIZON_STYLE,
86
+ resolution: int = 4096,
87
+ hide_colliding_labels: bool = True,
88
+ scale: float = 1.0,
89
+ autoscale: bool = False,
90
+ suppress_warnings: bool = True,
91
+ *args,
92
+ **kwargs,
93
+ ) -> "HorizonPlot":
94
+ super().__init__(
95
+ dt,
96
+ ephemeris,
97
+ style,
98
+ resolution,
99
+ hide_colliding_labels,
100
+ scale=scale,
101
+ autoscale=autoscale,
102
+ suppress_warnings=suppress_warnings,
103
+ *args,
104
+ **kwargs,
105
+ )
106
+
107
+ if azimuth[0] >= azimuth[1]:
108
+ raise ValueError("Azimuth min must be less than max")
109
+ if azimuth[1] - azimuth[0] > 180:
110
+ raise ValueError("Azimuth range cannot be greater than 180 degrees")
111
+
112
+ if altitude[0] >= altitude[1]:
113
+ raise ValueError("Altitude min must be less than max")
114
+ if altitude[1] - altitude[0] > 90:
115
+ raise ValueError("Altitude range cannot be greater than 90 degrees")
116
+
117
+ self.logger.debug("Creating HorizonPlot...")
118
+ self.alt = altitude
119
+ self.az = azimuth
120
+ self.center_alt = sum(altitude) / 2
121
+ self.center_az = sum(azimuth) / 2
122
+ self.lat = lat
123
+ self.lon = lon
124
+
125
+ self._geodetic = ccrs.Geodetic()
126
+ self._plate_carree = ccrs.PlateCarree()
127
+ self._crs = ccrs.CRS(
128
+ proj4_params=[
129
+ ("proj", "latlong"),
130
+ ("a", "6378137"),
131
+ ],
132
+ globe=ccrs.Globe(ellipse="sphere", flattening=0),
133
+ )
134
+
135
+ self._calc_position()
136
+ self._init_plot()
137
+ self._adjust_radec_minmax()
138
+
139
+ @cache
140
+ def _prepare_coords(self, ra, dec) -> (float, float):
141
+ """Converts RA/DEC to AZ/ALT"""
142
+ point = SkyfieldStar(ra_hours=ra, dec_degrees=dec)
143
+ position = self.observe(point).apparent()
144
+ pos_alt, pos_az, _ = position.altaz()
145
+ return pos_az.degrees, pos_alt.degrees
146
+
147
+ def _prepare_star_coords(self, df):
148
+ stars_apparent = self.observe(SkyfieldStar.from_dataframe(df)).apparent()
149
+ nearby_stars_alt, nearby_stars_az, _ = stars_apparent.altaz()
150
+ df["x"], df["y"] = (
151
+ nearby_stars_az.degrees,
152
+ nearby_stars_alt.degrees,
153
+ )
154
+ return df
155
+
156
+ def _plot_kwargs(self) -> dict:
157
+ return dict(transform=self._crs)
158
+
159
+ @cache
160
+ def in_bounds(self, ra, dec) -> bool:
161
+ """Determine if a coordinate is within the bounds of the plot.
162
+
163
+ Args:
164
+ ra: Right ascension, in hours (0...24)
165
+ dec: Declination, in degrees (-90...90)
166
+
167
+ Returns:
168
+ True if the coordinate is in bounds, otherwise False
169
+ """
170
+ az, alt = self._prepare_coords(ra, dec)
171
+ return self.in_bounds_altaz(alt, az)
172
+
173
+ def in_bounds_altaz(self, alt, az, scale: float = 1) -> bool:
174
+ """Determine if a coordinate is within the bounds of the plot.
175
+
176
+ Args:
177
+ alt: Altitude angle in degrees (0...90)
178
+ az: Azimuth angle in degrees (0...360)
179
+
180
+ Returns:
181
+ True if the coordinate is in bounds, otherwise False
182
+ """
183
+ if self.az[0] > 360 or self.az[1] > 360 and az < 90:
184
+ az += 360
185
+
186
+ return (
187
+ az < self.az[1]
188
+ and az > self.az[0]
189
+ and alt < self.alt[1]
190
+ and alt > self.alt[0]
191
+ )
192
+
193
+ def _polygon(self, points, style, **kwargs):
194
+ super()._polygon(points, style, transform=self._crs, **kwargs)
195
+
196
+ def _calc_position(self):
197
+ earth = self.ephemeris["earth"]
198
+
199
+ self.location = earth + wgs84.latlon(self.lat, self.lon)
200
+ self.observe = self.location.at(self.timescale).observe
201
+
202
+ locations = [
203
+ self.location.at(self.timescale).from_altaz(
204
+ alt_degrees=self.alt[0], az_degrees=self.az[0]
205
+ ), # lower left
206
+ self.location.at(self.timescale).from_altaz(
207
+ alt_degrees=self.alt[0], az_degrees=self.az[1]
208
+ ), # lower right
209
+ self.location.at(self.timescale).from_altaz(
210
+ alt_degrees=self.alt[1], az_degrees=self.center_az
211
+ ), # top center
212
+ self.location.at(self.timescale).from_altaz(
213
+ alt_degrees=self.center_alt, az_degrees=self.center_az
214
+ ), # center
215
+ # self.location.at(self.timescale).from_altaz(alt_degrees=self.alt[1], az_degrees=self.az[0]), # upper left
216
+ # self.location.at(self.timescale).from_altaz(alt_degrees=self.alt[1], az_degrees=self.az[1]), # upper right
217
+ ]
218
+
219
+ self.ra_min = None
220
+ self.ra_max = None
221
+ self.dec_max = None
222
+ self.dec_min = None
223
+
224
+ for location in locations:
225
+ ra, dec, _ = location.radec()
226
+ ra = ra.hours
227
+ dec = dec.degrees
228
+ if self.ra_min is None or ra < self.ra_min:
229
+ self.ra_min = ra
230
+
231
+ if self.ra_max is None or ra > self.ra_max:
232
+ self.ra_max = ra
233
+
234
+ if self.dec_min is None or dec < self.dec_min:
235
+ self.dec_min = dec
236
+
237
+ if self.dec_max is None or dec > self.dec_max:
238
+ self.dec_max = dec
239
+
240
+ def _adjust_radec_minmax(self):
241
+ if self.dec_max > 70 or self.dec_min < -70:
242
+ # naive method of getting all the stars near the poles
243
+ self.ra_min = 0
244
+ self.ra_max = 24
245
+
246
+ self.dec_min -= 20
247
+ self.dec_max += 20
248
+ self.ra_min -= 4
249
+ self.ra_max += 4
250
+
251
+ if self.ra_min < 0:
252
+ self.ra_min = 0
253
+
254
+ self.logger.debug(
255
+ f"Extent = RA ({self.ra_min:.2f}, {self.ra_max:.2f}) DEC ({self.dec_min:.2f}, {self.dec_max:.2f})"
256
+ )
257
+
258
+ def _in_bounds_xy(self, x: float, y: float) -> bool:
259
+ return self.in_bounds_altaz(y, x) # alt = y, az = x
260
+
261
+ def _read_geo_package(self, filename: str):
262
+ """Returns GeoDataFrame of a GeoPackage file"""
263
+
264
+ # if self.ra_min <= 0 and self.ra_max >= 24:
265
+ # lon_min = -180
266
+ # lon_max = 180
267
+ # else:
268
+ # lon_min = self.ra_max * 15 - 180 # ra_to_lon(24 - self.ra_max)
269
+ # lon_max = self.ra_min * 15 - 180 # ra_to_lon(24 - self.ra_min)
270
+
271
+ # extent = self._extent_mask()
272
+ # extent = (
273
+ # lon_min,
274
+ # self.dec_min,
275
+ # lon_max,
276
+ # self.dec_max,
277
+ # )
278
+
279
+ return gpd.read_file(
280
+ filename,
281
+ engine="pyogrio",
282
+ use_arrow=True,
283
+ # bbox=extent,
284
+ )
285
+
286
+ @use_style(PathStyle, "horizon")
287
+ def horizon(
288
+ self,
289
+ style: PathStyle = None,
290
+ labels: dict[int, str] = DEFAULT_HORIZON_LABELS,
291
+ show_degree_labels: bool = True,
292
+ degree_step: int = 15,
293
+ show_ticks: bool = True,
294
+ tick_step: int = 5,
295
+ ):
296
+ """
297
+ Plots rectangle for horizon that shows cardinal directions and azimuth labels.
298
+
299
+ Args:
300
+ style: Style of the horizon path. If None, then the plot's style definition will be used.
301
+ labels: Dictionary that maps azimuth values (0...360) to their cardinal direction labels (e.g. "N"). Default is to label each 45deg direction (e.g. "N", "NE", "E", etc)
302
+ show_degree_labels: If True, then azimuth degree labels will be plotted on the horizon path
303
+ degree_step: Step size for degree labels
304
+ show_ticks: If True, then tick marks will be plotted on the horizon path for every `tick_step` degree that is not also a degree label
305
+ tick_step: Step size for tick marks
306
+ """
307
+ bottom = patches.Polygon(
308
+ [
309
+ (0, 0),
310
+ (1, 0),
311
+ (1, -0.1 * self.scale),
312
+ (0, -0.1 * self.scale),
313
+ (0, 0),
314
+ ],
315
+ color=style.line.color.as_hex(),
316
+ transform=self.ax.transAxes,
317
+ clip_on=False,
318
+ )
319
+ self.ax.add_patch(bottom)
320
+
321
+ def az_to_ax(d):
322
+ return self._to_ax(d, self.alt[0])[0]
323
+
324
+ for az in range(self.az[0] + 2, self.az[1], 1):
325
+ az = int(az)
326
+
327
+ if az >= 360:
328
+ az -= 360
329
+
330
+ if labels.get(az):
331
+ self.ax.annotate(
332
+ labels.get(az),
333
+ (az_to_ax(az), -0.074 * self.scale),
334
+ xycoords=self.ax.transAxes,
335
+ **style.label.matplot_kwargs(self.scale),
336
+ clip_on=False,
337
+ )
338
+
339
+ if show_degree_labels and az % degree_step == 0:
340
+ self.ax.annotate(
341
+ str(az) + "\u00b0",
342
+ (az_to_ax(az), -0.011 * self.scale),
343
+ xycoords=self.ax.transAxes,
344
+ **self.style.gridlines.label.matplot_kwargs(self.scale),
345
+ clip_on=False,
346
+ )
347
+
348
+ elif show_ticks and az % tick_step == 0:
349
+ self.ax.annotate(
350
+ "|",
351
+ (az_to_ax(az), -0.011 * self.scale),
352
+ xycoords=self.ax.transAxes,
353
+ **self.style.gridlines.label.matplot_kwargs(self.scale / 2),
354
+ clip_on=False,
355
+ )
356
+
357
+ self.ax.plot(
358
+ [0, 1],
359
+ [-0.04 * self.scale, -0.04 * self.scale],
360
+ lw=1,
361
+ color=style.label.font_color.as_hex(),
362
+ clip_on=False,
363
+ transform=self.ax.transAxes,
364
+ )
365
+
366
+ @use_style(PathStyle, "gridlines")
367
+ def gridlines(
368
+ self,
369
+ style: PathStyle = None,
370
+ az_locations: list[float] = None,
371
+ alt_locations: list[float] = None,
372
+ ):
373
+ """
374
+ Plots gridlines
375
+
376
+ Args:
377
+ style: Styling of the gridlines. If None, then the plot's style (specified when creating the plot) will be used
378
+ az_locations: List of azimuth locations for the gridlines (in degrees, 0...360). Defaults to every 15 degrees
379
+ alt_locations: List of altitude locations for the gridlines (in degrees, -90...90). Defaults to every 10 degrees.
380
+
381
+ """
382
+ x_locations = az_locations or [x for x in range(0, 360, 15)]
383
+ x_locations = [x - 180 for x in x_locations]
384
+ y_locations = alt_locations or [d for d in range(-90, 90, 10)]
385
+
386
+ line_style_kwargs = style.line.matplot_kwargs()
387
+ gridlines = self.ax.gridlines(
388
+ draw_labels=False,
389
+ x_inline=False,
390
+ y_inline=False,
391
+ rotate_labels=False,
392
+ xpadding=12,
393
+ ypadding=12,
394
+ clip_on=True,
395
+ clip_path=self._background_clip_path,
396
+ gid="gridlines",
397
+ **line_style_kwargs,
398
+ )
399
+ gridlines.xlocator = FixedLocator(x_locations)
400
+ gridlines.ylocator = FixedLocator(y_locations)
401
+
402
+ @cache
403
+ def _to_ax(self, az: float, alt: float) -> tuple[float, float]:
404
+ """Converts az/alt to axes coordinates"""
405
+ x, y = self._proj.transform_point(az, alt, self._crs)
406
+ data_to_axes = self.ax.transData + self.ax.transAxes.inverted()
407
+ x_axes, y_axes = data_to_axes.transform((x, y))
408
+ return x_axes, y_axes
409
+
410
+ def _fit_to_ax(self) -> None:
411
+ bbox = self.ax.get_window_extent().transformed(
412
+ self.fig.dpi_scale_trans.inverted()
413
+ )
414
+ width, height = bbox.width, bbox.height
415
+ self.fig.set_size_inches(width, height)
416
+
417
+ def _plot_background_clip_path(self):
418
+ self._background_clip_path = patches.Rectangle(
419
+ (0, 0),
420
+ width=1,
421
+ height=1,
422
+ facecolor=self.style.background_color.as_hex(),
423
+ linewidth=0,
424
+ fill=True,
425
+ zorder=-3_000,
426
+ transform=self.ax.transAxes,
427
+ )
428
+
429
+ self.ax.add_patch(self._background_clip_path)
430
+
431
+ def _init_plot(self):
432
+ self._proj = ccrs.LambertAzimuthalEqualArea(
433
+ central_longitude=sum(self.az) / 2,
434
+ central_latitude=0,
435
+ )
436
+ self._proj.threshold = 100
437
+ self.fig = plt.figure(
438
+ figsize=(self.figure_size, self.figure_size),
439
+ facecolor=self.style.figure_background_color.as_hex(),
440
+ layout="constrained",
441
+ dpi=DPI,
442
+ )
443
+ self.ax = plt.axes(projection=self._proj)
444
+ self.ax.xaxis.set_visible(False)
445
+ self.ax.yaxis.set_visible(False)
446
+ self.ax.axis("off")
447
+
448
+ bounds = [
449
+ self.az[0],
450
+ self.az[1],
451
+ self.alt[0],
452
+ self.alt[1],
453
+ ]
454
+
455
+ self.ax.set_extent(bounds, crs=ccrs.PlateCarree())
456
+
457
+ self._plot_background_clip_path()
458
+ self._fit_to_ax()