Skip to content

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:

runs/
├── case_001/
│   ├── input.dat
│   └── mesh.vtk
└── case_002/
    ├── input.dat
    └── mesh.vtk
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, …).

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"
)
Updated 2 of 2 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}")
Matched files: 1
Train files: 4

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)
run_demo1234567890abcdef PENDING

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.