Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
8faabf5
initial WIP
jameshawkes Nov 8, 2025
1db5268
refactoring QubeNodeValues -> Coordinates. Nesting coordinates.
jameshawkes Nov 8, 2025
12f6929
refactoring coordinates type
jameshawkes Nov 9, 2025
7272a87
fmt
jameshawkes Nov 9, 2025
56da3fb
refactoring of coordinates and warning handling
jameshawkes Nov 9, 2025
d72c89e
started implementing set operations and select
jameshawkes Nov 11, 2025
284310f
move into workspace, add domain-specific crate, add string coordinates
jameshawkes Nov 13, 2025
bd782a1
move into workspace, add domain-specific crate, add string coordinates
jameshawkes Nov 13, 2025
0cf56c7
move to workspace
jameshawkes Nov 13, 2025
fcd2747
clean up
jameshawkes Nov 13, 2025
78504f6
implrment lazy structural hashing
jameshawkes Nov 13, 2025
d1ec78c
before refactoring node access
jameshawkes Nov 13, 2025
7dca097
refactor, allow removing nodes, implement pruning
jameshawkes Nov 14, 2025
f96ebb6
unsure
jameshawkes Jan 8, 2026
b4d515e
fix structural hash
mathleur Jan 8, 2026
8f36768
WIP: add algo for node union
mathleur Jan 15, 2026
8d9a12b
WIP: implement union
mathleur Jan 16, 2026
1f14e0b
WIP: node union method
mathleur Jan 19, 2026
8a3a7fc
WIP: overall union method
mathleur Jan 19, 2026
2e72667
WIP: fix tests
mathleur Jan 19, 2026
d9cf23d
WIP: add hash computation when we start merging
mathleur Jan 19, 2026
cc63331
WIP: add hash computation with children coordinates
mathleur Jan 20, 2026
2f5c2fe
WIP: add hash computation test for semi-similar qubes
mathleur Jan 20, 2026
42d2933
WIP: new try merge function
mathleur Jan 21, 2026
0b43f35
WIP: second implementation of node_union
mathleur Jan 22, 2026
649268e
WIP: start internal_set_op
mathleur Jan 22, 2026
2ae1f2b
WIP: implement internal_set_operations
mathleur Jan 23, 2026
ab5dc21
WIP: fix recursion of the union operation
mathleur Jan 26, 2026
f47bc6f
WIP: add children to intermediate intersection nodes
mathleur Jan 26, 2026
44e90be
WIP: working merge but with duplicates still
mathleur Jan 27, 2026
ec64869
fix bug wrong values appended
mathleur Jan 28, 2026
0d116eb
start implementing merge
mathleur Jan 28, 2026
811e7ba
clean up a bit
mathleur Jan 28, 2026
e77df10
WIP: add merging of coords
mathleur Jan 29, 2026
e4edf35
finish compression
mathleur Jan 29, 2026
25365ea
add compression test
mathleur Jan 29, 2026
68eebe2
semi-working compression
mathleur Jan 29, 2026
9460abf
fixed compression
mathleur Jan 29, 2026
c7f7c42
fix compression test
mathleur Jan 29, 2026
5f7589f
fix compression to remove duplicates as well, and fix union to remove…
mathleur Jan 29, 2026
f95c348
clean up
mathleur Jan 29, 2026
26902ba
add CI and QA
mathleur Jan 30, 2026
441e54f
add QA format
mathleur Jan 30, 2026
4fdf48d
ignore test scripts
mathleur Jan 30, 2026
e8beb5d
clean up
mathleur Jan 30, 2026
aab9c66
format rust code
mathleur Jan 30, 2026
a6e8eaf
harmonise structural hashes in Qube and NodeRef
mathleur Jan 30, 2026
0b861fb
add pre-commit
mathleur Jan 30, 2026
3b20bb5
clean up
mathleur Feb 3, 2026
e862319
fix build
mathleur Feb 3, 2026
bf77063
add comments as doc comments
mathleur Feb 3, 2026
0e3584e
Feat/cads rust adaptor (#63)
mathleur Feb 24, 2026
05343f4
Refactor/separate rust and python crates (#67)
sametd Feb 25, 2026
4eefb34
Feat/python rust docs (#71)
mathleur Mar 3, 2026
f777bad
Feat/fix fdb reader (#75)
mathleur Mar 17, 2026
cc8371e
Feat/python select (#80)
mathleur Mar 17, 2026
3306335
Add Python CI, update API docs, and fix Rust append behavior (#81)
mathleur Mar 24, 2026
e0c898c
Feat/fix select (#82)
mathleur Mar 26, 2026
d131b59
fix select method
mathleur Mar 31, 2026
c9a85fb
add int and datetime ranges
mathleur Apr 23, 2026
4882dba
add list of step ranges, and compression of sets into ranges where po…
mathleur Apr 24, 2026
dfd3c59
WIP: add range types
mathleur Jun 5, 2026
e427b57
Merge main and resolve conflicts with main-first structure
Copilot Jun 25, 2026
644981b
Remove hardcoded local FDB paths from merged meteo files
Copilot Jun 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions design_notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Questions

* How are you supposed to interact with the Qube

* I think the compressed nodes and structure should be hidden from the user as much as possible

* Select/filter to return similar Qubes makes sense

* Would a "path" make sense?
qube[("class", &["od"])][("type", &["fc"])]
Not sure how it would work with coordinates. These don't point to a whole node. Would need to store coordinates along the path.
It would prevent nodes from being visible to the user.

is the path really any different to a filter though?
it stores the route separately to the result, which is quite nice perhaps

a path suggests you can only navigate down the tree, whereas a filter does not care about the structure of the tree
in other words a path reveals the hierarchy, whereas the qube does not really need to show that

A path does not make sense. Treat the Qube not as a tree from outside.

* Methods should include:
sel
dims()
coords(dim) -> list of coordinates along that dim

transpose (change internal ordering of axes)
align (align two Qubes to have the same axes?)
squeeze() removes length-1 dims.
drop_dim()
group_by() to group along a dimension by some function
318 changes: 3 additions & 315 deletions py_qubed/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
use ::qubed::Coordinates;
use ::qubed::Datacube;
use ::qubed::Qube;
use ::qubed::select::SelectMode;
use pyo3::exceptions::PyTypeError;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList, PyModule, PySet};
use pyo3::types::{PyDict, PyList, PyModule};
use serde_json::Value as JsonValue;

mod helpers;

#[pyclass(name = "Qube", unsendable)]
#[pyclass(unsendable)]
pub struct PyQube {
inner: Qube,
}
Expand Down Expand Up @@ -75,30 +71,6 @@ impl PyQube {
}
}

#[staticmethod]
#[pyo3(signature = (datacube, order=None))]
pub fn from_datacube(
datacube: Bound<'_, PyDict>,
order: Option<Vec<String>>,
) -> PyResult<Self> {
let dc = pydict_to_datacube(datacube)?;
let order_slices: Option<&[String]> = order.as_deref();
Ok(Self { inner: Qube::from_datacube(&dc, order_slices) })
}

#[pyo3(signature = (datacube, order=None, accept_existing_order=false))]
pub fn append_datacube(
&mut self,
datacube: Bound<'_, PyDict>,
order: Option<Vec<String>>,
accept_existing_order: bool,
) -> PyResult<()> {
let dc = pydict_to_datacube(datacube)?;
let order_slices: Option<&[String]> = order.as_deref();
self.inner.append_datacube(dc, order_slices, accept_existing_order);
Ok(())
}

pub fn select(
&self,
request: Bound<'_, PyDict>,
Expand Down Expand Up @@ -200,7 +172,7 @@ impl PyQube {
let mut validated_qubes = Vec::with_capacity(others.len());
for item in others.iter() {
let other_cell =
item.cast::<PyQube>().map_err(|_| PyTypeError::new_err("expected Qube"))?;
item.cast::<PyQube>().map_err(|_| PyTypeError::new_err("expected PyQube"))?;
validated_qubes.push(other_cell.clone().unbind());
}

Expand All @@ -216,290 +188,6 @@ impl PyQube {
pub fn __repr__(&self) -> PyResult<String> {
Ok(format!("PyQube(root_id={:?})", self.inner.root()))
}

// -----------------------------------------------------------------------
// New utility methods (mirrors of the @support_qubed_output functions
// in forecast-in-a-box/fiab_plugin_ecmwf/qubed_utils.py)
// -----------------------------------------------------------------------

/// Return all dimension names and their union of coordinate values.
///
/// This is an alias for `all_unique_dim_coords()` and returns the same
/// ``dict[str, list[str]]`` mapping. The name `axes` matches the
/// terminology used in the qubed-utils helper library.
///
/// ```python
/// q = Qube.from_datacube({"param": "2t/tp", "time": "0/1/2"}, ["param", "time"])
/// ax = q.axes()
/// assert set(ax["param"]) == {"2t", "tp"}
/// assert set(ax["time"]) == {"0", "1", "2"}
/// ```
pub fn axes(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
let dim_coords = self.inner.all_unique_dim_coords();
let py_dict = PyDict::new(py);

for (dimension, coordinates) in dim_coords {
let coord_str = coordinates.to_string();
let values: Vec<&str> = if coord_str.is_empty() {
vec![]
} else if coord_str.contains('/') {
coord_str.split('/').collect()
} else {
vec![&coord_str]
};

let py_list = PyList::empty(py);
for value in values {
py_list.append(value)?;
}
py_dict.set_item(dimension, py_list)?;
}

Ok(py_dict.into_any().unbind())
}

/// Return the set of all dimension names present anywhere in the Qube.
///
/// ```python
/// q = Qube.from_datacube({"param": "2t/tp", "time": "0/1/2"}, ["param", "time"])
/// assert q.dimensions() == {"param", "time"}
/// ```
pub fn dimensions(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
let dims = self.inner.dimensions();
let py_set = PySet::new(py, dims.iter())?;
Ok(py_set.into_any().unbind())
}

/// Return the set of dimension names present in **every** leaf path.
///
/// For a Qube with uniform depth this equals `dimensions()`. When
/// branches differ in depth, only dimensions that appear in *all*
/// branches are returned.
///
/// ```python
/// q1 = Qube.from_datacube({"param": "2t", "time": "0/1"}, ["param", "time"])
/// q2 = Qube.from_datacube({"param": "msl"}, ["param"])
/// q1.append(q2)
/// assert q1.common_dimensions() == {"param"}
/// ```
pub fn common_dimensions(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
let common = self.inner.common_dimensions();
let py_set = PySet::new(py, common.iter())?;
Ok(py_set.into_any().unbind())
}

/// Wrap the entire Qube under one or more new outer dimensions.
///
/// `dimension` is a ``dict[str, list]`` where each key becomes a new
/// dimension name and the corresponding list supplies its coordinate
/// values. Dimensions are applied in insertion order: the *last* entry
/// in the dict becomes the outermost dimension.
///
/// The operation mutates the Qube in place and does **not** return a new
/// object (unlike the Python-reference implementation which follows an
/// immutable style).
///
/// ```python
/// q = Qube.from_datacube({"param": "2t/tp", "time": "0/1/2"}, ["param", "time"])
/// q.expand({"ensemble": ["ens1", "ens2"]})
/// assert "ensemble" in q.dimensions()
/// assert "param" in q.dimensions()
/// assert "time" in q.dimensions()
/// ```
pub fn expand(&mut self, dimension: Bound<'_, PyDict>) -> PyResult<()> {
for (k, v) in dimension.iter() {
let key: String = k
.extract()
.map_err(|_| PyTypeError::new_err("expand: dimension keys must be strings"))?;

let coords = if v.is_instance_of::<PyList>() {
let lst =
v.downcast::<PyList>().map_err(|e| PyTypeError::new_err(e.to_string()))?;
let joined = join_pylist_as_path(lst)?;
Coordinates::from_string(&joined)
} else {
let py_str = v.str()?;
let s: String = py_str.extract()?;
Coordinates::from_string(&s)
};

self.inner.expand(&key, coords).map_err(PyTypeError::new_err)?;
}
Ok(())
}

/// Remove one or more dimensions from the Qube.
///
/// `axis` may be a single dimension name (``str``) or a list of names.
/// All children of the removed nodes are re-parented to their
/// grandparent, preserving the rest of the structure. The result is
/// automatically compressed.
///
/// Raises ``ValueError`` if any of the specified dimensions do not exist
/// in the Qube.
///
/// ```python
/// q = Qube.from_datacube(
/// {"param": "2t/tp", "time": "0/1/2", "level": "1000/850"},
/// ["param", "time", "level"],
/// )
/// q.collapse("level")
/// assert "level" not in q.dimensions()
/// assert "param" in q.dimensions()
/// ```
pub fn collapse(&mut self, axis: Bound<'_, PyAny>) -> PyResult<()> {
// Accept either a single string or a list of strings.
let axes: Vec<String> = if axis.is_instance_of::<PyList>() {
let lst = axis.downcast::<PyList>().map_err(|e| PyTypeError::new_err(e.to_string()))?;
lst.iter()
.map(|item| {
item.str()
.and_then(|s| s.extract::<String>())
.map_err(|_| PyTypeError::new_err("collapse: axis names must be strings"))
})
.collect::<PyResult<_>>()?
} else {
let s: String = axis
.extract()
.map_err(|_| PyTypeError::new_err("collapse: axis must be a str or list[str]"))?;
vec![s]
};

// Validate that every requested dimension actually exists.
let existing_dims = self.inner.dimensions();
let missing: Vec<&str> = axes
.iter()
.filter(|a| !existing_dims.contains(a.as_str()))
.map(String::as_str)
.collect();

if !missing.is_empty() {
return Err(PyValueError::new_err(format!(
"Dimension(s) '{}' not found in qube (existing: {:?})",
missing.join(", "),
{
let mut sorted: Vec<&str> = existing_dims.iter().map(String::as_str).collect();
sorted.sort();
sorted
}
)));
}

self.inner.drop(axes).map_err(PyTypeError::new_err)
}

/// Collapse then expand in a single operation.
///
/// Equivalent to calling `collapse(axis)` followed by `expand(dimension)`
/// on the same Qube. Useful for replacing one dimension with another
/// (e.g. replacing "level" with "step").
///
/// ```python
/// q = Qube.from_datacube(
/// {"param": "2t/tp", "time": "0/1/2"},
/// ["param", "time"],
/// )
/// q.coxpand("time", {"step": ["s1", "s2"]})
/// assert "time" not in q.dimensions()
/// assert "step" in q.dimensions()
/// ```
pub fn coxpand(
&mut self,
axis: Bound<'_, PyAny>,
dimension: Bound<'_, PyDict>,
) -> PyResult<()> {
self.collapse(axis)?;
self.expand(dimension)
}

/// Check whether the Qube contains a given dimension or set of values.
///
/// * **`str`** — returns ``True`` if the named dimension exists anywhere
/// in the Qube.
/// * **`dict[str, list]`** — returns ``True`` if every key in the dict is
/// a dimension that exists *and* every value listed for that key is
/// present in that dimension's coordinate set.
/// * **`Qube`** — checks that every dimension/value present in the other
/// Qube also exists in this one (subset check).
///
/// ```python
/// q = Qube.from_datacube({"param": "2t/tp", "time": "0/1/2"}, ["param", "time"])
///
/// assert q.contains("param") is True
/// assert q.contains("level") is False
/// assert q.contains({"param": ["2t"]}) is True
/// assert q.contains({"param": ["xyz"]}) is False
/// assert q.contains({"time": ["0", "1"]}) is True
/// ```
pub fn contains(&self, py: Python<'_>, item: Bound<'_, PyAny>) -> PyResult<bool> {
// Helper: convert a Coordinates value to a Vec<String> of individual values.
let coords_to_strings = |coords: &::qubed::Coordinates| -> Vec<String> {
let s = coords.to_string();
if s.is_empty() {
vec![]
} else if s.contains('/') {
s.split('/').map(|v| v.to_owned()).collect()
} else {
vec![s]
}
};

// Pre-compute current axes as Vec<String> per dimension.
let current_axes: std::collections::BTreeMap<String, Vec<String>> = self
.inner
.all_unique_dim_coords()
.into_iter()
.map(|(k, v)| (k, coords_to_strings(&v)))
.collect();

// --- branch 1: item is a plain string (dimension name check) ---
if let Ok(dim_str) = item.extract::<String>() {
return Ok(current_axes.contains_key(&dim_str));
}

// --- branch 2: item is a dict ---
if let Ok(dict) = item.downcast::<PyDict>() {
return helpers::check_dict_against_axes(dict, &current_axes, py);
}

// --- branch 3: item is a Qube ---
if let Ok(other_cell) = item.downcast::<PyQube>() {
let other_ref = other_cell.borrow();
let other_axes: std::collections::BTreeMap<String, Vec<String>> = other_ref
.inner
.all_unique_dim_coords()
.into_iter()
.map(|(k, v)| (k, coords_to_strings(&v)))
.collect();

for (key, other_vals) in &other_axes {
match current_axes.get(key) {
None => return Ok(false),
Some(cur_vals) => {
for oval in other_vals {
if !cur_vals.contains(oval) {
return Ok(false);
}
}
}
}
}
return Ok(true);
}

Err(PyTypeError::new_err("contains: item must be a str, dict[str, list], or Qube"))
}
}

fn pydict_to_datacube(datacube: Bound<'_, PyDict>) -> PyResult<Datacube> {
let mut dc = Datacube::new();
for (k, v) in datacube.iter() {
let key: String =
k.extract().map_err(|_| PyTypeError::new_err("datacube keys must be strings"))?;
let val_str: String = v.str()?.extract()?;
dc.add_coordinate(&key, Coordinates::from_string(&val_str));
}
Ok(dc)
}

pub(crate) fn join_pylist_as_path(lst: &Bound<'_, PyList>) -> PyResult<String> {
Expand Down
Loading