stormtrack 0.4.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.
stormtrack/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ """stormtrack - IMD RSMC New Delhi cyclone best-track data as pandas / xarray.
2
+
3
+ Quick start
4
+ -----------
5
+ >>> import stormtrack as st
6
+ >>> bt = st.load() # download the pre-parsed dataset from GitHub
7
+ >>> df = bt.observations # tidy pandas DataFrame, one row per fix
8
+ >>> ds = bt.to_xarray() # (storm, step) xarray.Dataset
9
+ >>> bt = st.load(update=True) # re-fetch only if the repo published new data
10
+ >>> ib = st.load_ibtracs() # IBTrACS North Indian Ocean, same schema
11
+ >>> st.compare_tracks(bt, ib) # IMD vs IBTrACS, storm by storm
12
+
13
+ The published dataset is rebuilt from the IMD workbook by a scheduled GitHub
14
+ Action (see :mod:`stormtrack.pipeline`); ``load(source="imd")`` parses the raw
15
+ workbook directly instead.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from . import analysis, atcf, realtime, verification
21
+ from ._schema import GRADE_LONG, GRADE_ORDER, VALID_BASINS
22
+ from .compare import Comparison, compare_tracks
23
+ from .core import BestTracks, load, update
24
+ from .fetch import DEFAULT_URL, fetch_data_from_repo, fetch_workbook
25
+ from .ibtracs import build_ibtracs, fetch_ibtracs, load_ibtracs
26
+ from .parse import parse_workbook
27
+ from .pipeline import build
28
+ from .plot import plot_forecast, plot_track, plot_tracks
29
+ from .qc import flag_positions
30
+
31
+ __all__ = [
32
+ "load",
33
+ "update",
34
+ "build",
35
+ "build_ibtracs",
36
+ "load_ibtracs",
37
+ "fetch_ibtracs",
38
+ "compare_tracks",
39
+ "Comparison",
40
+ "analysis",
41
+ "BestTracks",
42
+ "parse_workbook",
43
+ "fetch_workbook",
44
+ "fetch_data_from_repo",
45
+ "flag_positions",
46
+ "plot_track",
47
+ "plot_tracks",
48
+ "plot_forecast",
49
+ "atcf",
50
+ "realtime",
51
+ "verification",
52
+ "DEFAULT_URL",
53
+ "GRADE_ORDER",
54
+ "GRADE_LONG",
55
+ "VALID_BASINS",
56
+ ]
57
+
58
+ try: # version is written by hatch-vcs at build time, from the git tag
59
+ from ._version import __version__
60
+ except ImportError: # pragma: no cover (source checkout without a build)
61
+ from importlib.metadata import PackageNotFoundError
62
+ from importlib.metadata import version as _pkg_version
63
+
64
+ try:
65
+ __version__ = _pkg_version("stormtrack")
66
+ except PackageNotFoundError:
67
+ __version__ = "0.0.0+unknown"
stormtrack/_repo.py ADDED
@@ -0,0 +1,57 @@
1
+ """Where the *published* dataset lives.
2
+
3
+ The parsed dataset is committed into this GitHub repository under ``data/`` and
4
+ refreshed by a scheduled GitHub Action (see ``pipeline.py`` and
5
+ ``.github/workflows/update-dataset.yml``). End users download those parquet
6
+ files straight from the repo's raw endpoint instead of parsing the IMD workbook
7
+ themselves.
8
+
9
+ The repo slug is baked in below but can be overridden with the ``STORMTRACK_REPO``
10
+ / ``STORMTRACK_BRANCH`` environment variables (handy for forks and CI).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+
17
+ # ``owner/name`` of the GitHub repository hosting the committed dataset.
18
+ DEFAULT_REPO = "atmospy/stormtrack"
19
+ DEFAULT_BRANCH = "main"
20
+
21
+ # Directory (within the repo) that holds the committed parquet + manifest.
22
+ DATA_DIR_NAME = "data"
23
+
24
+ _PLACEHOLDER = "OWNER/REPO"
25
+
26
+ RAW_HOST = "https://raw.githubusercontent.com"
27
+
28
+
29
+ def env(name: str, default=None):
30
+ """``STORMTRACK_<name>``, falling back to the legacy ``IMDTRACK_<name>`` (pre-rename)."""
31
+ return os.environ.get(f"STORMTRACK_{name}") or os.environ.get(f"IMDTRACK_{name}") or default
32
+
33
+
34
+ def repo_slug() -> str:
35
+ return env("REPO", DEFAULT_REPO).strip("/")
36
+
37
+
38
+ def repo_branch() -> str:
39
+ return env("BRANCH", DEFAULT_BRANCH)
40
+
41
+
42
+ def is_configured() -> bool:
43
+ """True once a real repo slug (not the placeholder) is available."""
44
+ return repo_slug() != _PLACEHOLDER
45
+
46
+
47
+ def raw_url(filename: str) -> str:
48
+ """Raw-content URL for ``data/<filename>`` in the published repo."""
49
+ if not is_configured():
50
+ raise RuntimeError(
51
+ "stormtrack: no GitHub repo configured for the published dataset.\n"
52
+ "Set the STORMTRACK_REPO environment variable (e.g. "
53
+ "STORMTRACK_REPO=myuser/stormtrack) or edit DEFAULT_REPO in "
54
+ "stormtrack/_repo.py, or call load(source='imd') to parse the IMD "
55
+ "workbook directly instead."
56
+ )
57
+ return f"{RAW_HOST}/{repo_slug()}/{repo_branch()}/{DATA_DIR_NAME}/{filename}"
stormtrack/_schema.py ADDED
@@ -0,0 +1,254 @@
1
+ """Canonical schema, column detection, and value-normalization helpers.
2
+
3
+ The IMD "Best Track" workbook is human-maintained, so column *headers* drift
4
+ between years (typos, abbreviations, re-orderings, split date columns). Rather
5
+ than rely on fixed positions we detect each field by keyword-matching the header
6
+ text. Everything a downstream module needs to know about the raw layout lives
7
+ here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ import re
14
+ from datetime import datetime
15
+ from typing import Optional
16
+
17
+ # Canonical field names, in the order we want them to appear in the DataFrame.
18
+ FIELDS = [
19
+ "serial",
20
+ "basin",
21
+ "name",
22
+ "date",
23
+ "time",
24
+ "lat",
25
+ "lon",
26
+ "ci_no",
27
+ "pressure",
28
+ "wind",
29
+ "pressure_drop",
30
+ "grade",
31
+ "oci",
32
+ "oci_diameter",
33
+ ]
34
+
35
+ # Human-readable descriptions / units, surfaced as xarray attrs.
36
+ FIELD_ATTRS = {
37
+ "serial": {"long_name": "Serial number of system during the year"},
38
+ "basin": {"long_name": "Basin of origin (BOB=Bay of Bengal, ARB=Arabian Sea, LAND)"},
39
+ "name": {"long_name": "Cyclone name (blank when unnamed)"},
40
+ "time": {"long_name": "Observation time", "timezone": "UTC"},
41
+ "lat": {"long_name": "Latitude", "units": "degrees_north"},
42
+ "lon": {"long_name": "Longitude", "units": "degrees_east"},
43
+ "ci_no": {"long_name": "Dvorak CI number (a.k.a. T number)"},
44
+ "pressure": {"long_name": "Estimated central pressure", "units": "hPa"},
45
+ "wind": {"long_name": "Maximum sustained surface wind", "units": "knots"},
46
+ "pressure_drop": {"long_name": "Pressure drop (delta P)", "units": "hPa"},
47
+ "grade": {"long_name": "Intensity grade (text)"},
48
+ "oci": {"long_name": "Pressure of outermost closed isobar", "units": "hPa"},
49
+ "oci_diameter": {"long_name": "Diameter of outermost closed isobar", "units": "degrees"},
50
+ }
51
+
52
+ # Which canonical fields are numeric (float) vs text.
53
+ NUMERIC_FIELDS = {"lat", "lon", "ci_no", "pressure", "wind", "pressure_drop", "oci", "oci_diameter"}
54
+
55
+ # Canonical basin codes. "AS" (Arabian Sea) is a newer abbreviation for ARB.
56
+ _BASIN_MAP = {
57
+ "BOB": "BOB",
58
+ "ARB": "ARB",
59
+ "AS": "ARB",
60
+ "LAND": "LAND",
61
+ }
62
+ VALID_BASINS = set(_BASIN_MAP)
63
+
64
+ # Canonical IMD intensity grades. Anything that maps to None is treated as a
65
+ # non-track (remark) marker or missing.
66
+ _GRADE_MAP = {
67
+ "D": "D", # Depression
68
+ "DD": "DD", # Deep Depression
69
+ "CS": "CS", # Cyclonic Storm
70
+ "SCS": "SCS", # Severe Cyclonic Storm
71
+ "VSCS": "VSCS", # Very Severe Cyclonic Storm
72
+ "ESCS": "ESCS", # Extremely Severe Cyclonic Storm
73
+ "SUCS": "SuCS", # Super Cyclonic Storm
74
+ }
75
+ GRADE_ORDER = ["D", "DD", "CS", "SCS", "VSCS", "ESCS", "SuCS"]
76
+
77
+ GRADE_LONG = {
78
+ "D": "Depression",
79
+ "DD": "Deep Depression",
80
+ "CS": "Cyclonic Storm",
81
+ "SCS": "Severe Cyclonic Storm",
82
+ "VSCS": "Very Severe Cyclonic Storm",
83
+ "ESCS": "Extremely Severe Cyclonic Storm",
84
+ "SuCS": "Super Cyclonic Storm",
85
+ }
86
+
87
+
88
+ def detect_columns(header_cells) -> dict[str, int]:
89
+ """Map canonical field name -> column index, from a header row.
90
+
91
+ Matching is keyword based and order-sensitive (more specific keywords are
92
+ tested first) so it survives the many header spellings seen across years.
93
+ """
94
+ mapping: dict[str, int] = {}
95
+ for idx, cell in enumerate(header_cells):
96
+ if cell is None:
97
+ continue
98
+ h = str(cell).strip().lower()
99
+ if not h:
100
+ continue
101
+ if "serial" in h:
102
+ field = "serial"
103
+ elif "basin" in h or "barbin" in h:
104
+ field = "basin"
105
+ elif h == "name" or h.startswith("name"):
106
+ field = "name"
107
+ elif "date" in h:
108
+ field = "date"
109
+ elif "time" in h:
110
+ field = "time"
111
+ elif "lat" in h:
112
+ field = "lat"
113
+ elif "long" in h or "lon" in h:
114
+ field = "lon"
115
+ elif "ci no" in h or "t. no" in h or "t.no" in h or h.startswith("ci"):
116
+ field = "ci_no"
117
+ elif "drop" in h or "delta" in h:
118
+ field = "pressure_drop"
119
+ elif "central pressure" in h or "e.c.p" in h or "ecp" in h:
120
+ field = "pressure"
121
+ elif "wind" in h:
122
+ field = "wind"
123
+ elif "diameter" in h or "size of outermost" in h:
124
+ field = "oci_diameter"
125
+ elif "isobar" in h:
126
+ field = "oci"
127
+ elif "grade" in h:
128
+ field = "grade"
129
+ else:
130
+ # Redundant split-date columns (DD/MM/YYYY) and anything else.
131
+ continue
132
+ # First occurrence wins; ignore duplicate/split columns.
133
+ mapping.setdefault(field, idx)
134
+ return mapping
135
+
136
+
137
+ def is_header_row(cells) -> bool:
138
+ return any(c is not None and "serial" in str(c).strip().lower() for c in cells)
139
+
140
+
141
+ def to_float(value) -> float:
142
+ """Coerce a messy cell to float, returning NaN for missing/sentinel values."""
143
+ if value is None:
144
+ return math.nan
145
+ if isinstance(value, (int, float)):
146
+ return float(value)
147
+ s = str(value).strip()
148
+ if s in ("", "-", "--", "N", "NA", "NaN", "nan"):
149
+ return math.nan
150
+ # Keep only leading numeric portion (handles stray units / spaces).
151
+ m = re.match(r"[-+]?\d*\.?\d+", s)
152
+ return float(m.group()) if m else math.nan
153
+
154
+
155
+ def parse_latlon(lat_raw, lon_raw) -> tuple[float, float]:
156
+ """Return ``(lat, lon)`` as floats, handling the packed ``"lat/lon"`` form.
157
+
158
+ Some rows (e.g. 2013 LEHAR/MADI) pack both coordinates into a single string
159
+ like ``"8.5/96.5"`` — and put it in *both* the latitude and longitude cells.
160
+ Naively taking the leading number gives ``lon == lat``. Detect the pair and
161
+ split it; otherwise coerce each cell independently.
162
+ """
163
+ for cell in (lat_raw, lon_raw):
164
+ if isinstance(cell, str) and "/" in cell:
165
+ parts = cell.split("/")
166
+ if len(parts) == 2:
167
+ a, b = to_float(parts[0]), to_float(parts[1])
168
+ if a == a and b == b: # both parsed (not NaN)
169
+ return a, b
170
+ return to_float(lat_raw), to_float(lon_raw)
171
+
172
+
173
+ def parse_date(value) -> Optional[datetime]:
174
+ """Parse a Date cell to a ``datetime`` (date part only), or None.
175
+
176
+ The workbook stores the date inconsistently: usually a real ``datetime``,
177
+ but sometimes a day-first text string (``"16-06-2008"``, ``"01/10/2025"``).
178
+ Both must be understood, or every fix in a text-dated storm gets dropped.
179
+ """
180
+ if value is None:
181
+ return None
182
+ if isinstance(value, datetime):
183
+ return datetime(value.year, value.month, value.day)
184
+ if isinstance(value, float) and math.isnan(value):
185
+ return None
186
+ s = str(value).strip()
187
+ # Day-first DD-MM-YYYY / DD/MM/YYYY / DD.MM.YYYY (IMD convention).
188
+ m = re.fullmatch(r"(\d{1,2})[-/.](\d{1,2})[-/.](\d{2,4})", s)
189
+ if not m:
190
+ return None
191
+ day, month, year = (int(g) for g in m.groups())
192
+ if year < 100:
193
+ year += 2000
194
+ try:
195
+ return datetime(year, month, day)
196
+ except ValueError:
197
+ return None
198
+
199
+
200
+ def parse_time(value) -> Optional[int]:
201
+ """Parse a Time (UTC) cell into minutes-since-midnight, or None if not a time.
202
+
203
+ Track rows use HHMM (e.g. '0300', 300, '0', 2100). Remark rows carry free
204
+ text here instead, for which we return None.
205
+ """
206
+ if value is None:
207
+ return None
208
+ if isinstance(value, float) and math.isnan(value):
209
+ return None
210
+ if isinstance(value, datetime):
211
+ return value.hour * 60 + value.minute
212
+ s = str(value).strip()
213
+ if not re.fullmatch(r"\d{1,4}", s):
214
+ return None
215
+ hhmm = int(s)
216
+ hh, mm = divmod(hhmm, 100)
217
+ if hh > 23 or mm > 59:
218
+ return None
219
+ return hh * 60 + mm
220
+
221
+
222
+ def normalize_basin(value) -> Optional[str]:
223
+ if value is None:
224
+ return None
225
+ return _BASIN_MAP.get(str(value).strip().upper())
226
+
227
+
228
+ def normalize_grade(value) -> Optional[str]:
229
+ if value is None:
230
+ return None
231
+ s = str(value).strip().upper()
232
+ if not s:
233
+ return None
234
+ # Collapse things like "D OVER LAND" -> "D".
235
+ token = re.split(r"[\s/]", s, maxsplit=1)[0]
236
+ return _GRADE_MAP.get(token)
237
+
238
+
239
+ def normalize_name(value) -> Optional[str]:
240
+ if value is None:
241
+ return None
242
+ s = str(value).strip().upper()
243
+ # Strip stray quotes / apostrophes (incl. curly) that cling to some names.
244
+ s = s.strip("'\"‘’“” ").strip()
245
+ # Dash-only cells are "unnamed" placeholders, not names.
246
+ if not s or set(s) <= {"-", "–", "—"}:
247
+ return None
248
+ # Reject remark text that spilled into the name column: real cyclone names
249
+ # are short alphabetic tokens (occasionally two words), never sentences.
250
+ if len(s) > 20 or any(ch.isdigit() for ch in s) or s.count(" ") > 1:
251
+ return None
252
+ if not all(ch.isalpha() or ch in " -" for ch in s):
253
+ return None
254
+ return s
stormtrack/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
+
24
+ __commit_id__ = commit_id = None