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 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.
@@ -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,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,8 @@
1
+ from pyozwald.ozwald import OzWALD, defaultozwald
2
+ from pyozwald.paths import Paths
3
+
4
+ __all__ = [
5
+ 'OzWALD',
6
+ 'defaultozwald',
7
+ 'Paths',
8
+ ]
@@ -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,5 @@
1
+ attrs
2
+ typing_extensions
3
+ pandas
4
+ xarray
5
+ netcdf4
@@ -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.*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+