pyozwald 0.1.0__tar.gz
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.
- pyozwald-0.1.0/LICENSE +21 -0
- pyozwald-0.1.0/PKG-INFO +153 -0
- pyozwald-0.1.0/README.md +124 -0
- pyozwald-0.1.0/pyozwald/__init__.py +8 -0
- pyozwald-0.1.0/pyozwald/download_ozwald.py +82 -0
- pyozwald-0.1.0/pyozwald/grid.py +62 -0
- pyozwald-0.1.0/pyozwald/ozwald.py +59 -0
- pyozwald-0.1.0/pyozwald/paths.py +55 -0
- pyozwald-0.1.0/pyozwald/store.py +328 -0
- pyozwald-0.1.0/pyozwald.egg-info/PKG-INFO +153 -0
- pyozwald-0.1.0/pyozwald.egg-info/SOURCES.txt +14 -0
- pyozwald-0.1.0/pyozwald.egg-info/dependency_links.txt +1 -0
- pyozwald-0.1.0/pyozwald.egg-info/requires.txt +5 -0
- pyozwald-0.1.0/pyozwald.egg-info/top_level.txt +1 -0
- pyozwald-0.1.0/pyproject.toml +51 -0
- pyozwald-0.1.0/setup.cfg +4 -0
pyozwald-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Borevitz Lab, Australian National University, and contributors
|
|
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.
|
pyozwald-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyozwald
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cached OzWALD daily meteorology and 8-day biophysical series for Australia — fetch once per grid point, never twice
|
|
5
|
+
Author: Borevitz Lab, Australian National University
|
|
6
|
+
Author-email: Yasar Adeel Ansari <u6737670@anu.edu.au>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/thestochasticman/pyozwald
|
|
9
|
+
Project-URL: Repository, https://github.com/thestochasticman/pyozwald
|
|
10
|
+
Project-URL: Issues, https://github.com/thestochasticman/pyozwald/issues
|
|
11
|
+
Keywords: ozwald,climate,australia,remote-sensing,agriculture,time-series
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: attrs
|
|
24
|
+
Requires-Dist: typing_extensions
|
|
25
|
+
Requires-Dist: pandas
|
|
26
|
+
Requires-Dist: xarray
|
|
27
|
+
Requires-Dist: netcdf4
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# pyozwald
|
|
31
|
+
|
|
32
|
+
**Cached [OzWALD](https://www.wenfo.org/ozwald/) time series for
|
|
33
|
+
Australia — fetch once per grid point, never twice.** OzWALD is ANU's
|
|
34
|
+
Water and Landscape Dynamics dataset: modelled daily meteorology
|
|
35
|
+
(~5 km) and 8-day biophysical variables (~500 m, MODIS-derived) served
|
|
36
|
+
as one OPeNDAP NetCDF per variable per year. Every observation this
|
|
37
|
+
machine ever samples lands in one SQLite store, so repeat requests,
|
|
38
|
+
nearby coordinates in the same cell, and extended date ranges all
|
|
39
|
+
reuse the same rows. Part of the
|
|
40
|
+
[Borevitz Lab](https://borevitzlab.anu.edu.au/) ecosystem.
|
|
41
|
+
|
|
42
|
+
## How it works
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
{data_root}/ozwald_store/
|
|
46
|
+
└── ozwald.db
|
|
47
|
+
├── observations(point, cadence, variable, date, value)
|
|
48
|
+
└── coverage(point, cadence, variable, year, through)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- Coordinates snap to a dedup grid matching each product's native
|
|
52
|
+
resolution — 0.05° for daily meteorology, 0.005° for the 8-day
|
|
53
|
+
variables — so nearby requests share one stored series per cadence.
|
|
54
|
+
- OzWALD's unit of delivery is one NetCDF per (variable, year), so
|
|
55
|
+
that's the unit of the coverage ledger. `Store.get_df(...)` diffs
|
|
56
|
+
the requested years × variables against it and samples **only the
|
|
57
|
+
missing cells**; a whole year is stored even when a sub-range was
|
|
58
|
+
requested, since the marginal cost is nil and it maximises reuse.
|
|
59
|
+
- `through` records the last date a year's file actually contained —
|
|
60
|
+
an in-progress year keeps being re-fetched until complete, then
|
|
61
|
+
never again.
|
|
62
|
+
- Writes are transactional (SQLite/WAL): a crash mid-fetch leaves the
|
|
63
|
+
cell unrecorded, and the next run re-fetches it.
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
The core API is **troi-agnostic** — a coordinate, dates, and a cadence:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from datetime import date
|
|
71
|
+
from pyozwald.store import Store
|
|
72
|
+
|
|
73
|
+
store = Store()
|
|
74
|
+
|
|
75
|
+
met = store.get_df(-33.516, 148.373, date(2023, 1, 1), date(2023, 12, 31))
|
|
76
|
+
# daily meteorology: time, Pg, Tmax, Tmin, Uavg, Ueff, VPeff, ...
|
|
77
|
+
|
|
78
|
+
veg = store.get_df(-33.516, 148.373, date(2023, 1, 1), date(2023, 12, 31),
|
|
79
|
+
cadence='8day', variables=['NDVI', 'LAI', 'GPP'])
|
|
80
|
+
# 8-day biophysical series on the ~500 m grid
|
|
81
|
+
|
|
82
|
+
store.fill(-33.516, 148.373, date(2023, 1, 1), date(2023, 12, 31)) # → 0: already local
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Pipelines that speak the shared `troi.troi.Troi` use the
|
|
86
|
+
adapters (evaluated at the bbox centre):
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
df = store.get_df_troi(troi, cadence='daily')
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`download_ozwald_daily(troi)` and `download_ozwald_8day(troi)`
|
|
93
|
+
remain as thin wrappers.
|
|
94
|
+
|
|
95
|
+
## Performance
|
|
96
|
+
|
|
97
|
+
Live measurements against NCI THREDDS — one grid point:
|
|
98
|
+
|
|
99
|
+
| Scenario | Fetched | Time |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| Cold fill — 2 daily variables × 1 year | 2 cells | 2.3 s |
|
|
102
|
+
| Same request again | nothing | **0.0 s** |
|
|
103
|
+
| Nearby coordinate, same ~5 km cell | nothing | **0.0 s** |
|
|
104
|
+
| Date range extended −1 year | 2 cells — *the new year only* | 1.8 s |
|
|
105
|
+
| 8-day NDVI, one year | 1 cell | 0.7 s |
|
|
106
|
+
| Read cached year (365 × 2) | — | 0.01 s |
|
|
107
|
+
|
|
108
|
+
(One *cell* = one variable × one year at one grid point.) Store
|
|
109
|
+
footprint: ~100 KB for the five cells above. Absolute times vary with
|
|
110
|
+
network and THREDDS load; the zeros are the point — they are ledger
|
|
111
|
+
lookups, no network involved.
|
|
112
|
+
|
|
113
|
+
## Install
|
|
114
|
+
|
|
115
|
+
### pip
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install git+https://github.com/thestochasticman/pyozwald.git
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Dependencies (the `troi` core included, pulled from GitHub) are
|
|
122
|
+
declared in `pyproject.toml` and installed automatically.
|
|
123
|
+
|
|
124
|
+
### From source
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
git clone https://github.com/thestochasticman/pyozwald.git
|
|
128
|
+
cd pyozwald
|
|
129
|
+
pip install -e .
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Package design (shared across the lab's packages — no inheritance,
|
|
133
|
+
composition only):
|
|
134
|
+
|
|
135
|
+
- **`Troi`** (from `troi`) — identity: what region, what dates.
|
|
136
|
+
- **`OzWALD`** (`pyozwald.ozwald`) — config: endpoint, variable
|
|
137
|
+
catalogs per cadence, dedup grid steps.
|
|
138
|
+
- **`Paths`** (`pyozwald.paths`) — derived location of the store for a
|
|
139
|
+
given `Config`.
|
|
140
|
+
- **`grid`** — the dedup grids (pure, offline-testable math).
|
|
141
|
+
- **`Store`** (`pyozwald.store`) — ties them together.
|
|
142
|
+
|
|
143
|
+
## Test
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
# offline (pure math + synthetic store):
|
|
147
|
+
python pyozwald/grid.py # True
|
|
148
|
+
python pyozwald/paths.py # True
|
|
149
|
+
python pyozwald/store.py # True
|
|
150
|
+
|
|
151
|
+
# live (small real samples from NCI THREDDS, incl. dedup assertions):
|
|
152
|
+
python pyozwald/download_ozwald.py # True
|
|
153
|
+
```
|
pyozwald-0.1.0/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# pyozwald
|
|
2
|
+
|
|
3
|
+
**Cached [OzWALD](https://www.wenfo.org/ozwald/) time series for
|
|
4
|
+
Australia — fetch once per grid point, never twice.** OzWALD is ANU's
|
|
5
|
+
Water and Landscape Dynamics dataset: modelled daily meteorology
|
|
6
|
+
(~5 km) and 8-day biophysical variables (~500 m, MODIS-derived) served
|
|
7
|
+
as one OPeNDAP NetCDF per variable per year. Every observation this
|
|
8
|
+
machine ever samples lands in one SQLite store, so repeat requests,
|
|
9
|
+
nearby coordinates in the same cell, and extended date ranges all
|
|
10
|
+
reuse the same rows. Part of the
|
|
11
|
+
[Borevitz Lab](https://borevitzlab.anu.edu.au/) ecosystem.
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
{data_root}/ozwald_store/
|
|
17
|
+
└── ozwald.db
|
|
18
|
+
├── observations(point, cadence, variable, date, value)
|
|
19
|
+
└── coverage(point, cadence, variable, year, through)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- Coordinates snap to a dedup grid matching each product's native
|
|
23
|
+
resolution — 0.05° for daily meteorology, 0.005° for the 8-day
|
|
24
|
+
variables — so nearby requests share one stored series per cadence.
|
|
25
|
+
- OzWALD's unit of delivery is one NetCDF per (variable, year), so
|
|
26
|
+
that's the unit of the coverage ledger. `Store.get_df(...)` diffs
|
|
27
|
+
the requested years × variables against it and samples **only the
|
|
28
|
+
missing cells**; a whole year is stored even when a sub-range was
|
|
29
|
+
requested, since the marginal cost is nil and it maximises reuse.
|
|
30
|
+
- `through` records the last date a year's file actually contained —
|
|
31
|
+
an in-progress year keeps being re-fetched until complete, then
|
|
32
|
+
never again.
|
|
33
|
+
- Writes are transactional (SQLite/WAL): a crash mid-fetch leaves the
|
|
34
|
+
cell unrecorded, and the next run re-fetches it.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
The core API is **troi-agnostic** — a coordinate, dates, and a cadence:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from datetime import date
|
|
42
|
+
from pyozwald.store import Store
|
|
43
|
+
|
|
44
|
+
store = Store()
|
|
45
|
+
|
|
46
|
+
met = store.get_df(-33.516, 148.373, date(2023, 1, 1), date(2023, 12, 31))
|
|
47
|
+
# daily meteorology: time, Pg, Tmax, Tmin, Uavg, Ueff, VPeff, ...
|
|
48
|
+
|
|
49
|
+
veg = store.get_df(-33.516, 148.373, date(2023, 1, 1), date(2023, 12, 31),
|
|
50
|
+
cadence='8day', variables=['NDVI', 'LAI', 'GPP'])
|
|
51
|
+
# 8-day biophysical series on the ~500 m grid
|
|
52
|
+
|
|
53
|
+
store.fill(-33.516, 148.373, date(2023, 1, 1), date(2023, 12, 31)) # → 0: already local
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Pipelines that speak the shared `troi.troi.Troi` use the
|
|
57
|
+
adapters (evaluated at the bbox centre):
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
df = store.get_df_troi(troi, cadence='daily')
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`download_ozwald_daily(troi)` and `download_ozwald_8day(troi)`
|
|
64
|
+
remain as thin wrappers.
|
|
65
|
+
|
|
66
|
+
## Performance
|
|
67
|
+
|
|
68
|
+
Live measurements against NCI THREDDS — one grid point:
|
|
69
|
+
|
|
70
|
+
| Scenario | Fetched | Time |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| Cold fill — 2 daily variables × 1 year | 2 cells | 2.3 s |
|
|
73
|
+
| Same request again | nothing | **0.0 s** |
|
|
74
|
+
| Nearby coordinate, same ~5 km cell | nothing | **0.0 s** |
|
|
75
|
+
| Date range extended −1 year | 2 cells — *the new year only* | 1.8 s |
|
|
76
|
+
| 8-day NDVI, one year | 1 cell | 0.7 s |
|
|
77
|
+
| Read cached year (365 × 2) | — | 0.01 s |
|
|
78
|
+
|
|
79
|
+
(One *cell* = one variable × one year at one grid point.) Store
|
|
80
|
+
footprint: ~100 KB for the five cells above. Absolute times vary with
|
|
81
|
+
network and THREDDS load; the zeros are the point — they are ledger
|
|
82
|
+
lookups, no network involved.
|
|
83
|
+
|
|
84
|
+
## Install
|
|
85
|
+
|
|
86
|
+
### pip
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install git+https://github.com/thestochasticman/pyozwald.git
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Dependencies (the `troi` core included, pulled from GitHub) are
|
|
93
|
+
declared in `pyproject.toml` and installed automatically.
|
|
94
|
+
|
|
95
|
+
### From source
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
git clone https://github.com/thestochasticman/pyozwald.git
|
|
99
|
+
cd pyozwald
|
|
100
|
+
pip install -e .
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Package design (shared across the lab's packages — no inheritance,
|
|
104
|
+
composition only):
|
|
105
|
+
|
|
106
|
+
- **`Troi`** (from `troi`) — identity: what region, what dates.
|
|
107
|
+
- **`OzWALD`** (`pyozwald.ozwald`) — config: endpoint, variable
|
|
108
|
+
catalogs per cadence, dedup grid steps.
|
|
109
|
+
- **`Paths`** (`pyozwald.paths`) — derived location of the store for a
|
|
110
|
+
given `Config`.
|
|
111
|
+
- **`grid`** — the dedup grids (pure, offline-testable math).
|
|
112
|
+
- **`Store`** (`pyozwald.store`) — ties them together.
|
|
113
|
+
|
|
114
|
+
## Test
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# offline (pure math + synthetic store):
|
|
118
|
+
python pyozwald/grid.py # True
|
|
119
|
+
python pyozwald/paths.py # True
|
|
120
|
+
python pyozwald/store.py # True
|
|
121
|
+
|
|
122
|
+
# live (small real samples from NCI THREDDS, incl. dedup assertions):
|
|
123
|
+
python pyozwald/download_ozwald.py # True
|
|
124
|
+
```
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Fetch OzWALD tables for a troi — via the machine-wide store.
|
|
2
|
+
|
|
3
|
+
Thin compatibility wrappers: the heavy lifting (grid snapping,
|
|
4
|
+
(variable x year) coverage ledger, OPeNDAP sampling) lives in
|
|
5
|
+
:class:`pyozwald.store.Store`. Kept as a module so the familiar
|
|
6
|
+
``download_ozwald_daily(troi)`` / ``download_ozwald_8day(troi)``
|
|
7
|
+
entry points survive.
|
|
8
|
+
"""
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from troi import Troi
|
|
11
|
+
from pyozwald.ozwald import OzWALD, defaultozwald
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def download_ozwald_daily(troi: Troi, variables: list[str] = None,
|
|
15
|
+
ozwald: OzWALD = defaultozwald) -> pd.DataFrame:
|
|
16
|
+
"""Daily OzWALD meteorology for the centre of ``troi.bbox``.
|
|
17
|
+
|
|
18
|
+
Fetches only the (variable, year) cells of the grid point that no
|
|
19
|
+
previous request has covered.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
pandas.DataFrame: One row per day with a ``time`` column and one
|
|
23
|
+
column per variable.
|
|
24
|
+
"""
|
|
25
|
+
from pyozwald.store import Store
|
|
26
|
+
store = Store(config=troi.config, ozwald=ozwald)
|
|
27
|
+
return store.get_df_troi(troi, cadence='daily', variables=variables)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def download_ozwald_8day(troi: Troi, variables: list[str] = None,
|
|
31
|
+
ozwald: OzWALD = defaultozwald) -> pd.DataFrame:
|
|
32
|
+
"""8-day OzWALD biophysical variables for the centre of ``troi.bbox``."""
|
|
33
|
+
from pyozwald.store import Store
|
|
34
|
+
store = Store(config=troi.config, ozwald=ozwald)
|
|
35
|
+
return store.get_df_troi(troi, cadence='8day', variables=variables)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_live_fetch_and_dedup():
|
|
39
|
+
"""Live: cold fetch of one daily variable-year; repeats and nearby
|
|
40
|
+
coordinates fetch nothing; extending a year fetches only the new year."""
|
|
41
|
+
import tempfile
|
|
42
|
+
from datetime import date
|
|
43
|
+
from troi import Config
|
|
44
|
+
from pyozwald.store import Store
|
|
45
|
+
|
|
46
|
+
tmpdir = tempfile.mkdtemp(prefix='pyozwald_live_test_')
|
|
47
|
+
cfg = Config(out_dir=tmpdir, tmp_dir=tmpdir)
|
|
48
|
+
store = Store(config=cfg)
|
|
49
|
+
lat, lon = -33.516, 148.373
|
|
50
|
+
|
|
51
|
+
fetched = store.fill(lat, lon, date(2023, 1, 1), date(2023, 12, 31),
|
|
52
|
+
variables=['Tmax'])
|
|
53
|
+
if fetched != 1: # one (variable, year) cell
|
|
54
|
+
return False
|
|
55
|
+
df = store.get_df(lat, lon, date(2023, 1, 1), date(2023, 12, 31),
|
|
56
|
+
variables=['Tmax'])
|
|
57
|
+
if len(df) != 365 or 'Tmax' not in df.columns or df['Tmax'].isna().all():
|
|
58
|
+
return False
|
|
59
|
+
# identical repeat -> nothing
|
|
60
|
+
if store.fill(lat, lon, date(2023, 1, 1), date(2023, 12, 31),
|
|
61
|
+
variables=['Tmax']) != 0:
|
|
62
|
+
return False
|
|
63
|
+
# ~300 m away, same ~5 km daily cell -> nothing
|
|
64
|
+
if store.fill(-33.514, 148.371, date(2023, 6, 1), date(2023, 6, 30),
|
|
65
|
+
variables=['Tmax']) != 0:
|
|
66
|
+
return False
|
|
67
|
+
# extend one year back -> exactly one new cell
|
|
68
|
+
if store.fill(lat, lon, date(2022, 1, 1), date(2023, 12, 31),
|
|
69
|
+
variables=['Tmax']) != 1:
|
|
70
|
+
return False
|
|
71
|
+
# an 8day variable is its own cell on its own grid
|
|
72
|
+
veg = store.get_df(lat, lon, date(2023, 1, 1), date(2023, 12, 31),
|
|
73
|
+
cadence='8day', variables=['NDVI'])
|
|
74
|
+
return len(veg) > 30 and 'NDVI' in veg.columns
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test():
|
|
78
|
+
return test_live_fetch_and_dedup()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == '__main__':
|
|
82
|
+
print(test())
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Dedup grids every stored OzWALD series is keyed to.
|
|
2
|
+
|
|
3
|
+
OzWALD serves two products at different native resolutions — daily
|
|
4
|
+
meteorology on a ~5 km (0.05°) grid, 8-day biophysical variables on a
|
|
5
|
+
~500 m (0.005°) grid. Requested coordinates snap to the matching grid
|
|
6
|
+
spacing, so nearby coordinates inside one cell resolve to the same
|
|
7
|
+
point and a point's series is only ever fetched once per
|
|
8
|
+
(variable, year).
|
|
9
|
+
|
|
10
|
+
All functions here are pure — no I/O, no store access.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
LON_MIN, LON_MAX = 110.0, 155.0
|
|
14
|
+
LAT_MIN, LAT_MAX = -45.0, -9.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def snap(lat: float, lon: float, step: float) -> tuple[float, float]:
|
|
18
|
+
"""Nearest grid point ``(lat, lon)`` at ``step`` degrees spacing."""
|
|
19
|
+
return (round(lat / step) * step, round(lon / step) * step)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def point_id(lat: float, lon: float, step: float) -> str:
|
|
23
|
+
"""Stable string key for the grid cell containing ``(lat, lon)``."""
|
|
24
|
+
slat, slon = snap(lat, lon, step)
|
|
25
|
+
return f'{slat:.3f},{slon:.3f}'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def in_bounds(lat: float, lon: float) -> bool:
|
|
29
|
+
"""True iff the coordinate lies inside OzWALD's Australian extent."""
|
|
30
|
+
return LAT_MIN <= lat <= LAT_MAX and LON_MIN <= lon <= LON_MAX
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_snap_is_idempotent():
|
|
34
|
+
slat, slon = snap(-33.51606, 148.37265, 0.05)
|
|
35
|
+
return snap(slat, slon, 0.05) == (slat, slon)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_nearby_points_share_a_cell():
|
|
39
|
+
# ~1 km apart inside one ~5 km daily cell -> same point
|
|
40
|
+
return point_id(-33.514, 148.371, 0.05) == point_id(-33.516, 148.373, 0.05)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_fine_grid_separates_them():
|
|
44
|
+
# ...but on the ~500 m 8-day grid they are different cells
|
|
45
|
+
return point_id(-33.514, 148.371, 0.005) != point_id(-33.516, 148.373, 0.005)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_bounds():
|
|
49
|
+
return in_bounds(-33.5, 148.4) and not in_bounds(-33.5, 100.0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test():
|
|
53
|
+
return all([
|
|
54
|
+
test_snap_is_idempotent(),
|
|
55
|
+
test_nearby_points_share_a_cell(),
|
|
56
|
+
test_fine_grid_separates_them(),
|
|
57
|
+
test_bounds(),
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == '__main__':
|
|
62
|
+
print(test())
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from attrs import frozen
|
|
2
|
+
|
|
3
|
+
@frozen
|
|
4
|
+
class OzWALD:
|
|
5
|
+
"""Endpoint and variable configuration for OzWALD OPeNDAP requests.
|
|
6
|
+
|
|
7
|
+
Two products, two cadences: ``daily`` meteorology (~5 km grid) and
|
|
8
|
+
``8day`` biophysical variables (~500 m grid). ``step`` gives the
|
|
9
|
+
dedup grid spacing per cadence — requests are snapped to it so
|
|
10
|
+
nearby coordinates share one stored series (matching the native
|
|
11
|
+
resolution of each product).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
base_url: str = 'https://thredds.nci.org.au/thredds/dodsC/ub8/au/OzWALD'
|
|
15
|
+
|
|
16
|
+
daily = {
|
|
17
|
+
'Pg': {'path': 'daily/meteo/Pg', 'file': 'OzWALD.daily.Pg.{year}.nc'},
|
|
18
|
+
'Tmax': {'path': 'daily/meteo/Tmax', 'file': 'OzWALD.Tmax.{year}.nc'},
|
|
19
|
+
'Tmin': {'path': 'daily/meteo/Tmin', 'file': 'OzWALD.Tmin.{year}.nc'},
|
|
20
|
+
'Uavg': {'path': 'daily/meteo/Uavg', 'file': 'OzWALD.Uavg.{year}.nc'},
|
|
21
|
+
'Ueff': {'path': 'daily/meteo/Ueff', 'file': 'OzWALD.Ueff.{year}.nc'},
|
|
22
|
+
'VPeff': {'path': 'daily/meteo/VPeff', 'file': 'OzWALD.VPeff.{year}.nc'},
|
|
23
|
+
'kTavg': {'path': 'daily/meteo/kTavg', 'file': 'OzWALD.kTavg.{year}.nc'},
|
|
24
|
+
'kTeff': {'path': 'daily/meteo/kTeff', 'file': 'OzWALD.kTeff.{year}.nc'},
|
|
25
|
+
'DWLReff': {'path': 'daily/meteo/DWLReff', 'file': 'OzWALD.DWLReff.{year}.nc'},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
eight_day = {
|
|
29
|
+
'LAI': {'path': '8day/LAI', 'file': 'OzWALD.LAI.{year}.nc'},
|
|
30
|
+
'GPP': {'path': '8day/GPP', 'file': 'OzWALD.GPP.{year}.nc'},
|
|
31
|
+
'NDVI': {'path': '8day/NDVI', 'file': 'OzWALD.NDVI.{year}.nc'},
|
|
32
|
+
'EVI': {'path': '8day/EVI', 'file': 'OzWALD.EVI.{year}.nc'},
|
|
33
|
+
'PV': {'path': '8day/PV', 'file': 'OzWALD.PV.{year}.nc'},
|
|
34
|
+
'NPV': {'path': '8day/NPV', 'file': 'OzWALD.NPV.{year}.nc'},
|
|
35
|
+
'BS': {'path': '8day/BS', 'file': 'OzWALD.BS.{year}.nc'},
|
|
36
|
+
'FMC': {'path': '8day/FMC', 'file': 'OzWALD.FMC.{year}.nc'},
|
|
37
|
+
'Qtot': {'path': '8day/Qtot', 'file': 'OzWALD.Qtot.{year}.nc'},
|
|
38
|
+
'Ssoil': {'path': '8day/Ssoil', 'file': 'OzWALD.Ssoil.{year}.nc'},
|
|
39
|
+
'OW': {'path': '8day/OW', 'file': 'OzWALD.OW.{year}.nc'},
|
|
40
|
+
'SN': {'path': '8day/SN', 'file': 'OzWALD.SN.{year}.nc'},
|
|
41
|
+
'Alb': {'path': '8day/Alb', 'file': 'OzWALD.Alb.{year}.nc'},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Dedup grid spacing (degrees) per cadence, matching each product's
|
|
45
|
+
# native resolution.
|
|
46
|
+
steps = {'daily': 0.05, '8day': 0.005}
|
|
47
|
+
|
|
48
|
+
def catalog(self, cadence: str) -> dict:
|
|
49
|
+
if cadence == 'daily':
|
|
50
|
+
return self.daily
|
|
51
|
+
if cadence == '8day':
|
|
52
|
+
return self.eight_day
|
|
53
|
+
raise ValueError(f"cadence must be 'daily' or '8day', got {cadence!r}")
|
|
54
|
+
|
|
55
|
+
def get_url(self, cadence: str, variable: str, year: int) -> str:
|
|
56
|
+
info = self.catalog(cadence)[variable]
|
|
57
|
+
return f'{self.base_url}/{info["path"]}/{info["file"].format(year=year)}'
|
|
58
|
+
|
|
59
|
+
defaultozwald = OzWALD()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Derived on-disk location of the machine-wide OzWALD store.
|
|
2
|
+
|
|
3
|
+
The store is keyed by :class:`troi.Config` (one store per
|
|
4
|
+
data root, shared by every request on this machine). Rule of thumb
|
|
5
|
+
across the lab's packages: user-settable inputs → Config, derived
|
|
6
|
+
locations → Paths. No inheritance — composition only.
|
|
7
|
+
"""
|
|
8
|
+
from attrs import frozen, field
|
|
9
|
+
from troi import Config, config as default_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@frozen
|
|
13
|
+
class Paths:
|
|
14
|
+
"""Where the pyozwald store lives for a given Config.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
config: The :class:`troi.Config` supplying the data root.
|
|
18
|
+
root: Store directory (``{config.tmp_dir}/ozwald_store``).
|
|
19
|
+
db: The SQLite database holding every observation and the
|
|
20
|
+
coverage ledger.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
```python
|
|
24
|
+
from pyozwald.paths import Paths
|
|
25
|
+
|
|
26
|
+
Paths().db # '~/Downloads/Troi-Tmp/ozwald_store/ozwald.db'
|
|
27
|
+
```
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
config: Config = default_config
|
|
31
|
+
|
|
32
|
+
root: str = field(init=False)
|
|
33
|
+
db: str = field(init=False)
|
|
34
|
+
|
|
35
|
+
root.default(lambda s: f'{s.config.tmp_dir}/ozwald_store')
|
|
36
|
+
db.default(lambda s: f'{s.root}/ozwald.db')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_paths_derive_from_config():
|
|
40
|
+
import tempfile
|
|
41
|
+
tmpdir = tempfile.mkdtemp(prefix='pyozwald_paths_test_')
|
|
42
|
+
cfg = Config(out_dir=tmpdir, tmp_dir=tmpdir)
|
|
43
|
+
paths = Paths(cfg)
|
|
44
|
+
return (
|
|
45
|
+
paths.root == f'{tmpdir}/ozwald_store'
|
|
46
|
+
and paths.db == f'{tmpdir}/ozwald_store/ozwald.db'
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test():
|
|
51
|
+
return test_paths_derive_from_config()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == '__main__':
|
|
55
|
+
print(test())
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""One machine-wide OzWALD store that fills itself on demand.
|
|
2
|
+
|
|
3
|
+
Every observation this machine ever fetches lands in a single SQLite
|
|
4
|
+
database, keyed by dedup grid point (:mod:`pyozwald.grid`), cadence,
|
|
5
|
+
variable and date:
|
|
6
|
+
|
|
7
|
+
{config.tmp_dir}/ozwald_store/
|
|
8
|
+
└── ozwald.db
|
|
9
|
+
├── observations(point, cadence, variable, date, value)
|
|
10
|
+
└── coverage(point, cadence, variable, year, through)
|
|
11
|
+
|
|
12
|
+
OzWALD is served as one OPeNDAP NetCDF per (variable, year), so the
|
|
13
|
+
unit of fetching — and of the coverage ledger — is the
|
|
14
|
+
``(point, variable, year)`` cell. ``Store.get_df(lat, lon, start, end)``
|
|
15
|
+
diffs the requested years x variables against the ledger and fetches
|
|
16
|
+
only the missing cells; a whole year is stored even when a sub-range
|
|
17
|
+
was requested, since the marginal cost is nil and it maximises reuse.
|
|
18
|
+
``through`` records the last date a year's file actually contained —
|
|
19
|
+
an in-progress year keeps being re-fetched until it is complete, then
|
|
20
|
+
never again.
|
|
21
|
+
"""
|
|
22
|
+
import sqlite3
|
|
23
|
+
from attrs import frozen, field
|
|
24
|
+
from datetime import date
|
|
25
|
+
from os import makedirs
|
|
26
|
+
|
|
27
|
+
import pandas as pd
|
|
28
|
+
|
|
29
|
+
from troi import Config, config as default_config
|
|
30
|
+
from pyozwald import grid
|
|
31
|
+
from pyozwald.ozwald import OzWALD, defaultozwald
|
|
32
|
+
from pyozwald.paths import Paths
|
|
33
|
+
|
|
34
|
+
_SCHEMA = """
|
|
35
|
+
CREATE TABLE IF NOT EXISTS observations (
|
|
36
|
+
point TEXT NOT NULL,
|
|
37
|
+
cadence TEXT NOT NULL,
|
|
38
|
+
variable TEXT NOT NULL,
|
|
39
|
+
date TEXT NOT NULL,
|
|
40
|
+
value REAL,
|
|
41
|
+
PRIMARY KEY (point, cadence, variable, date)
|
|
42
|
+
) WITHOUT ROWID;
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS coverage (
|
|
45
|
+
point TEXT NOT NULL,
|
|
46
|
+
cadence TEXT NOT NULL,
|
|
47
|
+
variable TEXT NOT NULL,
|
|
48
|
+
year INTEGER NOT NULL,
|
|
49
|
+
through TEXT NOT NULL,
|
|
50
|
+
PRIMARY KEY (point, cadence, variable, year)
|
|
51
|
+
) WITHOUT ROWID;
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def year_covered(through: str | None, year: int, end: date) -> bool:
|
|
56
|
+
"""Is a year's cell already sufficient for a request ending at ``end``?
|
|
57
|
+
|
|
58
|
+
``through`` is the last date the year's file contained when last
|
|
59
|
+
fetched (None = never fetched). The cell satisfies the request iff
|
|
60
|
+
it reaches the earlier of the requested end and the year's own end —
|
|
61
|
+
so completed years are fetched exactly once, and an in-progress year
|
|
62
|
+
is re-fetched only while the request actually needs newer days.
|
|
63
|
+
"""
|
|
64
|
+
if through is None:
|
|
65
|
+
return False
|
|
66
|
+
return date.fromisoformat(through) >= min(end, date(year, 12, 31))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@frozen
|
|
70
|
+
class Store:
|
|
71
|
+
"""The machine-wide OzWALD store: one ledger, zero re-fetches.
|
|
72
|
+
|
|
73
|
+
Composed from :class:`troi.Config` (where the store
|
|
74
|
+
lives) and :class:`pyozwald.ozwald.OzWALD` (endpoint + catalogs +
|
|
75
|
+
dedup grid steps). No inheritance.
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
```python
|
|
79
|
+
from datetime import date
|
|
80
|
+
from pyozwald.store import Store
|
|
81
|
+
|
|
82
|
+
store = Store()
|
|
83
|
+
met = store.get_df(-33.516, 148.373, date(2023, 1, 1), date(2023, 12, 31))
|
|
84
|
+
veg = store.get_df(-33.516, 148.373, date(2023, 1, 1), date(2023, 12, 31),
|
|
85
|
+
cadence='8day', variables=['NDVI', 'LAI'])
|
|
86
|
+
```
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
config: Config = default_config
|
|
90
|
+
ozwald: OzWALD = defaultozwald
|
|
91
|
+
paths: Paths = field(init=False)
|
|
92
|
+
|
|
93
|
+
paths.default(lambda s: Paths(s.config))
|
|
94
|
+
|
|
95
|
+
def __attrs_post_init__(s):
|
|
96
|
+
makedirs(s.paths.root, exist_ok=True)
|
|
97
|
+
|
|
98
|
+
def _db(s) -> sqlite3.Connection:
|
|
99
|
+
db = sqlite3.connect(s.paths.db)
|
|
100
|
+
db.execute('PRAGMA journal_mode=WAL')
|
|
101
|
+
db.executescript(_SCHEMA)
|
|
102
|
+
return db
|
|
103
|
+
|
|
104
|
+
def _resolve(s, lat, lon, cadence, variables):
|
|
105
|
+
if not grid.in_bounds(lat, lon):
|
|
106
|
+
raise ValueError(f'({lat}, {lon}) is outside the OzWALD extent')
|
|
107
|
+
catalog = s.ozwald.catalog(cadence)
|
|
108
|
+
variables = list(variables) if variables else list(catalog)
|
|
109
|
+
unknown = set(variables) - set(catalog)
|
|
110
|
+
if unknown:
|
|
111
|
+
raise ValueError(f'Unknown {cadence} variable(s): {sorted(unknown)}')
|
|
112
|
+
return grid.point_id(lat, lon, s.ozwald.steps[cadence]), variables
|
|
113
|
+
|
|
114
|
+
# -- fill -------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def fill(s, lat: float, lon: float, start: date, end: date,
|
|
117
|
+
cadence: str = 'daily', variables: list[str] = None) -> int:
|
|
118
|
+
"""Ensure every (variable x year) cell covering the request is
|
|
119
|
+
populated for the grid point containing ``(lat, lon)``.
|
|
120
|
+
|
|
121
|
+
Returns the number of (variable, year) cells actually fetched —
|
|
122
|
+
0 means full coverage already existed and no network was touched.
|
|
123
|
+
"""
|
|
124
|
+
pid, variables = s._resolve(lat, lon, cadence, variables)
|
|
125
|
+
db = s._db()
|
|
126
|
+
try:
|
|
127
|
+
fetched = 0
|
|
128
|
+
for variable in variables:
|
|
129
|
+
for year in range(start.year, end.year + 1):
|
|
130
|
+
row = db.execute(
|
|
131
|
+
'SELECT through FROM coverage WHERE point=? AND cadence=? '
|
|
132
|
+
'AND variable=? AND year=?', (pid, cadence, variable, year),
|
|
133
|
+
).fetchone()
|
|
134
|
+
if year_covered(row[0] if row else None, year, end):
|
|
135
|
+
continue
|
|
136
|
+
s._fetch_year(db, pid, cadence, variable, year)
|
|
137
|
+
fetched += 1
|
|
138
|
+
return fetched
|
|
139
|
+
finally:
|
|
140
|
+
db.close()
|
|
141
|
+
|
|
142
|
+
def _fetch_year(s, db, pid: str, cadence: str, variable: str, year: int) -> None:
|
|
143
|
+
"""Sample one (variable, year) NetCDF at the grid point and store
|
|
144
|
+
the whole year's series."""
|
|
145
|
+
import xarray as xr
|
|
146
|
+
slat, slon = (float(v) for v in pid.split(','))
|
|
147
|
+
url = s.ozwald.get_url(cadence, variable, year)
|
|
148
|
+
ds = xr.open_dataset(url)
|
|
149
|
+
try:
|
|
150
|
+
data = ds.sel(latitude=slat, longitude=slon, method='nearest')[variable].load()
|
|
151
|
+
finally:
|
|
152
|
+
ds.close()
|
|
153
|
+
# Some yearly files carry int32 time — normalise to datetime64.
|
|
154
|
+
times = pd.to_datetime(data.time.values)
|
|
155
|
+
values = data.values
|
|
156
|
+
with db:
|
|
157
|
+
db.executemany(
|
|
158
|
+
'INSERT OR REPLACE INTO observations (point, cadence, variable, date, value) '
|
|
159
|
+
'VALUES (?, ?, ?, ?, ?)',
|
|
160
|
+
[(pid, cadence, variable, str(t.date()),
|
|
161
|
+
None if pd.isna(v) else float(v))
|
|
162
|
+
for t, v in zip(times, values)],
|
|
163
|
+
)
|
|
164
|
+
db.execute(
|
|
165
|
+
'INSERT OR REPLACE INTO coverage (point, cadence, variable, year, through) '
|
|
166
|
+
'VALUES (?, ?, ?, ?, ?)',
|
|
167
|
+
(pid, cadence, variable, year, str(times.max().date())),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# -- read -------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def get_df(s, lat: float, lon: float, start: date, end: date,
|
|
173
|
+
cadence: str = 'daily', variables: list[str] = None) -> pd.DataFrame:
|
|
174
|
+
"""Return the OzWALD table for ``(lat, lon)`` x ``[start, end]``,
|
|
175
|
+
fetching only what's missing first.
|
|
176
|
+
|
|
177
|
+
Troi-agnostic — the data layer of the package. Pipelines that
|
|
178
|
+
speak :class:`troi.Troi` use :meth:`get_df_troi`.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
lat: Latitude in decimal degrees (EPSG:4326).
|
|
182
|
+
lon: Longitude in decimal degrees.
|
|
183
|
+
start: Inclusive start date.
|
|
184
|
+
end: Inclusive end date.
|
|
185
|
+
cadence: ``'daily'`` (meteorology, ~5 km) or ``'8day'``
|
|
186
|
+
(biophysical, ~500 m).
|
|
187
|
+
variables: Subset of the cadence's catalog; all of it if None.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
pandas.DataFrame: One row per timestep, a ``time`` column
|
|
191
|
+
(datetime64) plus one column per variable.
|
|
192
|
+
"""
|
|
193
|
+
s.fill(lat, lon, start, end, cadence=cadence, variables=variables)
|
|
194
|
+
pid, variables = s._resolve(lat, lon, cadence, variables)
|
|
195
|
+
db = s._db()
|
|
196
|
+
try:
|
|
197
|
+
long = pd.read_sql_query(
|
|
198
|
+
'SELECT date, variable, value FROM observations '
|
|
199
|
+
'WHERE point=? AND cadence=? AND variable IN (%s) '
|
|
200
|
+
'AND date >= ? AND date <= ? ORDER BY date'
|
|
201
|
+
% ','.join('?' * len(variables)),
|
|
202
|
+
db, params=(pid, cadence, *variables, str(start), str(end)),
|
|
203
|
+
)
|
|
204
|
+
finally:
|
|
205
|
+
db.close()
|
|
206
|
+
df = long.pivot(index='date', columns='variable', values='value').reset_index()
|
|
207
|
+
df.columns.name = None
|
|
208
|
+
df = df.rename(columns={'date': 'time'})
|
|
209
|
+
df['time'] = pd.to_datetime(df['time'])
|
|
210
|
+
return df
|
|
211
|
+
|
|
212
|
+
# -- Troi adapters (the reproducibility layer speaks Troi) ----------
|
|
213
|
+
|
|
214
|
+
def fill_troi(s, troi, cadence: str = 'daily', variables: list[str] = None) -> int:
|
|
215
|
+
""":meth:`fill` at the centre of a :class:`troi.Troi`."""
|
|
216
|
+
return s.fill(troi.centre_lat, troi.centre_lon, troi.start, troi.end,
|
|
217
|
+
cadence=cadence, variables=variables)
|
|
218
|
+
|
|
219
|
+
def get_df_troi(s, troi, cadence: str = 'daily', variables: list[str] = None) -> pd.DataFrame:
|
|
220
|
+
""":meth:`get_df` at the centre of a :class:`troi.Troi`."""
|
|
221
|
+
return s.get_df(troi.centre_lat, troi.centre_lon, troi.start, troi.end,
|
|
222
|
+
cadence=cadence, variables=variables)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# -- offline tests (synthetic rows, no network) -----------------------------
|
|
226
|
+
|
|
227
|
+
def _tmp_store() -> Store:
|
|
228
|
+
import tempfile
|
|
229
|
+
tmpdir = tempfile.mkdtemp(prefix='pyozwald_store_test_')
|
|
230
|
+
return Store(config=Config(out_dir=tmpdir, tmp_dir=tmpdir))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _prime(store: Store, pid: str, cadence: str, variable: str, year: int,
|
|
234
|
+
through: date, value: float = 1.0):
|
|
235
|
+
"""Insert a synthetic (variable, year) cell directly, bypassing the network."""
|
|
236
|
+
db = store._db()
|
|
237
|
+
days = pd.date_range(date(year, 1, 1), through, freq='D' if cadence == 'daily' else '8D')
|
|
238
|
+
with db:
|
|
239
|
+
db.executemany(
|
|
240
|
+
'INSERT OR REPLACE INTO observations (point, cadence, variable, date, value) '
|
|
241
|
+
'VALUES (?, ?, ?, ?, ?)',
|
|
242
|
+
[(pid, cadence, variable, str(d.date()), value) for d in days],
|
|
243
|
+
)
|
|
244
|
+
db.execute(
|
|
245
|
+
'INSERT OR REPLACE INTO coverage (point, cadence, variable, year, through) '
|
|
246
|
+
'VALUES (?, ?, ?, ?, ?)',
|
|
247
|
+
(pid, cadence, variable, year, str(through)),
|
|
248
|
+
)
|
|
249
|
+
db.close()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_year_covered_semantics():
|
|
253
|
+
complete = year_covered('2023-12-31', 2023, date(2024, 6, 30))
|
|
254
|
+
lagging = year_covered('2024-05-01', 2024, date(2024, 6, 30))
|
|
255
|
+
enough = year_covered('2024-05-01', 2024, date(2024, 4, 1))
|
|
256
|
+
never = year_covered(None, 2023, date(2023, 6, 30))
|
|
257
|
+
return complete and not lagging and enough and not never
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_fill_skips_covered_cells():
|
|
261
|
+
store = _tmp_store()
|
|
262
|
+
lat, lon = -33.516, 148.373
|
|
263
|
+
pid = grid.point_id(lat, lon, 0.05)
|
|
264
|
+
_prime(store, pid, 'daily', 'Tmax', 2023, date(2023, 12, 31))
|
|
265
|
+
return store.fill(lat, lon, date(2023, 3, 1), date(2023, 9, 30),
|
|
266
|
+
variables=['Tmax']) == 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_read_pivots_wide():
|
|
270
|
+
store = _tmp_store()
|
|
271
|
+
lat, lon = -33.516, 148.373
|
|
272
|
+
pid = grid.point_id(lat, lon, 0.05)
|
|
273
|
+
_prime(store, pid, 'daily', 'Tmax', 2023, date(2023, 12, 31), value=31.5)
|
|
274
|
+
_prime(store, pid, 'daily', 'Tmin', 2023, date(2023, 12, 31), value=12.5)
|
|
275
|
+
df = store.get_df(lat, lon, date(2023, 1, 1), date(2023, 12, 31),
|
|
276
|
+
variables=['Tmax', 'Tmin'])
|
|
277
|
+
return (
|
|
278
|
+
len(df) == 365
|
|
279
|
+
and float(df['Tmax'].iloc[0]) == 31.5
|
|
280
|
+
and float(df['Tmin'].iloc[0]) == 12.5
|
|
281
|
+
and str(df['time'].dtype).startswith('datetime64')
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_nearby_coordinate_shares_daily_cell():
|
|
286
|
+
store = _tmp_store()
|
|
287
|
+
pid = grid.point_id(-33.514, 148.371, 0.05)
|
|
288
|
+
_prime(store, pid, 'daily', 'Pg', 2023, date(2023, 12, 31))
|
|
289
|
+
return store.fill(-33.516, 148.373, date(2023, 1, 1), date(2023, 12, 31),
|
|
290
|
+
variables=['Pg']) == 0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_cadences_are_independent():
|
|
294
|
+
"""A covered daily cell must not satisfy an 8day request."""
|
|
295
|
+
store = _tmp_store()
|
|
296
|
+
lat, lon = -33.516, 148.373
|
|
297
|
+
_prime(store, grid.point_id(lat, lon, 0.05), 'daily', 'Tmax', 2023, date(2023, 12, 31))
|
|
298
|
+
db = store._db()
|
|
299
|
+
row = db.execute(
|
|
300
|
+
'SELECT through FROM coverage WHERE point=? AND cadence=? AND variable=? AND year=?',
|
|
301
|
+
(grid.point_id(lat, lon, 0.005), '8day', 'NDVI', 2023),
|
|
302
|
+
).fetchone()
|
|
303
|
+
db.close()
|
|
304
|
+
return row is None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_unknown_variable_raises():
|
|
308
|
+
store = _tmp_store()
|
|
309
|
+
try:
|
|
310
|
+
store.fill(-33.5, 148.4, date(2023, 1, 1), date(2023, 2, 1), variables=['NDVI'])
|
|
311
|
+
except ValueError:
|
|
312
|
+
return True # NDVI is 8day, not daily
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test():
|
|
317
|
+
return all([
|
|
318
|
+
test_year_covered_semantics(),
|
|
319
|
+
test_fill_skips_covered_cells(),
|
|
320
|
+
test_read_pivots_wide(),
|
|
321
|
+
test_nearby_coordinate_shares_daily_cell(),
|
|
322
|
+
test_cadences_are_independent(),
|
|
323
|
+
test_unknown_variable_raises(),
|
|
324
|
+
])
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == '__main__':
|
|
328
|
+
print(test())
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyozwald
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cached OzWALD daily meteorology and 8-day biophysical series for Australia — fetch once per grid point, never twice
|
|
5
|
+
Author: Borevitz Lab, Australian National University
|
|
6
|
+
Author-email: Yasar Adeel Ansari <u6737670@anu.edu.au>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/thestochasticman/pyozwald
|
|
9
|
+
Project-URL: Repository, https://github.com/thestochasticman/pyozwald
|
|
10
|
+
Project-URL: Issues, https://github.com/thestochasticman/pyozwald/issues
|
|
11
|
+
Keywords: ozwald,climate,australia,remote-sensing,agriculture,time-series
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: attrs
|
|
24
|
+
Requires-Dist: typing_extensions
|
|
25
|
+
Requires-Dist: pandas
|
|
26
|
+
Requires-Dist: xarray
|
|
27
|
+
Requires-Dist: netcdf4
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# pyozwald
|
|
31
|
+
|
|
32
|
+
**Cached [OzWALD](https://www.wenfo.org/ozwald/) time series for
|
|
33
|
+
Australia — fetch once per grid point, never twice.** OzWALD is ANU's
|
|
34
|
+
Water and Landscape Dynamics dataset: modelled daily meteorology
|
|
35
|
+
(~5 km) and 8-day biophysical variables (~500 m, MODIS-derived) served
|
|
36
|
+
as one OPeNDAP NetCDF per variable per year. Every observation this
|
|
37
|
+
machine ever samples lands in one SQLite store, so repeat requests,
|
|
38
|
+
nearby coordinates in the same cell, and extended date ranges all
|
|
39
|
+
reuse the same rows. Part of the
|
|
40
|
+
[Borevitz Lab](https://borevitzlab.anu.edu.au/) ecosystem.
|
|
41
|
+
|
|
42
|
+
## How it works
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
{data_root}/ozwald_store/
|
|
46
|
+
└── ozwald.db
|
|
47
|
+
├── observations(point, cadence, variable, date, value)
|
|
48
|
+
└── coverage(point, cadence, variable, year, through)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- Coordinates snap to a dedup grid matching each product's native
|
|
52
|
+
resolution — 0.05° for daily meteorology, 0.005° for the 8-day
|
|
53
|
+
variables — so nearby requests share one stored series per cadence.
|
|
54
|
+
- OzWALD's unit of delivery is one NetCDF per (variable, year), so
|
|
55
|
+
that's the unit of the coverage ledger. `Store.get_df(...)` diffs
|
|
56
|
+
the requested years × variables against it and samples **only the
|
|
57
|
+
missing cells**; a whole year is stored even when a sub-range was
|
|
58
|
+
requested, since the marginal cost is nil and it maximises reuse.
|
|
59
|
+
- `through` records the last date a year's file actually contained —
|
|
60
|
+
an in-progress year keeps being re-fetched until complete, then
|
|
61
|
+
never again.
|
|
62
|
+
- Writes are transactional (SQLite/WAL): a crash mid-fetch leaves the
|
|
63
|
+
cell unrecorded, and the next run re-fetches it.
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
The core API is **troi-agnostic** — a coordinate, dates, and a cadence:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from datetime import date
|
|
71
|
+
from pyozwald.store import Store
|
|
72
|
+
|
|
73
|
+
store = Store()
|
|
74
|
+
|
|
75
|
+
met = store.get_df(-33.516, 148.373, date(2023, 1, 1), date(2023, 12, 31))
|
|
76
|
+
# daily meteorology: time, Pg, Tmax, Tmin, Uavg, Ueff, VPeff, ...
|
|
77
|
+
|
|
78
|
+
veg = store.get_df(-33.516, 148.373, date(2023, 1, 1), date(2023, 12, 31),
|
|
79
|
+
cadence='8day', variables=['NDVI', 'LAI', 'GPP'])
|
|
80
|
+
# 8-day biophysical series on the ~500 m grid
|
|
81
|
+
|
|
82
|
+
store.fill(-33.516, 148.373, date(2023, 1, 1), date(2023, 12, 31)) # → 0: already local
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Pipelines that speak the shared `troi.troi.Troi` use the
|
|
86
|
+
adapters (evaluated at the bbox centre):
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
df = store.get_df_troi(troi, cadence='daily')
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`download_ozwald_daily(troi)` and `download_ozwald_8day(troi)`
|
|
93
|
+
remain as thin wrappers.
|
|
94
|
+
|
|
95
|
+
## Performance
|
|
96
|
+
|
|
97
|
+
Live measurements against NCI THREDDS — one grid point:
|
|
98
|
+
|
|
99
|
+
| Scenario | Fetched | Time |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| Cold fill — 2 daily variables × 1 year | 2 cells | 2.3 s |
|
|
102
|
+
| Same request again | nothing | **0.0 s** |
|
|
103
|
+
| Nearby coordinate, same ~5 km cell | nothing | **0.0 s** |
|
|
104
|
+
| Date range extended −1 year | 2 cells — *the new year only* | 1.8 s |
|
|
105
|
+
| 8-day NDVI, one year | 1 cell | 0.7 s |
|
|
106
|
+
| Read cached year (365 × 2) | — | 0.01 s |
|
|
107
|
+
|
|
108
|
+
(One *cell* = one variable × one year at one grid point.) Store
|
|
109
|
+
footprint: ~100 KB for the five cells above. Absolute times vary with
|
|
110
|
+
network and THREDDS load; the zeros are the point — they are ledger
|
|
111
|
+
lookups, no network involved.
|
|
112
|
+
|
|
113
|
+
## Install
|
|
114
|
+
|
|
115
|
+
### pip
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install git+https://github.com/thestochasticman/pyozwald.git
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Dependencies (the `troi` core included, pulled from GitHub) are
|
|
122
|
+
declared in `pyproject.toml` and installed automatically.
|
|
123
|
+
|
|
124
|
+
### From source
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
git clone https://github.com/thestochasticman/pyozwald.git
|
|
128
|
+
cd pyozwald
|
|
129
|
+
pip install -e .
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Package design (shared across the lab's packages — no inheritance,
|
|
133
|
+
composition only):
|
|
134
|
+
|
|
135
|
+
- **`Troi`** (from `troi`) — identity: what region, what dates.
|
|
136
|
+
- **`OzWALD`** (`pyozwald.ozwald`) — config: endpoint, variable
|
|
137
|
+
catalogs per cadence, dedup grid steps.
|
|
138
|
+
- **`Paths`** (`pyozwald.paths`) — derived location of the store for a
|
|
139
|
+
given `Config`.
|
|
140
|
+
- **`grid`** — the dedup grids (pure, offline-testable math).
|
|
141
|
+
- **`Store`** (`pyozwald.store`) — ties them together.
|
|
142
|
+
|
|
143
|
+
## Test
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
# offline (pure math + synthetic store):
|
|
147
|
+
python pyozwald/grid.py # True
|
|
148
|
+
python pyozwald/paths.py # True
|
|
149
|
+
python pyozwald/store.py # True
|
|
150
|
+
|
|
151
|
+
# live (small real samples from NCI THREDDS, incl. dedup assertions):
|
|
152
|
+
python pyozwald/download_ozwald.py # True
|
|
153
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
pyozwald/__init__.py
|
|
5
|
+
pyozwald/download_ozwald.py
|
|
6
|
+
pyozwald/grid.py
|
|
7
|
+
pyozwald/ozwald.py
|
|
8
|
+
pyozwald/paths.py
|
|
9
|
+
pyozwald/store.py
|
|
10
|
+
pyozwald.egg-info/PKG-INFO
|
|
11
|
+
pyozwald.egg-info/SOURCES.txt
|
|
12
|
+
pyozwald.egg-info/dependency_links.txt
|
|
13
|
+
pyozwald.egg-info/requires.txt
|
|
14
|
+
pyozwald.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyozwald
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyozwald"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Cached OzWALD daily meteorology and 8-day biophysical series for Australia — fetch once per grid point, never twice"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Borevitz Lab, Australian National University" },
|
|
12
|
+
{ name = "Yasar Adeel Ansari", email = "u6737670@anu.edu.au" },
|
|
13
|
+
]
|
|
14
|
+
license = { text = "MIT" }
|
|
15
|
+
requires-python = ">=3.11"
|
|
16
|
+
keywords = [
|
|
17
|
+
"ozwald",
|
|
18
|
+
"climate",
|
|
19
|
+
"australia",
|
|
20
|
+
"remote-sensing",
|
|
21
|
+
"agriculture",
|
|
22
|
+
"time-series",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Intended Audience :: Science/Research",
|
|
27
|
+
"License :: OSI Approved :: MIT License",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Topic :: Scientific/Engineering :: Atmospheric Science",
|
|
33
|
+
]
|
|
34
|
+
# The scientific stack (pandas, xarray, netcdf4) is provided by conda via
|
|
35
|
+
# environment.yml; only the lab core is declared here so `pip install -e .`
|
|
36
|
+
# is fast.
|
|
37
|
+
dependencies = [
|
|
38
|
+
"attrs",
|
|
39
|
+
"typing_extensions",
|
|
40
|
+
"pandas",
|
|
41
|
+
"xarray",
|
|
42
|
+
"netcdf4",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://github.com/thestochasticman/pyozwald"
|
|
47
|
+
Repository = "https://github.com/thestochasticman/pyozwald"
|
|
48
|
+
Issues = "https://github.com/thestochasticman/pyozwald/issues"
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.packages.find]
|
|
51
|
+
include = ["pyozwald", "pyozwald.*"]
|
pyozwald-0.1.0/setup.cfg
ADDED