diff --git a/Cargo.toml b/Cargo.toml index 3241550..f0eab1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] -members = ["spatialbench", "spatialbench-arrow", "spatialbench-cli"] +members = ["spatialbench", "spatialbench-arrow", "spatialbench-cli", "spatialbench-raster"] resolver = "2" diff --git a/spatialbench-raster/Cargo.toml b/spatialbench-raster/Cargo.toml new file mode 100644 index 0000000..91d0769 --- /dev/null +++ b/spatialbench-raster/Cargo.toml @@ -0,0 +1,32 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +[package] +name = "spatialbench-raster" +version = "0.3.0" +edition = { workspace = true } +authors = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +readme = { workspace = true } +description = "Raster benchmark data generation for SpatialBench" +keywords = ["spatial", "raster", "cog", "geotiff", "benchmark"] +categories = ["science::geo", "data-structures"] + +[dependencies] +spatialbench = { path = "../spatialbench", version = "0.3.0" } diff --git a/spatialbench-raster/src/footprint/mod.rs b/spatialbench-raster/src/footprint/mod.rs new file mode 100644 index 0000000..cdb20da --- /dev/null +++ b/spatialbench-raster/src/footprint/mod.rs @@ -0,0 +1,473 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Footprint grid generation using UTM-based tessellation. +//! +//! Each footprint is a fixed-metric-size tile (default 109,800m × 109,800m) +//! in the appropriate UTM zone, matching how real raster archives (Sentinel-2, +//! Landsat) tile data in projected coordinates. +//! +//! The UTM projection implementation follows the series expansion from +//! Snyder, J.P. (1987) "Map Projections — A Working Manual", USGS Professional +//! Paper 1395, equations 8-1 through 8-18 (forward) and 8-20 through 8-23 (inverse). + +use crate::scaling::ScalingTier; +use spatialbench::spatial::ContinentAffines; + +// WGS84 ellipsoid constants (Snyder 1987, Table 1). +const WGS84_A: f64 = 6_378_137.0; // Semi-major axis (meters) +const WGS84_F: f64 = 1.0 / 298.257223563; // Flattening +const WGS84_E2: f64 = 2.0 * WGS84_F - WGS84_F * WGS84_F; // Eccentricity squared +const WGS84_E_PRIME2: f64 = WGS84_E2 / (1.0 - WGS84_E2); // Second eccentricity squared +const UTM_K0: f64 = 0.9996; // UTM scale factor at central meridian +const UTM_FALSE_EASTING: f64 = 500_000.0; +const UTM_FALSE_NORTHING_SOUTH: f64 = 10_000_000.0; + +/// A footprint: a fixed-metric-size tile in a UTM projection. +#[derive(Debug, Clone, Copy)] +pub struct Footprint { + /// Unique footprint ID (0-based). + pub id: u32, + /// EPSG code for this footprint's CRS (e.g., 32617 for UTM zone 17N). + pub epsg: u32, + /// Origin (easting, northing) of the NW corner in the UTM CRS (meters). + pub origin: (f64, f64), + /// Bounding box in EPSG:4326: [west, south, east, north] degrees. + /// Used for STAC catalog bbox field. + pub bbox_4326: [f64; 4], +} + +/// Configuration for footprint generation. +#[derive(Debug, Clone, Copy)] +pub struct FootprintConfig { + /// COG width in pixels. + pub cog_width: u32, + /// COG height in pixels. + pub cog_height: u32, + /// Pixel resolution in meters. + pub resolution: u32, +} + +impl Default for FootprintConfig { + fn default() -> Self { + Self { + cog_width: 1830, + cog_height: 1830, + resolution: 60, + } + } +} + +impl FootprintConfig { + /// Footprint step size in meters (east-west). + pub fn step_x(&self) -> f64 { + self.cog_width as f64 * self.resolution as f64 + } + + /// Footprint step size in meters (north-south). + pub fn step_y(&self) -> f64 { + self.cog_height as f64 * self.resolution as f64 + } +} + +/// Generates footprints by tessellating continent affines into a UTM-based grid. +/// +/// Phase 1 uses only the S. North America affine. Multi-continent scaling +/// (engaging additional affines at higher SF) is deferred to Phase 1d. +#[derive(Debug, Clone)] +pub struct FootprintGrid { + affines: ContinentAffines, + config: FootprintConfig, + max_footprints: Option, +} + +impl FootprintGrid { + /// Create a new footprint grid. + /// + /// `max_footprints` caps the number of footprints generated (dev flag). + pub fn new( + affines: ContinentAffines, + config: FootprintConfig, + max_footprints: Option, + ) -> Self { + Self { + affines, + config, + max_footprints, + } + } + + /// Generate footprints for S. North America (Phase 1). + /// + /// Tessellates the continent affine extent into fixed-metric tiles per UTM zone. + /// Returns a Vec because the generation requires stateful iteration across zones. + pub fn generate(&self, tier: &ScalingTier) -> Vec { + let affine = self.affines.south_north_america; + // Affine layout: [width_deg, shx, west, shy, -height_deg, north] + let west = affine[2]; + let east = west + affine[0]; + let north = affine[5]; + let south = north + affine[4]; // affine[4] is negative + + let step_x = self.config.step_x(); + let step_y = self.config.step_y(); + + let limit = self + .max_footprints + .map(|m| m.min(tier.footprints)) + .unwrap_or(tier.footprints); + + let mut footprints = Vec::with_capacity(limit as usize); + + // Iterate over UTM zones that intersect the extent + let zone_start = lon_to_utm_zone(west); + let zone_end = lon_to_utm_zone(east); + + for zone in zone_start..=zone_end { + if footprints.len() as u32 >= limit { + break; + } + + // Zone's longitude bounds + let zone_west_lon = (zone as f64 - 1.0) * 6.0 - 180.0; + let zone_east_lon = zone_west_lon + 6.0; + + // Clip to continent extent + let clip_west = west.max(zone_west_lon); + let clip_east = east.min(zone_east_lon); + let clip_south = south; + let clip_north = north; + + // Convert clipped corners to UTM meters + let (min_e, min_n) = lonlat_to_utm(clip_west, clip_south, zone); + let (max_e, max_n) = lonlat_to_utm(clip_east, clip_north, zone); + + // Align to grid + let start_e = (min_e / step_x).floor() * step_x; + let start_n = (min_n / step_y).floor() * step_y; + + let mut northing = max_n; + while northing - step_y >= start_n { + let mut easting = start_e; + while easting + step_x <= max_e { + if footprints.len() as u32 >= limit { + break; + } + + // NW corner of this tile + let origin = (easting, northing); + + // Determine hemisphere from tile center latitude + let center_lat = + utm_to_lonlat(easting + step_x / 2.0, northing - step_y / 2.0, zone, true) + .1; + let is_north = center_lat >= 0.0; + let epsg = if is_north { 32600 + zone } else { 32700 + zone }; + + // Compute 4326 bbox from the NW and SE corners + let nw = utm_to_lonlat(easting, northing, zone, is_north); + let se = utm_to_lonlat(easting + step_x, northing - step_y, zone, is_north); + + let bbox_4326 = [ + nw.0.min(se.0), // west + nw.1.min(se.1), // south + nw.0.max(se.0), // east + nw.1.max(se.1), // north + ]; + + footprints.push(Footprint { + id: footprints.len() as u32, + epsg, + origin, + bbox_4326, + }); + + easting += step_x; + } + northing -= step_y; + } + } + + footprints.truncate(limit as usize); + footprints + } +} + +/// Determine UTM zone number (1-60) from longitude. +pub fn lon_to_utm_zone(lon: f64) -> u32 { + (((lon + 180.0) / 6.0).floor() as u32 + 1).clamp(1, 60) +} + +/// Convert (longitude, latitude) to UTM (easting, northing) in a given zone. +/// +/// Snyder 1987, equations 8-1 through 8-6 (forward Transverse Mercator). +pub fn lonlat_to_utm(lon: f64, lat: f64, zone: u32) -> (f64, f64) { + let lon0 = ((zone as f64 - 1.0) * 6.0 - 180.0 + 3.0).to_radians(); + let lat_rad = lat.to_radians(); + let lon_rad = lon.to_radians(); + + let n = WGS84_A / (1.0 - WGS84_E2 * lat_rad.sin().powi(2)).sqrt(); + let t = lat_rad.tan().powi(2); + let c = WGS84_E_PRIME2 * lat_rad.cos().powi(2); + let aa = (lon_rad - lon0) * lat_rad.cos(); + + let m = meridian_arc(lat_rad); + + let easting = UTM_K0 + * n + * (aa + + (1.0 - t + c) * aa.powi(3) / 6.0 + + (5.0 - 18.0 * t + t * t + 72.0 * c - 58.0 * WGS84_E_PRIME2) * aa.powi(5) / 120.0) + + UTM_FALSE_EASTING; + + let northing = UTM_K0 + * (m + n + * lat_rad.tan() + * (aa * aa / 2.0 + + (5.0 - t + 9.0 * c + 4.0 * c * c) * aa.powi(4) / 24.0 + + (61.0 - 58.0 * t + t * t + 600.0 * c - 330.0 * WGS84_E_PRIME2) * aa.powi(6) + / 720.0)); + + let northing = if lat < 0.0 { + northing + UTM_FALSE_NORTHING_SOUTH + } else { + northing + }; + + (easting, northing) +} + +/// Convert UTM (easting, northing) to (longitude, latitude) in a given zone. +/// +/// Snyder 1987, equations 8-20 through 8-23 (inverse Transverse Mercator). +pub fn utm_to_lonlat(easting: f64, northing: f64, zone: u32, is_north: bool) -> (f64, f64) { + let e1 = (1.0 - (1.0 - WGS84_E2).sqrt()) / (1.0 + (1.0 - WGS84_E2).sqrt()); + + let lon0 = ((zone as f64 - 1.0) * 6.0 - 180.0 + 3.0).to_radians(); + + let x = easting - UTM_FALSE_EASTING; + let y = if is_north { + northing + } else { + northing - UTM_FALSE_NORTHING_SOUTH + }; + + let m = y / UTM_K0; + let mu = m + / (WGS84_A + * (1.0 + - WGS84_E2 / 4.0 + - 3.0 * WGS84_E2 * WGS84_E2 / 64.0 + - 5.0 * WGS84_E2.powi(3) / 256.0)); + + let phi1 = mu + + (3.0 * e1 / 2.0 - 27.0 * e1.powi(3) / 32.0) * (2.0 * mu).sin() + + (21.0 * e1 * e1 / 16.0 - 55.0 * e1.powi(4) / 32.0) * (4.0 * mu).sin() + + (151.0 * e1.powi(3) / 96.0) * (6.0 * mu).sin(); + + let n1 = WGS84_A / (1.0 - WGS84_E2 * phi1.sin().powi(2)).sqrt(); + let t1 = phi1.tan().powi(2); + let c1 = WGS84_E_PRIME2 * phi1.cos().powi(2); + let r1 = WGS84_A * (1.0 - WGS84_E2) / (1.0 - WGS84_E2 * phi1.sin().powi(2)).powf(1.5); + let d = x / (n1 * UTM_K0); + + let lat = phi1 + - (n1 * phi1.tan() / r1) + * (d * d / 2.0 + - (5.0 + 3.0 * t1 + 10.0 * c1 - 4.0 * c1 * c1 - 9.0 * WGS84_E_PRIME2) * d.powi(4) + / 24.0 + + (61.0 + 90.0 * t1 + 298.0 * c1 + 45.0 * t1 * t1 + - 252.0 * WGS84_E_PRIME2 + - 3.0 * c1 * c1) + * d.powi(6) + / 720.0); + + let lon = lon0 + + (d - (1.0 + 2.0 * t1 + c1) * d.powi(3) / 6.0 + + (5.0 - 2.0 * c1 + 28.0 * t1 - 3.0 * c1 * c1 + 8.0 * WGS84_E_PRIME2 + 24.0 * t1 * t1) + * d.powi(5) + / 120.0) + / phi1.cos(); + + (lon.to_degrees(), lat.to_degrees()) +} + +/// Compute meridian arc length from equator to latitude `phi` (Snyder 1987, eq. 3-21). +fn meridian_arc(phi: f64) -> f64 { + let e4 = WGS84_E2 * WGS84_E2; + let e6 = e4 * WGS84_E2; + WGS84_A + * ((1.0 - WGS84_E2 / 4.0 - 3.0 * e4 / 64.0 - 5.0 * e6 / 256.0) * phi + - (3.0 * WGS84_E2 / 8.0 + 3.0 * e4 / 32.0 + 45.0 * e6 / 1024.0) * (2.0 * phi).sin() + + (15.0 * e4 / 256.0 + 45.0 * e6 / 1024.0) * (4.0 * phi).sin() + - (35.0 * e6 / 3072.0) * (6.0 * phi).sin()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scaling::scaling_tier; + + #[test] + fn utm_zone_assignment() { + assert_eq!(lon_to_utm_zone(-124.0), 10); + assert_eq!(lon_to_utm_zone(-70.0), 19); + assert_eq!(lon_to_utm_zone(-93.0), 15); + assert_eq!(lon_to_utm_zone(0.0), 31); + } + + #[test] + fn utm_roundtrip() { + // Test forward + inverse at a known point + let lon = -100.0; + let lat = 35.0; + let zone = lon_to_utm_zone(lon); + let (e, n) = lonlat_to_utm(lon, lat, zone); + let (lon2, lat2) = utm_to_lonlat(e, n, zone, true); + assert!((lon - lon2).abs() < 1e-6, "lon: {lon} vs {lon2}"); + assert!((lat - lat2).abs() < 1e-6, "lat: {lat} vs {lat2}"); + } + + #[test] + fn utm_roundtrip_edge_cases() { + // Near zone boundary + for &lon in &[-124.0, -69.0, -90.0] { + for &lat in &[12.0, 42.0, 30.0] { + let zone = lon_to_utm_zone(lon); + let (e, n) = lonlat_to_utm(lon, lat, zone); + let (lon2, lat2) = utm_to_lonlat(e, n, zone, lat >= 0.0); + assert!( + (lon - lon2).abs() < 1e-5, + "roundtrip failed for ({lon}, {lat}): got ({lon2}, {lat2})" + ); + assert!( + (lat - lat2).abs() < 1e-5, + "roundtrip failed for ({lon}, {lat}): got ({lon2}, {lat2})" + ); + } + } + } + + #[test] + fn generates_footprints() { + let tier = scaling_tier(1).unwrap(); + let grid = FootprintGrid::new( + ContinentAffines::default(), + FootprintConfig::default(), + None, + ); + let footprints = grid.generate(tier); + // The UTM tessellation may produce a different count than the scaling + // table's target (which was based on a 1° grid approximation). + // Verify we get a reasonable number of footprints and that it's capped. + assert!( + footprints.len() > 1000, + "expected >1000 footprints, got {}", + footprints.len() + ); + assert!( + footprints.len() <= tier.footprints as usize, + "exceeded tier cap: {} > {}", + footprints.len(), + tier.footprints + ); + } + + #[test] + fn max_footprints_caps() { + let tier = scaling_tier(1).unwrap(); + let grid = FootprintGrid::new( + ContinentAffines::default(), + FootprintConfig::default(), + Some(5), + ); + let footprints = grid.generate(tier); + assert_eq!(footprints.len(), 5); + } + + #[test] + fn footprint_has_valid_bbox() { + let tier = scaling_tier(1).unwrap(); + let grid = FootprintGrid::new( + ContinentAffines::default(), + FootprintConfig::default(), + Some(10), + ); + let footprints = grid.generate(tier); + for fp in &footprints { + // west < east + assert!( + fp.bbox_4326[0] < fp.bbox_4326[2], + "fp {}: invalid bbox", + fp.id + ); + // south < north + assert!( + fp.bbox_4326[1] < fp.bbox_4326[3], + "fp {}: invalid bbox", + fp.id + ); + // Reasonable geographic extent (~1° in each direction) + let width = fp.bbox_4326[2] - fp.bbox_4326[0]; + let height = fp.bbox_4326[3] - fp.bbox_4326[1]; + assert!( + width > 0.5 && width < 3.0, + "fp {}: width {width}° unexpected", + fp.id + ); + assert!( + height > 0.5 && height < 3.0, + "fp {}: height {height}° unexpected", + fp.id + ); + } + } + + #[test] + fn footprint_epsg_is_valid_utm() { + let tier = scaling_tier(1).unwrap(); + let grid = FootprintGrid::new( + ContinentAffines::default(), + FootprintConfig::default(), + Some(10), + ); + let footprints = grid.generate(tier); + for fp in &footprints { + // Northern hemisphere UTM: 32601-32660 + assert!( + fp.epsg >= 32601 && fp.epsg <= 32660, + "fp {}: invalid EPSG {}", + fp.id, + fp.epsg + ); + } + } + + #[test] + fn footprint_ids_sequential() { + let tier = scaling_tier(1).unwrap(); + let grid = FootprintGrid::new( + ContinentAffines::default(), + FootprintConfig::default(), + Some(20), + ); + let footprints = grid.generate(tier); + for (i, fp) in footprints.iter().enumerate() { + assert_eq!(fp.id, i as u32); + } + } +} diff --git a/spatialbench-raster/src/lib.rs b/spatialbench-raster/src/lib.rs new file mode 100644 index 0000000..85ee831 --- /dev/null +++ b/spatialbench-raster/src/lib.rs @@ -0,0 +1,26 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Raster benchmark data generation for SpatialBench. +//! +//! This crate provides the data model, scaling tables, footprint grid, and +//! topology factoring logic for the SpatialBench raster benchmark. COG writing +//! (Phase 1b) and STAC catalog generation are in separate crates. + +pub mod footprint; +pub mod scaling; +pub mod topology; diff --git a/spatialbench-raster/src/scaling/mod.rs b/spatialbench-raster/src/scaling/mod.rs new file mode 100644 index 0000000..d2e8a12 --- /dev/null +++ b/spatialbench-raster/src/scaling/mod.rs @@ -0,0 +1,125 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Scaling table mapping SF to pile dimensions and topology factorings. + +use std::io; + +/// One row of the SF → footprint/scene scaling table. +#[derive(Debug, Clone, Copy)] +pub struct ScalingTier { + /// Scale factor (1, 10, 100, 1000). + pub sf: u32, + /// Number of footprints (A). Derived from continent tiling. + pub footprints: u32, + /// Total COGs per footprint (N). Each topology factors this as M × T. + pub scenes_per_footprint: u32, + /// Narrow topology (M, T) factoring. + pub narrow: (u32, u32), + /// Balanced topology (M, T) factoring. + pub balanced: (u32, u32), + /// Wide topology (M, T) factoring. + pub wide: (u32, u32), +} + +/// The scaling ladder. Invariant: for every tier, +/// `narrow.0 * narrow.1 == balanced.0 * balanced.1 == wide.0 * wide.1 == scenes_per_footprint`. +pub const SCALING_TABLE: &[ScalingTier] = &[ + ScalingTier { + sf: 1, + footprints: 1_320, + scenes_per_footprint: 16, + narrow: (2, 8), + balanced: (4, 4), + wide: (8, 2), + }, + ScalingTier { + sf: 10, + footprints: 4_234, + scenes_per_footprint: 64, + narrow: (2, 32), + balanced: (8, 8), + wide: (32, 2), + }, + ScalingTier { + sf: 100, + footprints: 9_851, + scenes_per_footprint: 384, + narrow: (2, 192), + balanced: (12, 32), + wide: (96, 4), + }, + ScalingTier { + sf: 1000, + footprints: 28_164, + scenes_per_footprint: 1_152, + narrow: (2, 576), + balanced: (12, 96), + wide: (192, 6), + }, +]; + +/// Look up the scaling tier for a given SF. +pub fn scaling_tier(sf: u32) -> io::Result<&'static ScalingTier> { + SCALING_TABLE.iter().find(|t| t.sf == sf).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("unsupported scale factor: {sf}. Valid values: 1, 10, 100, 1000"), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scaling_table_invariants() { + for tier in SCALING_TABLE { + assert_eq!( + tier.narrow.0 * tier.narrow.1, + tier.scenes_per_footprint, + "Narrow M*T != N at SF={}", + tier.sf + ); + assert_eq!( + tier.balanced.0 * tier.balanced.1, + tier.scenes_per_footprint, + "Balanced M*T != N at SF={}", + tier.sf + ); + assert_eq!( + tier.wide.0 * tier.wide.1, + tier.scenes_per_footprint, + "Wide M*T != N at SF={}", + tier.sf + ); + } + } + + #[test] + fn scaling_tier_lookup() { + let tier = scaling_tier(1).unwrap(); + assert_eq!(tier.footprints, 1_320); + assert_eq!(tier.scenes_per_footprint, 16); + } + + #[test] + fn scaling_tier_invalid() { + assert!(scaling_tier(5).is_err()); + } +} diff --git a/spatialbench-raster/src/topology/mod.rs b/spatialbench-raster/src/topology/mod.rs new file mode 100644 index 0000000..05227ed --- /dev/null +++ b/spatialbench-raster/src/topology/mod.rs @@ -0,0 +1,171 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Topology definitions and scene assignment logic. + +use crate::scaling::ScalingTier; + +/// The three topology lenses over a shared COG pile. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Topology { + /// T-heavy: many items × few channels. Stresses item enumeration. + Narrow, + /// Balanced: moderate items × moderate channels. Stresses asset pruning. + Balanced, + /// M-heavy: few items × wide channels. Stresses bulk per-item read. + Wide, +} + +impl Topology { + /// All three topologies. + pub const ALL: [Topology; 3] = [Topology::Narrow, Topology::Balanced, Topology::Wide]; + + /// Return the (M, T) factoring for this topology from a scaling tier. + pub fn factor(&self, tier: &ScalingTier) -> (u32, u32) { + match self { + Topology::Narrow => tier.narrow, + Topology::Balanced => tier.balanced, + Topology::Wide => tier.wide, + } + } + + /// Directory name for output paths. + pub fn dir_name(&self) -> &'static str { + match self { + Topology::Narrow => "narrow", + Topology::Balanced => "balanced", + Topology::Wide => "wide", + } + } +} + +/// A scene's assignment within a topology: which mosaic slot and which timeslice. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SceneAssignment { + /// Channel/mosaic index within an item (0..M). + pub mosaic_id: u32, + /// Timeslice index (0..T). + pub timeslice_id: u32, +} + +/// Assign a `cog_id` (0-based, < N) to a `(mosaic_id, timeslice_id)` pair +/// given the topology's M value. +/// +/// The mapping is: `mosaic_id = cog_id % M`, `timeslice_id = cog_id / M`. +pub fn assign_scene(cog_id: u32, m: u32) -> SceneAssignment { + SceneAssignment { + mosaic_id: cog_id % m, + timeslice_id: cog_id / m, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scaling::SCALING_TABLE; + + #[test] + fn factor_sf1() { + let tier = &SCALING_TABLE[0]; + assert_eq!(Topology::Narrow.factor(tier), (2, 8)); + assert_eq!(Topology::Balanced.factor(tier), (4, 4)); + assert_eq!(Topology::Wide.factor(tier), (8, 2)); + } + + #[test] + fn scene_assignment_narrow() { + // M=2: cog 0 → (0,0), cog 1 → (1,0), cog 2 → (0,1), cog 3 → (1,1) + assert_eq!( + assign_scene(0, 2), + SceneAssignment { + mosaic_id: 0, + timeslice_id: 0 + } + ); + assert_eq!( + assign_scene(1, 2), + SceneAssignment { + mosaic_id: 1, + timeslice_id: 0 + } + ); + assert_eq!( + assign_scene(2, 2), + SceneAssignment { + mosaic_id: 0, + timeslice_id: 1 + } + ); + assert_eq!( + assign_scene(3, 2), + SceneAssignment { + mosaic_id: 1, + timeslice_id: 1 + } + ); + } + + #[test] + fn scene_assignment_wide() { + // M=8: cog 0 → (0,0), cog 7 → (7,0), cog 8 → (0,1) + assert_eq!( + assign_scene(0, 8), + SceneAssignment { + mosaic_id: 0, + timeslice_id: 0 + } + ); + assert_eq!( + assign_scene(7, 8), + SceneAssignment { + mosaic_id: 7, + timeslice_id: 0 + } + ); + assert_eq!( + assign_scene(8, 8), + SceneAssignment { + mosaic_id: 0, + timeslice_id: 1 + } + ); + } + + #[test] + fn all_cogs_assigned_exactly_once() { + let tier = &SCALING_TABLE[0]; // SF=1, N=16 + for topo in Topology::ALL { + let (m, t) = topo.factor(tier); + // Every cog_id in 0..N maps to a unique (mosaic, timeslice) + let mut seen = std::collections::HashSet::new(); + for cog_id in 0..tier.scenes_per_footprint { + let a = assign_scene(cog_id, m); + assert!(a.mosaic_id < m); + assert!(a.timeslice_id < t); + assert!(seen.insert((a.mosaic_id, a.timeslice_id))); + } + assert_eq!(seen.len(), (m * t) as usize); + } + } + + #[test] + fn dir_names() { + assert_eq!(Topology::Narrow.dir_name(), "narrow"); + assert_eq!(Topology::Balanced.dir_name(), "balanced"); + assert_eq!(Topology::Wide.dir_name(), "wide"); + } +}