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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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