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 +24 -0
- insitukit/coops.py +198 -0
- insitukit/erddap.py +107 -0
- insitukit/py.typed +0 -0
- insitukit/series.py +78 -0
- insitukit/stats.py +77 -0
- insitukit/store.py +98 -0
- insitukit-0.1.0.dist-info/METADATA +115 -0
- insitukit-0.1.0.dist-info/RECORD +11 -0
- insitukit-0.1.0.dist-info/WHEEL +4 -0
- insitukit-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
[](https://www.python.org/)
|
|
47
|
+
[](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,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.
|