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

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
cache
# Generated by Cargo
# will have compiled files and executables
debug
target
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Generated by cargo mutants
# Contains mutation testing data
**/mutants.out*/
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

1810
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "cloud_cover"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "cloud_cover"
path = "src/main.rs"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
bzip2 = "0.4"
plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder"] }
font8x8 = "0.2"
clap = { version = "4", features = ["derive"] }
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
anyhow = "1"

97
README.md Normal file
View File

@@ -0,0 +1,97 @@
# cloud_cover
Downloads total cloud cover (CLCT) predictions from the [DWD open-data
server](https://opendata.dwd.de/weather/nwp/icon-d2/grib/) and renders
one PNG map per forecast hour, centred on the Brandenburg / Berlin area.
This is a Rust port of `../cloud_cover_prediction.py`. The output format
differs: instead of using PyNGL's contour renderer the Rust version does a
direct point-by-point orthographic projection of the ICON-D2 grid onto a
bitmap, which avoids every system-library dependency (no CDO, no PyNIO, no
PyNGL, no libssl, no libbz2 — the binary is fully self-contained).
## Usage
```
cloud_cover <ISO_TIMESTAMP> <HOURS>
```
| Argument | Description |
|---|---|
| `ISO_TIMESTAMP` | Model run time in ISO 8601, e.g. `2026-03-03T18:00:00Z` |
| `HOURS` | Number of forecast steps to render (048) |
The timestamp must correspond to an ICON-D2 model run, which is issued
every 3 hours starting at 00 UTC (00, 03, 06, 09, 12, 15, 18, 21).
### Example
```sh
cargo run --release -- "2026-03-03T18:00:00Z" 12
```
Output PNGs are written to `cache/<date>_<hour>UTC/clct_000001.png`
`clct_NNNNNN.png`. Downloaded GRIB2 files are cached in the same directory
so that re-running the same timestamp skips the network requests.
## How it works
1. **Download**`reqwest` fetches the bzip2-compressed GRIB2 files from
the DWD server. Two coordinate files (`clat`, `clon`) give the latitude
and longitude of every point on the ICON-D2 icosahedral grid (~542 000
points for Germany). One `clct` file per forecast step contains the
total cloud cover percentage at each point.
2. **Decompress**`bzip2` decompresses the files in-memory.
3. **Parse** — A minimal built-in GRIB2 decoder extracts the data values.
Only simple packing (data representation template 0) is supported, which
is what DWD uses. Grid points that are flagged as absent by the section-6
bitmap are set to `NaN` and skipped during rendering.
4. **Project & render** — Each grid point is projected onto the image plane
using an orthographic projection centred on Falkensee (52.56 °N,
13.08 °E). Points are painted as 2 × 2 pixel squares, colour-coded by
cloud cover percentage. City markers and labels are drawn on top, and a
colour-scale legend is shown on the right. `plotters` writes the final
PNG.
## Cloud cover colour scale
| Cloud cover | Colour |
|---|---|
| < 1 % | Land background (clear) |
| 12 % | Very light blue |
| 25 % | Light blue |
| 510 % | Medium blue |
| 1020 % | Blue |
| 2050 % | Dark blue |
| 50100 % | Very dark blue |
## Marked locations
Falkensee · Pausin · Nauen · Hennigsdorf · Ketzin/Brückenkopf · Potsdam ·
Berlin (Mitte)
## Dependencies
All dependencies are pure Rust or vendored C code compiled into the binary.
No system shared libraries are required at runtime beyond libc.
| Crate | Role |
|---|---|
| `reqwest` (rustls-tls) | HTTP client with pure-Rust TLS |
| `bzip2` | bzip2 decompression (vendors libbzip2) |
| `plotters` + `plotters-bitmap` | PNG rendering with built-in bitmap font |
| `clap` | CLI argument parsing |
| `chrono` | Date/time handling |
| `anyhow` | Error propagation |
## Building
```sh
cd cloud_cover_rs
cargo build --release
```
The compiled binary ends up at `target/release/cloud_cover`.

48
src/download.rs Normal file
View File

@@ -0,0 +1,48 @@
use anyhow::{bail, Context, Result};
use bzip2::read::BzDecoder;
use std::fs;
use std::io::Read;
use std::path::Path;
/// Download a bzip2-compressed GRIB2 file and return its raw (decompressed) bytes.
///
/// If `cache_path` already exists on disk, reads from disk instead of downloading.
/// On a successful download, the decompressed bytes are written to `cache_path`.
pub fn fetch_grib(url: &str, cache_path: &Path) -> Result<Vec<u8>> {
if cache_path.exists() {
let bytes = fs::read(cache_path)
.with_context(|| format!("Failed to read cached file {}", cache_path.display()))?;
return Ok(bytes);
}
eprintln!(" Downloading {}", url);
let response = reqwest::blocking::get(url)
.with_context(|| format!("HTTP request failed for {}", url))?;
let status = response.status();
if !status.is_success() {
bail!("HTTP {} for {}", status, url);
}
let compressed_bytes = response
.bytes()
.with_context(|| format!("Failed to read response body from {}", url))?;
// Decompress bzip2
let mut decoder = BzDecoder::new(compressed_bytes.as_ref());
let mut decompressed = Vec::new();
decoder
.read_to_end(&mut decompressed)
.context("bzip2 decompression failed")?;
// Write to cache
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create cache directory {}", parent.display()))?;
}
fs::write(cache_path, &decompressed)
.with_context(|| format!("Failed to write cache file {}", cache_path.display()))?;
Ok(decompressed)
}

287
src/grib.rs Normal file
View File

@@ -0,0 +1,287 @@
/// Minimal GRIB2 decoder targeting DWD ICON-D2 files.
///
/// We only handle data representation template 0 (simple packing), which is
/// what DWD uses for CLCT, CLAT, and CLON fields. We don't need to understand
/// the grid definition template because lat/lon are provided in separate files.
///
/// GRIB2 section layout (WMO Manual on Codes):
/// Section 0: Indicator "GRIB", discipline, edition=2, total length
/// Section 1: Identification
/// Section 2: (optional) Local Use
/// Section 3: Grid Definition
/// Section 4: Product Definition
/// Section 5: Data Representation packing parameters
/// Section 6: Bit-Map
/// Section 7: Data packed values (only for non-missing points)
/// Section 8: End Marker "7777"
use anyhow::{bail, Context, Result};
/// Decode all data values from a GRIB2 message using simple packing.
///
/// Returns one `f32` per grid point (length == section-3 num_data_points).
/// Grid points marked absent by the section-6 bitmap are set to `f32::NAN`.
pub fn decode(data: &[u8]) -> Result<Vec<f32>> {
// Verify GRIB magic
if data.len() < 16 || &data[0..4] != b"GRIB" {
bail!("Not a GRIB file (missing magic bytes)");
}
if data[7] != 2 {
bail!("Only GRIB edition 2 is supported (got {})", data[7]);
}
let total_len = read_u64(&data[8..16]) as usize;
if data.len() < total_len {
bail!("GRIB data truncated: expected {} bytes, got {}", total_len, data.len());
}
let mut pos = 16; // after section 0
// Collected from sections
let mut num_data_points: u32 = 0; // section 3: total grid points (incl. missing)
let mut n_packed: u32 = 0; // section 5: number of actually packed values
let mut sec5_payload: Option<&[u8]> = None;
let mut bitmap: Option<&[u8]> = None; // raw bitmap bytes from section 6 (if present)
let mut sec7_payload: Option<&[u8]> = None;
while pos < total_len {
// Check for end marker "7777"
if pos + 4 <= total_len && &data[pos..pos + 4] == b"7777" {
break;
}
if pos + 5 > total_len {
bail!("Truncated section header at offset {}", pos);
}
let sec_len = read_u32(&data[pos..pos + 4]) as usize;
if sec_len < 5 {
bail!("Invalid section length {} at offset {}", sec_len, pos);
}
if pos + sec_len > total_len {
bail!("Section extends past end of GRIB message at offset {}", pos);
}
let sec_num = data[pos + 4];
match sec_num {
3 => {
// Grid Definition Section: bytes 6..9 = number of data points (uint32)
if sec_len >= 10 {
num_data_points = read_u32(&data[pos + 6..pos + 10]);
}
}
5 => {
// Data Representation Section
// bytes 5..8: number of packed values (may be < num_data_points when bitmap present)
// bytes 9..10: data representation template number
if sec_len < 11 {
bail!("Section 5 too short");
}
n_packed = read_u32(&data[pos + 5..pos + 9]);
let tmpl = read_u16(&data[pos + 9..pos + 11]);
if tmpl != 0 {
bail!(
"Unsupported data representation template {}; \
only template 0 (simple packing) is supported",
tmpl
);
}
sec5_payload = Some(&data[pos + 11..pos + sec_len]);
}
6 => {
// Bit-Map Section
// byte 5: bitmap indicator (0 = bitmap follows, 255 = no bitmap)
if sec_len < 6 {
bail!("Section 6 too short");
}
let indicator = data[pos + 5];
if indicator == 0 {
// Bitmap bytes follow from byte 6 onward
bitmap = Some(&data[pos + 6..pos + sec_len]);
}
// indicator == 255 means "no bitmap" — leave bitmap as None
}
7 => {
// Data Section: payload starts at byte 5
sec7_payload = Some(&data[pos + 5..pos + sec_len]);
}
_ => {}
}
pos += sec_len;
}
let sec5 = sec5_payload.context("Section 5 (Data Representation) not found")?;
let sec7 = sec7_payload.context("Section 7 (Data) not found")?;
// Fall back to n_packed if section 3 was absent or zero
if num_data_points == 0 {
num_data_points = n_packed;
}
// Decode the n_packed values that are actually stored in section 7
let packed_values = decode_simple_packing(sec5, sec7, n_packed)?;
// If there is a bitmap, expand packed_values to num_data_points,
// inserting NaN for every bit that is 0.
if let Some(bm) = bitmap {
expand_with_bitmap(packed_values, bm, num_data_points)
} else {
// No bitmap: packed_values should already cover all grid points
if packed_values.len() != num_data_points as usize {
bail!(
"Packed value count ({}) does not match grid size ({}) and no bitmap was found",
packed_values.len(),
num_data_points
);
}
Ok(packed_values)
}
}
/// Expand `packed` (length == number of set bits in bitmap) to `total` values,
/// placing NaN at positions where the bitmap bit is 0.
fn expand_with_bitmap(packed: Vec<f32>, bitmap: &[u8], total: u32) -> Result<Vec<f32>> {
let mut out = vec![f32::NAN; total as usize];
let mut packed_iter = packed.into_iter();
for i in 0..total as usize {
let byte = i / 8;
let bit = 7 - (i % 8); // GRIB2 bitmaps are MSB-first
if byte < bitmap.len() && (bitmap[byte] >> bit) & 1 == 1 {
out[i] = packed_iter.next().unwrap_or(f32::NAN);
}
// else: bit == 0 → missing, already NaN
}
Ok(out)
}
/// Decode simple packing (template 0).
///
/// Template 0 layout (offsets within section 5 payload, after section+template header):
/// bytes 0.. 3: R reference value (IEEE 754 f32, big-endian)
/// bytes 4.. 5: E binary scale factor (int16, big-endian)
/// bytes 6.. 7: D decimal scale factor (int16, big-endian)
/// byte 8: nBits bits per packed value
///
/// Decoding: value[i] = (R + X[i] × 2^E) × 10^(-D)
fn decode_simple_packing(sec5: &[u8], sec7: &[u8], n: u32) -> Result<Vec<f32>> {
if sec5.len() < 9 {
bail!("Section 5 template 0 payload too short ({} bytes)", sec5.len());
}
let r_bytes: [u8; 4] = sec5[0..4].try_into().unwrap();
let r = f32::from_bits(u32::from_be_bytes(r_bytes));
let e = read_i16(&sec5[4..6]);
let d = read_i16(&sec5[6..8]);
let n_bits = sec5[8] as u32;
if n_bits == 0 {
// All values equal the reference value
return Ok(vec![r * 10f32.powi(-(d as i32)); n as usize]);
}
if n_bits > 32 {
bail!("Implausible n_bits = {}", n_bits);
}
let scale_e = 2f32.powi(e as i32);
let scale_d = 10f32.powi(-(d as i32));
let mut values = Vec::with_capacity(n as usize);
let mut bit_offset: u64 = 0;
for _ in 0..n {
let packed = read_bits(sec7, bit_offset, n_bits)?;
let v = (r + packed as f32 * scale_e) * scale_d;
values.push(v);
bit_offset += n_bits as u64;
}
Ok(values)
}
/// Read `count` bits from `data` starting at `bit_offset` (MSB first).
fn read_bits(data: &[u8], bit_offset: u64, count: u32) -> Result<u32> {
let byte_start = (bit_offset / 8) as usize;
let bit_start = (bit_offset % 8) as u32;
// We need at most 5 bytes to read up to 32 bits at any bit alignment
let bytes_needed = ((bit_start + count + 7) / 8) as usize;
if byte_start + bytes_needed > data.len() {
bail!(
"Bit read out of bounds: offset={}, count={}, data_len={}",
bit_offset, count, data.len()
);
}
// Accumulate into a u64
let mut acc: u64 = 0;
for i in 0..bytes_needed {
acc = (acc << 8) | data[byte_start + i] as u64;
}
// Shift right to remove trailing bits, then mask to `count` bits
let shift = (bytes_needed as u32) * 8 - bit_start - count;
let mask = (1u64 << count) - 1;
Ok(((acc >> shift) & mask) as u32)
}
// ---- byte-order helpers ----
fn read_u64(b: &[u8]) -> u64 {
u64::from_be_bytes(b[0..8].try_into().unwrap())
}
fn read_u32(b: &[u8]) -> u32 {
u32::from_be_bytes(b[0..4].try_into().unwrap())
}
fn read_u16(b: &[u8]) -> u16 {
u16::from_be_bytes(b[0..2].try_into().unwrap())
}
fn read_i16(b: &[u8]) -> i16 {
i16::from_be_bytes(b[0..2].try_into().unwrap())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_read_bits_basic() {
let data = [0xCAu8]; // 0b11001010
assert_eq!(read_bits(&data, 0, 4).unwrap(), 12); // 0b1100
assert_eq!(read_bits(&data, 4, 4).unwrap(), 10); // 0b1010
}
#[test]
fn test_read_bits_cross_byte() {
// 0xCA 0xBD = 0b11001010_10111101; 8 bits from offset 4 = 0b10101011 = 0xAB
let data = [0xCAu8, 0xBDu8];
assert_eq!(read_bits(&data, 4, 8).unwrap(), 0xAB);
}
#[test]
fn test_zero_nbits() {
let r: u32 = 5.0f32.to_bits();
let mut sec5 = vec![0u8; 10];
sec5[0..4].copy_from_slice(&r.to_be_bytes());
let result = decode_simple_packing(&sec5, &[], 3).unwrap();
assert_eq!(result, vec![5.0f32, 5.0, 5.0]);
}
#[test]
fn test_expand_with_bitmap() {
// bitmap: 0b10110000 → positions 0, 2, 3 are present; 1, 4-7 are missing
let bitmap = [0b10110000u8];
let packed = vec![1.0f32, 2.0, 3.0];
let out = expand_with_bitmap(packed, &bitmap, 8).unwrap();
assert_eq!(out[0], 1.0);
assert!(out[1].is_nan());
assert_eq!(out[2], 2.0);
assert_eq!(out[3], 3.0);
for i in 4..8 {
assert!(out[i].is_nan());
}
}
}

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)
}

71
src/projection.rs Normal file
View File

@@ -0,0 +1,71 @@
/// Orthographic map projection.
///
/// Projects a geographic point (lat, lon) onto a 2-D plane as seen from directly
/// above the centre point (center_lat, center_lon).
///
/// Returns (x, y) normalised so that an angular distance of 1° from the centre
/// equals 1.0 in output units. Returns `None` when the point is on the far side
/// of the globe (not visible from the projection centre).
pub struct OrthoProjection {
/// Centre of projection in radians
phi0: f64,
lam0: f64,
/// Half-extents of the visible window in "projected units" (degrees of arc)
pub half_width: f64,
pub half_height: f64,
}
impl OrthoProjection {
/// `center_lat`, `center_lon` in degrees.
/// `half_width`, `half_height` are the angular half-extents of the view window
/// in degrees (e.g. 0.8 and 0.4 to match the original Python script).
pub fn new(center_lat: f64, center_lon: f64, half_width: f64, half_height: f64) -> Self {
Self {
phi0: center_lat.to_radians(),
lam0: center_lon.to_radians(),
half_width,
half_height,
}
}
/// Project `(lat, lon)` in degrees.
///
/// Returns `Some((x, y))` in degrees of arc from the centre (same units as
/// `half_width`/`half_height`), or `None` if the point is not visible.
pub fn project(&self, lat: f64, lon: f64) -> Option<(f64, f64)> {
let phi = lat.to_radians();
let lam = lon.to_radians();
let dlam = lam - self.lam0;
let cos_c = self.phi0.sin() * phi.sin() + self.phi0.cos() * phi.cos() * dlam.cos();
if cos_c <= 0.0 {
return None; // back of globe
}
// x and y are in radians; convert to degrees so they match the half_width unit
let x = phi.cos() * dlam.sin();
let y = self.phi0.cos() * phi.sin() - self.phi0.sin() * phi.cos() * dlam.cos();
Some((x.to_degrees(), y.to_degrees()))
}
/// Convert projected (x, y) in degrees-of-arc to pixel (col, row).
///
/// Returns `None` if the point falls outside the image boundary.
pub fn to_pixel(&self, x: f64, y: f64, width: u32, height: u32) -> Option<(i32, i32)> {
// Map [-half_width, +half_width] → [0, width]
// Map [+half_height, -half_height] → [0, height] (y flipped: north = up = row 0)
let col = (x + self.half_width) / (2.0 * self.half_width) * width as f64;
let row = (-y + self.half_height) / (2.0 * self.half_height) * height as f64;
let col_i = col as i32;
let row_i = row as i32;
if col_i < 0 || col_i >= width as i32 || row_i < 0 || row_i >= height as i32 {
None
} else {
Some((col_i, row_i))
}
}
}

227
src/render.rs Normal file
View File

@@ -0,0 +1,227 @@
use crate::projection::OrthoProjection;
use anyhow::{Context, Result};
use font8x8::{UnicodeFonts, BASIC_FONTS};
use plotters::prelude::*;
use std::path::Path;
const IMG_WIDTH: u32 = 900;
const IMG_HEIGHT: u32 = 600;
/// Map image centre and view window (must match what the Python script used).
const CENTER_LAT: f64 = 52.56;
const CENTER_LON: f64 = 13.08;
const HALF_W_DEG: f64 = 0.8; // horizontal half-extent in degrees of arc
const HALF_H_DEG: f64 = 0.4; // vertical half-extent
/// Cloud-cover thresholds and their associated RGB colours.
/// Values below the first threshold are drawn as the background colour.
const LEVELS: [(f32, (u8, u8, u8)); 7] = [
(1.0, (208, 232, 248)), // very thin
(2.0, (160, 200, 232)),
(5.0, (112, 168, 216)),
(10.0, ( 64, 128, 192)),
(20.0, ( 32, 80, 160)),
(50.0, ( 16, 32, 96)),
(100.0, ( 5, 10, 48)),
];
const BACKGROUND: RGBColor = RGBColor(240, 248, 255); // pale sky-blue
const LAND_BG: RGBColor = RGBColor(232, 236, 220); // muted green for land
const BLACK: RGBColor = RGBColor( 0, 0, 0);
const RED: RGBColor = RGBColor(220, 30, 30);
/// Locations to mark on the map: (label, longitude, latitude)
const LOCATIONS: &[(&str, f64, f64)] = &[
("Falkensee", 13.08, 52.56),
("Pausin", 13.04, 52.64),
("Nauen", 12.88, 52.605),
("Hennigsdorf", 13.205, 52.64),
("Ketzin/Brueckenkopf", 12.82, 52.49),
("Potsdam", 13.06, 52.40),
("Berlin (Mitte)", 13.39, 52.52),
];
fn cloud_color(cover: f32) -> Option<RGBColor> {
if cover < 1.0 {
return None; // clear sky — use background
}
for &(threshold, (r, g, b)) in LEVELS.iter().rev() {
if cover >= threshold {
return Some(RGBColor(r, g, b));
}
}
Some(RGBColor(LEVELS[0].1 .0, LEVELS[0].1 .1, LEVELS[0].1 .2))
}
/// Draw ASCII text using the built-in 8×8 pixel font.
///
/// Coordinates are in the drawing area's local coordinate system.
/// `scale` is the pixel-multiplier: 1 → 8×8 px/glyph, 2 → 16×16, etc.
/// Characters outside Basic Latin are rendered as '?'.
fn draw_pixel_text(
area: &DrawingArea<BitMapBackend, plotters::coord::Shift>,
text: &str,
x: i32,
y: i32,
scale: i32,
color: &RGBColor,
) {
let mut cx = x;
for ch in text.chars() {
let glyph_ch = if BASIC_FONTS.get(ch).is_some() { ch } else { '?' };
if let Some(glyph) = BASIC_FONTS.get(glyph_ch) {
for (row, &bits) in glyph.iter().enumerate() {
for col in 0i32..8 {
if bits & (1 << col) != 0 {
for dy in 0..scale {
for dx in 0..scale {
area.draw_pixel(
(cx + col * scale + dx, y + row as i32 * scale + dy),
color,
)
.ok();
}
}
}
}
}
}
cx += 8 * scale;
}
}
/// Render one forecast frame as a PNG.
///
/// - `lats`, `lons`, `cloud_cover` must all have the same length.
/// - `title` is displayed at the top of the image.
/// - Output is written to `output_path`.
pub fn render_frame(
output_path: &Path,
lats: &[f32],
lons: &[f32],
cloud_cover: &[f32],
title: &str,
) -> Result<()> {
let proj = OrthoProjection::new(CENTER_LAT, CENTER_LON, HALF_W_DEG, HALF_H_DEG);
let root = BitMapBackend::new(output_path, (IMG_WIDTH, IMG_HEIGHT)).into_drawing_area();
root.fill(&BACKGROUND).context("fill background")?;
const LEGEND_W: u32 = 130;
const TITLE_H: u32 = 40;
const MARGIN: u32 = 8;
let map_area = root.margin(TITLE_H, MARGIN, MARGIN, LEGEND_W + MARGIN);
let map_w = IMG_WIDTH - LEGEND_W - MARGIN * 2;
let map_h = IMG_HEIGHT - TITLE_H - MARGIN * 2;
map_area.fill(&LAND_BG).context("fill map area")?;
// Plot grid points
for i in 0..lats.len() {
let lat = lats[i] as f64;
let lon = lons[i] as f64;
let Some((px, py)) = proj.project(lat, lon) else { continue };
let Some((col, row)) = proj.to_pixel(px, py, map_w, map_h) else { continue };
let cover = cloud_cover[i];
if cover.is_nan() {
continue; // missing value (bitmap) — keep background
}
let color = match cloud_color(cover) {
Some(c) => c,
None => continue, // clear sky — keep land background
};
// Paint a 2×2 block to avoid gaps between grid points
for dy in 0..2i32 {
for dx in 0..2i32 {
let c = col + dx;
let r = row + dy;
if c >= 0 && c < map_w as i32 && r >= 0 && r < map_h as i32 {
map_area.draw_pixel((c, r), &color).ok();
}
}
}
}
// Draw city markers and labels
for &(label, lon, lat) in LOCATIONS {
let Some((px, py)) = proj.project(lat, lon) else { continue };
let Some((col, row)) = proj.to_pixel(px, py, map_w, map_h) else { continue };
// Small cross marker
for d in -3i32..=3i32 {
map_area.draw_pixel((col + d, row), &RED).ok();
map_area.draw_pixel((col, row + d), &RED).ok();
}
// Label offset slightly above and to the right of the marker
draw_pixel_text(&map_area, label, col + 5, row - 9, 1, &BLACK);
}
// Draw title (scale=2 → 16px tall)
draw_pixel_text(&root, title, MARGIN as i32, (TITLE_H / 2 - 8) as i32, 2, &BLACK);
// Draw legend
draw_legend(&root, IMG_WIDTH - LEGEND_W, TITLE_H)?;
root.present().context("write PNG")?;
Ok(())
}
fn draw_legend(
root: &DrawingArea<BitMapBackend, plotters::coord::Shift>,
x: u32,
y: u32,
) -> Result<()> {
draw_pixel_text(root, "Cloud cover", x as i32 + 4, y as i32 + 4, 1, &BLACK);
let box_h = 18i32;
let box_w = 24i32;
// Helper: draw one legend entry
let draw_entry = |label: &str, color: RGBColor, row_y: i32| {
root.draw(&Rectangle::new(
[(x as i32 + 4, row_y), (x as i32 + 4 + box_w, row_y + box_h)],
ShapeStyle { color: color.to_rgba(), filled: true, stroke_width: 0 },
))
.ok();
root.draw(&Rectangle::new(
[(x as i32 + 4, row_y), (x as i32 + 4 + box_w, row_y + box_h)],
ShapeStyle { color: BLACK.to_rgba(), filled: false, stroke_width: 1 },
))
.ok();
draw_pixel_text(
root,
label,
x as i32 + 4 + box_w + 4,
row_y + (box_h - 8) / 2,
1,
&BLACK,
);
};
// "< 1%" row (clear sky)
draw_entry("< 1%", LAND_BG, y as i32 + 22);
// Remaining threshold rows — use ASCII hyphen instead of en-dash
let entries = [
(1.0f32, "1-2%"),
(2.0, "2-5%"),
(5.0, "5-10%"),
(10.0, "10-20%"),
(20.0, "20-50%"),
(50.0, "50-100%"),
];
for (i, &(lo, label)) in entries.iter().enumerate() {
let row_y = y as i32 + 22 + (i as i32 + 1) * (box_h + 2);
let color = cloud_color(lo).unwrap_or(BACKGROUND);
draw_entry(label, color, row_y);
}
Ok(())
}