use std::collections::HashMap;
use std::sync::Arc;
use time::{Duration, OffsetDateTime};
use crate::cache::{Cache, FetchRequest};
use crate::error::{Error, Result};
use crate::format::{
ArrayData, Coord, DType, FormatRegistry, NativeDataset, NativeField, Reader, Selection,
};
pub type Window = (OffsetDateTime, OffsetDateTime);
#[derive(Debug, Clone)]
pub struct SourceTemporal {
pub start: OffsetDateTime,
pub end: Option<OffsetDateTime>,
pub frequency: Duration,
pub file_period: Duration,
pub time_dim: String,
pub records_per_sample: Option<u32>,
}
impl SourceTemporal {
pub fn new(start: OffsetDateTime, frequency: Duration, file_period: Duration) -> Self {
Self {
start,
end: None,
frequency,
file_period,
time_dim: "time".to_string(),
records_per_sample: None,
}
}
pub fn end(mut self, end: OffsetDateTime) -> Self {
self.end = Some(end);
self
}
pub fn time_dim(mut self, dim: impl Into<String>) -> Self {
self.time_dim = dim.into();
self
}
pub fn records_per_sample(mut self, n: u32) -> Self {
self.records_per_sample = Some(n);
self
}
}
pub const STORE_ACCESS_ENV: &str = "EARTHSCIIO_STORE_ACCESS";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StoreAccess {
#[default]
Cached,
Direct,
}
impl StoreAccess {
#[must_use]
pub fn from_env() -> Self {
match std::env::var(STORE_ACCESS_ENV) {
Ok(v) if v.trim().eq_ignore_ascii_case("direct") => StoreAccess::Direct,
_ => StoreAccess::Cached,
}
}
}
#[derive(Debug, Clone)]
pub struct DataSource {
pub name: String,
pub format: String,
pub variables: Vec<String>,
pub temporal: Option<SourceTemporal>,
pub url_template: String,
pub mirrors: Vec<String>,
pub auth_realm: Option<String>,
pub select: Selection,
pub reader_options: serde_json::Map<String, serde_json::Value>,
pub store_access: Option<StoreAccess>,
pub store_options: Vec<(String, String)>,
}
impl DataSource {
pub fn new(
name: impl Into<String>,
format: impl Into<String>,
url_template: impl Into<String>,
) -> Self {
Self {
name: name.into(),
format: format.into(),
variables: Vec::new(),
temporal: None,
url_template: url_template.into(),
mirrors: Vec::new(),
auth_realm: None,
select: Selection::All,
reader_options: serde_json::Map::new(),
store_access: None,
store_options: Vec::new(),
}
}
pub fn select(mut self, select: Selection) -> Self {
self.select = select;
self
}
pub fn reader_options(mut self, options: serde_json::Map<String, serde_json::Value>) -> Self {
self.reader_options = options;
self
}
pub fn store_access(mut self, access: StoreAccess) -> Self {
self.store_access = Some(access);
self
}
pub fn store_options<I, K, V>(mut self, options: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self.store_options = options
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect();
self
}
pub fn variables<I, S>(mut self, vars: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.variables = vars.into_iter().map(Into::into).collect();
self
}
pub fn temporal(mut self, temporal: SourceTemporal) -> Self {
self.temporal = Some(temporal);
self
}
pub fn mirrors<I, S>(mut self, mirrors: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.mirrors = mirrors.into_iter().map(Into::into).collect();
self
}
pub fn auth_realm(mut self, realm: impl Into<String>) -> Self {
self.auth_realm = Some(realm.into());
self
}
}
pub struct Provider {
loader: DataSource,
cache: Arc<Cache>,
window: Option<Window>,
reader: Arc<dyn Reader>,
buffers: HashMap<String, NativeField>,
coords: HashMap<String, Coord>,
current_dataset: Option<NativeDataset>,
current_anchor: Option<OffsetDateTime>,
current_file_anchor: Option<OffsetDateTime>,
files: Vec<(OffsetDateTime, NativeDataset)>,
store_access: StoreAccess,
}
impl Provider {
pub fn new(loader: DataSource, cache: Arc<Cache>, window: Option<Window>) -> Result<Self> {
Self::with_formats(loader, cache, window, &FormatRegistry::with_builtins())
}
pub fn with_formats(
loader: DataSource,
cache: Arc<Cache>,
window: Option<Window>,
formats: &FormatRegistry,
) -> Result<Self> {
let reader = formats
.get(&loader.format)
.ok_or_else(|| Error::UnknownFormat {
name: loader.format.clone(),
})?;
let reader = match reader.configured(&loader.reader_options)? {
Some(configured) => configured,
None => reader,
};
let store_access = loader.store_access.unwrap_or_else(StoreAccess::from_env);
if store_access == StoreAccess::Direct
&& reader.store_backed()
&& !reader.supports_direct_read()
{
return Err(Error::Format {
format: loader.format.clone(),
detail: format!(
"loader '{}' asked for uncached (direct) store reads, but the '{}' \
reader has no direct path in this build — rebuild earthsciio with \
the `object-store` feature, or use StoreAccess::Cached",
loader.name, loader.format
),
});
}
Ok(Self {
loader,
cache,
window,
reader,
store_access,
buffers: HashMap::new(),
coords: HashMap::new(),
current_dataset: None,
current_anchor: None,
current_file_anchor: None,
files: Vec::new(),
})
}
pub fn coords(&self) -> &HashMap<String, Coord> {
&self.coords
}
pub fn supports_selection(&self) -> bool {
self.reader.supports_selection()
}
#[must_use]
pub fn store_backed(&self) -> bool {
self.reader.store_backed()
}
pub fn array_shape(&self, var: &str) -> Result<Option<Vec<usize>>> {
if !self.reader.store_backed() {
return Ok(None);
}
let anchor = match &self.loader.temporal {
None => OffsetDateTime::UNIX_EPOCH,
Some(t) => t.start,
};
let url = self.resolve_url(anchor)?;
match self.store_access {
StoreAccess::Direct => {
self.reader
.array_shape_direct(&url, var, &self.loader.store_options)
}
StoreAccess::Cached => self.reader.array_shape(self.cache.clone(), &url, var),
}
}
#[must_use]
pub fn store_access(&self) -> StoreAccess {
self.store_access
}
pub fn materialize(&mut self) -> Result<HashMap<String, NativeField>> {
self.materialize_with_select(None)
}
pub fn materialize_with_select(
&mut self,
sel: Option<&Selection>,
) -> Result<HashMap<String, NativeField>> {
if sel.is_some() && !self.reader.supports_selection() {
return Err(Error::Format {
format: self.loader.format.clone(),
detail: format!(
"reader for format '{}' does not support select/pushdown",
self.loader.format
),
});
}
match self.loader.temporal.clone() {
None => {
let effective = sel.unwrap_or(&self.loader.select);
let url = self.resolve_url(OffsetDateTime::UNIX_EPOCH)?;
let ds = self.read_file(url, effective)?;
if sel.is_none() {
self.coords = ds.coords;
self.buffers = ds.variables;
Ok(self.buffers.clone())
} else {
Ok(ds.variables)
}
}
Some(t) => {
let first = self.lower_bound(&t);
match self.refresh_with_select(first, sel)? {
Some(buffers) => Ok(buffers),
None => Ok(self.buffers.clone()),
}
}
}
}
pub fn refresh(&mut self, t: OffsetDateTime) -> Result<Option<HashMap<String, NativeField>>> {
self.refresh_with_select(t, None)
}
pub fn refresh_with_select(
&mut self,
t: OffsetDateTime,
sel: Option<&Selection>,
) -> Result<Option<HashMap<String, NativeField>>> {
if sel.is_some() && !self.reader.supports_selection() {
return Err(Error::Format {
format: self.loader.format.clone(),
detail: format!(
"reader for format '{}' does not support select/pushdown",
self.loader.format
),
});
}
let Some(temporal) = self.loader.temporal.clone() else {
return Ok(None); };
let freq_s = temporal.frequency.whole_seconds();
let file_s = temporal.file_period.whole_seconds();
if freq_s <= 0 || file_s <= 0 {
return Err(Error::Format {
format: self.loader.format.clone(),
detail: "loader cadence frequency/file_period must be positive".to_string(),
});
}
if !matches!(temporal.records_per_sample, None | Some(1) | Some(2)) {
return Err(Error::Format {
format: self.loader.format.clone(),
detail: format!(
"records_per_sample must be 1 or 2, got {}",
temporal.records_per_sample.unwrap()
),
});
}
let anchor = snap_down(temporal.start, t, freq_s);
if let Some(select) = sel {
if temporal.records_per_sample == Some(2) {
return Ok(Some(self.bracket_peek(&temporal, anchor, freq_s, file_s, select)?));
}
let file_anchor = snap_down(temporal.start, anchor, file_s);
let url = self.resolve_url(file_anchor)?;
let ds = self.read_file(url, select)?;
let rec = ((anchor - file_anchor).whole_seconds() / freq_s) as usize;
return Ok(Some(slice_record_buffers(&ds, temporal.time_dim.as_str(), rec)?));
}
if self.current_anchor == Some(anchor) {
return Ok(None); }
if temporal.records_per_sample == Some(2) {
return self.refresh_bracket(&temporal, anchor, freq_s, file_s);
}
let file_anchor = snap_down(temporal.start, anchor, file_s);
if self.current_file_anchor != Some(file_anchor) {
let url = self.resolve_url(file_anchor)?;
let ds = self.read_file(url, &self.loader.select)?;
self.coords = ds.coords.clone();
self.current_dataset = Some(ds);
self.current_file_anchor = Some(file_anchor);
}
let rec = ((anchor - file_anchor).whole_seconds() / freq_s) as usize;
let ds = self
.current_dataset
.as_ref()
.expect("dataset loaded above for this file anchor");
let buffers = slice_record_buffers(ds, temporal.time_dim.as_str(), rec)?;
self.buffers = buffers;
self.current_anchor = Some(anchor);
Ok(Some(self.buffers.clone()))
}
pub fn refresh_times(&self) -> Vec<f64> {
let Some(temporal) = &self.loader.temporal else {
return Vec::new(); };
let freq_s = temporal.frequency.whole_seconds();
if freq_s <= 0 {
return Vec::new();
}
let lower = self.lower_bound(temporal);
let Some(upper) = self.window.map(|(_, b)| b).or(temporal.end) else {
return Vec::new(); };
let elapsed = (lower - temporal.start).whole_seconds();
let steps = (elapsed + freq_s - 1) / freq_s;
let mut anchor = temporal.start + Duration::seconds(steps * freq_s);
let mut out = Vec::new();
while anchor < upper {
out.push(anchor.unix_timestamp() as f64);
anchor += Duration::seconds(freq_s);
}
out
}
pub fn prefetch(&mut self, window: Window) -> Result<()> {
match self.loader.temporal.clone() {
None => {
self.fetch_blob(&self.resolve_url(OffsetDateTime::UNIX_EPOCH)?)?;
Ok(())
}
Some(t) => {
let file_s = t.file_period.whole_seconds();
if file_s <= 0 {
return Err(Error::Format {
format: self.loader.format.clone(),
detail: "loader file_period must be positive".to_string(),
});
}
let start = if window.0 > t.start {
window.0
} else {
t.start
};
let mut fa = snap_down(t.start, start, file_s);
while fa < window.1 {
let url = self.resolve_url(fa)?;
self.fetch_blob(&url)?;
fa += Duration::seconds(file_s);
}
Ok(())
}
}
}
fn lower_bound(&self, temporal: &SourceTemporal) -> OffsetDateTime {
match self.window {
Some((a, _)) if a > temporal.start => a,
_ => temporal.start,
}
}
fn resolve_url(&self, anchor: OffsetDateTime) -> Result<String> {
let tmpl = &self.loader.url_template;
if !tmpl.contains('[') || tmpl.starts_with("cds://") {
return Ok(tmpl.clone());
}
let desc =
time::format_description::parse_borrowed::<2>(tmpl).map_err(|e| Error::BadUrl {
url: tmpl.clone(),
detail: format!("invalid url_template: {e}"),
})?;
anchor.format(&desc).map_err(|e| Error::BadUrl {
url: tmpl.clone(),
detail: format!("formatting url_template at {anchor}: {e}"),
})
}
fn fetch_blob(&self, url: &str) -> Result<crate::CachedBlob> {
let mirrors: Vec<&str> = self.loader.mirrors.iter().map(String::as_str).collect();
let mut req = FetchRequest::new(url)
.loader(&self.loader.name)
.mirrors(&mirrors);
if let Some(realm) = &self.loader.auth_realm {
req = req.auth_realm(realm);
}
self.cache.fetch(&req)
}
fn read_file(&self, url: String, select: &Selection) -> Result<NativeDataset> {
if self.reader.store_backed() {
return match self.store_access {
StoreAccess::Direct => self.reader.read_store_direct(
&url,
&self.loader.variables,
select,
&self.loader.store_options,
),
StoreAccess::Cached => {
self.reader
.read_store(self.cache.clone(), &url, &self.loader.variables, select)
}
};
}
if !matches!(select, Selection::All) && !self.reader.supports_selection() {
return Err(Error::Format {
format: self.loader.format.clone(),
detail: format!(
"reader for format '{}' does not support select/pushdown",
self.loader.format
),
});
}
let blob = self.fetch_blob(&url)?;
self.reader
.read_native(&blob.path, &self.loader.variables, select)
}
fn ensure_file(&mut self, file_anchor: OffsetDateTime) -> Result<()> {
if let Some(pos) = self.files.iter().position(|(a, _)| *a == file_anchor) {
let entry = self.files.remove(pos);
self.files.push(entry);
return Ok(());
}
let url = self.resolve_url(file_anchor)?;
let ds = self.read_file(url, &self.loader.select)?;
self.files.push((file_anchor, ds));
while self.files.len() > 2 {
self.files.remove(0); }
Ok(())
}
fn file_ref(&self, file_anchor: OffsetDateTime) -> &NativeDataset {
&self
.files
.iter()
.find(|(a, _)| *a == file_anchor)
.expect("file ensured before file_ref")
.1
}
fn refresh_bracket(
&mut self,
temporal: &SourceTemporal,
anchor: OffsetDateTime,
freq_s: i64,
file_s: i64,
) -> Result<Option<HashMap<String, NativeField>>> {
let time_dim = temporal.time_dim.clone();
let file0_anchor = snap_down(temporal.start, anchor, file_s);
self.ensure_file(file0_anchor)?;
let rec0 = ((anchor - file0_anchor).whole_seconds() / freq_s) as usize;
let file0_len = time_len(self.file_ref(file0_anchor), &time_dim);
let next_anchor = anchor + Duration::seconds(freq_s);
let has_succ = temporal.end.map_or(true, |end| next_anchor < end);
let mut succ: Option<(OffsetDateTime, usize)> = None;
if has_succ {
if rec0 + 1 < file0_len {
succ = Some((file0_anchor, rec0 + 1)); } else {
let next_file_anchor = snap_down(temporal.start, next_anchor, file_s);
if self.ensure_file(next_file_anchor).is_ok() {
let r1 = ((next_anchor - next_file_anchor).whole_seconds() / freq_s) as usize;
if r1 < time_len(self.file_ref(next_file_anchor), &time_dim) {
succ = Some((next_file_anchor, r1));
}
}
}
}
let (succ_anchor, rec1, t1_epoch) = match succ {
Some((a, r)) => (a, r, next_anchor.unix_timestamp() as f64),
None => (file0_anchor, rec0, anchor.unix_timestamp() as f64),
};
let t0_epoch = anchor.unix_timestamp() as f64;
let file0 = self.file_ref(file0_anchor);
let file1 = self.file_ref(succ_anchor);
let mut buffers = HashMap::with_capacity(file0.variables.len());
for (name, field) in &file0.variables {
let is_temporal =
field.dims.first().map(String::as_str) == Some(time_dim.as_str());
let stacked = if is_temporal {
let f1 = file1.variables.get(name).unwrap_or(field);
stack_two_records(field, rec0, f1, rec1)?
} else {
field.clone() };
buffers.insert(name.clone(), stacked);
}
let mut coords = file0.coords.clone();
coords.insert(
time_dim.clone(),
Coord {
field: NativeField {
dtype: DType::Float64,
dims: vec![time_dim.clone()],
shape: vec![2],
data: ArrayData::F64(vec![t0_epoch, t1_epoch]),
fill_value: None,
},
units: Some("seconds since 1970-01-01T00:00:00Z".to_string()),
calendar: Some("standard".to_string()),
},
);
self.coords = coords;
self.buffers = buffers;
self.current_anchor = Some(anchor);
Ok(Some(self.buffers.clone()))
}
fn bracket_peek(
&self,
temporal: &SourceTemporal,
anchor: OffsetDateTime,
freq_s: i64,
file_s: i64,
select: &Selection,
) -> Result<HashMap<String, NativeField>> {
let time_dim = temporal.time_dim.as_str();
let file0_anchor = snap_down(temporal.start, anchor, file_s);
let file0 = self.read_file(self.resolve_url(file0_anchor)?, select)?;
let rec0 = ((anchor - file0_anchor).whole_seconds() / freq_s) as usize;
let file0_len = time_len(&file0, time_dim);
let next_anchor = anchor + Duration::seconds(freq_s);
let has_succ = temporal.end.map_or(true, |end| next_anchor < end);
let (succ_file, rec1) = if has_succ && rec0 + 1 < file0_len {
(None, rec0 + 1) } else if has_succ {
let nfa = snap_down(temporal.start, next_anchor, file_s);
match self.resolve_url(nfa).and_then(|u| self.read_file(u, select)) {
Ok(f1) => {
let r1 = ((next_anchor - nfa).whole_seconds() / freq_s) as usize;
if r1 < time_len(&f1, time_dim) {
(Some(f1), r1)
} else {
(None, rec0) }
}
Err(_) => (None, rec0), }
} else {
(None, rec0) };
let file1_ref: &NativeDataset = succ_file.as_ref().unwrap_or(&file0);
let mut buffers = HashMap::with_capacity(file0.variables.len());
for (name, field) in &file0.variables {
let is_temporal = field.dims.first().map(String::as_str) == Some(time_dim);
let stacked = if is_temporal {
let f1 = file1_ref.variables.get(name).unwrap_or(field);
stack_two_records(field, rec0, f1, rec1)?
} else {
field.clone()
};
buffers.insert(name.clone(), stacked);
}
Ok(buffers)
}
}
fn slice_record_buffers(
ds: &NativeDataset,
time_dim: &str,
rec: usize,
) -> Result<HashMap<String, NativeField>> {
let mut buffers = HashMap::with_capacity(ds.variables.len());
for (name, field) in &ds.variables {
let is_temporal = field.dims.first().map(String::as_str) == Some(time_dim);
let slice = if is_temporal {
field.select_leading(rec)?
} else {
field.clone()
};
buffers.insert(name.clone(), slice);
}
Ok(buffers)
}
fn time_len(ds: &NativeDataset, time_dim: &str) -> usize {
if let Some(coord) = ds.coords.get(time_dim) {
if let Some(pos) = coord.field.dims.iter().position(|d| d == time_dim) {
return coord.field.shape[pos];
}
}
for field in ds.variables.values() {
if let Some(pos) = field.dims.iter().position(|d| d == time_dim) {
return field.shape[pos];
}
}
0
}
fn stack_two_records(
f0: &NativeField,
rec0: usize,
f1: &NativeField,
rec1: usize,
) -> Result<NativeField> {
let a = f0.select_leading(rec0)?; let b = f1.select_leading(rec1)?;
let data = concat_array(&a.data, &b.data)?;
let mut dims = Vec::with_capacity(a.dims.len() + 1);
dims.push(f0.dims[0].clone());
dims.extend(a.dims.iter().cloned());
let mut shape = Vec::with_capacity(a.shape.len() + 1);
shape.push(2);
shape.extend(a.shape.iter().cloned());
Ok(NativeField {
dtype: f0.dtype,
dims,
shape,
data,
fill_value: f0.fill_value,
})
}
fn concat_array(a: &ArrayData, b: &ArrayData) -> Result<ArrayData> {
Ok(match (a, b) {
(ArrayData::F64(x), ArrayData::F64(y)) => {
let mut v = x.clone();
v.extend_from_slice(y);
ArrayData::F64(v)
}
(ArrayData::I64(x), ArrayData::I64(y)) => {
let mut v = x.clone();
v.extend_from_slice(y);
ArrayData::I64(v)
}
(ArrayData::I32(x), ArrayData::I32(y)) => {
let mut v = x.clone();
v.extend_from_slice(y);
ArrayData::I32(v)
}
(ArrayData::Str(x), ArrayData::Str(y)) => {
let mut v = x.clone();
v.extend_from_slice(y);
ArrayData::Str(v)
}
(ArrayData::Bool(x), ArrayData::Bool(y)) => {
let mut v = x.clone();
v.extend_from_slice(y);
ArrayData::Bool(v)
}
_ => {
return Err(Error::Format {
format: "native".to_string(),
detail: "cannot stack bracket records of differing dtypes".to_string(),
})
}
})
}
fn snap_down(start: OffsetDateTime, t: OffsetDateTime, step_s: i64) -> OffsetDateTime {
let elapsed = (t - start).whole_seconds();
let steps = elapsed.div_euclid(step_s); start + Duration::seconds(steps * step_s)
}