Commit a82f1f56 authored by Eric Duminil's avatar Eric Duminil
Browse files

Ingest all 11 HOSTRADA variables in a compact int16 archive



- Store values as int16 (scale 0.1), rounded to DWD's published
  resolution; transpose + Blosc/zstd5 codecs (2.0 MB vs 6.0 MB per chunk)
- Circular mean for wind direction; per-variable units, time convention
  and resolution as coordinates (fixes DWD's Pa/hPa label, documents
  hour-ending rsds)
- Stage months on disk and write one year at a time; track completed
  years in the store; refuse to overwrite without --resume
- --seed-from copies variables from an older archive instead of
  re-downloading them
- Set zarr fill_value to match _FillValue so unwritten hours are NaN
- Require zarr>=3 / Python>=3.11

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 5ed6eef9
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
__pycache__/ __pycache__/
*.pyc *.pyc
raw_cache/ raw_cache/
staging/
*.zarr/ *.zarr/
.DS_Store .DS_Store
*.egg-info *.egg-info
......
...@@ -4,21 +4,23 @@ END_MONTH=2026-08 ...@@ -4,21 +4,23 @@ END_MONTH=2026-08
help: ## Show this help. help: ## Show this help.
@egrep -h '(\s##\s|^##\s)' $(MAKEFILE_LIST) | egrep -v '^--' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[32m %-35s\033[0m %s\n", $$1, $$2}' @egrep -h '(\s##\s|^##\s)' $(MAKEFILE_LIST) | egrep -v '^--' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[32m %-35s\033[0m %s\n", $$1, $$2}'
transpose: ## Download and transpose ARCHIVE=./hostrada_coarse.zarr
uv run python -m hostrada_transpose.build_archive \ # Optional: older archive (same grid/time axis) whose variables are copied
# instead of downloaded again, e.g. make transpose SEED=./hostrada_coarse_tas_rsds.zarr
SEED=
BUILD=uv run python -m hostrada_transpose.build_archive \
--start ${START_MONTH} --end ${END_MONTH} \ --start ${START_MONTH} --end ${END_MONTH} \
--variables tas,rsds \ --raw-cache-dir ./raw_cache --staging-dir ./staging --archive-path ${ARCHIVE} \
--raw-cache-dir ./raw_cache --archive-path ./hostrada_coarse.zarr $(if ${SEED},--seed-from ${SEED})
transpose: ## Download and transpose all variables
${BUILD}
# Example with screen: # Example with screen:
# screen -S "Hostrada transpose" -L -Logfile "hostrada_transpose.log" make transpose # screen -S "Hostrada transpose" -L -Logfile "hostrada_transpose.log" make transpose
resume: ## Resume already started transpose resume: ## Resume already started transpose
uv run python -m hostrada_transpose.build_archive \ ${BUILD} --resume
--start ${START_MONTH} --end ${END_MONTH} \
--variables tas,rsds \
--raw-cache-dir ./raw_cache --archive-path ./hostrada_coarse.zarr \
--resume
# TODO: Show available data # TODO: Show available data
# TODO: Show data for given location / date range # TODO: Show data for given location / date range
......
...@@ -29,9 +29,23 @@ with no network access at all. ...@@ -29,9 +29,23 @@ with no network access at all.
## What it does ## What it does
1. **Downloads** each monthly national grid via `hostrada4py.hostrada.ensure_month_file` (full-grid mode, not point mode -- aggregation needs every cell). 1. **Downloads** each monthly national grid via `hostrada4py.hostrada.ensure_month_file` (full-grid mode, not point mode -- aggregation needs every cell).
2. **Aggregates** it to a coarser grid (`hostrada_transpose.aggregate`): a plain block mean, `AGGREGATION_FACTOR` cells per edge (default 10, i.e. ~10 km from HOSTRADA's verified 1 km x 1 km grid). 2. **Aggregates** it to a coarser grid (`hostrada_transpose.aggregate`): a plain block mean, `AGGREGATION_FACTOR` cells per edge (default 10, i.e. ~10 km from HOSTRADA's verified 1 km x 1 km grid). Wind direction is averaged as unit vectors (an arithmetic mean of 350° and 10° would be 180°).
3. **Writes** the aggregated month into its region of a pre-allocated Zarr store (`hostrada_transpose.archive`): a single array shaped `(time, Y, X, variable)`, chunked `(whole time range, 2 coarse cells, 2 coarse cells, all variables)`. Variables are stacked into one array (not one array per variable) so that a point query for every variable at one location is a single chunk read, matching how SimStadt will actually consume this. The time axis is one chunk spanning the entire configured range by default (`TIME_CHUNK_HOURS = None` in `config.py`) -- a point query reads its whole multi-decade series in one chunk read. Measured the write-amplification this causes during incremental ingestion (see `config.py`'s comment): real, but adds only ~15-20 minutes across a full 30-year run, negligible next to the ~20-30s/month network download that otherwise dominates. 3. **Rounds** the result to the precision DWD publishes (0.1 or 1 unit, see below) -- the archive never claims more detail than the source -- and **stages** it as a small `.npy` file in `--staging-dir`, then discards the raw download (`--keep-raw` to keep it).
4. **Discards** the raw national-grid download once aggregated (`--keep-raw` to keep it). 4. **Writes** one calendar year, all variables at once, into a pre-allocated Zarr v3 store (`hostrada_transpose.archive`), then records the year in the store's `completed_years` attribute. The store holds a single array shaped `(time, Y, X, variable)`, chunked `(whole time range, 2 coarse cells, 2 coarse cells, all variables)`: a point query for every variable at one location is a single chunk read of the whole multi-decade series. Writing into such a chunk re-encodes it, which is why writes are batched per year (~32 rewrites of the store, ~5 min each, instead of one per variable and month).
### Storage format
| Aspect | Choice | Why |
|---|---|---|
| Values | `int16`, CF `scale_factor=0.1`, `_FillValue=-32768` | DWD's own files are integers with 0.1 or 1 resolution; every range fits (largest: psl, ~10,600 steps). xarray decodes to float with NaN outside Germany. |
| Layout on disk | `transpose` codec, so each cell's time series is contiguous | Consecutive hours compress far better than neighbouring cells/variables |
| Compression | Blosc, zstd level 5, byte shuffle | Core Zarr v3 codecs only (readable without numcodecs extras) |
| Metadata | `var_units`, `var_long_name`, `var_cell_methods`, `var_time_convention`, `var_resolution` coordinates along `variable` | DWD's units/time semantics survive aggregation |
Measured on a real 2x2-cell chunk (tas+rsds, 1995-2026): **2.0 MB** vs. 6.0 MB
for the former float32/zstd-0 encoding. Blosc level 9 (1.9 MB, 13x slower
writes) and a numcodecs `Delta` filter (1.9 MB, non-core codec) were not worth
it.
Run it with: Run it with:
...@@ -39,32 +53,56 @@ Run it with: ...@@ -39,32 +53,56 @@ Run it with:
uv venv && uv pip install -e . uv venv && uv pip install -e .
python -m hostrada_transpose.build_archive \ python -m hostrada_transpose.build_archive \
--start 1995-01 --end 2026-08 \ --start 1995-01 --end 2026-08 \
--variables tas,rsds,hurs,sfcWind,clt,uhi \ --raw-cache-dir ./raw_cache --staging-dir ./staging --archive-path ./hostrada_coarse.zarr
--raw-cache-dir ./raw_cache --archive-path ./hostrada_coarse.zarr ```
or `make transpose`. All variables are ingested by default (`--variables` to
restrict). An existing archive is never overwritten: `--resume` (`make resume`)
continues it, skipping completed years and already-staged months.
`--seed-from OLD.zarr` copies variables an older archive already has (same
grid and time axis) instead of downloading them again, e.g. to upgrade the
former tas/rsds-only archive:
```bash
mv hostrada_coarse.zarr hostrada_coarse_tas_rsds.zarr
make transpose SEED=./hostrada_coarse_tas_rsds.zarr
``` ```
Reading one point's full time series back out looks like: Reading one point's full time series back out looks like:
```python ```python
import pyproj
import xarray as xr import xarray as xr
ds = xr.open_zarr("hostrada_coarse.zarr") ds = xr.open_zarr("hostrada_coarse.zarr")
point = ds["weather"].sel(Y=..., X=..., method="nearest") # all variables, full time range, one chunk read x, y = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:3034", always_xy=True).transform(9.1737, 48.7802)
tas = point.sel(variable="tas") df = ds["weather"].sel(X=x, Y=y, method="nearest").to_pandas() # all variables, full time range, one chunk read
ds["var_units"].to_pandas() # units per variable
``` ```
This is a long-running (hours), high-bandwidth (see estimate below), one-time This is a long-running (about a day), high-bandwidth (see estimate below),
batch job. Run it unattended on a machine with a stable connection, not from one-time batch job. Run it unattended on a machine with a stable connection,
an interactive SimStadt workflow step. not from an interactive SimStadt workflow step.
## Variables ## Variables
| Variable | Why | All 11 HOSTRADA variables. Resolution = precision of DWD's published values,
|---|---| to which the 10 km means are rounded. All values are UTC. All are
| `tas` | Ambient temperature -- confirmed required by SimStadt's `GreenWaterProcessor`/`EvapoTranspirationCalculator` | instantaneous at the timestamp, except `rsds`.
| `rsds` | GHI -- same, plus the base input for the derived DHI estimate |
| `hurs` | Relative humidity -- same | | Variable | Units | Resolution | Note |
| `sfcWind` | Wind speed -- same | |---|---|---|---|
| `clt`, `uhi` | Optional: only needed to enable `HostradaDiffuse.estimate(apply_weather_correction=True)`'s refinement of the DHI estimate. Drop them from `config.ALL_VARIABLES` if that refinement isn't worth the extra ingestion volume. | | `tas` | °C | 0.1 | Ambient temperature, required by SimStadt's GreenWater |
| `rsds` | W/m² | 0.1 | GHI. **Mean over the hour *ending* at the timestamp.** DWD's NetCDF `time_bnds` claim the hour starting at it; a clear-sky fit over 1995-2026 confirms hour-ending, as in DWD's PDF description. Use `t - 30 min` for solar geometry. |
| `hurs` | % | 0.1 | Relative humidity, required by GreenWater |
| `sfcWind` | m/s | 0.1 | Wind speed at 10 m, required by GreenWater |
| `sfcWind_direction` | ° | 1 | Circular (unit-vector) mean |
| `clt` | octa | 1 | Cloud cover; used by `HostradaDiffuse(apply_weather_correction=True)` |
| `uhi` | °C | 0.1 | Urban heat island intensity; same |
| `tdew` | °C | 0.1 | Dew point (DWD's long_name wrongly says "Daily Mean") |
| `mixr` | g/kg | 0.1 | Water vapour mixing ratio |
| `ps` | hPa | 1 | Surface pressure. DWD's files say `Pa`, but the values are hPa. |
| `psl` | hPa | 1 | Sea-level pressure, same unit issue |
**Precipitation is deliberately out of scope here.** It's required by **Precipitation is deliberately out of scope here.** It's required by
GreenWater but absent from both hostrada4py providers: not in DWD GreenWater but absent from both hostrada4py providers: not in DWD
...@@ -125,36 +163,39 @@ Confirmed with station 00183 (Arkona, a Baltic-coast lighthouse). ...@@ -125,36 +163,39 @@ Confirmed with station 00183 (Arkona, a Baltic-coast lighthouse).
`compare_station_to_model` now raises instead of silently returning garbage `compare_station_to_model` now raises instead of silently returning garbage
for such stations -- keep that guard if this logic is ever copied elsewhere. for such stations -- keep that guard if this logic is ever copied elsewhere.
## Data volume (30 years, verified against real cached files) ## Data volume
HOSTRADA's grid is a verified uniform 1000 m x 1000 m EPSG:3034 raster, HOSTRADA's grid is a verified uniform 1000 m x 1000 m EPSG:3034 raster,
938 x 720 cells. Per-variable annual download size, measured from a real 938 x 720 cells. Download volume for 1995-01..2026-08 (380 monthly files per
2025 cache: variable), summed from DWD's directory listings on 2026-10-07:
| Variable | GiB/year | | Variable | GiB | Variable | GiB |
|---|---| |---|---|---|---|
| `tas` | 1.84 | | `tas` | 57.8 | `clt` | 17.3 |
| `rsds` | 1.81 | | `rsds` | 59.0 | `uhi` | 20.4 |
| `hurs` | 2.61 | | `hurs` | 83.2 | `tdew` | 55.1 |
| `sfcWind` | 2.18 | | `sfcWind` | 69.2 | `mixr` | 36.0 |
| `clt` | 0.54 | | `sfcWind_direction` | 60.6 | `ps` | 62.5 |
| `uhi` | not sampled -- assumed similar to the others (~1.8) | | | | `psl` | 13.9 |
**~10.8 GiB/year for the 6 configured variables -> ~324 GB one-time **535 GiB in total, 418 GiB when tas/rsds are seeded from the older archive.**
ingestion for 1995-2024.** This is the unavoidable cost of reading the This is the unavoidable cost of reading the source files once (chunking
source files once (chunking forces reading ~100% of the bytes regardless of forces reading ~100% of the bytes regardless of how little of the data is
how little of the data is ultimately kept); it is paid once, not per city, ultimately kept); it is paid once, not per city. Measured throughput on a
and not repeated on every run. test month: ~25 s per file including aggregation.
Resulting archive after 10x spatial aggregation (93 x 72 coarse cells): Resulting archive (93 x 72 coarse cells, ~46% of them outside Germany):
uncompressed float32 estimate is on the order of several GiB for all 6 estimated ~10 GB for all 11 variables, i.e. **~10 MB per location** for the
variables over 30 years; real post-compression size is unmeasured -- full 1995-2026 series over HTTP (one chunk, 2x2 cells). This extrapolates
run a 1-2 year ingestion first and check before committing to the full from tas/rsds chunks (~1 MB per variable); ps/psl/clt should compress better,
30-year pull. wind direction worse. Measure on the finished archive.
Disk and memory during the build: one year in memory (~1.3 GB int16), staged
months for one year (~1.2 GB), at most one raw month file (~160 MB).
## Open questions / not yet done ## Open questions / not yet done
- Real, multi-station, multi-year DHI bias/RMSE characterization (see above). - Real, multi-station, multi-year DHI bias/RMSE characterization (see above).
- Precipitation: add `total_precipitation` to `hostrada4py`'s `CERRA_VARIABLES`, then decide how/whether to fold a 5.5 km CERRA series into this archive's output alongside the 10 km HOSTRADA-derived one. - Precipitation: add `total_precipitation` to `hostrada4py`'s `CERRA_VARIABLES`, then decide how/whether to fold a 5.5 km CERRA series into this archive's output alongside the 10 km HOSTRADA-derived one.
- `build_archive.py`'s `--resume` flag is a cheap single-cell NaN check, not real ingestion bookkeeping -- fine for restarting after a crash, not a substitute for a proper manifest if this is run incrementally over time. - The time axis is fixed when the archive is created. Appending newer months requires a rebuild (seeding everything from the old archive makes that download-free except for the new months -- not implemented yet).
- Final archive compression ratio is unmeasured. - `sfcWind_direction`: whether DWD uses 0° for calm (which would bias the circular mean) is unverified.
"""Spatial block-aggregation of one HOSTRADA monthly grid file.""" """Spatial block-aggregation of one HOSTRADA monthly grid file."""
from __future__ import annotations from __future__ import annotations
import numpy as np
import xarray as xr import xarray as xr
from hostrada4py.hostrada import find_variable from hostrada4py.hostrada import find_variable
from .config import AGGREGATION_FACTOR from .config import AGGREGATION_FACTOR, STORAGE_FILL, STORAGE_SCALE, VARIABLES, VariableSpec
def aggregate_month( def aggregate_month(
...@@ -22,10 +23,21 @@ def aggregate_month( ...@@ -22,10 +23,21 @@ def aggregate_month(
expected along the coastline/border, where roughly half the national expected along the coastline/border, where roughly half the national
bounding box has no land data at all (verified: ~50% NaN at 1 km, bounding box has no land data at all (verified: ~50% NaN at 1 km,
~46% NaN after 10x aggregation). ~46% NaN after 10x aggregation).
Angles (``aggregation="circular_mean"``) are averaged as unit vectors and
returned in [0, 360).
""" """
name = find_variable(var, ds) name = find_variable(var, ds)
da = ds[name] da = ds[name]
coarse = da.coarsen(X=factor, Y=factor, boundary="trim").mean()
def block_mean(a: xr.DataArray) -> xr.DataArray:
return a.coarsen(X=factor, Y=factor, boundary="trim").mean()
if VARIABLES[var].aggregation == "circular_mean":
rad = np.deg2rad(da)
coarse = np.rad2deg(np.arctan2(block_mean(np.sin(rad)), block_mean(np.cos(rad)))) % 360
else:
coarse = block_mean(da)
coarse.name = var coarse.name = var
coarse.attrs = dict(da.attrs) coarse.attrs = dict(da.attrs)
# HOSTRADA attaches 2-D lon/lat auxiliary coords (dims Y, X). coarsen() # HOSTRADA attaches 2-D lon/lat auxiliary coords (dims Y, X). coarsen()
...@@ -37,3 +49,13 @@ def aggregate_month( ...@@ -37,3 +49,13 @@ def aggregate_month(
if extra_coords: if extra_coords:
coarse = coarse.drop_vars(extra_coords) coarse = coarse.drop_vars(extra_coords)
return coarse return coarse
def to_storage(values: np.ndarray, spec: VariableSpec) -> np.ndarray:
"""Round to the source resolution and encode as int16 steps of STORAGE_SCALE."""
values = np.asarray(values, dtype="float64")
rounded = np.round(values / spec.resolution) * spec.resolution
if spec.aggregation == "circular_mean":
rounded = rounded % 360 # 359.6 rounds to 360, i.e. 0
steps = np.round(rounded / STORAGE_SCALE)
return np.where(np.isnan(steps), STORAGE_FILL, steps).astype("int16")
"""Create and incrementally fill the time-major coarse-grid Zarr archive. """Create and fill the time-major coarse-grid Zarr archive.
The archive is created once as an empty, NaN-filled template whose full The archive is created once as an empty template whose full shape and chunk
shape and chunk layout are known up front (we know the final time range and layout are known up front (we know the final time range and variable list
variable list before ingestion starts). Each monthly ingestion step then before ingestion starts). Each ingestion step then writes one calendar year
writes only its own region -- one variable's slice of one time range -- for all variables, without ever reading or rewriting unrelated data. This is
without ever reading or rewriting unrelated data. This is what makes what makes building a 30-year archive tractable without holding 30 years of
building a 30-year archive tractable without holding 30 years of data in data in memory at once.
memory at once.
Variables are stacked into a single array along a ``variable`` dimension Variables are stacked into a single array along a ``variable`` dimension
(rather than one array per variable) so that a point query for "every (rather than one array per variable) so that a point query for "every
variable at this location" is one chunk read, matching how SimStadt will variable at this location" is one chunk read, matching how SimStadt will
actually consume this archive (temperature+GHI+humidity+wind together, per actually consume this archive (temperature+GHI+humidity+wind together, per
point). Measured impact of this plus a time axis chunked as one single point).
multi-decade block: see config.py's comment on TIME_CHUNK_HOURS.
Storage: int16 steps of 0.1 (CF ``scale_factor``, decoded by xarray), a
``transpose`` codec so that each cell's time series is contiguous on disk,
and Blosc/zstd with byte shuffle. All three are core Zarr v3 codecs.
Measured on a real 2x2-cell chunk with tas+rsds over 1995-2026: 2.0 MB vs.
6.0 MB for float32/zstd0, writing in 0.05 s. Blosc clevel 9 (1.9 MB, 13x
slower writes) and numcodecs Delta (1.9 MB, not a core codec) weren't worth
it.
Per-variable metadata (units, long_name, cell_methods, time convention,
source resolution) is stored as coordinates along ``variable``, e.g.
``ds.var_units.sel(variable="rsds")``.
""" """
from __future__ import annotations from __future__ import annotations
import warnings
from pathlib import Path from pathlib import Path
from typing import Mapping, Sequence from typing import Mapping, Sequence
...@@ -24,10 +35,21 @@ import dask.array as da ...@@ -24,10 +35,21 @@ import dask.array as da
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import xarray as xr import xarray as xr
import zarr
from .config import SPATIAL_CHUNK_CELLS, TIME_CHUNK_HOURS from zarr.codecs import BloscCodec, TransposeCodec
from .config import (
AGGREGATION_FACTOR,
SPATIAL_CHUNK_CELLS,
STORAGE_DTYPE,
STORAGE_FILL,
STORAGE_SCALE,
TIME_CHUNK_HOURS,
VARIABLES,
)
ARRAY_NAME = "weather" ARRAY_NAME = "weather"
COMPLETED_YEARS_ATTR = "completed_years"
def build_time_index(start: str, end: str) -> pd.DatetimeIndex: def build_time_index(start: str, end: str) -> pd.DatetimeIndex:
...@@ -46,8 +68,8 @@ def create_archive_template( ...@@ -46,8 +68,8 @@ def create_archive_template(
``compute=False`` means the dask-backed NaN placeholder array is never ``compute=False`` means the dask-backed NaN placeholder array is never
actually materialised -- Zarr only records the store's shape, dtype and actually materialised -- Zarr only records the store's shape, dtype and
chunking. Real values are filled in later, one (variable, month) slice chunking. Real values are filled in later, one year at a time, by
at a time, by :func:`write_month`. :func:`write_block`.
""" """
n_time = len(time_index) n_time = len(time_index)
y_vals = coarse_coords["Y"] y_vals = coarse_coords["Y"]
...@@ -55,55 +77,68 @@ def create_archive_template( ...@@ -55,55 +77,68 @@ def create_archive_template(
ny, nx = len(y_vals), len(x_vals) ny, nx = len(y_vals), len(x_vals)
n_var = len(variables) n_var = len(variables)
time_chunk = n_time if TIME_CHUNK_HOURS is None else min(TIME_CHUNK_HOURS, n_time) time_chunk = n_time if TIME_CHUNK_HOURS is None else min(TIME_CHUNK_HOURS, n_time)
y_chunk = min(SPATIAL_CHUNK_CELLS, ny) chunks = (time_chunk, min(SPATIAL_CHUNK_CELLS, ny), min(SPATIAL_CHUNK_CELLS, nx), n_var)
x_chunk = min(SPATIAL_CHUNK_CELLS, nx)
placeholder = da.full((n_time, ny, nx, n_var), np.nan, dtype="float32", chunks=chunks)
placeholder = da.full( specs = [VARIABLES[v] for v in variables]
(n_time, ny, nx, n_var),
np.nan,
dtype="float32",
chunks=(time_chunk, y_chunk, x_chunk, n_var),
)
template = xr.Dataset( template = xr.Dataset(
{ARRAY_NAME: (("time", "Y", "X", "variable"), placeholder)}, {ARRAY_NAME: (("time", "Y", "X", "variable"), placeholder)},
coords={"time": time_index, "Y": y_vals, "X": x_vals, "variable": list(variables)}, coords={
"time": time_index,
"Y": ("Y", y_vals, {"units": "m", "standard_name": "projection_y_coordinate"}),
"X": ("X", x_vals, {"units": "m", "standard_name": "projection_x_coordinate"}),
"variable": list(variables),
"var_units": ("variable", [s.units for s in specs]),
"var_long_name": ("variable", [s.long_name for s in specs]),
"var_cell_methods": ("variable", [s.cell_methods for s in specs]),
"var_time_convention": ("variable", [s.time_convention for s in specs]),
"var_resolution": ("variable", [s.resolution for s in specs]),
},
attrs={
"title": "HOSTRADA v1.0, aggregated to ~10 km, time-major",
"source": "DWD HOSTRADA - High-resolution grids of hourly variables for Germany, v1.0",
"crs": "EPSG:3034",
"aggregation": f"{AGGREGATION_FACTOR}x{AGGREGATION_FACTOR} block mean of the 1 km grid "
"(circular mean for wind direction), rounded to the source resolution",
"time_zone": "UTC",
COMPLETED_YEARS_ATTR: [],
},
) )
template.to_zarr(path, mode="w", compute=False) encoding = {
ARRAY_NAME: {
"dtype": STORAGE_DTYPE,
"scale_factor": STORAGE_SCALE,
"_FillValue": STORAGE_FILL,
# Zarr's own fill value, used for never-written elements. Must
# match _FillValue, or unwritten hours decode as 0.0, not NaN.
"fill_value": STORAGE_FILL,
"chunks": chunks,
"filters": [TransposeCodec(order=(1, 2, 3, 0))],
"compressors": [BloscCodec(cname="zstd", clevel=5, shuffle="shuffle")],
}
}
template.to_zarr(path, mode="w", compute=False, encoding=encoding, zarr_format=3)
def write_block(path: Path, time_slice: slice, block: np.ndarray) -> None:
"""Write already-encoded int16 data for all variables over ``time_slice``.
Bypasses xarray on purpose: ``block`` is already in storage units (see
:func:`hostrada_transpose.aggregate.to_storage`), which avoids a float
copy of a year of data.
"""
zarr.open_array(Path(path) / ARRAY_NAME, mode="r+")[time_slice] = block
def write_month( def completed_years(path: Path) -> set[int]:
path: Path, return set(zarr.open_group(path, mode="r").attrs.get(COMPLETED_YEARS_ATTR, []))
var: str,
variables: Sequence[str],
month_data: xr.DataArray,
time_index: pd.DatetimeIndex,
) -> None:
"""Write one (variable, month) slice into its region of the archive.
Only ``var``'s data is written, as a width-1 slice along the ``variable``
axis -- the other variables already/later occupying that axis in the def mark_year_completed(path: Path, year: int) -> None:
same chunk are left untouched by this call (Zarr handles the group = zarr.open_group(path, mode="r+")
read-modify-write of the rest of the chunk internally). done = sorted(set(group.attrs.get(COMPLETED_YEARS_ATTR, [])) | {year})
""" group.attrs[COMPLETED_YEARS_ATTR] = done
first_time = month_data["time"].values[0] with warnings.catch_warnings():
start_idx = time_index.get_loc(first_time) # Consolidated metadata isn't in the v3 spec yet, but xarray uses it.
if not isinstance(start_idx, (int, np.integer)): warnings.filterwarnings("ignore", message="Consolidated metadata")
raise ValueError( zarr.consolidate_metadata(path)
f"Month start {first_time} is not aligned to the archive's hourly "
"time index -- check for gaps or a mismatched time zone."
)
end_idx = start_idx + month_data.sizes["time"]
var_idx = list(variables).index(var)
expanded = month_data.astype("float32").expand_dims(variable=[var])
ds = expanded.to_dataset(name=ARRAY_NAME)
ds = ds.drop_vars(["time", "Y", "X", "variable"], errors="ignore")
ds.to_zarr(
path,
region={
"time": slice(start_idx, end_idx),
"Y": slice(None),
"X": slice(None),
"variable": slice(var_idx, var_idx + 1),
},
)
...@@ -2,48 +2,79 @@ ...@@ -2,48 +2,79 @@
Usage: Usage:
python -m hostrada_transpose.build_archive \\ python -m hostrada_transpose.build_archive \\
--start 1995-01 --end 2024-12 \\ --start 1995-01 --end 2026-08 \\
--variables tas,rsds,hurs,sfcWind,clt,uhi \\ --raw-cache-dir ./raw_cache --archive-path ./hostrada_coarse.zarr \\
--raw-cache-dir ./raw_cache --archive-path ./hostrada_coarse.zarr [--seed-from ./old_archive.zarr] [--resume]
This is a long-running, one-time (or rarely-repeated) batch job -- expect it This is a long-running, one-time (or rarely-repeated) batch job -- expect it
to run for hours and to transfer on the order of 150-250 GB from DWD for the to run for a day or more and to transfer ~535 GiB from DWD for all 11
default 30-year, 6-variable configuration (see README.md for the estimate variables over 1995-01..2026-08 (sum of the published file sizes, checked
and its assumptions). It is meant to be run unattended, not from an 2026-10). It is meant to be run unattended, not from an interactive
interactive SimStadt workflow step. SimStadt workflow step.
Work is done one calendar year at a time. Each (variable, month) is
downloaded, aggregated, encoded and staged as a small .npy file in
``--staging-dir`` (the raw download is then deleted). Once all months of a
year are staged, the year is written to the archive for all variables at
once, recorded in the archive's ``completed_years`` attribute, and its
staged files are removed. An interrupted run restarted with ``--resume``
therefore skips completed years and already-staged months.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import logging import logging
import os
from pathlib import Path from pathlib import Path
import numpy as np
import pandas as pd
import xarray as xr
import hostrada4py.hostrada as hs import hostrada4py.hostrada as hs
from .aggregate import aggregate_month from .aggregate import aggregate_month, to_storage
from .archive import build_time_index, create_archive_template, write_month from .archive import (
ARRAY_NAME,
build_time_index,
completed_years,
create_archive_template,
mark_year_completed,
write_block,
)
from .config import ( from .config import (
ALL_VARIABLES, ALL_VARIABLES,
DEFAULT_ARCHIVE_PATH, DEFAULT_ARCHIVE_PATH,
DEFAULT_END, DEFAULT_END,
DEFAULT_RAW_CACHE_DIR, DEFAULT_RAW_CACHE_DIR,
DEFAULT_STAGING_DIR,
DEFAULT_START, DEFAULT_START,
STORAGE_FILL,
VARIABLES,
) )
logger = logging.getLogger("hostrada_transpose") logger = logging.getLogger("hostrada_transpose")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace: def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--start", default=DEFAULT_START, help="First month, e.g. 1995-01") parser.add_argument("--start", default=DEFAULT_START, help="First month, e.g. 1995-01")
parser.add_argument("--end", default=DEFAULT_END, help="Last month (inclusive), e.g. 2024-12") parser.add_argument("--end", default=DEFAULT_END, help="Last month (inclusive), e.g. 2026-08")
parser.add_argument( parser.add_argument(
"--variables", "--variables",
default=",".join(ALL_VARIABLES), default=",".join(ALL_VARIABLES),
help="Comma-separated canonical HOSTRADA variable names", help="Comma-separated canonical HOSTRADA variable names (default: all)",
) )
parser.add_argument("--raw-cache-dir", type=Path, default=DEFAULT_RAW_CACHE_DIR) parser.add_argument("--raw-cache-dir", type=Path, default=DEFAULT_RAW_CACHE_DIR)
parser.add_argument("--staging-dir", type=Path, default=DEFAULT_STAGING_DIR)
parser.add_argument("--archive-path", type=Path, default=DEFAULT_ARCHIVE_PATH) parser.add_argument("--archive-path", type=Path, default=DEFAULT_ARCHIVE_PATH)
parser.add_argument(
"--seed-from",
type=Path,
help="Existing archive (same grid and time axis, any encoding) to copy "
"variables from instead of downloading them again, e.g. an older "
"tas/rsds-only archive.",
)
parser.add_argument( parser.add_argument(
"--keep-raw", "--keep-raw",
action="store_true", action="store_true",
...@@ -53,70 +84,127 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: ...@@ -53,70 +84,127 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser.add_argument( parser.add_argument(
"--resume", "--resume",
action="store_true", action="store_true",
help="Skip (var, year, month) combinations whose region appears already " help="Continue an existing archive: skip years recorded as completed and "
"non-NaN in the archive. Cheap safety net for restarting an interrupted run " "months already staged. Without it, an existing archive is never touched.",
"-- NOT a substitute for keeping real ingestion-progress bookkeeping if this "
"job is run repeatedly.",
) )
return parser.parse_args(argv) return parser.parse_args(argv)
def _first_coarse_cell_is_written(archive_path: Path, var: str, variables: list[str], start_idx: int) -> bool: def _staged_month(var: str, year: int, month: int, expected_time: pd.DatetimeIndex, args) -> np.ndarray:
import numpy as np """Encoded coarse grid for one (variable, month), downloading it if not staged yet."""
import xarray as xr staged = args.staging_dir / f"{var}_{year:04d}{month:02d}.npy"
if staged.exists():
return np.load(staged)
from .archive import ARRAY_NAME logger.info("Fetch %s %04d-%02d", var, year, month)
raw_path = hs.ensure_month_file(var, year, month, args.raw_cache_dir)
var_idx = variables.index(var) with hs.read_month_file(raw_path) as ds:
with xr.open_zarr(archive_path) as ds: coarse = aggregate_month(ds, var)
sample = ( month_time = pd.DatetimeIndex(coarse["time"].values)
ds[ARRAY_NAME] if not month_time.equals(expected_time):
.isel(time=start_idx, Y=slice(0, 5), X=slice(0, 5), variable=var_idx) raise ValueError(
.values f"{var} {year:04d}-{month:02d}: time axis does not match the archive's hourly "
f"index ({len(month_time)} vs {len(expected_time)} steps, first "
f"{month_time[0] if len(month_time) else None} vs {expected_time[0]})."
) )
return bool(np.isfinite(sample).any()) encoded = to_storage(coarse.transpose("time", "Y", "X").values, VARIABLES[var])
args.staging_dir.mkdir(parents=True, exist_ok=True)
tmp = staged.with_suffix(".tmp.npy")
np.save(tmp, encoded)
os.replace(tmp, staged)
if not args.keep_raw:
raw_path.unlink(missing_ok=True)
return encoded
def _probe_coarse_coords(var: str, time_index: pd.DatetimeIndex, args) -> dict[str, np.ndarray]:
"""Coarse Y/X values from one real month (all variables share one grid)."""
year, month = time_index[0].year, time_index[0].month
raw_path = hs.ensure_month_file(var, year, month, args.raw_cache_dir)
with hs.read_month_file(raw_path) as ds:
coarse = aggregate_month(ds, var)
# The raw file is kept: the first year's ingestion picks it up again.
return {"Y": coarse["Y"].values, "X": coarse["X"].values}
def _check_seed(seed: xr.Dataset, coords: dict[str, np.ndarray], time_index: pd.DatetimeIndex) -> None:
for dim in ("Y", "X"):
if not np.allclose(seed[dim].values, coords[dim]):
raise ValueError(f"--seed-from archive has a different {dim} grid.")
missing = time_index.difference(pd.DatetimeIndex(seed["time"].values))
if len(missing):
raise ValueError(f"--seed-from archive lacks {len(missing)} hours, e.g. {missing[0]}.")
def main(argv: list[str] | None = None) -> None: def main(argv: list[str] | None = None) -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
args = parse_args(argv) args = parse_args(argv)
variables = [v.strip() for v in args.variables.split(",") if v.strip()] variables = [v.strip() for v in args.variables.split(",") if v.strip()]
unknown = sorted(set(variables) - set(VARIABLES))
if unknown:
raise SystemExit(f"Unknown variables {unknown}; known: {', '.join(VARIABLES)}")
time_index = build_time_index(args.start, args.end) time_index = build_time_index(args.start, args.end)
logger.info("Archive time range: %s - %s (%d hours)", time_index[0], time_index[-1], len(time_index)) logger.info("Archive time range: %s - %s (%d hours)", time_index[0], time_index[-1], len(time_index))
if not args.archive_path.exists(): seed = xr.open_zarr(args.seed_from) if args.seed_from else None
# Grab one real month's grid up front purely to read the coarse seeded = [v for v in variables if seed is not None and v in seed["variable"].values]
# Y/X coordinate values the template needs -- the first variable if seed is not None:
# in the list stands in for all of them, since they share one grid. logger.info("Copying %s from %s instead of downloading", seeded or "nothing", args.seed_from)
probe_year, probe_month = time_index[0].year, time_index[0].month
probe_path = hs.ensure_month_file(variables[0], probe_year, probe_month, args.raw_cache_dir)
with hs.read_month_file(probe_path) as probe_ds:
probe_coarse = aggregate_month(probe_ds, variables[0])
coarse_coords = {"Y": probe_coarse["Y"].values, "X": probe_coarse["X"].values}
create_archive_template(args.archive_path, variables, time_index, coarse_coords)
logger.info("Created archive template at %s", args.archive_path)
if not args.keep_raw:
probe_path.unlink(missing_ok=True)
for var in variables: if args.archive_path.exists():
for year, month in hs.month_range(time_index[0], time_index[-1]): if not args.resume:
start_idx = time_index.get_loc( raise SystemExit(
time_index[(time_index.year == year) & (time_index.month == month)][0] f"{args.archive_path} already exists. Pass --resume to continue it, "
"or move it away (e.g. to use it with --seed-from)."
) )
if args.resume and _first_coarse_cell_is_written(args.archive_path, var, variables, start_idx): with xr.open_zarr(args.archive_path) as existing:
logger.info("Skip %s %04d-%02d (already written)", var, year, month) if list(existing["variable"].values) != variables or not pd.DatetimeIndex(
continue existing["time"].values
).equals(time_index):
logger.info("Fetch %s %04d-%02d", var, year, month) raise SystemExit(f"{args.archive_path} was created with other variables or another time range.")
raw_path = hs.ensure_month_file(var, year, month, args.raw_cache_dir) coords = {"Y": existing["Y"].values, "X": existing["X"].values}
with hs.read_month_file(raw_path) as ds: else:
coarse = aggregate_month(ds, var) if seed is not None:
write_month(args.archive_path, var, variables, coarse, time_index) coords = {"Y": seed["Y"].values, "X": seed["X"].values}
logger.info("Wrote %s %04d-%02d", var, year, month) else:
coords = _probe_coarse_coords(variables[0], time_index, args)
create_archive_template(args.archive_path, variables, time_index, coords)
logger.info("Created archive template at %s", args.archive_path)
if seed is not None:
_check_seed(seed, coords, time_index)
if not args.keep_raw: done = completed_years(args.archive_path)
raw_path.unlink(missing_ok=True) for year in sorted(set(time_index.year)):
if year in done:
logger.info("Skip %d (already completed)", year)
continue
rows = np.flatnonzero(time_index.year == year)
year_slice = slice(int(rows[0]), int(rows[-1]) + 1)
year_time = time_index[year_slice]
block = np.full((len(year_time), len(coords["Y"]), len(coords["X"]), len(variables)), STORAGE_FILL, dtype="int16")
if seeded:
values = seed[ARRAY_NAME].sel(time=year_time, variable=seeded).transpose("time", "Y", "X", "variable").values
for k, var in enumerate(seeded):
block[..., variables.index(var)] = to_storage(values[..., k], VARIABLES[var])
del values
months = sorted(set(year_time.month))
for i, var in enumerate(variables):
if var in seeded:
continue
for month in months:
month_rows = np.flatnonzero(year_time.month == month)
block[month_rows, :, :, i] = _staged_month(var, year, month, year_time[month_rows], args)
logger.info("Write %d (%d variables)", year, len(variables))
write_block(args.archive_path, year_slice, block)
mark_year_completed(args.archive_path, year)
for staged in args.staging_dir.glob(f"*_{year:04d}??.npy"):
staged.unlink()
logger.info("Completed %d", year)
if __name__ == "__main__": if __name__ == "__main__":
......
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
# --- Variables ----------------------------------------------------------- # --- Variables -----------------------------------------------------------
@dataclass(frozen=True)
class VariableSpec:
"""How one HOSTRADA variable is aggregated, quantised and described.
``resolution`` is the precision DWD itself publishes (int32 values, with
``scale_factor=0.1`` for some variables, verified on the June 2025 files).
The 10 km block mean is rounded back to it, so the archive never claims
more precision than the source.
"""
units: str
long_name: str
resolution: float
cell_methods: str = "time: point"
time_convention: str = "instantaneous value at the timestamp (UTC)"
aggregation: str = "mean" # "mean" or "circular_mean" (angles in degrees)
_HOUR_ENDING = (
"mean over the hour ending at the timestamp (UTC). DWD's description says "
"'sum over the last hour'; the NetCDF time_bnds claim the hour starting at "
"the timestamp, but a clear-sky fit (1995-2026) confirms hour-ending."
)
VARIABLES: dict[str, VariableSpec] = {
"tas": VariableSpec("degC", "Near-Surface Air Temperature", 0.1),
"rsds": VariableSpec(
"W m-2", "Surface Downwelling Shortwave Radiation (GHI)", 0.1,
cell_methods="time: mean", time_convention=_HOUR_ENDING,
),
"hurs": VariableSpec("%", "Near-Surface Relative Humidity", 0.1),
"sfcWind": VariableSpec("m s-1", "Near-Surface Wind Speed (10 m)", 0.1),
# Block-averaging angles arithmetically is wrong (mean of 350 and 10 would
# be 180), so directions are averaged as unit vectors.
"sfcWind_direction": VariableSpec(
"degree", "Near-Surface Wind Direction (10 m)", 1.0, aggregation="circular_mean",
),
"clt": VariableSpec("octa", "Total Cloud Fraction", 1.0),
"uhi": VariableSpec("degC", "Canopy Urban Heat Island Intensity", 0.1),
# DWD's long_name says "Daily Mean Dew-point Temperature", but the data
# are hourly like every other HOSTRADA variable.
"tdew": VariableSpec("degC", "Near-Surface Dew-point Temperature", 0.1),
"mixr": VariableSpec("g kg-1", "Near-Surface Water Vapor Mixing Ratio", 0.1),
# DWD's files say units="Pa", but the stored values are 750-1050: hPa.
"ps": VariableSpec("hPa", "Surface Air Pressure", 1.0),
"psl": VariableSpec("hPa", "Sea Level Pressure", 1.0),
}
# tas, rsds, hurs, sfcWind: confirmed against SimStadt's GreenWater module # tas, rsds, hurs, sfcWind: confirmed against SimStadt's GreenWater module
# (de.hft.stuttgart.simstadt2.greenwater) -- ambient temperature, GHI, # (de.hft.stuttgart.simstadt2.greenwater) -- ambient temperature, GHI,
# relative humidity, wind speed. DHI is NOT a separate HOSTRADA variable; it # relative humidity, wind speed. DHI is NOT a separate HOSTRADA variable; it
# is derived from rsds via pvlib's Erbs/Erbs-Driesse decomposition # is derived from rsds via pvlib's Erbs/Erbs-Driesse decomposition
# (hostrada4py.hostradaDiffuse.HostradaDiffuse). # (hostrada4py.hostradaDiffuse.HostradaDiffuse). clt, uhi enable its
CORE_VARIABLES: tuple[str, ...] = ("tas", "rsds", "hurs", "sfcWind") # apply_weather_correction=True path. The rest completes the HOSTRADA set
# (e.g. for TMY3/EPW exports).
# clt, uhi: not required by SimStadt directly, only pulled in to enable ALL_VARIABLES: tuple[str, ...] = tuple(VARIABLES)
# HostradaDiffuse's apply_weather_correction=True path, which refines the
# Erbs-Driesse DHI estimate using cloud cover and urban-heat-island signals.
# Drop these from ALL_VARIABLES if that refinement isn't worth the extra
# ~2 GiB/year of ingestion.
DHI_CORRECTION_VARIABLES: tuple[str, ...] = ("clt", "uhi")
ALL_VARIABLES: tuple[str, ...] = CORE_VARIABLES + DHI_CORRECTION_VARIABLES
# Precipitation is intentionally NOT handled here. Neither the DWD HOSTRADA # Precipitation is intentionally NOT handled here. Neither the DWD HOSTRADA
# provider nor hostrada4py's CERRA provider currently expose it (confirmed by # provider nor hostrada4py's CERRA provider currently expose it (confirmed by
...@@ -26,6 +70,15 @@ ALL_VARIABLES: tuple[str, ...] = CORE_VARIABLES + DHI_CORRECTION_VARIABLES ...@@ -26,6 +70,15 @@ ALL_VARIABLES: tuple[str, ...] = CORE_VARIABLES + DHI_CORRECTION_VARIABLES
# CERRA_VARIABLES). Adding it belongs in hostrada4py itself, not here, since # CERRA_VARIABLES). Adding it belongs in hostrada4py itself, not here, since
# it's provider-level plumbing other hostrada4py consumers would want too. # it's provider-level plumbing other hostrada4py consumers would want too.
# --- Storage encoding ---------------------------------------------------
# Every variable is stored as int16 in steps of 0.1 (CF scale_factor, decoded
# transparently by xarray). Variables with a coarser source resolution (1 hPa,
# 1 octa, 1 degree) are simply multiples of 10. All ranges fit: the largest is
# psl, ~10,600 steps. -32768 marks missing data (outside Germany).
STORAGE_DTYPE = "int16"
STORAGE_SCALE = 0.1
STORAGE_FILL = -32768
# --- Spatial aggregation --------------------------------------------------- # --- Spatial aggregation ---------------------------------------------------
# HOSTRADA's native grid is a verified uniform 1000 m x 1000 m EPSG:3034 # HOSTRADA's native grid is a verified uniform 1000 m x 1000 m EPSG:3034
# raster (938 x 720 cells). AGGREGATION_FACTOR=10 -> ~10 km cells, a plain # raster (938 x 720 cells). AGGREGATION_FACTOR=10 -> ~10 km cells, a plain
...@@ -46,15 +99,10 @@ DEFAULT_END = "2026-08" ...@@ -46,15 +99,10 @@ DEFAULT_END = "2026-08"
# --- Archive chunking ------------------------------------------------- # --- Archive chunking -------------------------------------------------
# TIME_CHUNK_HOURS = None means "the whole configured time range in one # TIME_CHUNK_HOURS = None means "the whole configured time range in one
# chunk" -- a single point query then reads its full multi-decade series in # chunk" -- a single point query then reads its full multi-decade series in
# one chunk read. Measured the write-amplification this causes during # one chunk read. Writing into such a chunk is a read-modify-write of the
# incremental monthly ingestion (read-modify-write of an increasingly-full # whole chunk, so build_archive stages months on disk and writes one full
# chunk): ~0.9s/write when a 10-year chunk is nearly empty, rising to # calendar year (all variables) at a time: ~32 rewrites of the store instead
# ~1.9s/write once it's full (120-step benchmark). That adds on the order of # of one per (variable, month).
# 15-20 minutes across a full 30-year ingestion -- negligible next to the
# ~20-30s/month network download that otherwise dominates ingestion time
# at this archive's few-GB total size. Set this to an hour count (e.g. one
# decade) instead if this is ever re-run against a much larger archive
# where that overhead would no longer be negligible.
TIME_CHUNK_HOURS: int | None = None TIME_CHUNK_HOURS: int | None = None
# Coarse cells per spatial chunk edge: a point query only ever decompresses # Coarse cells per spatial chunk edge: a point query only ever decompresses
# a SPATIAL_CHUNK_CELLS x SPATIAL_CHUNK_CELLS neighbourhood (20 km x 20 km # a SPATIAL_CHUNK_CELLS x SPATIAL_CHUNK_CELLS neighbourhood (20 km x 20 km
...@@ -63,4 +111,5 @@ SPATIAL_CHUNK_CELLS = 2 ...@@ -63,4 +111,5 @@ SPATIAL_CHUNK_CELLS = 2
# --- Paths -------------------------------------------------------------- # --- Paths --------------------------------------------------------------
DEFAULT_RAW_CACHE_DIR = Path("raw_cache") DEFAULT_RAW_CACHE_DIR = Path("raw_cache")
DEFAULT_STAGING_DIR = Path("staging")
DEFAULT_ARCHIVE_PATH = Path("hostrada_coarse.zarr") DEFAULT_ARCHIVE_PATH = Path("hostrada_coarse.zarr")
...@@ -7,12 +7,12 @@ name = "hostrada-transpose" ...@@ -7,12 +7,12 @@ name = "hostrada-transpose"
version = "0.1.0" version = "0.1.0"
description = "One-time ETL: rechunk HOSTRADA into a compact, time-major, spatially-aggregated archive for SimStadt." description = "One-time ETL: rechunk HOSTRADA into a compact, time-major, spatially-aggregated archive for SimStadt."
readme = "README.md" readme = "README.md"
requires-python = ">=3.10,<3.14" requires-python = ">=3.11,<3.14"
license = {text = "MIT"} license = {text = "MIT"}
dependencies = [ dependencies = [
"hostrada4py @ git+https://github.com/UdK-VPT/hostrada4py.git", "hostrada4py @ git+https://github.com/UdK-VPT/hostrada4py.git",
"xarray", "xarray",
"zarr", "zarr>=3",
"dask", "dask",
"pandas", "pandas",
"numpy", "numpy",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment