Initial commit

This commit is contained in:
Schuwi
2026-03-03 23:26:05 +01:00
commit 6c9a20bf59
9 changed files with 2784 additions and 0 deletions

204
src/main.rs Normal file
View File

@@ -0,0 +1,204 @@
mod download;
mod grib;
mod projection;
mod render;
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Datelike, Timelike, Utc};
use clap::Parser;
use std::path::PathBuf;
const BASE_URL: &str = "https://opendata.dwd.de/weather/nwp/icon-d2/grib/";
const GRIB_NAME_BASE: &str = "icon-d2_germany_icosahedral_";
const CACHE_DIR: &str = "cache";
#[derive(Parser)]
#[command(
name = "cloud_cover",
about = "Download and visualise DWD ICON-D2 cloud cover predictions as PNG maps",
long_about = "Downloads total cloud cover (CLCT) predictions from the DWD open-data server \
and renders one PNG map per forecast hour.\n\n\
The timestamp must correspond to an ICON-D2 model run (every 3 hours, \
starting at 00 UTC). Example: \"2022-03-04T09:00:00Z\""
)]
struct Args {
/// Model run timestamp in ISO 8601 format, e.g. "2022-03-04T09:00:00Z"
iso_timestamp: String,
/// Number of forecast hours to download and render (048)
#[arg(value_name = "HOURS")]
prediction_hours: u32,
}
fn main() -> Result<()> {
let args = Args::parse();
// Parse timestamp
let dt: DateTime<Utc> = args
.iso_timestamp
.parse::<DateTime<Utc>>()
.with_context(|| format!("Could not parse timestamp {:?}", args.iso_timestamp))?;
// Validate hour: ICON-D2 runs at 00, 03, 06, 09, 12, 15, 18, 21 UTC
if dt.hour() % 3 != 0 {
bail!(
"ICON-D2 only runs every 3 hours starting at 00 UTC. \
Got hour={}, which is not a multiple of 3.",
dt.hour()
);
}
if args.prediction_hours > 48 {
bail!(
"ICON-D2 predicts at most 48 hours into the future (requested {}).",
args.prediction_hours
);
}
// Build cache directory, e.g. cache/2022-03-04_09UTC/
let cache_subdir = format!(
"{:04}-{:02}-{:02}_{:02}UTC",
dt.year(),
dt.month(),
dt.day(),
dt.hour()
);
let cache_dir = PathBuf::from(CACHE_DIR).join(&cache_subdir);
std::fs::create_dir_all(&cache_dir)
.with_context(|| format!("Cannot create cache directory {}", cache_dir.display()))?;
// URL prefix for this model run: BASE_URL + "{HH}/"
let run_url = format!("{}{:02}/", BASE_URL, dt.hour());
let date_str = format!(
"{:04}{:02}{:02}{:02}",
dt.year(),
dt.month(),
dt.day(),
dt.hour()
);
// Download & decode coordinate grids
eprintln!("Downloading coordinate grids (clat / clon)...");
let lats = download_and_decode(
&run_url,
&date_str,
"clat",
"time-invariant",
None,
&cache_dir,
)
.context("clat")?;
let lons = download_and_decode(
&run_url,
&date_str,
"clon",
"time-invariant",
None,
&cache_dir,
)
.context("clon")?;
if lats.len() != lons.len() {
bail!(
"clat and clon grids have different lengths ({} vs {})",
lats.len(),
lons.len()
);
}
eprintln!(" Grid has {} points.", lats.len());
// Download, decode, and render each forecast hour
eprintln!("Rendering {} frame(s)...", args.prediction_hours);
let mut output_paths = Vec::new();
for step in 0..args.prediction_hours {
let cloud = download_and_decode(
&run_url,
&date_str,
"clct",
"single-level",
Some(step),
&cache_dir,
)
.with_context(|| format!("clct step {}", step))?;
if cloud.len() != lats.len() {
bail!(
"clct step {} has {} values but coordinate grids have {}",
step,
cloud.len(),
lats.len()
);
}
let forecast_dt = dt + chrono::Duration::hours(step as i64);
let title = if step == 0 {
format!(
"Conditions at {} UTC",
forecast_dt.format("%Y-%m-%d %H:%M")
)
} else {
format!(
"Prediction at {} UTC (+{:02}h)",
forecast_dt.format("%Y-%m-%d %H:%M"),
step
)
};
let out_name = format!("clct_{:06}.png", step + 1);
let out_path = cache_dir.join(&out_name);
render::render_frame(&out_path, &lats, &lons, &cloud, &title)
.with_context(|| format!("Rendering frame {}", step))?;
eprintln!(" [{:02}/{:02}] {}", step + 1, args.prediction_hours, out_name);
output_paths.push(out_path);
}
eprintln!("\nDone. {} PNG(s) written to {}:", output_paths.len(), cache_dir.display());
for p in &output_paths {
eprintln!(" {}", p.display());
}
Ok(())
}
/// Build the DWD URL, download the bzip2-compressed GRIB2 file, and decode it.
///
/// `param` variable name ("clat", "clon", "clct")
/// `kind` "time-invariant" or "single-level"
/// `step` forecast step in hours (None for time-invariant files)
fn download_and_decode(
run_url: &str,
date_str: &str,
param: &str,
kind: &str,
step: Option<u32>,
cache_dir: &PathBuf,
) -> Result<Vec<f32>> {
let step_str = step.map(|s| format!("{:03}", s)).unwrap_or_else(|| "000".to_string());
// DWD filename pattern:
// icon-d2_germany_icosahedral_time-invariant_YYYYMMDDHH_000_0_clat.grib2.bz2
// icon-d2_germany_icosahedral_single-level_YYYYMMDDHH_NNN_2d_clct.grib2.bz2
let file_name_bz2 = format!(
"{}{}_{}_{}_{}_{}.grib2.bz2",
GRIB_NAME_BASE, kind, date_str, step_str,
if kind == "time-invariant" { "0" } else { "2d" },
param
);
let url = format!("{}{}/{}", run_url, param, file_name_bz2);
let cache_path = cache_dir.join(format!("{}.grib2", {
// cache as e.g. clat.grib2 or clct_003.grib2
if let Some(s) = step {
format!("{}_{:03}", param, s)
} else {
param.to_string()
}
}));
let grib_bytes = download::fetch_grib(&url, &cache_path)?;
let values = grib::decode(&grib_bytes)
.with_context(|| format!("GRIB2 decode failed for {}", cache_path.display()))?;
Ok(values)
}