Initial commit
This commit is contained in:
204
src/main.rs
Normal file
204
src/main.rs
Normal 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 (0–48)
|
||||
#[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)
|
||||
}
|
||||
Reference in New Issue
Block a user