diff --git a/Cargo.toml b/Cargo.toml index 9c6f513..39038e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,10 +24,12 @@ clap = { version = "=4.5.3", features = ["derive", "env"] } # Compositing image = { version = "=0.25.0", features = ["jpeg", "gif", "png", "pnm", "qoi", "tga", "tiff", "webp", "bmp", "dds"], default-features = false } fimg = "=0.4.41" -png = "=0.17.13" # Scraping ureq = "=2.9.6" serde = { version = "=1.0.197", features = ["derive"] } serde_json = "=1.0.114" -rayon = "=1.9.0" \ No newline at end of file +rayon = "=1.9.0" + +[dev-dependencies] +png = "=0.17.13" diff --git a/README.md b/README.md index 9682bea..b4cda61 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,11 @@ There are several satellites to choose from, each covering a different region of - Meteosat 9 (Africa, Middle East, India, Central Asia) - Meteosat 10 (Atlantic Ocean, Africa, Europe) +Satpaper can also select SLIDER sectors and products. Full-disk imagery remains the default. +Regional sectors such as GOES CONUS and mesoscale imagery are cropped and scaled to fill the +requested wallpaper resolution. Some regional feeds have irregular source footprints, so their +source-side no-data corners remain black. Sector availability varies by satellite. + It's also possible to specify a custom background image, if desired. ## Warning - Data Usage @@ -72,6 +77,7 @@ Description=Run Satpaper on login. # You should adjust these values as needed/preferred. [Service] Environment=SATPAPER_SATELLITE=goes-east +Environment=SATPAPER_SECTOR=full_disk Environment=SATPAPER_RESOLUTION_X=2560 Environment=SATPAPER_RESOLUTION_Y=1440 Environment=SATPAPER_DISK_SIZE=94 @@ -116,6 +122,8 @@ launchctl start $HOME/Library/LaunchAgents/com.satpaper.plist SATPAPER_SATELLITE goes-east + SATPAPER_SECTOR + full_disk SATPAPER_RESOLUTION_X 2560 SATPAPER_RESOLUTION_Y @@ -160,11 +168,21 @@ Thanks to `cyberbit`, everything you need to build and run a Satpaper Docker ima ### Basic/Required - `-s`/`--satellite`/`SATPAPER_SATELLITE` - the satellite to source imagery from. - Possible values: `goes-east`, `goes-west`, `himawari`, `meteosat9`, and `meteosat10`. +- `-c`/`--sector`/`SATPAPER_SECTOR` - the SLIDER sector to source imagery from. + - Defaults to `full_disk`. + - GOES East/West support `full_disk`, `conus`, `mesoscale_01`, and `mesoscale_02`. + - Himawari supports `full_disk`, `japan`, and `mesoscale_01`. + - Meteosat currently supports `full_disk` only. +- `-p`/`--product`/`SATPAPER_PRODUCT` - the SLIDER imagery product. + - Defaults to the selected sector's preferred product (`geocolor` for full-disk imagery and + GOES regional imagery; `band_13` for Himawari regional imagery). + - Product availability varies by satellite and sector. Invalid combinations fail before tile downloads. - `-x`/`--resolution-x`/`SATPAPER_RESOLUTION_X` (and equivalents for the `y` dimension) - the width/height of the generated wallpaper. - Any arbitary resolution should work, including vertical aspect ratios. - `-d`/`--disk-size`/`SATPAPER_DISK_SIZE` - the size of the "disk" (Earth) relative to the generated wallpaper's smaller dimension. - Required to be an integer value in the range `[1, 100]` inclusive, mapping to a percentage value. - For most desktop environments, a value in the 90-95 range will give the most detail while preventing parts from being cut off by UI elements like taskbars. + - Applies to `full_disk`; regional sectors fill the output resolution. - `-t`/`--target-path`/`SATPAPER_TARGET_PATH` - where the generated wallpaper should be saved. - Satpaper will output to a file called "satpaper_latest.png" at this path. - Example: if the argument is `/home/user/Pictures`, the output will be at `/home/user/Pictures/satpaper_latest.png`. @@ -174,6 +192,7 @@ Thanks to `cyberbit`, everything you need to build and run a Satpaper Docker ima - Most common image formats are supported. - For best results, the image should match the specified resolution, but Satpaper will resize the image to fit if need be. - Satpaper uses a basic "marching" algorithm to find the bounds of the Earth and apply transparency to the original image, but it's not perfect - some black bordering and/or jagged edges may remain. (Unfortunately, the canonical algorithm for this problem - flood filling - doesn't really work, because it tends to end up eating into the Earth at night. If you have an idea for a better solution, please let me know!) + - Applies to `full_disk`; regional sectors fill the output and ignore this option. - `-w`/`--wallpaper-command`/`SATPAPER_WALLPAPER_COMMAND` - custom command to run when a wallpaper is generated. - This overrides the automatic update handling. - The command will be run as `sh -c "{command} file://{image_path}"`. diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index be93942..d7d8930 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -6,8 +6,9 @@ services: dockerfile: ./docker/Dockerfile environment: - SATPAPER_SATELLITE=goes-east + - SATPAPER_SECTOR=full_disk - SATPAPER_RESOLUTION_X=2560 - SATPAPER_RESOLUTION_Y=1440 - SATPAPER_DISK_SIZE=95 volumes: - - ../images:/home/rust/images \ No newline at end of file + - ../images:/home/rust/images diff --git a/src/config.rs b/src/config.rs index 47dd2f9..bfd79e6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,62 +1,73 @@ use std::path::PathBuf; +use anyhow::{bail, Result}; use clap::{Parser, ValueEnum}; -use fimg::Image; #[derive(Debug, Clone, Parser)] #[command(author, version, about, long_about = None)] pub struct Config { /// The satellite to source imagery from. - /// + /// /// Options include: - /// + /// /// - GOES East (covers most of North and South America) - /// + /// /// - GOES West (Pacific Ocean and parts of the western US) - /// + /// /// - Himawari (Oceania and East Asia) - /// + /// /// - Meteosat 9 (Africa, Middle East, India, Central Asia) - /// + /// /// - Meteosat 10 (Atlantic Ocean, Africa, Europe) #[arg(short, long, env = "SATPAPER_SATELLITE")] pub satellite: Satellite, /// The X resolution/width of the generated wallpaper. - #[arg(short = 'x', long, env = "SATPAPER_RESOLUTION_X")] + #[arg(short = 'x', long, value_parser = clap::value_parser!(u32).range(1..), env = "SATPAPER_RESOLUTION_X")] pub resolution_x: u32, /// The Y resolution/height of the generated wallpaper. - #[arg(short = 'y', long, env = "SATPAPER_RESOLUTION_Y")] + #[arg(short = 'y', long, value_parser = clap::value_parser!(u32).range(1..), env = "SATPAPER_RESOLUTION_Y")] pub resolution_y: u32, + /// The SLIDER sector to source imagery from. + #[arg( + short = 'c', + long, + env = "SATPAPER_SECTOR", + default_value = "full_disk" + )] + pub sector: Sector, + /// The SLIDER product to source. Defaults to the selected sector's preferred product. + #[arg(short = 'p', long, env = "SATPAPER_PRODUCT")] + pub product: Option, /// The size of the "disk" (Earth) relative to the generated wallpaper's /// smaller dimension. - /// + /// /// Values in the 90-95 range are the best if you want maximum detail. #[arg(short, long, value_parser = clap::value_parser!(u32).range(1..=100), env = "SATPAPER_DISK_SIZE")] pub disk_size: u32, /// Where generated wallpapers should be saved. - /// + /// /// Satpaper will output to a file called "satpaper_latest.png" at this path. #[arg(short, long, env = "SATPAPER_TARGET_PATH")] pub target_path: PathBuf, /// Command to run to change the wallpaper. This overrides automatic update handling. - /// - /// The command will be ran as `sh -c "{wallpaper_command} file://{path}"`. + /// + /// The command will be ran as `sh -c "{wallpaper_command} file://{path}"`. #[arg(short, long, env = "SATPAPER_WALLPAPER_COMMAND")] pub wallpaper_command: Option, /// Whether or not to only run once. - /// + /// /// By default, Satpaper is designed to run in the background - it stays resident once launched /// and periodically attempts to update your wallpaper. - /// + /// /// With --once set, Satpaper will instead generate one wallpaper and terminate, without /// affecting your existing wallpaper or staying resident. - /// + /// /// This is ideal if you want to use Satpaper as a simple wallpaper generator or as part of a larger script/program. #[arg(short, long, env = "SATPAPER_ONCE", default_value_t = false)] pub once: bool, /// A background image to use instead of the default pure black. - /// - /// For best results, the image should match the specified resolution, + /// + /// For best results, the image should match the specified resolution, /// but Satpaper will resize the image to fit if need be. #[arg(short, long, env = "SATPAPER_BACKGROUND_IMAGE")] pub background_image: Option, @@ -71,6 +82,92 @@ pub enum Satellite { Meteosat10, } +#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] +pub enum Sector { + #[value(name = "full_disk")] + FullDisk, + #[value(name = "conus")] + Conus, + #[value(name = "mesoscale_01")] + Mesoscale01, + #[value(name = "mesoscale_02")] + Mesoscale02, + #[value(name = "japan")] + Japan, +} + +impl Sector { + pub fn id(self) -> &'static str { + match self { + Self::FullDisk => "full_disk", + Self::Conus => "conus", + Self::Mesoscale01 => "mesoscale_01", + Self::Mesoscale02 => "mesoscale_02", + Self::Japan => "japan", + } + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub struct ContentBounds { + pub left: u32, + pub top: u32, + pub right: u32, + pub bottom: u32, + pub denominator: u32, +} + +impl ContentBounds { + const FULL: Self = Self { + left: 0, + top: 0, + right: 1, + bottom: 1, + denominator: 1, + }; + + const GOES_CONUS: Self = Self { + left: 0, + top: 124, + right: 625, + bottom: 501, + denominator: 625, + }; + + const HIMAWARI_JAPAN: Self = Self { + left: 76, + top: 120, + right: 724, + bottom: 636, + denominator: 750, + }; + + pub fn aspect_ratio(self) -> f64 { + f64::from(self.right - self.left) / f64::from(self.bottom - self.top) + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum Composition { + FullDisk, + Regional(ContentBounds), +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub struct SourceSpec { + pub sector: Sector, + pub max_zoom: u32, + pub native_tile_size: u32, + pub default_product: &'static str, + pub composition: Composition, +} + +impl SourceSpec { + pub fn tile_count(self) -> u32 { + 1 << self.max_zoom + } +} + impl Config { pub fn disk(&self) -> u32 { let smaller_dim = self.resolution_x.min(self.resolution_y); @@ -78,6 +175,14 @@ impl Config { let disk_dim = smaller_dim as f32 * (self.disk_size as f32 / 100.0); disk_dim.floor() as u32 } + + pub fn source_spec(&self) -> Result { + self.satellite.source_spec(self.sector) + } + + pub fn product_for<'a>(&'a self, source: &SourceSpec) -> &'a str { + self.product.as_deref().unwrap_or(source.default_product) + } } impl Satellite { @@ -89,39 +194,175 @@ impl Satellite { GOESWest => "goes-18", Himawari => "himawari", Meteosat9 => "meteosat-9", - Meteosat10 => "meteosat-0deg" + Meteosat10 => "meteosat-0deg", } } - pub fn max_zoom(self) -> u32 { + pub fn source_spec(self, sector: Sector) -> Result { use Satellite::*; + use Sector::*; - match self { - GOESEast | GOESWest | Himawari => 4, - Meteosat9 | Meteosat10 => 3, - } + let source = match (self, sector) { + (GOESEast | GOESWest, FullDisk) => SourceSpec { + sector, + max_zoom: 4, + native_tile_size: 678, + default_product: "geocolor", + composition: Composition::FullDisk, + }, + (GOESEast | GOESWest, Conus) => SourceSpec { + sector, + max_zoom: 4, + native_tile_size: 625, + default_product: "geocolor", + composition: Composition::Regional(ContentBounds::GOES_CONUS), + }, + (GOESEast | GOESWest, Mesoscale01 | Mesoscale02) => SourceSpec { + sector, + max_zoom: 2, + native_tile_size: 500, + default_product: "geocolor", + composition: Composition::Regional(ContentBounds::FULL), + }, + (Himawari, FullDisk) => SourceSpec { + sector, + max_zoom: 4, + native_tile_size: 688, + default_product: "geocolor", + composition: Composition::FullDisk, + }, + (Himawari, Japan) => SourceSpec { + sector, + max_zoom: 3, + native_tile_size: 750, + default_product: "band_13", + composition: Composition::Regional(ContentBounds::HIMAWARI_JAPAN), + }, + (Himawari, Mesoscale01) => SourceSpec { + sector, + max_zoom: 2, + native_tile_size: 500, + default_product: "band_13", + composition: Composition::Regional(ContentBounds::FULL), + }, + (Meteosat9 | Meteosat10, FullDisk) => SourceSpec { + sector, + max_zoom: 3, + native_tile_size: 464, + default_product: "geocolor", + composition: Composition::FullDisk, + }, + _ => bail!( + "sector '{}' is not available for satellite '{}'", + sector.id(), + self.id() + ), + }; + + Ok(source) } +} + +#[cfg(test)] +mod tests { + use super::*; - pub fn tile_image(self) -> Image, 3> { - Image::alloc(self.tile_size(), self.tile_size()).boxed() + fn required_args() -> Vec<&'static str> { + vec![ + "satpaper", + "--satellite", + "goes-east", + "--resolution-x", + "1920", + "--resolution-y", + "1080", + "--disk-size", + "95", + "--target-path", + ".", + ] } - pub fn tile_count(self) -> u32 { - use Satellite::*; + #[test] + fn defaults_to_full_disk_and_sector_product() { + let config = Config::try_parse_from(required_args()).unwrap(); + let source = config.source_spec().unwrap(); - match self { - GOESEast | GOESWest | Himawari => 16, - Meteosat9 | Meteosat10 => 8, - } + assert_eq!(config.sector, Sector::FullDisk); + assert!(config.product.is_none()); + assert_eq!(config.product_for(&source), "geocolor"); } - pub fn tile_size(self) -> u32 { - use Satellite::*; + #[test] + fn parses_sector_and_product_overrides() { + let mut args = required_args(); + args.extend(["--sector", "conus", "--product", "cira_geoproxy"]); + let config = Config::try_parse_from(args).unwrap(); + let source = config.source_spec().unwrap(); - match self { - GOESEast | GOESWest => 678, - Himawari => 688, - Meteosat9 | Meteosat10 => 464, + assert_eq!(config.sector, Sector::Conus); + assert_eq!(config.product_for(&source), "cira_geoproxy"); + } + + #[test] + fn source_geometry_matches_slider() { + let conus = Satellite::GOESEast.source_spec(Sector::Conus).unwrap(); + assert_eq!(conus.max_zoom, 4); + assert_eq!(conus.tile_count(), 16); + assert_eq!(conus.native_tile_size, 625); + + let mesoscale = Satellite::GOESWest + .source_spec(Sector::Mesoscale02) + .unwrap(); + assert_eq!(mesoscale.max_zoom, 2); + assert_eq!(mesoscale.tile_count(), 4); + + let japan = Satellite::Himawari.source_spec(Sector::Japan).unwrap(); + assert_eq!(japan.max_zoom, 3); + assert_eq!(japan.tile_count(), 8); + assert_eq!(japan.default_product, "band_13"); + } + + #[test] + fn rejects_unavailable_sector_for_satellite() { + let error = Satellite::Meteosat9.source_spec(Sector::Conus).unwrap_err(); + assert!(error.to_string().contains("not available")); + } + + #[test] + fn rejects_zero_dimensions() { + for flag in ["--resolution-x", "--resolution-y"] { + let mut args = required_args(); + let value = args.iter().position(|arg| *arg == flag).unwrap() + 1; + args[value] = "0"; + assert!(Config::try_parse_from(args).is_err()); } } -} \ No newline at end of file + + #[test] + fn conus_bounds_preserve_regional_aspect_ratio() { + let Composition::Regional(bounds) = Satellite::GOESEast + .source_spec(Sector::Conus) + .unwrap() + .composition + else { + panic!("CONUS must use regional composition"); + }; + + assert!((bounds.aspect_ratio() - (625.0 / 377.0)).abs() < f64::EPSILON); + } + + #[test] + fn japan_bounds_cover_the_slider_footprint() { + let Composition::Regional(bounds) = Satellite::Himawari + .source_spec(Sector::Japan) + .unwrap() + .composition + else { + panic!("Japan must use regional composition"); + }; + + assert_eq!(bounds, ContentBounds::HIMAWARI_JAPAN); + assert!((bounds.aspect_ratio() - (648.0 / 516.0)).abs() < f64::EPSILON); + } +} diff --git a/src/main.rs b/src/main.rs index ebdfea7..bf9775e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,18 +30,26 @@ fn main() -> Result<()> { fn update_wallpaper() -> Result<()> { let config = Config::parse(); + config + .source_spec() + .context("Invalid satellite and sector configuration")?; let mut timestamp = None; loop { log::debug!("Checking timestamp..."); - let new = slider::fetch_latest_timestamp(&config) - .unwrap_or_else(|err| { + let new = match slider::fetch_latest_timestamp(&config) { + Ok(timestamp) => timestamp, + Err(err) if config.once => { + return Err(err).context("Failed to fetch the selected SLIDER source") + } + Err(err) => { log::error!("Failed to fetch latest timestamp: {err}"); log::error!("Check aborted; waiting until next go round."); timestamp.unwrap_or(0) - }); + } + }; if timestamp .map_or(true, |old| old != new) @@ -50,7 +58,7 @@ fn update_wallpaper() -> Result<()> { log::debug!("Old timestamp: {timestamp:?}, new timestamp: {new}"); log::info!("Fetching updated source and compositing new wallpaper..."); - if slider::composite_latest_image(&config)? { + if slider::composite_latest_image(&config, new)? { timestamp = Some(new); if config.once { @@ -85,6 +93,8 @@ mod tests { satellite: Satellite::GOESEast, resolution_x: 2556, resolution_y: 1440, + sector: Sector::FullDisk, + product: None, disk_size: 95, target_path: ".".into(), wallpaper_command: None, @@ -92,10 +102,11 @@ mod tests { background_image: None }; - slider::composite_latest_image(&config)?; + let timestamp = slider::fetch_latest_timestamp(&config)?; + slider::composite_latest_image(&config, timestamp)?; std::fs::remove_file("./satpaper_latest.png")?; Ok(()) } -} \ No newline at end of file +} diff --git a/src/slider.rs b/src/slider.rs index 1d5f773..9d45763 100644 --- a/src/slider.rs +++ b/src/slider.rs @@ -1,83 +1,91 @@ -use std::sync::{PoisonError, OnceLock, Mutex}; +use std::io::Read; +use std::sync::{Mutex, OnceLock, PoisonError}; use std::time::Duration; -use anyhow::{Result, Context}; -use fimg::{OverlayAt, Image as Img, scale::Lanczos3}; +use anyhow::{bail, ensure, Context, Result}; +use fimg::{scale::Lanczos3, Image as Img, OverlayAt}; use rayon::prelude::*; -use serde::{Deserialize, de}; +use serde::{de, Deserialize}; use ureq::AgentBuilder; -use super::{ - Config, - OUTPUT_NAME -}; +use super::{Composition, Config, ContentBounds, SourceSpec, OUTPUT_NAME}; /// rgb all the way down pub type Image = Img; const SLIDER_BASE_URL: &str = "https://rammb-slider.cira.colostate.edu"; -const SLIDER_SECTOR: &str = "full_disk"; -const SLIDER_PRODUCT: &str = "geocolor"; const TIMEOUT: Duration = Duration::from_secs(30); -pub fn composite_latest_image(config: &Config) -> Result { - download(config) - .and_then(|image| { composite(config, image)?; Ok(true) }) +pub fn composite_latest_image(config: &Config, timestamp: u64) -> Result { + download(config, timestamp) + .and_then(|image| { + composite(config, image)?; + Ok(true) + }) .or_else(|err| { + if config.once { + return Err(err); + } + log::error!("Failed to download source image: {err}"); log::error!("Composition aborted; waiting until next go round."); Ok(false) }) } -fn download(config: &Config) -> Result>> { - let tile_count = config.satellite.tile_count(); +fn download(config: &Config, timestamp: u64) -> Result>> { + let mut source = config.source_spec()?; + let product = config.product_for(&source); let agent = AgentBuilder::new() .timeout(TIMEOUT) .user_agent("satpaper") .build(); - let time = Time::fetch(config)?; - let (year, month, day) = Date::fetch(config)?.split(); + let date = timestamp_date(timestamp)?; + source.max_zoom = effective_zoom(&agent, config, &source, product, date, timestamp)?; + let tile_count = source.tile_count(); - let disk_dim = config.disk(); - let tile_size = disk_dim / tile_count; + let (canvas_dim, tile_size) = download_geometry(config, source); + ensure!( + tile_size > 0, + "requested output is too small for the selected source" + ); let tiles = (0..tile_count) - .flat_map(|x| { - (0..tile_count) - .map(move |y| (x, y)) - }) + .flat_map(|x| (0..tile_count).map(move |y| (x, y))) .par_bridge() .map(|(x, y)| -> Result<_> { - // year:04 i am hilarious - let url = format!( - "{SLIDER_BASE_URL}/data/imagery/{year:04}/{month:02}/{day:02}/{}---{SLIDER_SECTOR}/{SLIDER_PRODUCT}/{}/{:02}/{x:03}_{y:03}.png", - config.satellite.id(), - time.as_int(), - config.satellite.max_zoom() - ); + let url = tile_url(config, &source, product, date, timestamp, x, y); log::info!("Scraping tile at ({x}, {y})."); - + let resp = agent .get(&url) - .call()?; - - let len: usize = resp.header("Content-Length") - .expect("Response header should have Content-Length") - .parse()?; - - let reader = resp.into_reader(); - let dec = png::Decoder::new(reader); - let mut reader = dec.read_info()?; - let mut buf = config.satellite.tile_image(); - let info = reader.next_frame(unsafe { buf.buffer_mut() })?; - debug_assert!(matches!(info.color_type, png::ColorType::Rgb)); - let buf = buf.scale::(tile_size, tile_size); + .call() + .with_context(|| format!("Failed to download SLIDER tile at {url}"))?; + + let len = resp + .header("Content-Length") + .and_then(|value| value.parse::().ok()) + .unwrap_or_default(); + + let buf = decode_tile(resp.into_reader())?; + ensure!( + buf.width() == source.native_tile_size && buf.height() == source.native_tile_size, + "SLIDER tile at {url} is {}x{}, expected {}x{}", + buf.width(), + buf.height(), + source.native_tile_size, + source.native_tile_size, + ); + let buf = if buf.width() == tile_size && buf.height() == tile_size { + buf + } else { + buf.scale::(tile_size, tile_size) + }; log::info!( "Finished scraping tile at ({x}, {y}). Size: {:.2}KiB", @@ -86,51 +94,181 @@ fn download(config: &Config) -> Result>> { Ok((x, y, buf)) }); - + log::info!("Stitching tiles..."); - let stitched = Mutex::new(Image::alloc(disk_dim, disk_dim).boxed()); - tiles.try_for_each(|a|{ + let stitched = Mutex::new(Image::alloc(canvas_dim, canvas_dim).boxed()); + tiles.try_for_each(|a| { let (y, x, buf) = a?; // yes, this is possible lockless. // no, i will not do it. // if you do it, construct a sendable pointer, then exclusively use .add and slice::from_raw_parts(_mut) // SAFETY: tiles iterates over the number of tiles, each tile == tile_size, `stitched` is a image of tile_size * tile_count. - unsafe { stitched.lock().unwrap_or_else(PoisonError::into_inner).overlay_at(&buf, x * tile_size, y * tile_size) }; + unsafe { + stitched + .lock() + .unwrap_or_else(PoisonError::into_inner) + .overlay_at(&buf, x * tile_size, y * tile_size) + }; anyhow::Ok(()) })?; Ok(stitched.into_inner().unwrap()) } +fn effective_zoom( + agent: &ureq::Agent, + config: &Config, + source: &SourceSpec, + product: &str, + date: (u16, u8, u8), + timestamp: u64, +) -> Result { + let zoom = find_effective_zoom(source.max_zoom, |zoom| { + let mut candidate = *source; + candidate.max_zoom = zoom; + let url = tile_url(config, &candidate, product, date, timestamp, 0, 0); + + match agent.get(&url).call() { + Ok(_) => Ok(true), + Err(ureq::Error::Status(404, _)) => Ok(false), + Err(err) => Err(err).with_context(|| format!("Failed to probe SLIDER tile at {url}")), + } + })?; + + if let Some(zoom) = zoom { + return Ok(zoom); + } + + bail!( + "SLIDER has no tiles for satellite '{}', sector '{}', product '{}' at timestamp {timestamp}", + config.satellite.id(), + source.sector.id(), + product, + ) +} + +fn find_effective_zoom( + max_zoom: u32, + mut available: impl FnMut(u32) -> Result, +) -> Result> { + for zoom in (0..=max_zoom).rev() { + if available(zoom)? { + return Ok(Some(zoom)); + } + } + + Ok(None) +} + +fn decode_tile(mut reader: impl Read) -> Result>> { + let mut encoded = Vec::new(); + reader.read_to_end(&mut encoded)?; + + let rgba = image::load_from_memory_with_format(&encoded, image::ImageFormat::Png)?.into_rgba8(); + let (width, height) = rgba.dimensions(); + let mut rgb = Vec::with_capacity(width as usize * height as usize * 3); + + for pixel in rgba.pixels() { + let alpha = u16::from(pixel[3]); + rgb.extend( + pixel.0[..3] + .iter() + .map(|value| ((u16::from(*value) * alpha + 127) / 255) as u8), + ); + } + + Ok(Image::build(width, height).buf(rgb.into_boxed_slice())) +} + +fn download_geometry(config: &Config, source: SourceSpec) -> (u32, u32) { + let tile_count = source.tile_count(); + + match source.composition { + Composition::FullDisk => { + let canvas_dim = config.disk(); + (canvas_dim, canvas_dim / tile_count) + } + Composition::Regional(bounds) => { + let desired = config + .resolution_x + .max((f64::from(config.resolution_y) * bounds.aspect_ratio()).ceil() as u32); + let tile_size = desired.div_ceil(tile_count); + (tile_size * tile_count, tile_size) + } + } +} + +fn tile_url( + config: &Config, + source: &SourceSpec, + product: &str, + (year, month, day): (u16, u8, u8), + timestamp: u64, + x: u32, + y: u32, +) -> String { + format!( + "{SLIDER_BASE_URL}/data/imagery/{year:04}/{month:02}/{day:02}/{}---{}/{product}/{timestamp}/{:02}/{x:03}_{y:03}.png", + config.satellite.id(), + source.sector.id(), + source.max_zoom, + ) +} + fn composite(config: &Config, source: Image>) -> Result<()> { log::info!("Compositing..."); + let source_spec = config.source_spec()?; + let composite = match source_spec.composition { + Composition::FullDisk => composite_full_disk(config, source)?, + Composition::Regional(bounds) => { + if config.background_image.is_some() { + log::warn!( + "Background images are ignored for regional sectors, which fill the output" + ); + } + + composite_regional(config, source, bounds) + } + }; + + log::info!("Compositing complete."); + + composite.save(config.target_path.join(OUTPUT_NAME)); + + log::info!("Output saved."); + + Ok(()) +} + +fn composite_full_disk(config: &Config, source: Image>) -> Result>> { let disk_dim = config.disk(); let composite = if let Some(path) = &config.background_image { static BG: OnceLock>> = OnceLock::new(); - let mut bg = BG.get_or_try_init(|| { - use image::io::Reader; + let mut bg = BG + .get_or_try_init(|| { + use image::io::Reader; - let image = Reader::open(path) - .context("Failed to open background image at path {path:?}")? - .decode() - .context("Failed to load background image - corrupt or unsupported?")? - .into_rgb8(); + let image = Reader::open(path) + .context("Failed to open background image at path {path:?}")? + .decode() + .context("Failed to load background image - corrupt or unsupported?")? + .into_rgb8(); - let mut image = Image::build(image.width(), image.height()).buf(image.into_vec().into_boxed_slice()); + let mut image = Image::build(image.width(), image.height()) + .buf(image.into_vec().into_boxed_slice()); - if image.width() != config.resolution_x || - image.height() != config.resolution_y - { - log::info!("Resizing background image to fit..."); + if image.width() != config.resolution_x || image.height() != config.resolution_y { + log::info!("Resizing background image to fit..."); - image = image.scale::(config.resolution_x, config.resolution_y); - } + image = image.scale::(config.resolution_x, config.resolution_y); + } - anyhow::Ok(image) - })?.clone(); + anyhow::Ok(image) + })? + .clone(); log::info!("Compositing source into destination..."); @@ -138,34 +276,94 @@ fn composite(config: &Config, source: Image>) -> Result<()> { bg.as_mut(), source.as_ref(), (config.resolution_x - disk_dim) / 2, - (config.resolution_y - disk_dim) / 2 + (config.resolution_y - disk_dim) / 2, ); bg - } - else { + } else { let mut behind = Image::alloc(config.resolution_x, config.resolution_y).boxed(); - unsafe { + unsafe { behind.overlay_at( &source, (config.resolution_x - disk_dim) / 2, (config.resolution_y - disk_dim) / 2, - ) + ) }; behind }; - - log::info!("Compositing complete."); - composite.save( - config.target_path.join(OUTPUT_NAME) + Ok(composite) +} + +fn composite_regional( + config: &Config, + source: Image>, + bounds: ContentBounds, +) -> Image> { + let source_width = source.width(); + let source_height = source.height(); + let scale = |value: u32, dimension: u32| { + ((u64::from(value) * u64::from(dimension)) / u64::from(bounds.denominator)) as u32 + }; + + let cropped = crop_image( + source, + scale(bounds.left, source_width), + scale(bounds.top, source_height), + scale(bounds.right, source_width), + scale(bounds.bottom, source_height), ); + scale_to_cover(cropped, config.resolution_x, config.resolution_y) +} - log::info!("Output saved."); +fn scale_to_cover( + source: Image>, + target_width: u32, + target_height: u32, +) -> Image> { + let scale = (f64::from(target_width) / f64::from(source.width())) + .max(f64::from(target_height) / f64::from(source.height())); + let scaled_width = (f64::from(source.width()) * scale).ceil() as u32; + let scaled_height = (f64::from(source.height()) * scale).ceil() as u32; + let scaled = if scaled_width == source.width() && scaled_height == source.height() { + source + } else { + source.scale::(scaled_width, scaled_height) + }; + let offset_x = (scaled_width - target_width) / 2; + let offset_y = (scaled_height - target_height) / 2; + + crop_image( + scaled, + offset_x, + offset_y, + offset_x + target_width, + offset_y + target_height, + ) +} - Ok(()) +fn crop_image( + source: Image>, + left: u32, + top: u32, + right: u32, + bottom: u32, +) -> Image> { + assert!(left < right && top < bottom); + assert!(right <= source.width() && bottom <= source.height()); + + let mut cropped = Image::alloc(right - left, bottom - top).boxed(); + for x in left..right { + for y in top..bottom { + unsafe { + cropped.set_pixel(x - left, y - top, source.pixel(x, y)); + } + } + } + + cropped } const BLACK: [u8; 3] = [4; 3]; @@ -173,16 +371,11 @@ const BLACK: [u8; 3] = [4; 3]; #[derive(Clone, Copy, Debug)] enum Direction { Left, - Right + Right, } // Identifies the bounds of the Earth in the image -fn cutout_disk( - mut bg: Image<&mut [u8]>, - earth: Image<&[u8]>, - offset_x: u32, - offset_y: u32 -) { +fn cutout_disk(mut bg: Image<&mut [u8]>, earth: Image<&[u8]>, offset_x: u32, offset_y: u32) { // Find the midpoint and max of the edges. let x_max = earth.width() - 1; let y_max = earth.height() - 1; @@ -200,14 +393,14 @@ fn cutout_disk( // Step linearly through the image pixels until we encounter a non-black pixel, // returning its coordinates. - let march = |mut x: u32, y: u32, direction: Direction| -> u32 { + let march = |mut x: u32, y: u32, direction: Direction| -> u32 { log::debug!("Performing cutout march for direction {direction:?}..."); loop { // SAFETY: march if unsafe { earth.pixel(x, y) } > BLACK { log::debug!("Found disk bounds at {x}, {y}."); - break x + break x; }; step(&mut x, direction); @@ -236,8 +429,13 @@ fn cutout_disk( log::debug!("Starting cutout process..."); - let inside = |x: u32| move |y: u32| { - ((x_center as i32 - x as i32) * (x_center as i32 - x as i32) + (y_center as i32 - y as i32) * (y_center as i32 - y as i32)).isqrt() < radius as i32 + let inside = |x: u32| { + move |y: u32| { + ((x_center as i32 - x as i32) * (x_center as i32 - x as i32) + + (y_center as i32 - y as i32) * (y_center as i32 - y as i32)) + .isqrt() + < radius as i32 + } }; for x in 0..earth.width() { @@ -251,19 +449,22 @@ fn cutout_disk( } pub fn fetch_latest_timestamp(config: &Config) -> Result { - Ok(Time::fetch(config)?.as_int()) + let source = config.source_spec()?; + let product = config.product_for(&source); + Ok(Time::fetch(config, &source, product)?.as_int()) } #[derive(Debug, Deserialize)] struct Time { #[serde(rename = "timestamps_int")] #[serde(deserialize_with = "one")] - timestamp: u64 + timestamp: u64, } fn one<'de, D>(deserializer: D) -> Result where - D: serde::Deserializer<'de> { + D: serde::Deserializer<'de>, +{ struct Visit; impl<'de> de::Visitor<'de> for Visit { type Value = u64; @@ -272,10 +473,9 @@ where write!(f, "array of u64") } - fn visit_seq>(self, mut seq: S) -> Result { - let value = seq.next_element()? - .ok_or(de::Error::custom("empty seq"))?; - + fn visit_seq>(self, mut seq: S) -> Result { + let value = seq.next_element()?.ok_or(de::Error::custom("empty seq"))?; + #[allow(clippy::redundant_pattern_matching)] while let Some(_) = seq.next_element::()? {} @@ -285,17 +485,14 @@ where deserializer.deserialize_seq(Visit {}) } - impl Time { - pub fn fetch(config: &Config) -> Result { - let url = format!( - "{SLIDER_BASE_URL}/data/json/{}/{SLIDER_SECTOR}/{SLIDER_PRODUCT}/latest_times.json", - config.satellite.id() - ); - + pub fn fetch(config: &Config, source: &SourceSpec, product: &str) -> Result { + let url = metadata_url(config, source, product, "latest_times.json"); + let json = ureq::get(&url) .timeout(TIMEOUT) - .call()? + .call() + .with_context(|| invalid_source_context(config, source, product, &url))? .into_reader(); Ok(serde_json::from_reader(json)?) @@ -306,42 +503,191 @@ impl Time { } } -#[derive(Debug, Deserialize)] -struct Date { - #[serde(rename = "dates_int")] - #[serde(deserialize_with = "one")] - date: u64 +fn timestamp_date(timestamp: u64) -> Result<(u16, u8, u8)> { + ensure!( + (10_000_000_000_000..=99_999_999_999_999).contains(×tamp), + "invalid SLIDER timestamp {timestamp}: expected YYYYMMDDHHMMSS" + ); + + let date = timestamp / 1_000_000; + let year = (date / 10_000) as u16; + let month = ((date / 100) % 100) as u8; + let day = (date % 100) as u8; + + ensure!( + (1..=12).contains(&month) && (1..=31).contains(&day), + "invalid date in SLIDER timestamp {timestamp}" + ); + + Ok((year, month, day)) +} + +fn metadata_url(config: &Config, source: &SourceSpec, product: &str, document: &str) -> String { + format!( + "{SLIDER_BASE_URL}/data/json/{}/{}/{product}/{document}", + config.satellite.id(), + source.sector.id(), + ) +} + +fn invalid_source_context( + config: &Config, + source: &SourceSpec, + product: &str, + url: &str, +) -> String { + format!( + "SLIDER has no metadata for satellite '{}', sector '{}', product '{}'; verify that combination is available ({url})", + config.satellite.id(), + source.sector.id(), + product, + ) } -impl Date { - pub fn fetch(config: &Config) -> Result { - let url = format!( - "{SLIDER_BASE_URL}/data/json/{}/{SLIDER_SECTOR}/{SLIDER_PRODUCT}/available_dates.json", - config.satellite.id() +#[test] +#[allow(clippy::inconsistent_digit_grouping)] +fn extracts_date_from_timestamp() { + assert_eq!(timestamp_date(2023_10_26_12_34_56).unwrap(), (2023, 10, 26)); + assert_eq!(timestamp_date(2027_04_25_00_00_00).unwrap(), (2027, 4, 25)); + assert!(timestamp_date(2027_13_25_00_00_00).is_err()); + assert!(timestamp_date(2027_04_25).is_err()); +} + +#[cfg(test)] +mod source_tests { + use std::io::Cursor; + use std::path::PathBuf; + + use super::*; + use crate::{Satellite, Sector}; + + fn config(sector: Sector) -> Config { + Config { + satellite: Satellite::GOESEast, + resolution_x: 160, + resolution_y: 90, + sector, + product: None, + disk_size: 95, + target_path: PathBuf::from("."), + wallpaper_command: None, + once: true, + background_image: None, + } + } + + #[test] + fn constructs_sector_specific_urls() { + let config = config(Sector::Conus); + let source = config.source_spec().unwrap(); + let product = config.product_for(&source); + + assert_eq!( + metadata_url(&config, &source, product, "latest_times.json"), + "https://rammb-slider.cira.colostate.edu/data/json/goes-19/conus/geocolor/latest_times.json" ); + assert_eq!( + tile_url( + &config, + &source, + product, + (2026, 7, 16), + 20260716212615, + 8, + 9, + ), + "https://rammb-slider.cira.colostate.edu/data/imagery/2026/07/16/goes-19---conus/geocolor/20260716212615/04/008_009.png" + ); + } - let json = ureq::get(&url) - .timeout(TIMEOUT) - .call()? - .into_reader(); + #[test] + fn regional_composition_fills_exact_output_dimensions() { + let config = config(Sector::Conus); + let mut source = Image::alloc(160, 160).boxed(); + for x in 0..source.width() { + for y in 0..source.height() { + unsafe { source.set_pixel(x, y, [255; 3]) }; + } + } + let Composition::Regional(bounds) = config.source_spec().unwrap().composition else { + panic!("CONUS must use regional composition"); + }; - Ok(serde_json::from_reader(json)?) + let output = composite_regional(&config, source, bounds); + assert_eq!(output.width(), 160); + assert_eq!(output.height(), 90); + assert_eq!(unsafe { output.pixel(80, 45) }, [255; 3]); } - /// Splits date into year, month, and day - pub fn split(&self) -> (u16, u8, u8) { - let dig = |n: u8| ((self.date / 10u64.pow(u32::from(n))) % 10) as u8; - ( - (u16::from(dig(7)) * 1000) + (u16::from(dig(6)) * 100) + (u16::from(dig(5)) * 10) + u16::from(dig(4)), // yyyy - (dig(3) * 10) + dig(2), // mm - (dig(1) * 10) + dig(0), // dd - ) + #[test] + fn regional_download_geometry_uses_sector_tile_count() { + let config = config(Sector::Mesoscale01); + let source = config.source_spec().unwrap(); + let (canvas, tile) = download_geometry(&config, source); + + assert_eq!(source.tile_count(), 4); + assert_eq!(canvas, tile * 4); + assert!(canvas >= config.resolution_x); + assert!(canvas >= config.resolution_y); } -} -#[test] -#[allow(clippy::inconsistent_digit_grouping)] -fn test_date_split() { - assert_eq!(Date { date: 2023_10_26 }.split(), (2023, 10, 26)); - assert_eq!(Date { date: 2027_04_25 }.split(), (2027, 4, 25)); -} \ No newline at end of file + #[test] + fn product_zoom_adjustment_uses_highest_available_level() { + let mut probed = Vec::new(); + let zoom = find_effective_zoom(3, |candidate| { + probed.push(candidate); + Ok(candidate <= 1) + }) + .unwrap(); + + assert_eq!(zoom, Some(1)); + assert_eq!(probed, [3, 2, 1]); + } + + #[test] + fn decodes_indexed_png_tiles_to_rgb() { + let mut data = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut data, 2, 1); + encoder.set_color(png::ColorType::Indexed); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_palette(vec![10, 20, 30, 40, 50, 60]); + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(&[0, 1]).unwrap(); + } + + let image = decode_tile(Cursor::new(data)).unwrap(); + assert_eq!(image.width(), 2); + assert_eq!(image.height(), 1); + assert_eq!(unsafe { image.pixel(0, 0) }, [10, 20, 30]); + assert_eq!(unsafe { image.pixel(1, 0) }, [40, 50, 60]); + } + + #[test] + fn decodes_rgba_png_tiles_over_black() { + let data = encode_single_pixel_png(png::ColorType::Rgba, &[100, 50, 200, 128]); + let image = decode_tile(Cursor::new(data)).unwrap(); + + assert_eq!(unsafe { image.pixel(0, 0) }, [50, 25, 100]); + } + + #[test] + fn decodes_grayscale_png_tiles_to_rgb() { + let data = encode_single_pixel_png(png::ColorType::Grayscale, &[77]); + let image = decode_tile(Cursor::new(data)).unwrap(); + + assert_eq!(unsafe { image.pixel(0, 0) }, [77, 77, 77]); + } + + fn encode_single_pixel_png(color: png::ColorType, pixel: &[u8]) -> Vec { + let mut data = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut data, 1, 1); + encoder.set_color(color); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(pixel).unwrap(); + } + data + } +}