insitukit 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.
insitukit/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Fetch, summarise and publish in situ ocean observations.
2
+
3
+ - ``insitukit.coops``: NOAA CO-OPS (Tides and Currents) observations, tide predictions and station metadata.
4
+ - ``insitukit.erddap``: a paced, budgeted ERDDAP tabledap client for any server.
5
+ - ``insitukit.stats``: daily means with a coverage rule, exceedance, joint histograms, marine heatwave climatology.
6
+ - ``insitukit.series``: the compact JSON form of a regular time series, and back.
7
+ - ``insitukit.store``: where the JSON goes (a directory, Cloudflare Workers KV or Google Cloud Storage), behind a
8
+ guard on object names and sizes.
9
+
10
+ Call ``identify`` once at start-up so every request names your application and a contact.
11
+ """
12
+
13
+ __version__ = "0.1.0"
14
+
15
+
16
+ def identify(user_agent: str, application: str | None = None) -> None:
17
+ """Name the caller on every request: ``user_agent`` as the HTTP User-Agent (include a URL or an address a data
18
+ provider can reach you at), and ``application`` as CO-OPS's ``application`` parameter (default: unchanged)."""
19
+ from . import coops
20
+
21
+ coops.USER_AGENT = user_agent
22
+ coops._session.headers["User-Agent"] = user_agent
23
+ if application is not None:
24
+ coops.APPLICATION = application
insitukit/coops.py ADDED
@@ -0,0 +1,198 @@
1
+ """Fetch observations and tide predictions from the NOAA CO-OPS data API (Tides and Currents).
2
+
3
+ Every request asks for metric units and GMT; converting to other units is the caller's. The API answers HTTP 200 with an ``{"error": ...}`` body for a bad request, and "No data
4
+ was found" when a station lacks the sensor or the window falls in a gap: that is an empty result,
5
+ not a failure. Server errors and timeouts retry with backoff.
6
+
7
+ Per-request range limits (checked against the live API, 2026-10):
8
+
9
+ - 6-minute observations (``water_temperature``, ``air_temperature``, ``wind``, ``air_pressure``,
10
+ ``water_level``): 31 days. We ask for 30-day spans.
11
+ - the same products with ``interval=h`` (the reading at the top of each hour), and ``hourly_height``:
12
+ 365 days, compared by calendar date, so one calendar year (including a leap year) fits one request.
13
+
14
+ Requests carry ``USER_AGENT`` and ``APPLICATION``; set your own with ``insitukit.identify``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import time
21
+ from datetime import datetime, timedelta, timezone
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+ import requests
26
+
27
+ from . import __version__
28
+
29
+ BASE_URL = "https://api.tidesandcurrents.noaa.gov/api/prod/datagetter"
30
+ META_URL = "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/{station}.json"
31
+ USER_AGENT = f"insitukit/{__version__} (+https://github.com/lhzn-io/insitukit)"
32
+ APPLICATION = "insitukit"
33
+ SIX_MINUTE_SPAN = timedelta(days=30)
34
+ # Value fields per product, renamed to the column names returned by fetch().
35
+ FIELDS = {
36
+ "wind": {"s": "wind_speed", "d": "wind_direction", "g": "wind_gust"},
37
+ }
38
+ DATUM_PRODUCTS = {"water_level", "hourly_height", "high_low", "predictions"}
39
+
40
+ # The API sits behind a gateway that answers 403 "Forbidden" (not 429) once a client sends too many
41
+ # requests in a short period; a burst of about 300 requests in 10 minutes was enough. We pace every
42
+ # request and treat 403 and 429 as throttling, retried with backoff like a server error.
43
+ MIN_INTERVAL = 1.0
44
+ THROTTLED = {403, 429}
45
+
46
+ log = logging.getLogger(__name__)
47
+ _session = requests.Session()
48
+ _session.headers["User-Agent"] = USER_AGENT
49
+ _last_request = 0.0
50
+ # Requests sent since import (or since the caller reset it); used for logging and cost checks.
51
+ request_count = 0
52
+
53
+
54
+ class CoopsError(RuntimeError):
55
+ """The API rejected the request (bad station, product or date range)."""
56
+
57
+
58
+ def _pace() -> None:
59
+ """Keep at least MIN_INTERVAL seconds between requests."""
60
+ global _last_request
61
+ wait = _last_request + MIN_INTERVAL - time.monotonic()
62
+ if wait > 0:
63
+ time.sleep(wait)
64
+ _last_request = time.monotonic()
65
+
66
+
67
+ def _get(params: dict, retries: int = 4, timeout: int = 120) -> dict | None:
68
+ """GET the data API with retries; return None when the API reports no data for the window."""
69
+ global request_count
70
+ query = {"units": "metric", "time_zone": "gmt", "format": "json", "application": APPLICATION, **params}
71
+ delay = 5.0
72
+ for attempt in range(1, retries + 1):
73
+ request_count += 1
74
+ _pace()
75
+ try:
76
+ resp = _session.get(BASE_URL, params=query, timeout=timeout)
77
+ except requests.RequestException as exc:
78
+ log.warning("request failed (%s), attempt %d: %s", exc.__class__.__name__, attempt, params)
79
+ else:
80
+ if resp.status_code == 200:
81
+ body = resp.json()
82
+ err = (body.get("error") or {}).get("message") if isinstance(body, dict) else None
83
+ if err is None:
84
+ return body
85
+ if "no data was found" in err.lower():
86
+ return None
87
+ raise CoopsError(f"{err.strip()} ({params})")
88
+ if resp.status_code < 500 and resp.status_code not in THROTTLED:
89
+ raise CoopsError(f"HTTP {resp.status_code} ({params})")
90
+ log.warning("HTTP %d, attempt %d: %s", resp.status_code, attempt, params)
91
+ if attempt < retries:
92
+ time.sleep(delay)
93
+ delay *= 2
94
+ raise RuntimeError(f"giving up after {retries} attempts: {params}")
95
+
96
+
97
+ def _stamp(ts: datetime) -> str:
98
+ return ts.astimezone(timezone.utc).strftime("%Y%m%d %H:%M")
99
+
100
+
101
+ def spans(start: datetime, end: datetime, hourly: bool) -> list[tuple[datetime, datetime]]:
102
+ """Request windows covering ``[start, end)``: calendar years for hourly data, 30 days otherwise."""
103
+ out = []
104
+ lo = start
105
+ while lo < end:
106
+ if hourly:
107
+ hi = min(datetime(lo.year + 1, 1, 1, tzinfo=timezone.utc), end)
108
+ else:
109
+ hi = min(lo + SIX_MINUTE_SPAN, end)
110
+ out.append((lo, hi))
111
+ lo = hi
112
+ return out
113
+
114
+
115
+ def fetch(
116
+ product: str,
117
+ station: str,
118
+ start: datetime,
119
+ end: datetime,
120
+ interval: str | None = None,
121
+ datum: str = "MLLW",
122
+ ) -> pd.DataFrame:
123
+ """Observations for ``start <= time < end`` with a UTC ``time`` column and float value columns.
124
+
125
+ Single-value products return one column named after the product; ``wind`` returns
126
+ ``wind_speed`` (m/s), ``wind_direction`` (degrees true) and ``wind_gust`` (m/s). Readings the
127
+ API flags as missing come back as NaN. Long windows are split to respect the range limits.
128
+ """
129
+ fields = FIELDS.get(product, {"v": product})
130
+ hourly = interval == "h" or product == "hourly_height"
131
+ frames = []
132
+ for lo, hi in spans(start, end, hourly):
133
+ # The API's end_date is inclusive; stop one minute short so adjacent spans do not overlap.
134
+ params = {"product": product, "station": station, "begin_date": _stamp(lo), "end_date": _stamp(hi - timedelta(minutes=1))}
135
+ if interval:
136
+ params["interval"] = interval
137
+ if product in DATUM_PRODUCTS:
138
+ params["datum"] = datum
139
+ body = _get(params)
140
+ rows = (body or {}).get("data") or []
141
+ log.info("%s %s %s..%s: %d rows", station, product, params["begin_date"], params["end_date"], len(rows))
142
+ if rows:
143
+ frames.append(pd.DataFrame(rows))
144
+ cols = ["time", *fields.values()]
145
+ if not frames:
146
+ return pd.DataFrame({c: pd.Series(dtype="datetime64[ns, UTC]" if c == "time" else float) for c in cols})
147
+ raw = pd.concat(frames, ignore_index=True)
148
+ df = pd.DataFrame({"time": pd.to_datetime(raw["t"], utc=True)})
149
+ for key, name in fields.items():
150
+ df[name] = pd.to_numeric(raw[key], errors="coerce") if key in raw.columns else np.nan
151
+ df = df[(df["time"] >= pd.Timestamp(start)) & (df["time"] < pd.Timestamp(end))]
152
+ return df.sort_values("time").drop_duplicates("time").reset_index(drop=True)
153
+
154
+
155
+ def tide_events(station: str, start: datetime, end: datetime, datum: str = "MLLW") -> list[dict]:
156
+ """High and low tide predictions in ``[start, end)``: ``[{"time": Timestamp, "m": float, "type": "H"|"L"}]``."""
157
+ params = {
158
+ "product": "predictions",
159
+ "station": station,
160
+ "begin_date": _stamp(start),
161
+ "end_date": _stamp(end - timedelta(minutes=1)),
162
+ "interval": "hilo",
163
+ "datum": datum,
164
+ }
165
+ body = _get(params) or {}
166
+ out = []
167
+ for row in body.get("predictions") or []:
168
+ try:
169
+ out.append({"time": pd.Timestamp(row["t"], tz="UTC"), "m": float(row["v"]), "type": row["type"]})
170
+ except (KeyError, TypeError, ValueError):
171
+ continue
172
+ return out
173
+
174
+
175
+ def predictions(station: str, start: datetime, end: datetime, datum: str = "MLLW") -> pd.Series:
176
+ """6-minute tide predictions (m above ``datum``) in ``[start, end)``, indexed by UTC time. A station's own
177
+ predictions taken from its observed water level give the surge without the phase shift of a nearby station's."""
178
+ vals: dict[pd.Timestamp, float] = {}
179
+ for lo, hi in spans(start, end, hourly=False):
180
+ body = _get({"product": "predictions", "station": station, "datum": datum, "interval": "6",
181
+ "begin_date": _stamp(lo), "end_date": _stamp(hi - timedelta(minutes=1))}) or {}
182
+ for row in body.get("predictions") or []:
183
+ try:
184
+ vals[pd.Timestamp(row["t"], tz="UTC")] = float(row["v"])
185
+ except (KeyError, TypeError, ValueError):
186
+ continue
187
+ s = pd.Series(vals, dtype=float).sort_index()
188
+ return s[(s.index >= pd.Timestamp(start)) & (s.index < pd.Timestamp(end))]
189
+
190
+
191
+ def metadata(station: str) -> dict:
192
+ """Station metadata with details and sensors (name, lat, lng, established, sensor list)."""
193
+ global request_count
194
+ request_count += 1
195
+ _pace()
196
+ resp = _session.get(META_URL.format(station=station), params={"expand": "details,sensors"}, timeout=60)
197
+ resp.raise_for_status()
198
+ return resp.json()["stations"][0]
insitukit/erddap.py ADDED
@@ -0,0 +1,107 @@
1
+ """A small ERDDAP tabledap client.
2
+
3
+ Requests run one at a time, at least ``min_interval`` apart; each attempt counts against a per-run budget; a 503 or
4
+ 429 with Retry-After is waited out; and a 404 whose body says "no matching results" is an empty window, not a missing
5
+ dataset. After a run of failed connections the server is marked unreachable and later requests fail at once. The
6
+ state lives on the instance, so two servers never share a budget or an unreachable flag.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import logging
13
+ import time
14
+ from datetime import datetime, timezone
15
+
16
+ import pandas as pd
17
+ import requests
18
+
19
+ from . import coops
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ NERACOOS_URL = "https://data.neracoos.org/erddap"
24
+
25
+
26
+ class Unreachable(RuntimeError):
27
+ """The server did not accept a connection on any retry earlier in this run."""
28
+
29
+
30
+ class BudgetExceeded(RuntimeError):
31
+ """This run has made as many requests to the server as its budget allows."""
32
+
33
+
34
+ class Erddap:
35
+ """One ERDDAP server. ``user_agent`` defaults to the one set with ``insitukit.identify``."""
36
+
37
+ def __init__(self, base_url: str, budget: int = 60, min_interval: float = 1.0, connect_timeout: int = 10,
38
+ user_agent: str | None = None):
39
+ self.base_url = base_url.rstrip("/")
40
+ self.budget = budget
41
+ self.min_interval = min_interval
42
+ self.connect_timeout = connect_timeout
43
+ self.requests = 0
44
+ self.bytes = 0
45
+ self.unreachable: str | None = None
46
+ self._last_start = 0.0
47
+ self._session = requests.Session()
48
+ self._session.headers["User-Agent"] = user_agent or coops.USER_AGENT
49
+
50
+ def summary(self) -> str:
51
+ return f"{self.base_url}: {self.requests} requests of a budget of {self.budget}, {self.bytes / 1e6:.1f} MB"
52
+
53
+ def _get(self, url: str, retries: int = 3, timeout: int = 90) -> requests.Response | None:
54
+ if self.unreachable:
55
+ raise Unreachable(f"{self.base_url} unreachable since {self.unreachable}; not tried: {url}")
56
+ delay = 5.0
57
+ connect_failures = 0
58
+ for attempt in range(1, retries + 1):
59
+ if self.requests >= self.budget:
60
+ raise BudgetExceeded(f"{self.requests} requests reached this run's budget; not sent: {url}")
61
+ wait = self._last_start + self.min_interval - time.monotonic()
62
+ if wait > 0:
63
+ time.sleep(wait)
64
+ self._last_start = time.monotonic()
65
+ self.requests += 1
66
+ try:
67
+ resp = self._session.get(url, timeout=(self.connect_timeout, timeout))
68
+ except requests.RequestException as exc:
69
+ if isinstance(exc, (requests.ConnectTimeout, requests.ConnectionError)) and not isinstance(exc, requests.ReadTimeout):
70
+ connect_failures += 1
71
+ log.warning("request failed (%s), attempt %d: %s", exc.__class__.__name__, attempt, url)
72
+ else:
73
+ self.bytes += len(resp.content)
74
+ if resp.status_code == 200:
75
+ return resp
76
+ if resp.status_code == 404 and "no matching results" in resp.text.lower():
77
+ return None
78
+ log.warning("HTTP %d, attempt %d: %s", resp.status_code, attempt, url)
79
+ if resp.status_code == 404:
80
+ raise RuntimeError(f"dataset not found: {url}")
81
+ if resp.status_code in (429, 503):
82
+ try:
83
+ delay = max(delay, min(60.0, float(resp.headers.get("Retry-After", ""))))
84
+ except ValueError:
85
+ pass
86
+ if attempt < retries:
87
+ time.sleep(delay)
88
+ delay *= 2
89
+ if connect_failures == retries:
90
+ self.unreachable = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
91
+ raise Unreachable(f"no connection after {retries} attempts: {url}")
92
+ raise RuntimeError(f"giving up after {retries} attempts: {url}")
93
+
94
+ def fetch(self, dataset_id: str, start: datetime, end: datetime, columns: list[str]) -> pd.DataFrame:
95
+ """Rows for ``start <= time < end``: a UTC ``time`` column and the requested columns (floats where numeric).
96
+ Only the named columns are asked for (no ``depth`` unless listed: many datasets have none)."""
97
+ stamp = lambda ts: ts.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") # noqa: E731
98
+ query = ",".join(["time", *columns])
99
+ url = f"{self.base_url}/tabledap/{dataset_id}.csv?{query}&time%3E={stamp(start)}&time%3C{stamp(end)}"
100
+ resp = self._get(url)
101
+ if resp is None:
102
+ return pd.DataFrame({c: pd.Series(dtype="datetime64[ns, UTC]" if c == "time" else float) for c in ["time", *columns]})
103
+ df = pd.read_csv(io.StringIO(resp.text), skiprows=[1]) # row 2 of an ERDDAP CSV holds units
104
+ df["time"] = pd.to_datetime(df["time"], utc=True)
105
+ for c in columns:
106
+ df[c] = pd.to_numeric(df[c], errors="coerce")
107
+ return df
insitukit/py.typed ADDED
File without changes
insitukit/series.py ADDED
@@ -0,0 +1,78 @@
1
+ """The compact JSON form of a regular time series.
2
+
3
+ One series::
4
+
5
+ {"t0": "2026-10-01T00:00:00Z", "step": 3600, "unit": "m", "values": [0.41, 0.44, null, 0.52]}
6
+
7
+ Several series on one grid (a frame)::
8
+
9
+ {"t0": "2026-10-01T00:00:00Z", "step": 3600, "wind_kt": [8.1, 9.4], "dir_deg": [215.0, 220.0]}
10
+
11
+ ``t0`` is the first value's time in UTC, ``step`` the spacing in seconds, and a missing value is ``null``. The grid
12
+ runs without a break from the first value to the last, so a reader rebuilds every time from ``t0``, ``step`` and the
13
+ position alone. An empty series has ``t0`` null. Values are rounded on the way out, which with the implicit times
14
+ keeps a year of hourly data to a few tens of kilobytes.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from datetime import datetime
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+
24
+
25
+ def iso(ts: datetime | pd.Timestamp) -> str:
26
+ """A time as ``YYYY-MM-DDTHH:MM:SSZ`` in UTC."""
27
+ return pd.Timestamp(ts).tz_convert("UTC").strftime("%Y-%m-%dT%H:%M:%SZ")
28
+
29
+
30
+ def _grid(index: pd.DatetimeIndex, step: int) -> pd.DatetimeIndex:
31
+ return pd.date_range(index[0], index[-1], freq=f"{step}s")
32
+
33
+
34
+ def _rounded(values, places: int) -> list[float | None]:
35
+ return [None if pd.isna(v) else round(float(v), places) for v in values]
36
+
37
+
38
+ def encode(s: pd.Series, unit: str, places: int, step: int = 3600) -> dict:
39
+ """A series on a UTC grid of ``step`` seconds (indexed on that grid already, gaps allowed) as JSON."""
40
+ s = s.dropna()
41
+ if s.empty:
42
+ return {"t0": None, "step": step, "unit": unit, "values": []}
43
+ full = s.reindex(_grid(s.index, step))
44
+ return {"t0": iso(full.index[0]), "step": step, "unit": unit, "values": _rounded(full, places)}
45
+
46
+
47
+ def decode(obj: dict | None) -> pd.Series:
48
+ """The values of an encoded series that are present, indexed by UTC time."""
49
+ if not obj or not obj.get("t0") or not obj.get("values"):
50
+ return pd.Series(dtype=float, index=pd.DatetimeIndex([], tz="UTC"))
51
+ idx = pd.date_range(pd.Timestamp(obj["t0"]), periods=len(obj["values"]), freq=f"{obj.get('step', 3600)}s")
52
+ return pd.Series([np.nan if v is None else v for v in obj["values"]], index=idx, dtype=float).dropna()
53
+
54
+
55
+ def encode_frame(frame: pd.DataFrame, places: dict[str, int] | int = 1, step: int = 3600) -> dict:
56
+ """Columns on one UTC grid of ``step`` seconds as JSON, each rounded to ``places`` (one number, or per column
57
+ with 1 for any not named)."""
58
+ frame = frame.dropna(how="all")
59
+ if frame.empty:
60
+ return {"t0": None, "step": step}
61
+ full = frame.reindex(_grid(frame.index, step))
62
+ out: dict = {"t0": iso(full.index[0]), "step": step}
63
+ for col in full.columns:
64
+ p = places if isinstance(places, int) else places.get(col, 1)
65
+ out[col] = _rounded(full[col], p)
66
+ return out
67
+
68
+
69
+ def decode_frame(obj: dict | None, skip: tuple[str, ...] = ()) -> pd.DataFrame:
70
+ """The rows of an encoded frame with any value present. Every list is a column except those named in ``skip``
71
+ (lists a writer added beside the columns, such as indices of flagged values)."""
72
+ if not obj or not obj.get("t0"):
73
+ return pd.DataFrame(index=pd.DatetimeIndex([], tz="UTC"), dtype=float)
74
+ cols = [k for k, v in obj.items() if isinstance(v, list) and k not in skip]
75
+ if not cols:
76
+ return pd.DataFrame(index=pd.DatetimeIndex([], tz="UTC"), dtype=float)
77
+ idx = pd.date_range(pd.Timestamp(obj["t0"]), periods=len(obj[cols[0]]), freq=f"{obj.get('step', 3600)}s")
78
+ return pd.DataFrame({c: pd.to_numeric(pd.Series(obj[c], index=idx), errors="coerce") for c in cols}).dropna(how="all")
insitukit/stats.py ADDED
@@ -0,0 +1,77 @@
1
+ """Summaries of a long record: daily means with a coverage rule, exceedance, a joint histogram of two series measured
2
+ at nearly the same times, and a marine heatwave climatology as Hobday et al. (2016) build theirs
3
+ (doi:10.1016/j.pocean.2015.12.014).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+
14
+ def day_means(s: pd.Series, per_day: int, digits: int, how: str = "mean", tz: str = "UTC") -> pd.Series:
15
+ """Daily means (or another resampler such as ``"max"``) in ``tz`` of a series indexed by aware times, kept only for
16
+ a day with at least half its ``per_day`` records."""
17
+ s = s.dropna()
18
+ s.index = s.index.tz_convert(tz)
19
+ g = s.resample("D")
20
+ out = getattr(g, how)().where(g.count() >= per_day / 2)
21
+ return out.round(digits)
22
+
23
+
24
+ def exceedance(values, xs: list[float]) -> list[float | None]:
25
+ """The share of the finite values above each x (to 4 decimals); None for each x when there are none."""
26
+ v = np.asarray(values, dtype=float)
27
+ v = v[np.isfinite(v)]
28
+ return [round(float((v > x).mean()), 4) if v.size else None for x in xs]
29
+
30
+
31
+ def joint(a: pd.Series, b: pd.Series, a_edges: list[float], b_edges: list[float], pair_s: int) -> list[list[int]]:
32
+ """Counts of ``a`` by its bins (rows) against ``b`` at the nearest time within ``pair_s`` seconds (columns).
33
+ Bins are ``[edge, next edge)``; the last is open, and a value below the first edge counts in the first."""
34
+ counts = np.zeros((len(a_edges), len(b_edges)), dtype=int)
35
+ if a.empty or b.empty:
36
+ return counts.tolist()
37
+ left = pd.DataFrame({"a": a}).sort_index()
38
+ right = pd.DataFrame({"b": b}).sort_index()
39
+ m = pd.merge_asof(left, right, left_index=True, right_index=True, direction="nearest",
40
+ tolerance=pd.Timedelta(seconds=pair_s)).dropna()
41
+ i = np.clip(np.searchsorted(a_edges, m["a"].to_numpy(), side="right") - 1, 0, len(a_edges) - 1)
42
+ j = np.clip(np.searchsorted(b_edges, m["b"].to_numpy(), side="right") - 1, 0, len(b_edges) - 1)
43
+ np.add.at(counts, (i, j), 1)
44
+ return counts.tolist()
45
+
46
+
47
+ def climatology(days: pd.Series, window: int = 5, smooth: int = 31) -> dict:
48
+ """A day-of-year climatology from daily means indexed by date: for each day, every daily mean within ``window``
49
+ days of it across the years, then the mean, standard deviation and 90th percentile, each smoothed with a
50
+ ``smooth``-day running mean (Hobday et al. 2016 use 5 and 31).
51
+
52
+ Returns 366 values of each (``mean``, ``sd``, ``p90``, ``n``): day 60 (29 February) repeats 28 February's, so a
53
+ reader can index by day of year in any year.
54
+ """
55
+ d = days.dropna()
56
+ # Days 1 to 365: in a leap year 29 February joins 28 February and every later day moves back one.
57
+ idx = pd.DatetimeIndex(d.index)
58
+ day = np.asarray(idx.day_of_year)
59
+ doy = day - (np.asarray(idx.is_leap_year) & (day >= 60))
60
+ vals = d.to_numpy()
61
+ mean, sd, p90, n = [], [], [], []
62
+ for k in range(1, 366):
63
+ dist = np.minimum(np.abs(doy - k), 365 - np.abs(doy - k))
64
+ w = vals[dist <= window]
65
+ n.append(int(w.size))
66
+ mean.append(float(w.mean()) if w.size else math.nan)
67
+ sd.append(float(w.std(ddof=1)) if w.size > 1 else math.nan)
68
+ p90.append(float(np.percentile(w, 90)) if w.size else math.nan)
69
+
70
+ def smoothed(x: list[float]) -> list[float | None]:
71
+ s = pd.Series(x * 3).rolling(smooth, center=True, min_periods=smooth // 2).mean().iloc[365:730]
72
+ return [None if pd.isna(v) else round(float(v), 2) for v in s]
73
+
74
+ def leap(x: list) -> list:
75
+ return x[:59] + [x[58]] + x[59:]
76
+
77
+ return {"mean": leap(smoothed(mean)), "sd": leap(smoothed(sd)), "p90": leap(smoothed(p90)), "n": leap(n)}
insitukit/store.py ADDED
@@ -0,0 +1,98 @@
1
+ """Where published JSON goes: a local directory, Cloudflare Workers KV (``kv://<account_id>/<namespace_id>``, token in
2
+ ``CLOUDFLARE_API_TOKEN``), or a ``gs://`` prefix.
3
+
4
+ Writes pass a cost guard: an object name must match the caller's ``writable`` pattern and stay under ``max_bytes``, so
5
+ a misbehaving loop can rewrite the same few objects but never multiply them. Each site passes its own pattern.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ import re
14
+ from pathlib import Path
15
+ from urllib.parse import quote
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+ KV_API = "https://api.cloudflare.com/client/v4/accounts/{account}/storage/kv/namespaces/{ns}/values/{key}"
20
+ MAX_OBJECT_BYTES = 8 * 1024 * 1024
21
+
22
+
23
+ class Store:
24
+ def __init__(self, root: str, writable: re.Pattern[str], max_bytes: int = MAX_OBJECT_BYTES):
25
+ self.root = root.rstrip("/")
26
+ self.writable = writable
27
+ self.max_bytes = max_bytes
28
+ self._bucket = None
29
+ self._kv = None
30
+ if self.root.startswith("kv://"):
31
+ import requests
32
+
33
+ account, _, ns = self.root[5:].partition("/")
34
+ token = os.environ["CLOUDFLARE_API_TOKEN"].strip()
35
+ self._kv = (account, ns, requests.Session())
36
+ self._kv[2].headers["Authorization"] = f"Bearer {token}"
37
+ elif self.root.startswith("gs://"):
38
+ from google.cloud import storage
39
+
40
+ bucket, _, prefix = self.root[5:].partition("/")
41
+ self._bucket = storage.Client().bucket(bucket)
42
+ self._prefix = prefix
43
+
44
+ def _blob(self, rel: str):
45
+ name = f"{self._prefix}/{rel}" if self._prefix else rel
46
+ return self._bucket.blob(name)
47
+
48
+ def _kv_url(self, rel: str) -> str:
49
+ account, ns, _ = self._kv
50
+ return KV_API.format(account=account, ns=ns, key=quote(rel, safe=""))
51
+
52
+ def read_json(self, rel: str) -> dict | None:
53
+ if self._kv is not None:
54
+ resp = self._kv[2].get(self._kv_url(rel), timeout=60)
55
+ if resp.status_code == 404:
56
+ return None
57
+ resp.raise_for_status()
58
+ return resp.json()
59
+ if self._bucket is not None:
60
+ blob = self._blob(rel)
61
+ return json.loads(blob.download_as_text()) if blob.exists() else None
62
+ path = Path(self.root) / rel
63
+ return json.loads(path.read_text()) if path.exists() else None
64
+
65
+ def read_bytes(self, rel: str) -> bytes | None:
66
+ if self._kv is not None:
67
+ resp = self._kv[2].get(self._kv_url(rel), timeout=60)
68
+ if resp.status_code == 404:
69
+ return None
70
+ resp.raise_for_status()
71
+ return resp.content
72
+ if self._bucket is not None:
73
+ blob = self._blob(rel)
74
+ return blob.download_as_bytes() if blob.exists() else None
75
+ path = Path(self.root) / rel
76
+ return path.read_bytes() if path.exists() else None
77
+
78
+ def write_json(self, rel: str, obj: dict, max_age: int) -> None:
79
+ text = json.dumps(obj, separators=(",", ":"), allow_nan=False)
80
+ self.write_bytes(rel, text.encode(), max_age, content_type="application/json")
81
+
82
+ def write_bytes(self, rel: str, data: bytes, max_age: int, content_type: str = "application/octet-stream") -> None:
83
+ if not self.writable.fullmatch(rel):
84
+ raise ValueError(f"refusing to write unexpected object name: {rel}")
85
+ if len(data) > self.max_bytes:
86
+ raise ValueError(f"refusing to write {rel}: {len(data)} bytes exceeds {self.max_bytes}")
87
+ if self._kv is not None:
88
+ resp = self._kv[2].put(self._kv_url(rel), data=data, headers={"Content-Type": content_type}, timeout=120)
89
+ resp.raise_for_status()
90
+ elif self._bucket is not None:
91
+ blob = self._blob(rel)
92
+ blob.cache_control = f"public, max-age={max_age}"
93
+ blob.upload_from_string(data, content_type=content_type)
94
+ else:
95
+ path = Path(self.root) / rel
96
+ path.parent.mkdir(parents=True, exist_ok=True)
97
+ path.write_bytes(data)
98
+ log.info("wrote %s/%s (%d bytes)", self.root, rel, len(data))
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.5
2
+ Name: insitukit
3
+ Version: 0.1.0
4
+ Summary: Fetch, summarise and publish in situ ocean observations: NOAA CO-OPS and ERDDAP clients, record statistics, and a compact JSON time-series format.
5
+ Project-URL: Issues, https://github.com/lhzn-io/insitukit/issues
6
+ Project-URL: Source, https://github.com/lhzn-io/insitukit
7
+ Author-email: Long Horizon Observatory <dfry@lhzn.io>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 Long Horizon Observatory
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Classifier: Development Status :: 3 - Alpha
31
+ Classifier: Intended Audience :: Science/Research
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Topic :: Scientific/Engineering :: Hydrology
35
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
36
+ Requires-Python: >=3.11
37
+ Requires-Dist: numpy>=1.26
38
+ Requires-Dist: pandas>=2.2
39
+ Requires-Dist: requests>=2.32
40
+ Provides-Extra: gcs
41
+ Requires-Dist: google-cloud-storage>=2.18; extra == 'gcs'
42
+ Description-Content-Type: text/markdown
43
+
44
+ # insitukit
45
+
46
+ [![Python](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/)
47
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
48
+
49
+ Fetch, summarise and publish in situ ocean observations: NOAA CO-OPS and ERDDAP clients, record statistics, and a
50
+ compact JSON time-series format.
51
+
52
+ forcingkit prepares what drives a coastal model and topobathykit builds the ground it runs on; insitukit handles what
53
+ the instruments in the water actually measured. It reads tide gauges and buoys from NOAA CO-OPS and any ERDDAP server
54
+ with care for the provider: requests are paced, budgeted and identified, and an empty window is an empty result, not
55
+ an error. It reduces long records to the summaries people read: daily means only where a day is at least half
56
+ covered, exceedance curves, joint histograms, and marine heatwave climatologies after Hobday et al. (2016). And it
57
+ writes them as small JSON series behind a guard that refuses unexpected object names or sizes, so a web page can draw
58
+ years of data from a few files.
59
+
60
+ ## Install
61
+
62
+ ```bash
63
+ pip install insitukit # CO-OPS, ERDDAP, statistics, the JSON form, local and Workers KV storage
64
+ pip install "insitukit[gcs]" # also Google Cloud Storage
65
+ ```
66
+
67
+ ## Use
68
+
69
+ ```python
70
+ from datetime import datetime, timezone
71
+
72
+ import insitukit
73
+ from insitukit import coops, series, stats
74
+ from insitukit.erddap import Erddap
75
+
76
+ insitukit.identify("my-site/1.0 (+https://example.org; data@example.org)", application="my-site")
77
+
78
+ start, end = datetime(2026, 10, 1, tzinfo=timezone.utc), datetime(2026, 11, 1, tzinfo=timezone.utc)
79
+
80
+ # Observed water level and the station's own predictions; their difference is the surge.
81
+ level = coops.fetch("water_level", "8419870", start, end).set_index("time")["water_level"]
82
+ surge = level - coops.predictions("8419870", start, end).reindex(level.index)
83
+
84
+ # A buoy's temperature from an ERDDAP server, at most 20 requests this run.
85
+ server = Erddap("https://data.neracoos.org/erddap", budget=20)
86
+ temp = server.fetch("UNH_CML", start, end, ["temperature"]).set_index("time")["temperature"]
87
+
88
+ daily = stats.day_means(temp, per_day=24, digits=2, tz="America/New_York")
89
+ hourly = series.encode(surge.resample("h").mean(), unit="m", places=3) # {"t0": ..., "step": 3600, ...}
90
+ ```
91
+
92
+ ## Modules
93
+
94
+ | Module | Contents |
95
+ | :--- | :--- |
96
+ | `insitukit.coops` | `fetch` (observations; long windows split to the API's limits), `predictions` (6-minute tides), `tide_events` (highs and lows), `metadata`. Metric units and UTC throughout. |
97
+ | `insitukit.erddap` | `Erddap(base_url, budget, min_interval)`: tabledap CSV into a DataFrame, with a per-run request budget and an unreachable flag. |
98
+ | `insitukit.stats` | `day_means` (coverage rule), `exceedance`, `joint` (two series paired by nearest time), `climatology` (Hobday et al. 2016: 11-day window, 31-day smoothing, mean, SD, 90th percentile). |
99
+ | `insitukit.series` | `encode` and `decode` for one series, `encode_frame` and `decode_frame` for several on one grid, `iso`. |
100
+ | `insitukit.store` | `Store(root, writable, max_bytes)`: JSON and bytes to a directory, `kv://<account>/<namespace>` (Cloudflare Workers KV, token in `CLOUDFLARE_API_TOKEN`) or `gs://bucket/prefix`, refusing names outside `writable` and objects over `max_bytes`. |
101
+
102
+ ## Etiquette
103
+
104
+ CO-OPS requests are at least 1 s apart, and 403 and 429 answers are treated as throttling and retried with backoff.
105
+ ERDDAP requests are at least `min_interval` apart and stop at the run's `budget`. Both send the User-Agent given to
106
+ `identify`; include a way for the provider to reach you.
107
+
108
+ ## Reference
109
+
110
+ Hobday, A. J., et al. (2016). A hierarchical approach to defining marine heatwaves. *Progress in Oceanography*, 141,
111
+ 227-238. doi:10.1016/j.pocean.2015.12.014
112
+
113
+ ## License
114
+
115
+ MIT. Copyright (c) 2026 Long Horizon Observatory.
@@ -0,0 +1,11 @@
1
+ insitukit/__init__.py,sha256=awu_kR3V_nS4nxQTMcdYXgxkN04rkHk7XTlpQiFBHIc,1204
2
+ insitukit/coops.py,sha256=_55rOEGY6zu1VCFTeo_p4QJewOqSJsOSfJpOJXcqlwo,8638
3
+ insitukit/erddap.py,sha256=8bg_s3fJrcDmioSxKIBTpLreiyWVQi8hEzn1-UC-wCs,5039
4
+ insitukit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ insitukit/series.py,sha256=SsqSRpBHoHJxJ8cPweY3BLcjw69HzPxV9gVfOe9yn1o,3596
6
+ insitukit/stats.py,sha256=wfcVCyDOLaHg3EF8TcwyJMz6_9laTYshqLy0cxrrhNc,3732
7
+ insitukit/store.py,sha256=g0VJ4ZFyT2RE-vGNKNrHRarwklM-vgaZWzbTkbuHmUk,4142
8
+ insitukit-0.1.0.dist-info/METADATA,sha256=DmdPLY_m17t621irGIL0iD-QLT0XoZGLWNudzEVYuuY,6059
9
+ insitukit-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ insitukit-0.1.0.dist-info/licenses/LICENSE,sha256=VqXVp0x0MUmTZo0dT3YluvQiXyrUVAIhd5gEv_BdA28,1081
11
+ insitukit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Long Horizon Observatory
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.