dwdpheno 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dwdpheno/__init__.py +31 -0
- dwdpheno/__main__.py +5 -0
- dwdpheno/_catalog.py +398 -0
- dwdpheno/_download.py +211 -0
- dwdpheno/_http.py +136 -0
- dwdpheno/_plot.py +357 -0
- dwdpheno/_quality.py +58 -0
- dwdpheno/_series.py +279 -0
- dwdpheno/_stations.py +354 -0
- dwdpheno/_text.py +71 -0
- dwdpheno/cli.py +299 -0
- dwdpheno/data/cities.txt +7127 -0
- dwdpheno/data/counties.geojson +1 -0
- dwdpheno/data/regions.txt +80184 -0
- dwdpheno/data/states.geojson +1 -0
- dwdpheno-1.0.0.dist-info/METADATA +679 -0
- dwdpheno-1.0.0.dist-info/RECORD +21 -0
- dwdpheno-1.0.0.dist-info/WHEEL +5 -0
- dwdpheno-1.0.0.dist-info/entry_points.txt +2 -0
- dwdpheno-1.0.0.dist-info/licenses/LICENSE +21 -0
- dwdpheno-1.0.0.dist-info/top_level.txt +1 -0
dwdpheno/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""dwdpheno - download plant-phenology observations of the German Weather Service (DWD).
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import dwdpheno as dp
|
|
6
|
+
|
|
7
|
+
st = dp.stations(county="Kassel rural district")
|
|
8
|
+
obs = dp.download("winter wheat", years=(2015, 2024), county="Kassel rural district")
|
|
9
|
+
curves = dp.series(obs)
|
|
10
|
+
stats = dp.summary(obs)
|
|
11
|
+
dp.plot_station(obs, station_id=obs.station_id.iloc[0])
|
|
12
|
+
|
|
13
|
+
Data: Deutscher Wetterdienst (DWD) Climate Data Center, CC BY 4.0.
|
|
14
|
+
"""
|
|
15
|
+
__version__ = "1.0.0"
|
|
16
|
+
|
|
17
|
+
from ._http import DWDPhenoError, NetworkError, NoMatchError, default_cache_dir
|
|
18
|
+
from ._text import fold, matches
|
|
19
|
+
from ._catalog import CROP_COLUMNS, PHASE_COLUMNS, bbch_for, crops, find_bbch, phases
|
|
20
|
+
from ._download import OBS_COLUMNS, QUALITY_BYTE, QUALITY_LEVEL, download
|
|
21
|
+
from ._plot import plot_phase_trend, plot_series, plot_station
|
|
22
|
+
from ._series import BBCH_PRINCIPAL_STAGES, SERIES_COLUMNS, SUMMARY_COLUMNS, pchip, series, summary
|
|
23
|
+
from ._stations import STATION_COLUMNS, assign_counties, places, select_stations, stations
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"__version__", "stations", "select_stations", "places", "crops", "phases", "download",
|
|
27
|
+
"series", "summary", "plot_series", "plot_station", "plot_phase_trend", "find_bbch", "bbch_for", "fold", "matches",
|
|
28
|
+
"assign_counties", "pchip", "default_cache_dir", "QUALITY_BYTE", "QUALITY_LEVEL",
|
|
29
|
+
"BBCH_PRINCIPAL_STAGES", "STATION_COLUMNS", "CROP_COLUMNS", "PHASE_COLUMNS", "OBS_COLUMNS",
|
|
30
|
+
"SERIES_COLUMNS", "SUMMARY_COLUMNS", "DWDPhenoError", "NoMatchError", "NetworkError",
|
|
31
|
+
]
|
dwdpheno/__main__.py
ADDED
dwdpheno/_catalog.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""The DWD phenology catalog: directory listings, data files, plant / phase names, BBCH codes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
from typing import Dict, List, NamedTuple, Optional, Sequence, Union
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from . import _http
|
|
12
|
+
from ._text import as_list, select_names, squash
|
|
13
|
+
|
|
14
|
+
BASE_URL = "https://opendata.dwd.de/climate_environment/CDC/observations_germany/phenology"
|
|
15
|
+
HELP_URL = "https://opendata.dwd.de/climate_environment/CDC/help"
|
|
16
|
+
PLANT_URL = f"{HELP_URL}/PH_Beschreibung_Pflanze.txt"
|
|
17
|
+
PHASE_URL = f"{HELP_URL}/PH_Beschreibung_Phase.txt"
|
|
18
|
+
|
|
19
|
+
REPORTERS = {"annual": ("annual_reporters", "Jahresmelder"),
|
|
20
|
+
"immediate": ("immediate_reporters", "Sofortmelder")}
|
|
21
|
+
GROUPS = {"annual": ("crops", "wild", "fruit", "vine", "farming"),
|
|
22
|
+
"immediate": ("crops", "wild", "fruit", "vine")}
|
|
23
|
+
PARTITIONS = ("historical", "recent")
|
|
24
|
+
GROUP_TOKENS = ("Landwirtschaft_Kulturpflanze", "Wildwachsende_Pflanze", "Obst",
|
|
25
|
+
"Weinrebe", "Wein", "Feldarbeit", "Weidegang")
|
|
26
|
+
|
|
27
|
+
CROP_COLUMNS = ["crop", "crop_de", "species", "objekt_id", "dwd_token", "group", "reporters",
|
|
28
|
+
"first_year", "last_year", "files"]
|
|
29
|
+
PHASE_COLUMNS = ["objekt_id", "crop", "phase_id", "phase_name", "bbch_code"]
|
|
30
|
+
|
|
31
|
+
# fixed exceptions of the token -> plant rule
|
|
32
|
+
TOKEN_OBJEKT = {"Rueben_ohne_Sortenangabe": 250}
|
|
33
|
+
ENGLISH_OVERRIDE = {250: "beet (variety unknown)"} # DWD calls both 25 and 250 "beet"
|
|
34
|
+
|
|
35
|
+
_HREF = re.compile(r'<a\s+href="([^"?#]+)"', re.IGNORECASE)
|
|
36
|
+
_GROUP_ALT = "|".join(sorted(GROUP_TOKENS, key=len, reverse=True))
|
|
37
|
+
_DATA_RE = {
|
|
38
|
+
rep: re.compile(rf"^PH_{infix}_({_GROUP_ALT})_(.+?)"
|
|
39
|
+
rf"(?:_(\d{{4}})_(\d{{4}})_hist|_(hist)|_(akt))\.txt$")
|
|
40
|
+
for rep, (_, infix) in REPORTERS.items()
|
|
41
|
+
}
|
|
42
|
+
_RECORD_END = re.compile(r";\s*eor\s*;", re.IGNORECASE)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DataFile(NamedTuple):
|
|
46
|
+
reporter: str
|
|
47
|
+
group: str
|
|
48
|
+
partition: str
|
|
49
|
+
group_token: str
|
|
50
|
+
crop: str # the DWD crop token, e.g. "Apfel_fruehe_Reife"
|
|
51
|
+
y0: Optional[int]
|
|
52
|
+
y1: Optional[int]
|
|
53
|
+
filename: str
|
|
54
|
+
url: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _net(cache=None, offline=False, max_age_days=1.0) -> dict:
|
|
58
|
+
return dict(cache=cache, offline=offline, max_age_days=max_age_days)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------------------------
|
|
62
|
+
# DWD text tables
|
|
63
|
+
# ---------------------------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def read_table(raw: bytes) -> pd.DataFrame:
|
|
66
|
+
"""Parse a small DWD text table as strings.
|
|
67
|
+
|
|
68
|
+
DWD files are ``;``-separated, blank-padded, windows-1252, and every record ends in
|
|
69
|
+
``eor;`` (sometimes ``eor ;``). Records may contain line breaks, so they are split on the
|
|
70
|
+
record end, not on newlines. Empty cells become missing.
|
|
71
|
+
"""
|
|
72
|
+
text = raw.decode("cp1252", errors="replace")
|
|
73
|
+
parts = _RECORD_END.split(text)
|
|
74
|
+
if len(parts) < 2: # no record ends at all: one record per line
|
|
75
|
+
parts = text.splitlines()
|
|
76
|
+
header = [h.strip() for h in parts[0].split(";")]
|
|
77
|
+
while header and header[-1] in ("", "eor"):
|
|
78
|
+
header.pop()
|
|
79
|
+
n = len(header)
|
|
80
|
+
rows = []
|
|
81
|
+
for rec in parts[1:]:
|
|
82
|
+
rec = " ".join(rec.replace("\x1a", " ").split())
|
|
83
|
+
if not rec:
|
|
84
|
+
continue
|
|
85
|
+
cells = [c.strip() for c in rec.split(";")]
|
|
86
|
+
rows.append([c or None for c in (cells + [""] * n)[:n]])
|
|
87
|
+
return pd.DataFrame(rows, columns=header, dtype=object)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_missing_text(s: pd.Series) -> pd.Series:
|
|
91
|
+
return s.isna() | s.astype("string").str.fullmatch(r"-*").fillna(True).astype(bool)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_plants(raw: bytes) -> pd.DataFrame:
|
|
95
|
+
"""``PH_Beschreibung_Pflanze.txt`` → ``objekt_id, crop_de, crop, species``."""
|
|
96
|
+
df = read_table(raw)
|
|
97
|
+
df.columns = [c.lower() for c in df.columns]
|
|
98
|
+
out = pd.DataFrame({
|
|
99
|
+
"objekt_id": pd.to_numeric(df["objekt_id"], errors="coerce").astype("Int64"),
|
|
100
|
+
"crop_de": df["objekt"].astype("string"),
|
|
101
|
+
"crop": df["objekt_englisch"].astype("string"),
|
|
102
|
+
"species": df["objekt_latein"].astype("string"),
|
|
103
|
+
}).dropna(subset=["objekt_id"])
|
|
104
|
+
for c in ("crop", "species"):
|
|
105
|
+
out[c] = out[c].where(~_is_missing_text(out[c]), None)
|
|
106
|
+
for oid, name in ENGLISH_OVERRIDE.items():
|
|
107
|
+
out.loc[out["objekt_id"] == oid, "crop"] = name
|
|
108
|
+
out["crop"] = out["crop"].fillna(out["crop_de"])
|
|
109
|
+
return out.drop_duplicates("objekt_id").reset_index(drop=True)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_phase_names(raw: bytes) -> pd.DataFrame:
|
|
113
|
+
"""``PH_Beschreibung_Phase.txt`` → ``phase_id, phase_name`` (English), ``phase_name_de``."""
|
|
114
|
+
df = read_table(raw)
|
|
115
|
+
df.columns = [c.lower() for c in df.columns]
|
|
116
|
+
out = pd.DataFrame({
|
|
117
|
+
"phase_id": pd.to_numeric(df["phase_id"], errors="coerce").astype("Int64"),
|
|
118
|
+
"phase_name": df["phase_englisch"].astype("string"),
|
|
119
|
+
"phase_name_de": df["phase"].astype("string"),
|
|
120
|
+
}).dropna(subset=["phase_id"])
|
|
121
|
+
out["phase_name"] = out["phase_name"].fillna(out["phase_name_de"])
|
|
122
|
+
return out.drop_duplicates("phase_id").reset_index(drop=True)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_PHASEDEF_RENAME = {"Objekt_id": "objekt_id", "Objekt": "objekt_de", "Phasen_id": "phase_id",
|
|
126
|
+
"Phase_id": "phase_id", "Phase": "phase_de", "BBCH_Code": "bbch_code"}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_phase_definitions(raw: bytes) -> pd.DataFrame:
|
|
130
|
+
"""Phase-definition file → ``objekt_id, objekt_de, phase_id, phase_de, bbch_code``."""
|
|
131
|
+
cols = ["objekt_id", "objekt_de", "phase_id", "phase_de", "bbch_code"]
|
|
132
|
+
df = read_table(raw).rename(columns=_PHASEDEF_RENAME)
|
|
133
|
+
if "objekt_id" not in df.columns or "phase_id" not in df.columns:
|
|
134
|
+
return pd.DataFrame(columns=cols)
|
|
135
|
+
for c in cols:
|
|
136
|
+
if c not in df.columns:
|
|
137
|
+
df[c] = None
|
|
138
|
+
df = df[cols].copy()
|
|
139
|
+
for c in ("objekt_id", "phase_id"):
|
|
140
|
+
df[c] = pd.to_numeric(df[c], errors="coerce").astype("Int64")
|
|
141
|
+
bbch = df["bbch_code"].astype("string").str.strip()
|
|
142
|
+
bbch = bbch.str.replace(r"^(-?\d+)\.0$", r"\1", regex=True)
|
|
143
|
+
df["bbch_code"] = bbch.where(bbch.str.match(r"^\d").fillna(False).astype(bool), None)
|
|
144
|
+
for c in ("objekt_de", "phase_de"):
|
|
145
|
+
df[c] = df[c].astype("string")
|
|
146
|
+
return df.dropna(subset=["objekt_id", "phase_id"])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def plants(**net) -> pd.DataFrame:
|
|
150
|
+
"""DWD plant names: ``objekt_id, crop_de, crop`` (English), ``species`` (Latin)."""
|
|
151
|
+
return parse_plants(_http.fetch(PLANT_URL, **_net(**net)))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def phase_names(**net) -> pd.DataFrame:
|
|
155
|
+
"""DWD phase names: ``phase_id, phase_name`` (English), ``phase_name_de``."""
|
|
156
|
+
return parse_phase_names(_http.fetch(PHASE_URL, **_net(**net)))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------------------------
|
|
160
|
+
# Listings and data files
|
|
161
|
+
# ---------------------------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def folder_url(reporter: str, group: str, partition: str) -> str:
|
|
164
|
+
return f"{BASE_URL}/{REPORTERS[reporter][0]}/{group}/{partition}/"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_listing(page: Union[bytes, str]) -> List[str]:
|
|
168
|
+
"""File names linked from an Apache/nginx directory index (no sub-folders)."""
|
|
169
|
+
if isinstance(page, bytes):
|
|
170
|
+
page = page.decode("utf-8", errors="replace")
|
|
171
|
+
out = []
|
|
172
|
+
for href in _HREF.findall(page):
|
|
173
|
+
name = html.unescape(href)
|
|
174
|
+
if name.endswith("/") or name.startswith(("..", "/")):
|
|
175
|
+
continue
|
|
176
|
+
out.append(name)
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def list_folder(reporter: str, group: str, partition: str, **net) -> List[str]:
|
|
181
|
+
"""File names in one DWD folder; a missing folder gives ``[]``."""
|
|
182
|
+
try:
|
|
183
|
+
return parse_listing(_http.fetch(folder_url(reporter, group, partition), **_net(**net)))
|
|
184
|
+
except _http.NetworkError as e:
|
|
185
|
+
if e.status in (404, 410):
|
|
186
|
+
return []
|
|
187
|
+
raise
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parse_data_filename(name: str, reporter: str):
|
|
191
|
+
"""``(group_token, crop_token, y0, y1)`` of a data file name, else ``None``.
|
|
192
|
+
|
|
193
|
+
``..._hist.txt`` without a year range is a historical file with unknown years.
|
|
194
|
+
"""
|
|
195
|
+
m = _DATA_RE[reporter].match(name)
|
|
196
|
+
if not m:
|
|
197
|
+
return None
|
|
198
|
+
gtok, crop, y0, y1, _hist, _akt = m.groups()
|
|
199
|
+
if crop.endswith(("_Spezifizierung", "_Notiz")):
|
|
200
|
+
return None
|
|
201
|
+
return gtok, crop, (int(y0) if y0 else None), (int(y1) if y1 else None)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def data_files(reporters: Sequence[str] = ("annual", "immediate"),
|
|
205
|
+
partitions: Sequence[str] = PARTITIONS,
|
|
206
|
+
groups: Optional[Sequence[str]] = None, **net) -> List[DataFile]:
|
|
207
|
+
"""All data files; of duplicate historical versions only the greatest ``y1`` is kept."""
|
|
208
|
+
best: Dict[tuple, DataFile] = {}
|
|
209
|
+
for rep in reporters:
|
|
210
|
+
for grp in GROUPS[rep]:
|
|
211
|
+
if groups is not None and grp not in groups:
|
|
212
|
+
continue
|
|
213
|
+
for part in partitions:
|
|
214
|
+
for name in list_folder(rep, grp, part, **net):
|
|
215
|
+
p = parse_data_filename(name, rep)
|
|
216
|
+
if p is None:
|
|
217
|
+
continue
|
|
218
|
+
gtok, crop, y0, y1 = p
|
|
219
|
+
f = DataFile(rep, grp, part, gtok, crop, y0, y1, name,
|
|
220
|
+
folder_url(rep, grp, part) + name)
|
|
221
|
+
key = (rep, grp, part, gtok, crop)
|
|
222
|
+
old = best.get(key)
|
|
223
|
+
if old is None or (y1 or 0, -(y0 or 0)) > (old.y1 or 0, -(old.y0 or 0)):
|
|
224
|
+
best[key] = f
|
|
225
|
+
return sorted(best.values(), key=lambda d: (d.crop, d.reporter, d.partition, d.group))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def plant_of_token(token: str, plant_table: pd.DataFrame) -> Optional[int]:
|
|
229
|
+
"""Objekt id of a file token: equal squashed German name, else the shortest German name
|
|
230
|
+
whose squashed form starts with the squashed token, else ``None``."""
|
|
231
|
+
if token in TOKEN_OBJEKT:
|
|
232
|
+
return TOKEN_OBJEKT[token]
|
|
233
|
+
st = squash(token)
|
|
234
|
+
names = [(int(o), str(n), squash(n)) for o, n in
|
|
235
|
+
zip(plant_table["objekt_id"], plant_table["crop_de"]) if pd.notna(n)]
|
|
236
|
+
exact = [o for o, _, s in names if s == st]
|
|
237
|
+
if exact:
|
|
238
|
+
return exact[0]
|
|
239
|
+
pref = sorted((len(n), o) for o, n, s in names if s.startswith(st))
|
|
240
|
+
return pref[0][1] if pref else None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------------------------
|
|
244
|
+
# Crop catalog and selection
|
|
245
|
+
# ---------------------------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def _catalog(files: Sequence[DataFile], plant_table: pd.DataFrame) -> pd.DataFrame:
|
|
248
|
+
by_id = plant_table.set_index("objekt_id")
|
|
249
|
+
rows: Dict[str, dict] = {}
|
|
250
|
+
for f in files:
|
|
251
|
+
r = rows.get(f.crop)
|
|
252
|
+
if r is None:
|
|
253
|
+
oid = plant_of_token(f.crop, plant_table)
|
|
254
|
+
if oid is not None and oid in by_id.index:
|
|
255
|
+
p = by_id.loc[oid]
|
|
256
|
+
names = (p["crop"], p["crop_de"], p["species"])
|
|
257
|
+
else:
|
|
258
|
+
oid, names = None, (f.crop.replace("_", " "), f.crop.replace("_", " "), None)
|
|
259
|
+
r = rows[f.crop] = {"crop": names[0], "crop_de": names[1], "species": names[2],
|
|
260
|
+
"objekt_id": oid, "dwd_token": f.crop, "group": f.group,
|
|
261
|
+
"reporters": set(), "first_year": None, "last_year": None,
|
|
262
|
+
"files": 0}
|
|
263
|
+
r["reporters"].add(f.reporter)
|
|
264
|
+
r["files"] += 1
|
|
265
|
+
if f.y0 is not None:
|
|
266
|
+
r["first_year"] = f.y0 if r["first_year"] is None else min(r["first_year"], f.y0)
|
|
267
|
+
if f.y1 is not None:
|
|
268
|
+
r["last_year"] = f.y1 if r["last_year"] is None else max(r["last_year"], f.y1)
|
|
269
|
+
if f.partition == "recent": # recent files run up to the current year
|
|
270
|
+
r["last_year"] = max(r["last_year"] or 0, datetime.date.today().year)
|
|
271
|
+
for r in rows.values():
|
|
272
|
+
r["reporters"] = ";".join(x for x in ("annual", "immediate") if x in r["reporters"])
|
|
273
|
+
df = pd.DataFrame(list(rows.values()), columns=CROP_COLUMNS)
|
|
274
|
+
for c in ("objekt_id", "first_year", "last_year", "files"):
|
|
275
|
+
df[c] = pd.to_numeric(df[c]).astype("Int64")
|
|
276
|
+
for c in ("crop", "crop_de", "species", "dwd_token", "group", "reporters"):
|
|
277
|
+
df[c] = df[c].astype("string")
|
|
278
|
+
df = df.assign(_k=df["crop"].str.lower()).sort_values(["group", "_k", "dwd_token"],
|
|
279
|
+
kind="stable")
|
|
280
|
+
return df.drop(columns="_k").reset_index(drop=True)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def crops(*, cache=None, offline: bool = False, max_age_days: float = 1.0) -> pd.DataFrame:
|
|
284
|
+
"""The crop / species catalog: one row per DWD crop token.
|
|
285
|
+
|
|
286
|
+
Columns ``crop`` (English), ``crop_de`` (German), ``species`` (Latin), ``objekt_id``,
|
|
287
|
+
``dwd_token``, ``group``, ``reporters`` (``annual``, ``immediate`` or ``annual;immediate``),
|
|
288
|
+
``first_year``/``last_year`` (from the historical file names; ``last_year`` is the current
|
|
289
|
+
year when a recent file exists) and ``files``.
|
|
290
|
+
Sorted by group and English name.
|
|
291
|
+
"""
|
|
292
|
+
net = _net(cache, offline, max_age_days)
|
|
293
|
+
return _catalog(data_files(**net), plants(**net))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def select_crops(queries: Sequence[str], catalog: pd.DataFrame) -> pd.DataFrame:
|
|
297
|
+
"""Catalog rows selected by crop queries (English, German or token; exact match first,
|
|
298
|
+
otherwise every query word must be a prefix of a word of one of the names)."""
|
|
299
|
+
cands = list(zip(catalog["crop"], catalog["crop_de"], catalog["dwd_token"]))
|
|
300
|
+
hit = sorted({i for q in queries for i in select_names(q, cands)})
|
|
301
|
+
return catalog.iloc[hit]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------------------------
|
|
305
|
+
# Phases and BBCH codes
|
|
306
|
+
# ---------------------------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def phase_files(reporters: Sequence[str] = ("annual", "immediate"),
|
|
309
|
+
groups: Optional[Sequence[str]] = None, **net) -> List[str]:
|
|
310
|
+
"""URLs of the phase-definition files (recent folder preferred over historical)."""
|
|
311
|
+
urls: Dict[tuple, str] = {}
|
|
312
|
+
for rep in reporters:
|
|
313
|
+
for grp in GROUPS[rep]:
|
|
314
|
+
if groups is not None and grp not in groups:
|
|
315
|
+
continue
|
|
316
|
+
for part in ("recent", "historical"):
|
|
317
|
+
for name in list_folder(rep, grp, part, **net):
|
|
318
|
+
if name.startswith("PH_Beschreibung_Phasendefinition_") and name.endswith(".txt"):
|
|
319
|
+
urls.setdefault((rep, grp, name), folder_url(rep, grp, part) + name)
|
|
320
|
+
return list(urls.values())
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def load_phase_definitions(reporters: Sequence[str] = ("annual", "immediate"),
|
|
324
|
+
groups: Optional[Sequence[str]] = None, **net) -> pd.DataFrame:
|
|
325
|
+
"""All phase definitions, one row per ``(objekt_id, phase_id)`` (rows with BBCH preferred)."""
|
|
326
|
+
frames = []
|
|
327
|
+
for url in phase_files(reporters, groups, **net):
|
|
328
|
+
try:
|
|
329
|
+
frames.append(parse_phase_definitions(_http.fetch(url, **_net(**net))))
|
|
330
|
+
except _http.NetworkError as e:
|
|
331
|
+
if e.status not in (404, 410):
|
|
332
|
+
raise
|
|
333
|
+
frames = [f for f in frames if len(f)]
|
|
334
|
+
if not frames:
|
|
335
|
+
return parse_phase_definitions(b"")
|
|
336
|
+
df = pd.concat(frames, ignore_index=True)
|
|
337
|
+
df = df.assign(_nobbch=df["bbch_code"].isna())
|
|
338
|
+
df = df.sort_values(["objekt_id", "phase_id", "_nobbch"], kind="stable")
|
|
339
|
+
df = df.drop_duplicates(["objekt_id", "phase_id"], keep="first")
|
|
340
|
+
return df.drop(columns="_nobbch").reset_index(drop=True)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def name_phases(defs: pd.DataFrame, plant_table: pd.DataFrame,
|
|
344
|
+
phase_table: pd.DataFrame) -> pd.DataFrame:
|
|
345
|
+
"""Attach English plant and phase names to phase definitions (German as fallback)."""
|
|
346
|
+
df = defs.merge(plant_table[["objekt_id", "crop"]], on="objekt_id", how="left")
|
|
347
|
+
df = df.merge(phase_table[["phase_id", "phase_name"]], on="phase_id", how="left")
|
|
348
|
+
df["crop"] = df["crop"].astype("string").fillna(df["objekt_de"])
|
|
349
|
+
df["phase_name"] = df["phase_name"].astype("string").fillna(df["phase_de"])
|
|
350
|
+
return df
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def phases(crops: Union[None, str, Sequence[str]] = None, *, cache=None, offline: bool = False,
|
|
354
|
+
max_age_days: float = 1.0) -> pd.DataFrame:
|
|
355
|
+
"""Phases with BBCH codes, optionally only for the given crops.
|
|
356
|
+
|
|
357
|
+
Columns ``objekt_id, crop, phase_id, phase_name, bbch_code`` (English names; ``bbch_code``
|
|
358
|
+
is missing when DWD defines none).
|
|
359
|
+
"""
|
|
360
|
+
net = _net(cache, offline, max_age_days)
|
|
361
|
+
plant_table = plants(**net)
|
|
362
|
+
df = name_phases(load_phase_definitions(**net), plant_table, phase_names(**net))
|
|
363
|
+
qs = as_list(crops)
|
|
364
|
+
if qs:
|
|
365
|
+
cat = select_crops(qs, _catalog(data_files(**net), plant_table))
|
|
366
|
+
df = df[df["objekt_id"].isin(cat["objekt_id"].dropna().tolist())]
|
|
367
|
+
df = df.sort_values(["objekt_id", "phase_id"], kind="stable")
|
|
368
|
+
return df[PHASE_COLUMNS].reset_index(drop=True)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
_PHASE_CACHE: Dict[tuple, pd.DataFrame] = {}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def find_bbch(phase_id: int, objekt_id: Optional[int] = None, *,
|
|
375
|
+
phase_table: Optional[pd.DataFrame] = None, **net) -> pd.DataFrame:
|
|
376
|
+
"""Phase rows (with BBCH code) for a DWD ``phase_id``, optionally for one ``objekt_id``.
|
|
377
|
+
|
|
378
|
+
The same ``phase_id`` means different things for different plants; pass ``objekt_id``
|
|
379
|
+
(e.g. 202 = winter wheat, 310 = apple) to disambiguate.
|
|
380
|
+
"""
|
|
381
|
+
if phase_table is None:
|
|
382
|
+
key = tuple(sorted((k, str(v)) for k, v in net.items()))
|
|
383
|
+
if key not in _PHASE_CACHE:
|
|
384
|
+
_PHASE_CACHE[key] = phases(**net)
|
|
385
|
+
phase_table = _PHASE_CACHE[key]
|
|
386
|
+
df = phase_table[phase_table["phase_id"] == phase_id]
|
|
387
|
+
if objekt_id is not None:
|
|
388
|
+
df = df[df["objekt_id"] == objekt_id]
|
|
389
|
+
return df.reset_index(drop=True)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def bbch_for(phase_id: int, objekt_id: int, *, phase_table: Optional[pd.DataFrame] = None,
|
|
393
|
+
**net) -> Optional[str]:
|
|
394
|
+
"""The BBCH code (string) of one plant's phase, or ``None``."""
|
|
395
|
+
rows = find_bbch(phase_id, objekt_id, phase_table=phase_table, **net)
|
|
396
|
+
if not len(rows) or pd.isna(rows.iloc[0]["bbch_code"]):
|
|
397
|
+
return None
|
|
398
|
+
return str(rows.iloc[0]["bbch_code"])
|
dwdpheno/_download.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Download tidy phenological observations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Callable, Iterable, Optional, Sequence, Set, Tuple, Union
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from . import _http
|
|
11
|
+
from ._text import as_list
|
|
12
|
+
from . import _catalog
|
|
13
|
+
from ._catalog import PARTITIONS
|
|
14
|
+
from ._quality import (QUALITY_BYTE, QUALITY_LEVEL, QualityArg, filter_quality, meanings,
|
|
15
|
+
parse_codes)
|
|
16
|
+
from ._stations import select_stations, station_table
|
|
17
|
+
|
|
18
|
+
OBS_COLUMNS = ["station_id", "station_name", "latitude", "longitude", "elevation_m", "state",
|
|
19
|
+
"county", "crop", "crop_de", "species", "objekt_id", "phase_id", "phase_name",
|
|
20
|
+
"bbch_code", "year", "date", "day_of_year", "quality_byte", "quality_byte_meaning",
|
|
21
|
+
"quality_level", "quality_level_meaning", "reporter", "partition", "source_file"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_RAW_COLUMNS = {"Stations_id": "station_id", "Referenzjahr": "year",
|
|
25
|
+
"Qualitaetsniveau": "quality_level", "Objekt_id": "objekt_id",
|
|
26
|
+
"Phase_id": "phase_id", "Eintrittsdatum": "date",
|
|
27
|
+
"Eintrittsdatum_QB": "quality_byte", "Jultag": "day_of_year"}
|
|
28
|
+
_INT_COLUMNS = ("station_id", "year", "quality_level", "objekt_id", "phase_id", "quality_byte",
|
|
29
|
+
"day_of_year")
|
|
30
|
+
|
|
31
|
+
YearsArg = Union[None, int, str, Tuple[int, int], Sequence[int]]
|
|
32
|
+
Progress = Callable[[int, int, str], None]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_years(years: YearsArg) -> Optional[Tuple[int, int, Optional[Set[int]]]]:
|
|
36
|
+
"""``None``; ``2005``; ``(y0, y1)`` (inclusive range); list of years; or the strings
|
|
37
|
+
``"2000-2020"``, ``"2005"``, ``"1990,1995"``. Returns ``(lo, hi, explicit_set_or_None)``."""
|
|
38
|
+
if years is None:
|
|
39
|
+
return None
|
|
40
|
+
if isinstance(years, str):
|
|
41
|
+
s = years.strip()
|
|
42
|
+
if "-" in s and "," not in s:
|
|
43
|
+
a, b = s.split("-", 1)
|
|
44
|
+
years = (int(a), int(b))
|
|
45
|
+
elif "," in s:
|
|
46
|
+
years = [int(y) for y in s.split(",") if y.strip()]
|
|
47
|
+
else:
|
|
48
|
+
years = int(s)
|
|
49
|
+
if isinstance(years, (int,)) and not isinstance(years, bool):
|
|
50
|
+
return int(years), int(years), None
|
|
51
|
+
if isinstance(years, tuple) and len(years) == 2:
|
|
52
|
+
lo, hi = sorted(int(y) for y in years)
|
|
53
|
+
return lo, hi, None
|
|
54
|
+
ys = sorted({int(y) for y in years})
|
|
55
|
+
if not ys:
|
|
56
|
+
raise ValueError("years is empty")
|
|
57
|
+
return ys[0], ys[-1], set(ys)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_observations(raw: bytes, station_ids: Optional[Iterable[int]] = None,
|
|
61
|
+
years: YearsArg = None, chunksize: int = 500_000) -> pd.DataFrame:
|
|
62
|
+
"""Parse a DWD phenology data file, keeping only ``station_ids`` and ``years``."""
|
|
63
|
+
yr = parse_years(years)
|
|
64
|
+
ids = None if station_ids is None else set(int(i) for i in station_ids)
|
|
65
|
+
reader = pd.read_csv(io.BytesIO(raw), sep=";", encoding="cp1252", skipinitialspace=True,
|
|
66
|
+
usecols=lambda c: c.strip() in _RAW_COLUMNS, dtype=str,
|
|
67
|
+
chunksize=chunksize)
|
|
68
|
+
parts = []
|
|
69
|
+
for chunk in reader:
|
|
70
|
+
chunk = chunk.rename(columns=lambda c: _RAW_COLUMNS[c.strip()])
|
|
71
|
+
for c in _INT_COLUMNS:
|
|
72
|
+
chunk[c] = pd.to_numeric(chunk[c].str.strip(), errors="coerce")
|
|
73
|
+
keep = chunk["station_id"].notna()
|
|
74
|
+
if ids is not None:
|
|
75
|
+
keep &= chunk["station_id"].isin(ids)
|
|
76
|
+
if yr is not None:
|
|
77
|
+
lo, hi, explicit = yr
|
|
78
|
+
keep &= chunk["year"].between(lo, hi)
|
|
79
|
+
if explicit is not None:
|
|
80
|
+
keep &= chunk["year"].isin(explicit)
|
|
81
|
+
parts.append(chunk[keep])
|
|
82
|
+
df = pd.concat(parts, ignore_index=True) if parts else pd.DataFrame(columns=list(_RAW_COLUMNS.values()))
|
|
83
|
+
for c in _INT_COLUMNS:
|
|
84
|
+
df[c] = pd.to_numeric(df[c], errors="coerce").astype("Int64")
|
|
85
|
+
df["date"] = pd.to_datetime(df["date"].astype("string").str.strip(), format="%Y%m%d",
|
|
86
|
+
errors="coerce")
|
|
87
|
+
return df
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _overlaps(f, yr) -> bool:
|
|
91
|
+
if yr is None or f.y0 is None or f.y1 is None:
|
|
92
|
+
return True
|
|
93
|
+
return not (f.y1 < yr[0] or f.y0 > yr[1])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _empty() -> pd.DataFrame:
|
|
97
|
+
return pd.DataFrame({c: pd.Series(dtype=object) for c in OBS_COLUMNS})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def download(crops: Union[str, Sequence[str]], years: YearsArg = None, *, station_id=None,
|
|
101
|
+
name=None, state=None, county=None, city: Optional[str] = None,
|
|
102
|
+
lat: Optional[float] = None, lon: Optional[float] = None,
|
|
103
|
+
radius_km: Optional[float] = None, nearest: Optional[int] = None, bbox=None,
|
|
104
|
+
polygon=None, reporter: str = "both", active: bool = False,
|
|
105
|
+
partition: str = "both", quality: QualityArg = None,
|
|
106
|
+
quality_level: QualityArg = None, cache=None, offline: bool = False,
|
|
107
|
+
max_age_days: float = 1.0, verbose: bool = False,
|
|
108
|
+
progress: Optional[Progress] = None) -> pd.DataFrame:
|
|
109
|
+
"""Download DWD phenological observations as one tidy DataFrame.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
crops : crop names in English, German or as DWD file token (``"winter wheat"``, ``"Apfel"``,
|
|
114
|
+
``"Apfel_fruehe_Reife"``); an exact name selects just that crop, otherwise every word
|
|
115
|
+
must be the start of a word of the name (``"wheat"`` = winter + spring wheat).
|
|
116
|
+
years : ``2005``, ``(2000, 2020)`` (inclusive), ``[1990, 1995]``, or ``None`` for all.
|
|
117
|
+
station_id, name, state, county, city, lat, lon, radius_km, nearest, bbox, polygon, reporter,
|
|
118
|
+
active : station selection, see :func:`dwdpheno.stations`.
|
|
119
|
+
partition : ``"historical"``, ``"recent"`` or ``"both"``.
|
|
120
|
+
quality, quality_level : keep only these DWD quality bytes (``1 2 3 5 8 0``; a missing byte
|
|
121
|
+
counts as 0) / quality levels (``1 2 7 10``), e.g. ``quality=[1, 2, 3]``. Default: keep
|
|
122
|
+
everything.
|
|
123
|
+
cache, offline, max_age_days : on-disk cache control.
|
|
124
|
+
verbose : print progress to stderr; ``progress(i, n, text)`` receives the same events.
|
|
125
|
+
|
|
126
|
+
Returns one row per observation with the columns in :data:`OBS_COLUMNS`; overlapping
|
|
127
|
+
historical/recent rows are de-duplicated (recent wins).
|
|
128
|
+
"""
|
|
129
|
+
crop_list = as_list(crops) or []
|
|
130
|
+
if not crop_list:
|
|
131
|
+
raise ValueError("at least one crop is required")
|
|
132
|
+
if reporter not in ("annual", "immediate", "both"):
|
|
133
|
+
raise ValueError(f"reporter must be 'annual', 'immediate' or 'both', got {reporter!r}")
|
|
134
|
+
if partition not in ("historical", "recent", "both"):
|
|
135
|
+
raise ValueError(f"partition must be 'historical', 'recent' or 'both', got {partition!r}")
|
|
136
|
+
reporters = ["annual", "immediate"] if reporter == "both" else [reporter]
|
|
137
|
+
partitions = list(PARTITIONS) if partition == "both" else [partition]
|
|
138
|
+
yr = parse_years(years)
|
|
139
|
+
parse_codes(quality, QUALITY_BYTE, "quality byte") # validate before any download
|
|
140
|
+
parse_codes(quality_level, QUALITY_LEVEL, "quality level")
|
|
141
|
+
net = dict(cache=cache, offline=offline, max_age_days=max_age_days)
|
|
142
|
+
|
|
143
|
+
def report(i: int, n: int, text: str) -> None:
|
|
144
|
+
if progress is not None:
|
|
145
|
+
progress(i, n, text)
|
|
146
|
+
if verbose:
|
|
147
|
+
print(f"[{i}/{n}] {text}", file=sys.stderr, flush=True)
|
|
148
|
+
|
|
149
|
+
table = station_table(**net)
|
|
150
|
+
sel = select_stations(table, station_id=station_id, name=name, state=state, county=county,
|
|
151
|
+
city=city, lat=lat, lon=lon, radius_km=radius_km, nearest=nearest,
|
|
152
|
+
bbox=bbox, polygon=polygon, reporter=reporter, active=active)
|
|
153
|
+
if sel.empty:
|
|
154
|
+
raise _http.NoMatchError("no station matches the selection")
|
|
155
|
+
|
|
156
|
+
plant_table = _catalog.plants(**net)
|
|
157
|
+
all_files = _catalog.data_files(reporters, partitions, **net)
|
|
158
|
+
cat = _catalog.select_crops(crop_list, _catalog._catalog(all_files, plant_table))
|
|
159
|
+
tokens = set(cat["dwd_token"])
|
|
160
|
+
files = [f for f in all_files if f.crop in tokens and _overlaps(f, yr)]
|
|
161
|
+
if not files:
|
|
162
|
+
raise _http.NoMatchError(f"no DWD data file matches crops {crop_list!r}"
|
|
163
|
+
" (see dwdpheno.crops())")
|
|
164
|
+
report(0, len(files), f"{len(sel)} stations, {len(files)} files")
|
|
165
|
+
defs = _catalog.load_phase_definitions(reporters, sorted({f.group for f in files}), **net)
|
|
166
|
+
named = _catalog.name_phases(defs, plant_table, _catalog.phase_names(**net))
|
|
167
|
+
token_names = cat.set_index("dwd_token")[["crop", "crop_de", "species"]]
|
|
168
|
+
|
|
169
|
+
ids = sel["station_id"].tolist()
|
|
170
|
+
frames = []
|
|
171
|
+
for i, f in enumerate(files, 1):
|
|
172
|
+
report(i, len(files), f.filename)
|
|
173
|
+
obs = parse_observations(_http.fetch(f.url, **net), ids, years)
|
|
174
|
+
if len(obs):
|
|
175
|
+
tn = token_names.loc[f.crop]
|
|
176
|
+
frames.append(obs.assign(reporter=f.reporter, partition=f.partition,
|
|
177
|
+
source_file=f.filename, _crop=tn["crop"],
|
|
178
|
+
_crop_de=tn["crop_de"], _species=tn["species"]))
|
|
179
|
+
if not frames:
|
|
180
|
+
return _empty()
|
|
181
|
+
out = pd.concat(frames, ignore_index=True)
|
|
182
|
+
|
|
183
|
+
# historical/recent overlap: recent wins
|
|
184
|
+
out = out.assign(_p=(out["partition"] != "recent").astype(int))
|
|
185
|
+
out = out.sort_values("_p", kind="stable").drop_duplicates(
|
|
186
|
+
["station_id", "objekt_id", "phase_id", "year", "reporter"], keep="first").drop(columns="_p")
|
|
187
|
+
out = filter_quality(out, quality, quality_level) # after the dedupe: recent still wins
|
|
188
|
+
if out.empty:
|
|
189
|
+
return _empty()
|
|
190
|
+
|
|
191
|
+
meta = table[["station_id", "station_name", "latitude", "longitude", "elevation_m", "state",
|
|
192
|
+
"county"]]
|
|
193
|
+
out = out.merge(meta, on="station_id", how="left")
|
|
194
|
+
# names of the row's objekt (English / German / Latin); the file's plant as fallback
|
|
195
|
+
out = out.merge(plant_table, on="objekt_id", how="left")
|
|
196
|
+
for c in ("crop", "crop_de", "species"):
|
|
197
|
+
out[c] = out[c].astype("string").fillna(out["_" + c].astype("string"))
|
|
198
|
+
ph = named[["objekt_id", "phase_id", "phase_name", "bbch_code"]]
|
|
199
|
+
out = out.merge(ph, on=["objekt_id", "phase_id"], how="left")
|
|
200
|
+
missing = out["phase_name"].isna()
|
|
201
|
+
if missing.any(): # phase not defined for this plant: generic English phase name
|
|
202
|
+
generic = _catalog.phase_names(**net).set_index("phase_id")["phase_name"]
|
|
203
|
+
out.loc[missing, "phase_name"] = out.loc[missing, "phase_id"].map(generic)
|
|
204
|
+
out["quality_byte_meaning"] = meanings(out["quality_byte"], QUALITY_BYTE)
|
|
205
|
+
out["quality_level_meaning"] = meanings(out["quality_level"], QUALITY_LEVEL)
|
|
206
|
+
for c in ("reporter", "partition", "source_file", "phase_name", "bbch_code"):
|
|
207
|
+
out[c] = out[c].astype("string")
|
|
208
|
+
out = out[OBS_COLUMNS].sort_values(["crop", "station_id", "year", "date", "phase_id"],
|
|
209
|
+
kind="stable").reset_index(drop=True)
|
|
210
|
+
report(len(files), len(files), f"{len(out)} observations")
|
|
211
|
+
return out
|