use std::collections::BTreeMap;
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::sync::Arc;
use serde_json::{json, Map, Value};
use zarrs::array::{Array, ArrayMetadata};
#[cfg(not(target_arch = "wasm32"))]
use zarrs::filesystem::FilesystemStore;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy)]
pub struct BloscProfile {
pub cname: &'static str,
pub clevel: u8,
pub shuffle: bool,
}
pub const BLOSC_DIAGNOSTIC: BloscProfile = BloscProfile {
cname: "zstd",
clevel: 5,
shuffle: true,
};
pub const BLOSC_CHECKPOINT: BloscProfile = BloscProfile {
cname: "zstd",
clevel: 7,
shuffle: true,
};
#[derive(Debug, Clone, Copy)]
pub struct ZstdProfile {
pub level: i32,
pub checksum: bool,
}
pub const ZSTD_WASM: ZstdProfile = ZstdProfile {
level: 5,
checksum: false,
};
#[derive(Debug, Clone, Copy)]
pub enum CodecProfile {
Blosc(BloscProfile),
Zstd(ZstdProfile),
}
fn profile(name: &str) -> Result<CodecProfile> {
match name {
"diagnostic" => Ok(CodecProfile::Blosc(BLOSC_DIAGNOSTIC)),
"checkpoint" => Ok(CodecProfile::Blosc(BLOSC_CHECKPOINT)),
"wasm" => Ok(CodecProfile::Zstd(ZSTD_WASM)),
other => Err(err(format!(
"unknown codec profile '{other}' (expected 'diagnostic', 'checkpoint', or 'wasm')"
))),
}
}
#[derive(Debug, Clone)]
pub struct WriteCoord {
pub name: String,
pub values: Vec<f64>,
pub attrs: Map<String, Value>,
}
#[derive(Debug, Clone)]
pub struct WriteVar {
pub name: String,
pub dims: Vec<String>,
pub attrs: Map<String, Value>,
pub data: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct OutputSchema {
pub dims: Vec<(String, usize)>,
pub time_dim: String,
pub chunk_shape: BTreeMap<String, usize>,
pub shard_shape: BTreeMap<String, usize>,
pub coords: Vec<WriteCoord>,
pub vars: Vec<WriteVar>,
pub group_attrs: Map<String, Value>,
pub profile: String,
}
fn err(detail: impl Into<String>) -> Error {
Error::Format {
format: "zarr".to_string(),
detail: detail.into(),
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn write_zarr_v3(base: &Path, schema: &OutputSchema) -> Result<()> {
let _ = profile(&schema.profile)?; std::fs::create_dir_all(base).map_err(|e| Error::io(Some(base.to_path_buf()), e))?;
let store = Arc::new(
FilesystemStore::new(base)
.map_err(|e| err(format!("open filesystem store at {}: {e}", base.display())))?,
);
write_all_to_store(store, &base.to_string_lossy(), schema)
}
pub fn write_all_to_store<S>(store: Arc<S>, base_url: &str, schema: &OutputSchema) -> Result<()>
where
S: zarrs::storage::ReadableWritableStorageTraits + 'static,
{
let codec = profile(&schema.profile)?;
let codec = &codec;
let dim_len: BTreeMap<&str, usize> =
schema.dims.iter().map(|(k, v)| (k.as_str(), *v)).collect();
for (d, _) in &schema.dims {
let c = *schema
.chunk_shape
.get(d)
.ok_or_else(|| err(format!("dim '{d}' missing from chunk_shape")))?;
let s = *schema
.shard_shape
.get(d)
.ok_or_else(|| err(format!("dim '{d}' missing from shard_shape")))?;
if c == 0 || s == 0 || s % c != 0 {
return Err(err(format!(
"shard_shape[{d}]={s} must be a nonzero multiple of chunk_shape[{d}]={c}"
)));
}
}
let group_meta = json!({
"zarr_format": 3,
"node_type": "group",
"attributes": Value::Object(schema.group_attrs.clone()),
});
set_json(store.as_ref(), "zarr.json", &group_meta)?;
for co in &schema.coords {
let shape = vec![co.values.len()];
write_array(
store.clone(),
&co.name,
std::slice::from_ref(&co.name),
&shape,
schema,
codec,
&co.attrs,
&co.values,
)?;
}
for v in &schema.vars {
if !v.dims.iter().any(|d| d == &schema.time_dim) {
return Err(err(format!(
"streaming var '{}' must include the time dim '{}'",
v.name, schema.time_dim
)));
}
let shape: Vec<usize> = v
.dims
.iter()
.map(|d| {
dim_len
.get(d.as_str())
.copied()
.ok_or_else(|| err(format!("var '{}' dim '{d}' not in schema dims", v.name)))
})
.collect::<Result<_>>()?;
let expect: usize = shape.iter().product();
if v.data.len() != expect {
return Err(err(format!(
"var '{}' data length {} != product(shape {:?}) = {expect}",
v.name,
v.data.len(),
shape
)));
}
write_array(store.clone(), &v.name, &v.dims, &shape, schema, codec, &v.attrs, &v.data)?;
}
write_output_manifest(store.as_ref(), base_url, schema, codec)
}
fn inner_compressor(codec: &CodecProfile, typesize: usize) -> Value {
match codec {
CodecProfile::Blosc(b) => json!({
"name": "blosc", "configuration": {
"cname": b.cname,
"clevel": b.clevel,
"shuffle": if b.shuffle { "shuffle" } else { "noshuffle" },
"typesize": typesize,
"blocksize": 0,
}
}),
CodecProfile::Zstd(z) => json!({
"name": "zstd", "configuration": {
"level": z.level,
"checksum": z.checksum,
}
}),
}
}
fn sharding_codec(inner: &[usize], codec: &CodecProfile, typesize: usize) -> Value {
json!({
"name": "sharding_indexed",
"configuration": {
"chunk_shape": inner,
"codecs": [
{"name": "bytes", "configuration": {"endian": "little"}},
inner_compressor(codec, typesize),
],
"index_codecs": [
{"name": "bytes", "configuration": {"endian": "little"}},
{"name": "crc32c"},
],
"index_location": "end",
},
})
}
fn array_meta(
dims: &[String],
shape: &[usize],
schema: &OutputSchema,
codec: &CodecProfile,
attrs: &Map<String, Value>,
) -> Result<Value> {
let inner: Vec<usize> = dims
.iter()
.map(|d| {
schema
.chunk_shape
.get(d)
.copied()
.ok_or_else(|| err(format!("dim '{d}' missing from chunk_shape")))
})
.collect::<Result<_>>()?;
let shard: Vec<usize> = dims
.iter()
.map(|d| {
schema
.shard_shape
.get(d)
.copied()
.ok_or_else(|| err(format!("dim '{d}' missing from shard_shape")))
})
.collect::<Result<_>>()?;
let mut a = attrs.clone();
a.insert("_ARRAY_DIMENSIONS".to_string(), json!(dims));
Ok(json!({
"zarr_format": 3,
"node_type": "array",
"shape": shape,
"data_type": "float64",
"chunk_grid": {"name": "regular", "configuration": {"chunk_shape": shard}},
"chunk_key_encoding": {"name": "default", "configuration": {"separator": "/"}},
"fill_value": 0.0,
"codecs": [sharding_codec(&inner, codec, std::mem::size_of::<f64>())],
"attributes": Value::Object(a),
"dimension_names": dims,
}))
}
#[allow(clippy::too_many_arguments)]
fn write_array<S>(
store: Arc<S>,
name: &str,
dims: &[String],
shape: &[usize],
schema: &OutputSchema,
codec: &CodecProfile,
attrs: &Map<String, Value>,
data: &[f64],
) -> Result<()>
where
S: zarrs::storage::ReadableWritableStorageTraits + 'static,
{
let meta_json = array_meta(dims, shape, schema, codec, attrs)?;
let metadata: ArrayMetadata = serde_json::from_value(meta_json)
.map_err(|e| err(format!("array '{name}' metadata is not valid v3: {e}")))?;
let array = Array::new_with_metadata(store, &format!("/{name}"), metadata)
.map_err(|e| err(format!("build zarr array '{name}': {e}")))?;
array
.store_metadata()
.map_err(|e| err(format!("write metadata for '{name}': {e}")))?;
let subset = array.subset_all();
array
.store_array_subset(&subset, data)
.map_err(|e| err(format!("write data for '{name}': {e}")))?;
Ok(())
}
fn set_json<S>(store: &S, key: &str, value: &Value) -> Result<()>
where
S: zarrs::storage::WritableStorageTraits,
{
let bytes = serde_json::to_vec_pretty(value)
.map_err(|e| err(format!("serialize {key}: {e}")))?;
let store_key = zarrs::storage::StoreKey::new(key)
.map_err(|e| err(format!("invalid store key '{key}': {e}")))?;
store
.set(&store_key, zarrs::storage::Bytes::from(bytes))
.map_err(|e| err(format!("write {key}: {e}")))
}
fn write_output_manifest<S>(
store: &S,
base_url: &str,
schema: &OutputSchema,
codec: &CodecProfile,
) -> Result<()>
where
S: zarrs::storage::WritableStorageTraits,
{
let n_records = schema
.dims
.iter()
.find(|(d, _)| d == &schema.time_dim)
.map(|(_, l)| *l)
.unwrap_or(0);
let shard_time = schema
.shard_shape
.get(&schema.time_dim)
.copied()
.unwrap_or(n_records.max(1));
let time_vals: Option<&Vec<f64>> = schema
.coords
.iter()
.find(|c| c.name == schema.time_dim)
.map(|c| &c.values);
let mut time_shards = Vec::new();
if shard_time > 0 && n_records > 0 {
let mut index = 0usize;
let mut start = 0usize;
while start < n_records {
let end = (start + shard_time).min(n_records);
let n = end - start;
let t_start = time_vals.and_then(|v| v.get(start).copied()).unwrap_or(start as f64);
let t_end = time_vals
.and_then(|v| v.get(end - 1).copied())
.unwrap_or((end - 1) as f64);
time_shards.push(json!({
"index": index, "t_start": t_start, "t_end": t_end, "n": n,
}));
index += 1;
start = end;
}
}
let last_t: Value = if n_records == 0 {
Value::Null
} else {
time_vals
.and_then(|v| v.last().copied())
.map(|t| json!(t))
.unwrap_or_else(|| json!((n_records - 1) as f64))
};
let vars: Vec<Value> = schema
.vars
.iter()
.map(|v| {
let mut entry = json!({"name": v.name, "dims": v.dims, "dtype": "float64"});
if !v.attrs.is_empty() {
entry["attrs"] = Value::Object(v.attrs.clone());
}
entry
})
.collect();
let dims: Vec<Value> = schema
.dims
.iter()
.map(|(k, v)| json!({"name": k, "length": v}))
.collect();
let codec_meta = match codec {
CodecProfile::Blosc(b) => json!({
"id": "blosc",
"cname": b.cname,
"clevel": b.clevel,
"shuffle": if b.shuffle { "shuffle" } else { "noshuffle" },
}),
CodecProfile::Zstd(z) => json!({
"id": "zstd",
"level": z.level,
"checksum": z.checksum,
}),
};
let manifest = json!({
"schema": "earthsciio/output-manifest/v1",
"base_url": base_url,
"format": "zarr",
"zarr_format": 3,
"profile": schema.profile,
"codec": codec_meta,
"time_dim": schema.time_dim,
"dims": dims,
"vars": vars,
"chunk_shape": schema.chunk_shape,
"shard_shape": schema.shard_shape,
"time_shards": time_shards,
"last_t": last_t,
"n_records": n_records,
"created_at": crate::clock::now_rfc3339(),
});
set_json(store, "output_manifest.json", &manifest)
}