Initial commit
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
1810
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal 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
97
README.md
Normal 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 (0–48) |
|
||||||
|
|
||||||
|
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) |
|
||||||
|
| 1–2 % | Very light blue |
|
||||||
|
| 2–5 % | Light blue |
|
||||||
|
| 5–10 % | Medium blue |
|
||||||
|
| 10–20 % | Blue |
|
||||||
|
| 20–50 % | Dark blue |
|
||||||
|
| 50–100 % | 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
48
src/download.rs
Normal 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
287
src/grib.rs
Normal 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
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)
|
||||||
|
}
|
||||||
71
src/projection.rs
Normal file
71
src/projection.rs
Normal 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
227
src/render.rs
Normal 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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user