Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
134 changes: 132 additions & 2 deletions docs/src/python/py_qubed.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,139 @@ print(q)

---

### Query
### Structural utilities

These methods implement structural helpers to work with complex Qubes.

#### `axes() -> dict[str, list[str]]`

Return all dimension names and the union of their coordinate values across the
entire Qube. This is an alias for `all_unique_dim_coords()` with a name that
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()
# {'param': ['2t', 'tp'], 'time': ['0', '1', '2']}
assert set(ax["param"]) == {"2t", "tp"}
```

#### `dimensions() -> set[str]`

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"}
```

#### `common_dimensions() -> set[str]`

Return the set of dimension names that appear in **every** leaf path
(datacube). For a Qube with uniform depth this equals `dimensions()`. For
an irregular Qube where some branches are shallower, only the dimensions
shared by 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"} # "time" absent in branch 2
```

#### `expand(dimension: dict[str, list]) -> None`

Wrap the entire Qube tree under one or more new outer dimensions. Each key
in `dimension` becomes a new dimension name; the associated list supplies its
coordinate values.

Dimensions are applied in dict insertion order. The **last** entry in the
dict becomes the outermost dimension of the resulting tree. The operation
mutates the Qube in place.

```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()
# The new dimension wraps the original tree:
# root
# └── ensemble=ens1/ens2
# └── param=2t/tp
# └── time=0/1/2

# Multiple dimensions at once:
q.expand({"member": ["m1"], "batch": ["b1", "b2"]})
# "batch" ends up outermost since it was last in the dict.
```

#### `collapse(axis: str | list[str]) -> None`

Remove one or more dimensions from the Qube. `axis` may be a single
dimension name or a list of names. Children of 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.

```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()

# Remove multiple dimensions at once:
q.collapse(["time", "param"])
assert q.dimensions() == set()
```

#### `coxpand(axis: str | list[str], dimension: dict[str, list]) -> None`

Collapse one or more dimensions and then expand with new ones in a single
call. Equivalent to `collapse(axis)` followed by `expand(dimension)`.

Useful for replacing a dimension with a different one while preserving the
rest of the structure.

```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()
assert "param" in q.dimensions()
```

#### `contains(item: str | dict | Qube) -> bool`

Check whether the Qube contains a given dimension or set of values.

| `item` type | Meaning |
|---|---|
| `str` | Returns `True` if the named dimension exists anywhere in the Qube. |
| `dict[str, list]` | Returns `True` if every key exists as a dimension **and** every listed value is present in that dimension's coordinate set. |
| `Qube` | Returns `True` if every dimension+value from the other Qube is also present here (subset check). |

```python
q = Qube.from_datacube({"param": "2t/tp", "time": "0/1/2"}, ["param", "time"])

assert q.contains("param") # True
assert q.contains("level") # False

assert q.contains({"param": ["2t"]}) # True
assert q.contains({"param": ["xyz"]}) # False
assert q.contains({"param": ["2t"], "time": ["0"]}) # True
assert q.contains({"time": ["0", "999"]}) # False – 999 absent

subset = Qube.from_datacube({"param": "2t", "time": "0"}, ["param", "time"])
assert q.contains(subset) # True
```

---


#### `all_unique_dim_coords() -> dict[str, list[str]]`

Return a dictionary mapping each dimension name to a list of all coordinate values that appear anywhere in the Qube.

Expand Down
43 changes: 42 additions & 1 deletion docs/src/rust/qubed.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ let q = Qube::from_json(json!({
| `append_datacube` | `fn append_datacube(&mut self, dc: Datacube, order: Option<&[String]>, accept_existing_order: bool)` | Append a single Datacube |
| `drop` | `fn drop<I>(&mut self, to_drop: I) -> Result<(), String>` | Remove one or more dimensions, re-parenting their children, then compress |
| `squeeze` | `fn squeeze(&mut self) -> Result<(), String>` | Drop every dimension whose union of values has length 1 |
| `expand` | `fn expand(&mut self, key: &str, values: Coordinates) -> Result<(), String>` | Wrap the entire tree under a new outer dimension |

**Example — building programmatically:**
```rust
Expand Down Expand Up @@ -116,6 +117,44 @@ q.squeeze().unwrap();
// class=1 is the only value for that dimension, so it is dropped
```

**Example — expand:**
```rust
use qubed::{Qube, Coordinates};

let mut q = Qube::from_ascii("root\n└── param=2t/tp\n └── time=0/1/2").unwrap();
q.expand("ensemble", Coordinates::from_string("ens1/ens2")).unwrap();

// Tree is now:
// root
// └── ensemble=ens1/ens2
// └── param=2t/tp
// └── time=0/1/2

let dims = q.dimensions();
assert!(dims.contains("ensemble"));
assert!(dims.contains("param"));
assert!(dims.contains("time"));
```

**Example — common_dimensions:**
```rust
use qubed::{Qube, Datacube, Coordinates};

let mut dc1 = Datacube::new();
dc1.add_coordinate("param", Coordinates::from_string("2t/tp"));
dc1.add_coordinate("time", Coordinates::from_string("0/1/2"));
let mut q = Qube::from_datacube(&dc1, Some(&["param".to_string(), "time".to_string()]));

let mut dc2 = Datacube::new();
dc2.add_coordinate("param", Coordinates::from_string("msl"));
let mut other = Qube::from_datacube(&dc2, None);
q.append(&mut other);

let common = q.common_dimensions();
assert!(common.contains("param"));
assert!(!common.contains("time")); // "time" absent in the second branch
```

### Compression

```rust
Expand Down Expand Up @@ -169,7 +208,9 @@ Remove branches that don't contain **all** of the specified dimensions.
| `to_datacubes` | `fn to_datacubes(&self) -> Vec<Datacube>` | Decompose into leaf-path datacubes |
| `datacube_count` | `fn datacube_count(&self) -> usize` | Count leaf identifiers without expansion |
| `is_empty` | `fn is_empty(&self) -> bool` | True if root has no children and no coordinates |
| `all_unique_dim_coords` | `fn all_unique_dim_coords(&mut self) -> BTreeMap<String, Coordinates>` | Union of all coordinates per dimension |
| `all_unique_dim_coords` | `fn all_unique_dim_coords(&self) -> BTreeMap<String, Coordinates>` | Union of all coordinates per dimension |
| `dimensions` | `fn dimensions(&self) -> HashSet<String>` | Set of all dimension names present in the Qube |
| `common_dimensions` | `fn common_dimensions(&self) -> HashSet<String>` | Dimension names present in **every** leaf path |
| `root` | `fn root(&self) -> NodeIdx` | Root node index |
| `node` | `fn node(&self, id: NodeIdx) -> Option<NodeRef>` | Read-only reference to a node |
| `dimension` | `fn dimension(&self, s: &str) -> Option<Dimension>` | Look up dimension by name |
Expand Down
43 changes: 43 additions & 0 deletions py_qubed/src/helpers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//! Internal helpers used by the PyO3 bindings.
//!
//! Nothing in this module is exposed to Python; the functions here exist solely
//! to keep `lib.rs` readable by factoring out auxiliary logic.

use pyo3::exceptions::PyTypeError;
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList};

/// Check that every key→[values] entry in `dict` is satisfied by `axes`.
///
/// Returns `Ok(true)` when every dimension key in `dict` is present in `axes`
/// and every queried value for that dimension is found in `axes`'s value list.
/// Returns `Ok(false)` on the first mismatch.
pub(crate) fn check_dict_against_axes(
dict: &Bound<'_, PyDict>,
axes: &std::collections::BTreeMap<String, Vec<String>>,
_py: Python<'_>,
) -> PyResult<bool> {
for (k, v) in dict.iter() {
let key: String =
k.extract().map_err(|_| PyTypeError::new_err("contains: dict keys must be strings"))?;

let query_vals: Vec<String> = if v.is_instance_of::<PyList>() {
let lst = v.downcast::<PyList>().map_err(|e| PyTypeError::new_err(e.to_string()))?;
lst.iter().map(|it| Ok(it.str()?.extract::<String>()?)).collect::<PyResult<_>>()?
} else {
vec![v.str()?.extract::<String>()?]
};

match axes.get(&key) {
None => return Ok(false),
Some(cur_vals) => {
for qval in &query_vals {
if !cur_vals.contains(qval) {
return Ok(false);
}
}
}
}
}
Ok(true)
}
Loading
Loading