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 +67 -0
- stormtrack/_repo.py +57 -0
- stormtrack/_schema.py +254 -0
- stormtrack/_version.py +24 -0
- stormtrack/analysis.py +312 -0
- stormtrack/atcf.py +197 -0
- stormtrack/cli.py +147 -0
- stormtrack/compare.py +187 -0
- stormtrack/core.py +256 -0
- stormtrack/dataset.py +113 -0
- stormtrack/fetch.py +209 -0
- stormtrack/ibtracs.py +413 -0
- stormtrack/parse.py +411 -0
- stormtrack/pipeline.py +123 -0
- stormtrack/plot.py +545 -0
- stormtrack/qc.py +192 -0
- stormtrack/realtime.py +268 -0
- stormtrack/store.py +127 -0
- stormtrack/validate.py +161 -0
- stormtrack/verification.py +271 -0
- stormtrack-0.4.0.dist-info/METADATA +321 -0
- stormtrack-0.4.0.dist-info/RECORD +25 -0
- stormtrack-0.4.0.dist-info/WHEEL +4 -0
- stormtrack-0.4.0.dist-info/entry_points.txt +2 -0
- stormtrack-0.4.0.dist-info/licenses/LICENSE +28 -0
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
|