Describing your data¶
Collections tell Nexus what data exists. Cases tell it how that data should be processed.
A simulation project often contains many logical simulations inside one collection. Rather than processing every file individually, recipes work on cases — the units of work that will be transformed together.
This page builds in order: group files into cases, understand roles / modalities and companions, browse admitted cases and assets, then tag items and select which cases run. Running recipes is in the next section.
Case grouping¶
Case grouping turns a collection tree into cases — the work units recipes process together. Path scope belongs here — not in selection.
Start from where the files for one simulation live.
| Your data looks like… | Start with |
|---|---|
| One folder contains one case | CaseStructure.folder |
| Files for one case are spread across several directory trees or include shared resources | CaseStructure.compose |
| Cases are linked by values stored inside CSV, HDF5, or similar content | CaseGrouping.content_join |
Use explicit ResourceMap, Schema, and CaseGrouping.role_based when layout validation and case composition need to be configured independently. For grouping-only shortcuts when folder boundaries or path templates are enough, see the conveniences below.
Grouping can be admitted either while ingesting or
against files already in the collection. Like the
schema plan gate,
case grouping is validated before you get a stable pin: pass case_grouping= to
collection.plan(...) / upload(...), or call
collection.case_grouping.admit(...) when membership already exists. On success
you get AdmittedCases
over a full structured membership_commitment, not a bare id. Use
plan.validation_gates for per-gate status on the ingestion path. If the schema
gate blocks a plan, grouping is skipped upstream.
Existing collection data vs ingest¶
Same CaseGrouping and the same cases= binding; only the candidate origin
differs (frozen membership vs incoming datasource).
from miura.nexus.case_grouping import CaseGrouping
grouping = CaseGrouping.root_path("/", depth=1)
# Files already in the collection — no datasource or ingestion plan
cases = await collection.case_grouping.admit(
grouping,
group_name="data",
schema_extras="include", # "include" | "ignore"
)
recipe = await project.deploy_recipe(
"process-cases",
recipe_spec,
cases=cases,
)
During ingest, admit is a plan/upload side effect:
from miura.nexus.case_grouping import CaseGrouping
from miura.sdk import LocalDataSource, UploadMode
grouping = CaseGrouping.root_path("/", depth=1)
upload = await collection.upload(
LocalDataSource("/data/simulations"),
mode=UploadMode.REPLACE,
case_grouping=grouping,
)
recipe = await project.deploy_recipe(
"process-cases",
recipe_spec,
cases=upload.cases,
)
Plan-first (admit at dry-run, before bytes move):
plan = await collection.plan(
LocalDataSource("/data/simulations"),
mode=UploadMode.REPLACE,
case_grouping=grouping,
)
cases = plan.cases
upload = await plan.apply()
Sync collections expose the same collection.case_grouping.admit(...) /
.probe(...) methods (blocking). Connector plan(..., case_grouping=) is
unchanged and still unsupported.
Runnable walkthrough: existing_collection_case_grouping.py.
Folder-shaped cases¶
When one repeated folder is one case and its named files are the case members, author schema and grouping together:
simulations/
├── sim_000001/
│ ├── mesh.stl
│ └── field.xdmf
└── sim_000002/
├── mesh.stl
└── field.xdmf
from miura.sdk import CaseStructure, File, Number, capture, one_or_more
case_id = capture("case_id", Number(min_digits=6, max_digits=6))
structure = CaseStructure.folder(
path="sim_" + case_id,
members={
"mesh": File("mesh.stl"),
"result_field": File("field.xdmf"),
},
)
schema = structure.schema(count=one_or_more())
case_grouping = structure.case_grouping()
For processing-specific admission rules, pass a
ValidationProfile
when projecting the grouping — e.g.
structure.case_grouping(validation_profile=profile) or
grouping.with_validation_profile(profile). Profiles stay on CaseGrouping, not
on the physical folder / compose definition. Runnable:
case_set.py.
Reach for multi-root cases when one case draws its files from
separate directory trees or shared resources. Use
explicit grouping controls when layout validation
and case composition must be configured independently. Content joins stay on
CaseGrouping.content_join.
Multi-root cases¶
When members live under several top-level folders and join on a shared id,
author schema and grouping together with CaseStructure.compose. Paths, the
join key, and required/optional role policy are authored once — compose
projects both schema and case_grouping without a separate composition rule:
BRep/
├── wing_a.stp
└── wing_b.stp
SurfaceMesh/
├── wing_a.stl
└── wing_b.stl
VolumeMesh/
├── wing_a.vtk
└── wing_b.vtk
from miura.nexus.case_grouping import optional, required
from miura.sdk import CaseStructure, File, Folder, Ref, ResourceMap
design_id = Ref("design_id")
resources = ResourceMap(
components={"design_id": design_id},
resources={
"cad_brep": File("BRep/" + design_id + ".stp"),
"surface_mesh": File("SurfaceMesh/" + design_id + ".stl"),
"volume_mesh": File("VolumeMesh/" + design_id + ".vtk"),
},
)
structure = CaseStructure.compose(
resources=resources,
roots=[Folder("BRep"), Folder("SurfaceMesh"), Folder("VolumeMesh")],
key="design_id",
roles=[
required("cad_brep"),
optional("surface_mesh"),
required("volume_mesh"),
],
)
schema = structure.schema()
case_grouping = structure.case_grouping()
Omit count= on structure.schema() — each compose root carries its own
occurrence. The default projection keeps shallow compose roots only (enough
for layout validation). For local upload filtering that drops undeclared paths
(plan.ignored_extras), project with
structure.schema(ignore_extras=True): ResourceMap paths nest under each
compose root, path-template placeholders compile as segment wildcards, and
Folder(...) bundle resources get permissive children: "any" subtrees.
CSV/content evidence stays on CaseGrouping.content_join —
not on compose. For fully independent Schema and grouping authoring, see
explicit grouping controls. For coordinates or
mixed shorthand/MemberRole lists without CaseStructure, see
Advanced case grouping.
When you need MemberRole¶
required(...) / optional(...) (or a plain string role name) cover the common
path. Pass a full MemberRole
when the role name differs from the resource name, when a member is shared across
cases (shareability="many_case_groups"), or when a role joins on only part of
the case key:
from miura.nexus.case_grouping import MemberRole
from miura.sdk import Cardinality
roles = [
MemberRole(
"cad_geometry",
"cad_brep",
"design_id",
Cardinality.exactly(1),
shareability="many_case_groups",
),
required("volume_mesh"),
]
Content-linked cases¶
When the join key is a column inside a table (not only a path segment), use
content join with a CSV selector. Content evidence is a CaseGrouping concern —
CaseStructure.compose does not accept selectors.
from miura.nexus.case_grouping import CaseGrouping
from miura.nexus.case_grouping.advanced import CsvRowSelector
from miura.sdk import File, Ref, ResourceMap
from miura.sdk.schema import AnyString
design_id = Ref("design_id")
resources = ResourceMap(
components={"design_id": AnyString()},
resources={
"mesh": File("meshes/" + design_id + ".stl"),
"parameters": File("tables/design_params.csv"),
},
)
case_grouping = CaseGrouping.content_join(
name="design_csv_join_v1",
resources=resources,
key="design_id",
join_key="design_id",
evidence=[
CsvRowSelector(resource_ref="parameters", key_column="design_id"),
],
roles=["mesh", "parameters"],
)
| Piece | Role |
|---|---|
key= / join_key= |
Shared id in the path and in the table column |
evidence= |
How to read that id from content (here a CSV column via CsvRowSelector) |
roles= |
Which ResourceMap resources belong in each design case |
Rows in design_params.csv join to mesh files that share the same design_id.
For semantic coordinates or full MemberRoles on a content join, see Advanced case grouping.
Explicit grouping controls¶
When layout validation and case composition need to be configured independently,
author a shared ResourceMap, bind it into
Schema with Schema.from_resources(...), and
compose cases with CaseGrouping.role_based. Authoring uses the same pattern
vocabulary as Schema: components,
Ref,
path templates, and
Cardinality.
Each
MemberRole
binds a resource into a case.
Example tree (one OpenFOAM-style case folder per work unit):
case_000001/
├── system/
│ └── controlDict
└── 0/
├── U
└── p
case_000002/
├── system/
│ └── controlDict
└── 0/
├── U
└── p
from miura.nexus.case_grouping import CaseGrouping, required
from miura.sdk import File, Folder, Number, Ref, ResourceMap, Schema, one_or_more
case_id = Ref("case_id")
resources = ResourceMap(
components={"case_id": Number(min_digits=6, max_digits=6)},
resources={
"control_dict": File("case_" + case_id + "/system/controlDict"),
"velocity": File("case_" + case_id + "/0/U"),
"pressure": File("case_" + case_id + "/0/p"),
},
)
schema = Schema.from_resources(
resources,
roots=[Folder("case_" + case_id, count=one_or_more())],
)
case_grouping = CaseGrouping.role_based(
name="openfoam_case_roles_v1",
resources=resources,
key="case_id",
roles=["control_dict", "velocity", "pressure"],
)
| Piece | Role |
|---|---|
name= |
Spec id for this grouping (versioned label you pass to plan / CaseSet) |
resources= |
Path templates (resources) and reusable path fragments (components / Ref) |
key= |
Path component that ties member files into one case |
roles= |
What each case must include: string means one required file with that resource name; optional / one_or_more adjust cardinality |
Those OpenFOAM names illustrate free-form roles for grouping only. Published logical assets with a modality_id require a separate publication step (not authored on MemberRole). Prefer folder-shaped or multi-root CaseStructure when schema and grouping should be authored together.
For semantic ids, renamed roles, selectors, or a custom admission rule, see Advanced case grouping.
Grouping-only conveniences¶
Use these when you only need case boundaries or a compact path-template join — without authoring schema and grouping together.
Folder boundaries only — root_path¶
Each immediate child of a walk root is one case — no role names required:
from miura.nexus.case_grouping import CaseGrouping
case_grouping = CaseGrouping.root_path("/runs", depth=1)
Use this when the folder already is the case and you do not need named member roles. Prefer folder-shaped cases when you need named logical assets or companions from one folder per case.
Path-template join — join_by_path_id¶
Extract a shared id from path templates and join files that share it. Useful
when Schema is authored independently (or kept permissive) and you only need a
compact path join — without a shared ResourceMap lifecycle:
# id in folder names # id in file names
sim_001/input.dat input/case_001.dat
sim_001/mesh.vtk mesh/case_001.vtk
sim_002/input.dat input/case_002.dat
sim_002/mesh.vtk mesh/case_002.vtk
from miura.nexus.case_grouping import CaseGrouping
case_grouping = CaseGrouping.join_by_path_id(
"case_id",
{
"input": "sim_{case_id}/input.dat",
"mesh": "sim_{case_id}/mesh.vtk",
},
require=["input", "mesh"],
)
# Same idea when files are split by modality directory:
# CaseGrouping.join_by_path_id(
# "case_id",
# {
# "input": "input/case_{case_id}.dat",
# "mesh": "mesh/case_{case_id}.vtk",
# },
# require=["input", "mesh"],
# )
Pattern keys become role names. require=[...] lists which roles must be present for a case to admit. Published modalities require names from the Asset Role table — other names group cases but do not attach a modality_id. Prefer multi-root cases when schema and grouping should be authored together over several directory trees.
Logical assets¶
Inside a case, files fill the member roles you just declared. After projection, each role from the Asset Role table becomes a logical asset with two different ids:
| Configurable by you? | What it is | |
|---|---|---|
Role (MemberRole name) |
Yes — any string at admission | How this case uses a file (control_dict, velocity, cad_geometry, …) |
Modality (modality_id) |
No — product vocabulary | What kind of asset it is for the platform (mesh, field, parameters, …) |
Never treat role and modality as the same string. A case lists roles on member_roles; each published logical asset exposes both role and modality_id.
Asset modality¶
Active modality ids come from the Collection Service logical-asset vocabulary (not from Epico):
modality_id |
Meaning |
|---|---|
mesh |
Surface / volume mesh (also used for CAD-like geometry roles such as cad_geometry) |
field |
Simulation field / solution payload |
parameters |
Scalars, KPIs, coefficients, tabular parameters |
case_input_bundle |
Compatibility bundle for some root_path projections |
Asset Role¶
A role name is the label you give a file inside a case (mesh, boundary_solution, …).
- To group cases — any name works (multi-root, content-linked, explicit controls, or grouping-only conveniences).
- To publish a logical asset with a
modality_id— the role name must appear in this table. That is how a role becomes a modality; Epico does not invent another mapping.
modality_id |
Role names that publish as that modality |
|---|---|
mesh |
mesh, surface_mesh, volume_mesh, cad_geometry, cad_brep |
field |
field_payload, solution, result_field, boundary_solution, result_file, pressure_field, cfd_volume, main_input, volume_solution, field_time_series |
parameters |
force_moment_coeffs, force_kpis, geo_values, ref_values, bracket_scalar, parameter_row |
Names outside the table (for example OpenFOAM control_dict / velocity / pressure) still admit cases and support selection, but they do not get a modality_id.
Companions¶
A companion is another logical asset in the same case that the primary one needs — typically a field that depends on a mesh.
Model that in two places: put both assets in the case, then declare the dependency on the recipe.
Layout (one case folder, two roles from the Asset Role table):
case_000/
├── case_000.vtk # role boundary_solution → modality field
└── case_000_mesh.vtk # role mesh → modality mesh
from miura.nexus.case_grouping import CaseGrouping
case_grouping = CaseGrouping.join_by_path_id(
"case_id",
{
"boundary_solution": "{case_id}/{case_id}.vtk",
"mesh": "{case_id}/{case_id}_mesh.vtk",
},
require=["boundary_solution", "mesh"],
)
join_by_path_id is a grouping-only convenience for this two-role folder. Prefer folder-shaped cases when the layout is one folder per case and you want schema and grouping authored together.
require=[...] means a case only admits when both roles are present. Both names must be in the Asset Role table so each side publishes with a modality_id.
require=[...] is what guarantees the companion: a case only admits when both
roles are present, so every case the recipe runs already has its mesh. Keep the
companion story here in grouping. Most recipes need nothing more — the converter
works from the staged case files. To bind the run by primary and companion
modalities on the recipe itself, see
Companions in Transforming data.
Required companions must resolve for the primary asset to be complete; optional companions are included only when requested.
Browse admitted cases¶
Only after grouping has been admitted should you open a
CaseSet with the admitted cases
(collection.case_grouping.admit(...), plan.cases / upload.cases, or
collection.admitted_cases(collection.resolve_case_grouping_result(stored_id))
when you only kept the id). Bare result_id strings and probe results are not
accepted. Pass those same cases when you deploy_recipe(..., cases=…) or
recipe.update(cases=…). Materialize the asset projection once, then list cases
and logical assets:
cases = plan.cases # or upload.cases / await collection.case_grouping.admit(grouping)
case_set = collection.case_set(cases)
try:
case_set.get_projection()
except Exception:
case_set.materialize_projection()
cases = case_set.list_cases(limit=50)
assets = case_set.list_logical_assets(limit=50)
For the VTK companion layout, expect two cases (case_000, case_001) and
logical assets with roles boundary_solution / mesh published as modalities
field / mesh.
Full runnable: case_set.py. See also Case discovery and Cases.
Attach metadata¶
Item attributes attach semantic meaning to paths without changing file bytes. You will use the same keys in selection to decide which cases a recipe admits.
| Field | Purpose | Notes |
|---|---|---|
split |
Dataset partition | One of train, val, test, holdout, unassigned |
labels |
Key/value tags | Free-form strings (e.g. solver, stage) |
annotations |
Extra notes | Free-form key/value (not used by case selection today) |
Tag one item¶
Prefer the item helper when you already have an item handle:
item = collection.get_item("/case_000001/0/U")
item.set_attributes(
split="train",
labels={"solver": "simpleFoam", "stage": "baseline"},
annotations={"note": "baseline field"},
)
print(collection.get_item_attributes(item.uuid))
{'split': 'train', 'labels': {'solver': 'simpleFoam', 'stage': 'baseline'}, 'annotations': {'note': 'baseline field'}}
Or write through the collection with an ItemAttributes row:
from miura.api.annotations import ItemAttributes
collection.set_item_attributes(
ItemAttributes(
item_id=item.uuid,
split="train",
labels={"solver": "simpleFoam", "stage": "baseline"},
)
)
Tag many items¶
Pass a list of ItemAttributes for a batch update:
from miura.api.annotations import ItemAttributes
rows = [
ItemAttributes(
item_id=files[0].uuid,
split="train",
labels={"solver": "simpleFoam", "stage": "baseline"},
),
ItemAttributes(
item_id=files[1].uuid,
split="val",
labels={"solver": "simpleFoam", "stage": "candidate"},
),
]
result = collection.set_item_attributes(rows)
print(
f"Updated {result.get('success_count', 0)} of "
f"{result.get('total_count', len(rows))} items"
)
Read and clear attributes¶
attrs = collection.get_item_attributes(item.uuid)
# Drop one field group (e.g. free-form annotations only)
collection.clear_item_attributes(item.uuid, keys=["annotations"])
Filter items by attributes¶
ItemSelect is conjunctive: every predicate must match. Use it with list_items, iter_items, and count_items:
from miura.api.annotations import ItemSelect
train_baseline = collection.list_items(
include_dirs=False,
select=ItemSelect(
split="train",
labels={"solver": "simpleFoam", "stage": "baseline"},
),
)
train_count = collection.count_items(select=ItemSelect(split="train"))
print(f"Matched files: {len(train_baseline)}")
print(f"Train files: {train_count}")
Full runnable: annotations.py.
Selection¶
CaseSelection narrows which cases a recipe admits, using the item metadata you just attached. Labels and split belong here; path scope stays in case grouping. See also Recipe.
Build a conjunctive predicate (every clause must hold):
from miura.sdk.recipe import CaseSelection
selection = CaseSelection.where(
split="train",
labels={"solver": "simpleFoam"},
)
Pass selection into the recipe spec; pin admitted cases at deploy — selection alone does nothing until a run uses the deployed recipe:
from miura.sdk import RecipeSpec
spec = RecipeSpec.from_sigma_converter(
source="source-vtk",
target="target-xdmf",
converter=converter,
case_selection=selection,
)
recipe = project.deploy_recipe(
"vtk-to-xdmf",
spec,
description="Convert VTK meshes to XDMF",
cases=plan.cases,
)
run = recipe.run() # inputs="pinned" by default
run = run.wait()
print(run.id, run.state)
Primary path: deploy_recipe(..., cases=…) → recipe.run() → run.wait(). For a typed rebind on an existing handle, use recipe.update(cases=…). Explicit first-only creation uses create_recipe (conflicts if the name exists). Strict create-or-verify uses get_or_create_recipe (raises RecipeSpecConflictError on mismatch). Use the same label keys you set with ItemAttributes / item.set_attributes (for example solver, stage). See Transforming data and recipe_lifecycle.py.