besselian2shape 0.1.0__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.
@@ -0,0 +1,16 @@
1
+ from .cache import download_besselian_csv, get_cache_dir, get_cached_csv_path
2
+ from .cli import main
3
+ from .elements import BesselianElements, find_by_date, load_all
4
+ from .export import generate_eclipse_kml, generate_eclipse_shapefiles
5
+
6
+ __all__ = [
7
+ "BesselianElements",
8
+ "download_besselian_csv",
9
+ "find_by_date",
10
+ "generate_eclipse_kml",
11
+ "generate_eclipse_shapefiles",
12
+ "get_cache_dir",
13
+ "get_cached_csv_path",
14
+ "load_all",
15
+ "main",
16
+ ]
@@ -0,0 +1,51 @@
1
+ """Download and local caching of NASA's Besselian elements dataset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ import requests
9
+ from platformdirs import user_cache_dir
10
+
11
+ BESSELIAN_ELEMENTS_URL = (
12
+ "https://eclipse.gsfc.nasa.gov/eclipse_besselian_from_mysqldump2.csv"
13
+ )
14
+
15
+ APP_NAME = "besselian2shape"
16
+
17
+
18
+ def get_cache_dir() -> Path:
19
+ """Return the local cache directory, creating it if necessary."""
20
+ cache_dir = Path(user_cache_dir(APP_NAME))
21
+ cache_dir.mkdir(parents=True, exist_ok=True)
22
+ return cache_dir
23
+
24
+
25
+ def get_cached_csv_path() -> Path:
26
+ """Return the path where the Besselian elements CSV is (or will be) cached."""
27
+ return get_cache_dir() / "eclipse_besselian_from_mysqldump2.csv"
28
+
29
+
30
+ def download_besselian_csv(force: bool = False) -> Path:
31
+ """Ensure the NASA Besselian elements CSV is cached locally, downloading it
32
+ if it is not already present (or if `force` is True).
33
+
34
+ Returns the path to the cached file.
35
+ """
36
+ dest = get_cached_csv_path()
37
+ if dest.exists() and not force:
38
+ return dest
39
+
40
+ response = requests.get(BESSELIAN_ELEMENTS_URL, stream=True, timeout=60)
41
+ response.raise_for_status()
42
+
43
+ tmp_path = dest.with_suffix(dest.suffix + ".part")
44
+ try:
45
+ with open(tmp_path, "wb") as f:
46
+ shutil.copyfileobj(response.raw, f)
47
+ tmp_path.replace(dest)
48
+ finally:
49
+ tmp_path.unlink(missing_ok=True)
50
+
51
+ return dest
besselian2shape/cli.py ADDED
@@ -0,0 +1,127 @@
1
+ """Command-line interface for besselian2shape."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .cache import download_besselian_csv
10
+ from .elements import find_by_date
11
+ from .export import generate_eclipse_kml, generate_eclipse_shapefiles
12
+
13
+ _ALL_FORMATS = ("shp", "kml", "kmz")
14
+
15
+
16
+ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
17
+ parser = argparse.ArgumentParser(
18
+ prog="besselian2shape",
19
+ description="Generate ESRI shapefiles and/or KML/KMZ files for a solar eclipse's path.",
20
+ )
21
+ parser.add_argument(
22
+ "year",
23
+ type=int,
24
+ help="eclipse year, astronomical numbering (1 BCE = 0, 2 BCE = -1, ...)",
25
+ )
26
+ parser.add_argument("month", type=int, help="eclipse month (1-12)")
27
+ parser.add_argument("day", type=int, help="eclipse day of month")
28
+ parser.add_argument(
29
+ "-o",
30
+ "--output",
31
+ type=Path,
32
+ default=None,
33
+ metavar="DIR",
34
+ help="output directory (default: ./eclipse_YYYY-MM-DD)",
35
+ )
36
+ parser.add_argument(
37
+ "-f",
38
+ "--format",
39
+ dest="formats",
40
+ action="append",
41
+ choices=(*_ALL_FORMATS, "all"),
42
+ metavar="{shp,kml,kmz,all}",
43
+ help="output format to generate; may be repeated (default: shp)",
44
+ )
45
+ parser.add_argument(
46
+ "--step-minutes",
47
+ type=float,
48
+ default=0.5,
49
+ metavar="MINUTES",
50
+ help="time resolution used to sample the eclipse path (default: 0.5)",
51
+ )
52
+ parser.add_argument(
53
+ "--penumbral-resolution",
54
+ type=float,
55
+ default=0.25,
56
+ metavar="DEGREES",
57
+ help="grid resolution for the penumbral visibility boundary (default: 0.25)",
58
+ )
59
+ parser.add_argument(
60
+ "--penumbral-step-minutes",
61
+ type=float,
62
+ default=1.5,
63
+ metavar="MINUTES",
64
+ help="time step for the penumbral visibility raster scan (default: 1.5)",
65
+ )
66
+ parser.add_argument(
67
+ "--refresh-cache",
68
+ action="store_true",
69
+ help="re-download the Besselian elements dataset even if already cached",
70
+ )
71
+ return parser.parse_args(argv)
72
+
73
+
74
+ def main(argv: list[str] | None = None) -> int:
75
+ args = _parse_args(argv)
76
+
77
+ formats = list(dict.fromkeys(args.formats or ["shp"])) # de-dupe, preserve order
78
+ if "all" in formats:
79
+ formats = list(_ALL_FORMATS)
80
+
81
+ if args.refresh_cache:
82
+ print("Refreshing cached Besselian elements dataset...", file=sys.stderr)
83
+ download_besselian_csv(force=True)
84
+
85
+ try:
86
+ elements = find_by_date(args.year, args.month, args.day)
87
+ except LookupError as exc:
88
+ print(f"error: {exc}", file=sys.stderr)
89
+ return 1
90
+
91
+ output_dir = args.output or Path(f"eclipse_{args.year:04d}-{args.month:02d}-{args.day:02d}")
92
+
93
+ if "shp" in formats:
94
+ written = generate_eclipse_shapefiles(
95
+ args.year,
96
+ args.month,
97
+ args.day,
98
+ output_dir,
99
+ step_minutes=args.step_minutes,
100
+ elements=elements,
101
+ penumbral_resolution_deg=args.penumbral_resolution,
102
+ penumbral_step_minutes=args.penumbral_step_minutes,
103
+ )
104
+ for name, path in written.items():
105
+ print(f"wrote {name}: {path}")
106
+
107
+ for fmt in ("kml", "kmz"):
108
+ if fmt not in formats:
109
+ continue
110
+ out_path = output_dir / f"eclipse.{fmt}"
111
+ written_path = generate_eclipse_kml(
112
+ args.year,
113
+ args.month,
114
+ args.day,
115
+ out_path,
116
+ step_minutes=args.step_minutes,
117
+ elements=elements,
118
+ penumbral_resolution_deg=args.penumbral_resolution,
119
+ penumbral_step_minutes=args.penumbral_step_minutes,
120
+ )
121
+ print(f"wrote {fmt}: {written_path}")
122
+
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ raise SystemExit(main())
@@ -0,0 +1,138 @@
1
+ """Parsing of NASA's Besselian elements CSV and lookup by date."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from dataclasses import dataclass, fields
7
+ from pathlib import Path
8
+
9
+ from .cache import download_besselian_csv
10
+
11
+ _INT_FIELDS = {
12
+ "year", "month", "day", "luna_num", "saros", "cat_no", "canon_plate",
13
+ "etype", "PNS", "UNS", "NCN", "nSer", "nSeq", "nJLE",
14
+ }
15
+
16
+ _FLOAT_FIELDS = {
17
+ "dt", "gamma", "magnitude", "lat_dd_ge", "lng_dd_ge", "sun_alt", "sun_azm",
18
+ "path_width", "duration_secs", "julian_date", "t0",
19
+ "x0", "x1", "x2", "x3", "y0", "y1", "y2", "y3", "d0", "d1", "d2",
20
+ "mu0", "mu1", "mu2", "l10", "l11", "l12", "l20", "l21", "l22",
21
+ "tan_f1", "tan_f2", "tmin", "tmax",
22
+ }
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class BesselianElements:
27
+ """Besselian elements and metadata for a single solar eclipse.
28
+
29
+ Field names and units follow NASA's Five Millennium Canon of Solar
30
+ Eclipses mysqldump export: x/y are the coordinates of the shadow axis
31
+ on the fundamental plane, d/mu describe the axis's declination and
32
+ hour angle, l1/l2 are the penumbral/umbral radii, and tan_f1/tan_f2
33
+ are the penumbral/umbral cone angles. All are polynomials in time (in
34
+ hours) measured from t0 (TDT), valid over [tmin, tmax].
35
+ """
36
+
37
+ year: int
38
+ month: int
39
+ day: int
40
+ td_ge: str
41
+ dt: float
42
+ luna_num: int
43
+ saros: int
44
+ eclipse_type: str
45
+ gamma: float
46
+ magnitude: float
47
+ lat_ge: str
48
+ lng_ge: str
49
+ lat_dd_ge: float
50
+ lng_dd_ge: float
51
+ sun_alt: float
52
+ sun_azm: float
53
+ path_width: float
54
+ central_duration: str
55
+ duration_secs: float
56
+ cat_no: int
57
+ canon_plate: int
58
+ julian_date: float
59
+ t0: float
60
+ x0: float
61
+ x1: float
62
+ x2: float
63
+ x3: float
64
+ y0: float
65
+ y1: float
66
+ y2: float
67
+ y3: float
68
+ d0: float
69
+ d1: float
70
+ d2: float
71
+ mu0: float
72
+ mu1: float
73
+ mu2: float
74
+ l10: float
75
+ l11: float
76
+ l12: float
77
+ l20: float
78
+ l21: float
79
+ l22: float
80
+ tan_f1: float
81
+ tan_f2: float
82
+ tmin: float
83
+ tmax: float
84
+ etype: int
85
+ PNS: int
86
+ UNS: int
87
+ NCN: int
88
+ nSer: int
89
+ nSeq: int
90
+ nJLE: int
91
+
92
+ @classmethod
93
+ def from_row(cls, row: dict[str, str]) -> "BesselianElements":
94
+ kwargs = {}
95
+ for f in fields(cls):
96
+ raw = row[f.name]
97
+ if f.name in _INT_FIELDS:
98
+ kwargs[f.name] = int(float(raw))
99
+ elif f.name in _FLOAT_FIELDS:
100
+ kwargs[f.name] = float(raw)
101
+ else:
102
+ kwargs[f.name] = raw
103
+ return cls(**kwargs)
104
+
105
+
106
+ def load_all(csv_path: Path | None = None) -> list[BesselianElements]:
107
+ """Load every eclipse's Besselian elements from the (cached) CSV.
108
+
109
+ Downloads and caches the CSV first if `csv_path` is not given and no
110
+ cached copy exists yet.
111
+ """
112
+ path = csv_path if csv_path is not None else download_besselian_csv()
113
+ with open(path, newline="", encoding="utf-8") as f:
114
+ reader = csv.DictReader(f)
115
+ return [BesselianElements.from_row(row) for row in reader]
116
+
117
+
118
+ def find_by_date(
119
+ year: int, month: int, day: int, csv_path: Path | None = None
120
+ ) -> BesselianElements:
121
+ """Return the Besselian elements for the solar eclipse on the given
122
+ (proleptic Gregorian, astronomical-numbered) calendar date.
123
+
124
+ Use astronomical year numbering for BCE dates (e.g. 1 BCE is year 0,
125
+ 2 BCE is year -1). Raises LookupError if no eclipse occurred on that
126
+ date in the dataset.
127
+ """
128
+ matches = [
129
+ e for e in load_all(csv_path)
130
+ if e.year == year and e.month == month and e.day == day
131
+ ]
132
+ if not matches:
133
+ raise LookupError(f"No solar eclipse found for {year:04d}-{month:02d}-{day:02d}")
134
+ if len(matches) > 1:
135
+ raise LookupError(
136
+ f"Multiple eclipses found for {year:04d}-{month:02d}-{day:02d}: {matches}"
137
+ )
138
+ return matches[0]
@@ -0,0 +1,178 @@
1
+ """Top-level API: generate ESRI shapefiles or KML/KMZ for a solar eclipse
2
+ by date.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ from . import kml as kml_writer
10
+ from .elements import BesselianElements, find_by_date
11
+ from .geometry import central_line, is_central, limits_to_ring, umbral_limits
12
+ from .raster import penumbral_coverage_rings
13
+ from .shapefiles import (
14
+ write_polygon_shapefile,
15
+ write_polygon_shapefile_with_holes,
16
+ write_polyline_shapefile,
17
+ )
18
+
19
+ # Grid resolution and time step for the penumbral visibility raster scan
20
+ # (see raster.py). The path/umbral computation's own `step_minutes` isn't
21
+ # reused here -- it controls sampling density along a 1-D analytic curve,
22
+ # a different kind of parameter from a 2-D grid's resolution.
23
+ _PENUMBRAL_RESOLUTION_DEG = 0.25
24
+ _PENUMBRAL_STEP_MINUTES = 1.5
25
+
26
+
27
+ class _Layers:
28
+ def __init__(
29
+ self,
30
+ e: BesselianElements,
31
+ step_minutes: float,
32
+ penumbral_resolution_deg: float,
33
+ penumbral_step_minutes: float,
34
+ ):
35
+ self.penumbral_polygons = penumbral_coverage_rings(
36
+ e,
37
+ lambda s: (s.l1, s.l1p),
38
+ resolution_deg=penumbral_resolution_deg,
39
+ step_minutes=penumbral_step_minutes,
40
+ )
41
+ self.central_line: list[tuple[float, float]] = []
42
+ self.umbral_ring: list[tuple[float, float]] = []
43
+ if is_central(e):
44
+ self.central_line = central_line(e, step_minutes)
45
+ self.umbral_ring = limits_to_ring(umbral_limits(e, step_minutes))
46
+
47
+
48
+ def _load_layers(
49
+ year: int,
50
+ month: int,
51
+ day: int,
52
+ csv_path: Path | None,
53
+ step_minutes: float,
54
+ elements: BesselianElements | None,
55
+ penumbral_resolution_deg: float,
56
+ penumbral_step_minutes: float,
57
+ ) -> tuple[BesselianElements, _Layers]:
58
+ e = elements if elements is not None else find_by_date(year, month, day, csv_path=csv_path)
59
+ layers = _Layers(e, step_minutes, penumbral_resolution_deg, penumbral_step_minutes)
60
+ if not layers.penumbral_polygons:
61
+ raise RuntimeError(
62
+ f"Could not compute a penumbral path for {year:04d}-{month:02d}-{day:02d}"
63
+ )
64
+ return e, layers
65
+
66
+
67
+ def generate_eclipse_shapefiles(
68
+ year: int,
69
+ month: int,
70
+ day: int,
71
+ output_dir: Path | str,
72
+ csv_path: Path | None = None,
73
+ step_minutes: float = 0.5,
74
+ elements: BesselianElements | None = None,
75
+ penumbral_resolution_deg: float = _PENUMBRAL_RESOLUTION_DEG,
76
+ penumbral_step_minutes: float = _PENUMBRAL_STEP_MINUTES,
77
+ ) -> dict[str, Path]:
78
+ """Generate ESRI shapefiles for the solar eclipse on the given
79
+ (proleptic Gregorian, astronomical-numbered) calendar date.
80
+
81
+ Writes into `output_dir`:
82
+ - "penumbral_path.shp": polygon (possibly multi-part, e.g. a main
83
+ region plus separate disjoint loops for a shadow path that passes
84
+ close to a pole) of the region on Earth from which at least a
85
+ partial eclipse is visible (all eclipse types).
86
+ - "central_line.shp": polyline of the path of totality/annularity's
87
+ central line (total/annular/hybrid eclipses only).
88
+ - "umbral_path.shp": polygon of the path of totality/annularity
89
+ (total/annular/hybrid eclipses only).
90
+
91
+ Each shapefile is written as a single-feature .shp/.shx/.dbf/.prj set
92
+ in WGS84 geographic coordinates. `step_minutes` controls how finely
93
+ the umbral/central-line path is sampled along its length.
94
+ `penumbral_resolution_deg`/`penumbral_step_minutes` control the
95
+ coverage-grid resolution and time step used to compute the penumbral
96
+ boundary (see `raster.penumbral_coverage_rings`); the defaults are a
97
+ reasonable accuracy/runtime balance, coarsened here mainly for fast
98
+ tests.
99
+
100
+ If `elements` is given, it is used directly instead of looking `year`,
101
+ `month`, `day` up in the (cached) CSV -- useful when generating more
102
+ than one output format for the same eclipse, to avoid re-parsing the
103
+ dataset each time.
104
+
105
+ Raises LookupError if no eclipse occurred on that date. Returns a dict
106
+ mapping each layer name written to its .shp path.
107
+ """
108
+ e, layers = _load_layers(
109
+ year, month, day, csv_path, step_minutes, elements,
110
+ penumbral_resolution_deg, penumbral_step_minutes,
111
+ )
112
+ output_dir = Path(output_dir)
113
+
114
+ written: dict[str, Path] = {
115
+ "penumbral_path": write_polygon_shapefile_with_holes(
116
+ output_dir / "penumbral_path.shp", layers.penumbral_polygons, e
117
+ )
118
+ }
119
+
120
+ if layers.central_line or layers.umbral_ring:
121
+ path_fields = [("duration", "C", 8, 0), ("path_km", "F", 10, 2)]
122
+ path_record = {"duration": e.central_duration, "path_km": e.path_width}
123
+
124
+ if layers.central_line:
125
+ written["central_line"] = write_polyline_shapefile(
126
+ output_dir / "central_line.shp",
127
+ layers.central_line,
128
+ e,
129
+ extra_fields=path_fields,
130
+ extra_record=path_record,
131
+ )
132
+ if layers.umbral_ring:
133
+ written["umbral_path"] = write_polygon_shapefile(
134
+ output_dir / "umbral_path.shp",
135
+ layers.umbral_ring,
136
+ e,
137
+ extra_fields=path_fields,
138
+ extra_record=path_record,
139
+ )
140
+
141
+ return written
142
+
143
+
144
+ def generate_eclipse_kml(
145
+ year: int,
146
+ month: int,
147
+ day: int,
148
+ output_path: Path | str,
149
+ csv_path: Path | None = None,
150
+ step_minutes: float = 0.5,
151
+ elements: BesselianElements | None = None,
152
+ penumbral_resolution_deg: float = _PENUMBRAL_RESOLUTION_DEG,
153
+ penumbral_step_minutes: float = _PENUMBRAL_STEP_MINUTES,
154
+ ) -> Path:
155
+ """Generate a single KML (or, if `output_path` ends in .kmz, KMZ) file
156
+ for the solar eclipse on the given date, containing the same layers as
157
+ `generate_eclipse_shapefiles` as separate Placemarks in one Document:
158
+ the penumbral path (all eclipse types), and, for total/annular/hybrid
159
+ eclipses, the central line and umbral path.
160
+
161
+ See `generate_eclipse_shapefiles` for the other parameters.
162
+
163
+ Raises LookupError if no eclipse occurred on that date.
164
+ """
165
+ e, layers = _load_layers(
166
+ year, month, day, csv_path, step_minutes, elements,
167
+ penumbral_resolution_deg, penumbral_step_minutes,
168
+ )
169
+ output_path = Path(output_path)
170
+
171
+ doc = kml_writer.build_document(e)
172
+ kml_writer.add_penumbral_path(doc, e, layers.penumbral_polygons)
173
+ if layers.central_line:
174
+ kml_writer.add_central_line(doc, e, layers.central_line)
175
+ if layers.umbral_ring:
176
+ kml_writer.add_umbral_path(doc, e, layers.umbral_ring)
177
+
178
+ return kml_writer.write_kml(doc, output_path)