eorst/
stac_helpers.rs

1//! STAC helpers for querying and processing STAC ItemCollections.
2//!
3//! This module provides utility functions for working with STAC (SpatioTemporal Asset Catalog)
4//! data including extracting datetimes, asset names, and sourcing files.
5
6use chrono::{DateTime, FixedOffset};
7use gdal::vector::Geometry;
8use itertools::Itertools;
9use log::debug;
10use std::path::PathBuf;
11use stac::ItemCollection;
12
13/// Returns unique datetimes from a vector, merging those within 6 hours of each other.
14pub fn unique_datetimes_in_range(dates: Vec<DateTime<FixedOffset>>) -> Vec<DateTime<FixedOffset>> {
15    let six_hours = chrono::Duration::hours(6);
16    let mut result: Vec<DateTime<FixedOffset>> = vec![];
17    let mut last_date = dates[0];
18
19    for &date in dates[1..].iter() {
20        if date - last_date <= six_hours {
21        } else {
22            result.push(last_date);
23            last_date = date;
24        }
25    }
26    result.push(last_date); // push the last date
27
28    result
29}
30
31/// Extracts sorted datetimes from a STAC ItemCollection.
32pub fn get_sorted_datetimes(feature_collection: &ItemCollection) -> Vec<DateTime<FixedOffset>> {
33    let mut dates: Vec<DateTime<FixedOffset>> = feature_collection
34        .items
35        .iter()
36        .map(|i| {
37            DateTime::parse_from_rfc3339(&i.properties.datetime.to_owned().unwrap().to_rfc3339())
38                .unwrap()
39        })
40        .collect();
41    dates.sort();
42
43    dates
44}
45
46/// Extracts asset names from a STAC ItemCollection.
47pub fn get_asset_names(feature_collection: &ItemCollection) -> Vec<String> {
48    let mut names = Vec::new();
49
50    for item in &feature_collection.items {
51        for asset in item.assets.values() {
52            if let Some(eo_bands) = asset.additional_fields.get("eo:bands") {
53                // normal EO band logic
54                let eo_band = &eo_bands[0];
55                let mut name = eo_band["common_name"]
56                    .as_str()
57                    .unwrap_or_else(|| eo_band["name"].as_str().unwrap())
58                    .to_owned();
59                if name == "None" {
60                    name = eo_band["name"]
61                        .as_str()
62                        .unwrap_or("unknown")
63                        .to_owned();
64                }
65                names.push(name);
66            } else {
67                // fallback: just use the asset key
68                names.push(asset.title.clone().unwrap_or_else(|| "unknown".to_string()));
69            }
70        }
71    }
72
73    let mut names = names.into_iter().unique().collect::<Vec<_>>();
74    names.sort();
75    names
76}
77
78fn get_name_from_bands(bands: Option<&serde_json::value::Value>) -> Option<String> {
79    if let Some(json_array) = bands {
80        if let Some(json_object) = json_array.get(0) {
81            // Some catalogs will store the layer name under common_name
82            if let Some(name_field) = json_object.get("common_name") {
83                if let Some(name) = name_field.as_str() {
84                    return Some(name.to_owned());
85                }
86            }
87            // and some others, simply under name; So if the above condition fails (no common_name) it falls back
88            // to the name.
89            if let Some(name_field) = json_object.get("name") {
90                if let Some(name) = name_field.as_str() {
91                    return Some(name.to_owned());
92                }
93            }
94        }
95    }
96    None
97}
98
99fn canonical_asset_name(asset: &stac::Asset) -> String {
100    // First, try eo:bands
101    if let Some(eo_bands) = asset.additional_fields.get("eo:bands") {
102        let eo_band = &eo_bands[0];
103        let mut name = eo_band["common_name"]
104            .as_str()
105            .or_else(|| eo_band["name"].as_str())
106            .unwrap_or("unknown")
107            .to_string();
108        if name == "None" {
109            name = eo_band["name"].as_str().unwrap_or("unknown").to_string();
110        }
111        return name.to_lowercase();
112    }
113
114    // Fallback: check title
115    if let Some(title) = &asset.title {
116        // map known QA bands to standard names
117        debug!("title {:?}", title);
118        match title.as_str() {
119            "Surface Temperature Band" => "surface temperature band".to_string(),
120            "Pixel Quality Assessment Band" => "pixel quality assessment band".to_string(),
121            "RADSAT QA Band" => "qa_radsat".to_string(),
122            _ => title.to_lowercase().replace(' ', "_"),
123        }
124    } else {
125        // fallback to some generic string
126        "unknown".to_string()
127    }
128}
129
130/// Gets file paths for a specific asset from STAC items.
131pub fn get_sources_for_asset(items: &Vec<stac::Item>, asset_name: &str) -> Vec<PathBuf> {
132    let mut sources = Vec::new();
133
134    for item in items {
135        for asset in item.assets.values() {
136            let name = canonical_asset_name(asset);
137
138            if name == asset_name.to_lowercase() {
139                sources.push(PathBuf::from(&asset.href));
140            }
141        }
142    }
143    sources
144}
145
146/// Gets the asset href for a specific date and asset name from a STAC feature collection.
147pub fn get_asset_href(
148    feature_collection: &ItemCollection,
149    date_time: &DateTime<FixedOffset>,
150    asset_name: &str,
151) -> PathBuf {
152    let mut found_asset = stac::Asset::new("test");
153    for item in &feature_collection.items {
154        let date = DateTime::parse_from_rfc3339(
155            &item.properties.datetime.to_owned().unwrap().to_rfc3339(),
156        )
157        .unwrap();
158
159        for asset in item.assets.values() {
160            let asset = asset.clone();
161            let bands = asset.additional_fields.get("eo:bands");
162            let mut names = Vec::new();
163            if let Some(name) = get_name_from_bands(bands) {
164                names.push(name);
165            } else {
166                panic!("Name not found.");
167            }
168
169            if (names[0] == asset_name) && (date == *date_time) {
170                found_asset = asset.clone();
171            }
172        }
173    }
174    PathBuf::from(found_asset.href)
175}
176
177/// Gets STAC items matching a specific date (within 24 hours).
178pub fn get_items_for_date(
179    feature_collection: &ItemCollection,
180    date_time: &DateTime<FixedOffset>,
181) -> Vec<stac::Item> {
182    let mut found_items = Vec::new();
183
184    for item in &feature_collection.items {
185        let date = DateTime::parse_from_rfc3339(
186            &item.properties.datetime.to_owned().unwrap().to_rfc3339(),
187        )
188        .unwrap();
189
190        let delta = *date_time - date;
191
192        if delta.abs() <= chrono::Duration::hours(24) {
193            found_items.push(item.to_owned());
194        }
195    }
196    found_items
197}
198
199/// Swaps x and y coordinates in a geometry.
200pub fn swap_coordinates(gdal_geometry: &Geometry) -> Geometry {
201    let wkt = gdal_geometry.wkt().unwrap();
202
203    let mut swapped_wkt = String::new();
204    swapped_wkt.push_str("POLYGON ((");
205    let tokens: Vec<&str> = wkt.split([',', '(', ')']).collect();
206
207    // Split the WKT string by spaces and parenthesesi
208    for token in tokens {
209        if !token.starts_with("POLY") {
210            // Split each token by comma to get the coordinate values
211            let coordinates: Vec<&str> = token.split(' ').collect();
212            if coordinates.len() == 2 {
213                // Swap the X and Y coordinates
214                let x = coordinates[0].trim();
215                let y = coordinates[1].trim();
216
217                // Append the swapped coordinates to the new WKT string
218                let to_append = &format!("{} {} {}, ", y, x, 0);
219
220                swapped_wkt.push_str(to_append);
221            }
222        }
223
224        //swapped_wkt.push(','); // Add space between coordinates
225    }
226    swapped_wkt.push_str("))");
227    let mut result = swapped_wkt;
228
229    // Find the last occurrence of a comma
230    if let Some(last_comma_index) = result.rfind(',') {
231        // Remove the comma from the string
232        result.remove(last_comma_index);
233    }
234
235    Geometry::from_wkt(&result).unwrap()
236}