Data Access Architecture
This page describes the current XGC-Analysis data-access layout and the catalog/read-plan path that is being introduced to make readers independent of physical filenames. It is intended as an orientation page for developers. The API reference pages generated from docstrings remain the authoritative source for individual classes and functions.
Current implementation status
XGC-Analysis currently has a catalog/read-plan framework with two separable backend responsibilities:
Catalog discovery backends answer which products, variables, and logical steps exist. The directory backend lives in
xgc_analysis.catalog.directory_catalog; the campaign backend lives inxgc_analysis.catalog.campaign_catalog.Source-reader backends read selected variables and ADIOS steps from one physical source. Catalog reads use the FileReader-backed
xgc_analysis.adios_file_reader.AdiosFileSourceReaderinterface. Directory catalogs open and close local BP sources per batched read; campaign catalogs keep the ACA FileReader open and reuse it for read-plan execution.
Everything above these two backend layers uses common catalog objects:
xgc_analysis.catalog.DataProduct,
xgc_analysis.catalog.SourceInfo,
xgc_analysis.catalog.StepInfo, and
xgc_analysis.catalog.ReadPlan. Product readers should only depend on
those common objects and on raw values returned by a source reader; they should
not care whether the source is a local BP directory or an HPC-Campaign file.
Most runtime readers for field, moment, source, neutral, heat, one-dimensional,
sheath, and distribution-function products now use this catalog/read-plan path.
The standalone read_bp_file helper remains available for low-level scripts
and for readers that have not yet been converted. The main remaining package
reader in that category is diffusion_data.py, whose products have
workflow-specific logical structure that still needs a separate catalog
integration pass.
Simulation is a client of the reading framework. It owns shared
interpretation objects such as xgc_analysis.mesh.Mesh,
xgc_analysis.magnetic_field.MagneticField, and
xgc_analysis.velocity_grid.VelocityGrid, but it should not define the
backend mechanics.
Catalog discovery
The catalog is constructed with:
from xgc_analysis.catalog import open_catalog, open_campaign_catalog
directory_catalog = open_catalog("/path/to/xgc/output")
campaign_catalog = open_campaign_catalog("/path/to/xgc/output/campaign.aca")
The directory backend scans the dataset root for entries ending in .bp. It
groups physical files into logical XGC products. For example:
xgc.3d.00010.bp
xgc.3d.00012.bp
become one product keyed as:
xgc.3d.bp
During this scan the backend first builds an internal grouping equivalent to:
grouped = {
"xgc.3d.bp": [
(10, Path("xgc.3d.00010.bp")),
(12, Path("xgc.3d.00012.bp")),
],
"xgc.oneddiag.bp": [
(None, Path("xgc.oneddiag.bp")),
],
}
For each physical source it creates a
xgc_analysis.catalog.SourceInfo object:
SourceInfo(
source_id="xgc.3d.00010.bp",
path=Path(".../xgc.3d.00010.bp"),
file_index=10,
mtime=...,
variables={
"pot": VariableInfo(...),
"time": VariableInfo(...),
},
step_values=[...],
time_values=[...],
adios_step_count=...,
)
step_values are read from the first available logical-step variable in this
order:
gstepsteptimestep
time_values are read from time when available. If metadata collection
is disabled or a source cannot be opened, the catalog still records the source
path and filename-derived information so the dataset can be inspected at a
coarser level.
The source records are then grouped into a
xgc_analysis.catalog.DataProduct:
DataProduct(
key="xgc.3d.bp",
label="xgc.3d.bp",
product_type=ProductType.FIELD_3D,
layout=ProductLayout.FILE_SEQUENCE,
sources=[SourceInfo(...), SourceInfo(...)],
variables={
"pot": VariableInfo(...),
"time": VariableInfo(...),
},
product_family="field",
)
The final catalog stores products in:
catalog.products: dict[str, DataProduct]
with product keys such as "xgc.3d.bp", "xgc.2d.bp", and
"xgc.oneddiag.bp".
The campaign backend starts from campaign-qualified ADIOS variable names such as:
xgc.f2d.00010.bp/e_den
xgc.diffusion_coeff.bp/gstep
It strips the source prefix when building product metadata, so readers still
see variables as "e_den" or "gstep". The prefix remains in
SourceInfo.source_id so the campaign source reader can reconstruct the
qualified ADIOS name at read time.
Logical steps
The catalog exposes the logical steps available for a product and optionally for a single variable:
steps = catalog.available_steps("xgc.3d.bp", "pot")
Internally this creates candidate source fragments:
candidates: dict[int, list[StepFragment]]
where the dictionary key is the logical XGC step. Each
xgc_analysis.catalog.StepFragment maps one logical step to one
physical source and ADIOS step:
StepFragment(
source_id="xgc.3d.00010.bp",
source_path=Path(".../xgc.3d.00010.bp"),
logical_step=10,
adios_step=0,
file_index=10,
time=...,
)
Logical step assignment follows this policy:
Use explicit
gstep,step, ortimestepvalues when available.Otherwise, for file-sequence products, use
file_index + adios_step.Otherwise, use the ADIOS step ordinal.
Several physical fragments can advertise the same logical step. The catalog
selects one fragment using a newer-source-wins policy based primarily on source
modification time. The returned data structure is a list of
xgc_analysis.catalog.StepInfo objects:
StepInfo(
logical_step=10,
selected_fragment=StepFragment(...),
duplicate_fragments=[StepFragment(...), ...],
dedup_policy="newer_source_wins",
)
Read planning
A read plan resolves a user request in logical XGC steps into the physical source fragments that must be opened:
plan = catalog.plan_read(
"xgc.3d.bp",
"pot",
[10, 11],
missing="raise",
)
The catalog first builds:
step_map: dict[int, StepInfo]
Then it groups selected fragments by source:
grouped = {
("xgc.3d.bp", Path(".../xgc.3d.bp")): [
(10, 0),
(11, 1),
],
}
The returned xgc_analysis.catalog.ReadPlan contains one or more
xgc_analysis.catalog.ReadFragment objects:
ReadPlan(
product_key="xgc.3d.bp",
variable="pot",
requested_steps=[10, 11],
fragments=[
ReadFragment(
source_id="xgc.3d.bp",
source_path=Path(".../xgc.3d.bp"),
variable="pot",
logical_steps=[10, 11],
adios_steps=[0, 1],
),
],
missing_steps=[],
missing_policy=MissingStepPolicy.RAISE,
)
Missing logical steps are handled during planning:
missing="raise"raises immediately if a requested step is unavailable.missing="skip"omits unavailable steps from the fragments and records them inplan.missing_steps.missing="zero"also records unavailable steps inplan.missing_steps. The executor does not synthesize zero arrays because only the product reader knows the correct wrapped type, shape, and dtype.
Read-plan execution
The executor reads one or more plans through a source-reader backend:
from xgc_analysis.catalog import execute_read_plan, execute_read_plans
execution = execute_read_plan(plan)
batched_execution = execute_read_plans([plan_for_eden, plan_for_time])
For regular local BP files, the default backend is
xgc_analysis.catalog.read_regular_bp_steps(), which now delegates to
adios2.FileReader and reads exact ADIOS steps with step_selection. The
batched executor groups all fragments by physical source, so each local BP
source is opened once for the union of requested variables and ADIOS steps in
that batch.
Campaign catalogs carry a default catalog.source_reader backed by the same
FileReader source-reader class. Campaign discovery keeps an ACA FileReader
open for metadata refreshes, but data reads use a fresh ACA FileReader per
batched read by default because remote campaign handles can become unusable
after earlier reads. The source-reader result has the same shape for both
backends:
{
adios_step: {
"variable_name": value,
},
}
This is the main backend boundary.
Static product reads
Static support products are accessed with a thinner helper because they do not need a user-facing logical step range:
from xgc_analysis.catalog import read_static_variables
values = read_static_variables(
catalog,
"xgc.f0.mesh.bp",
["f0_nmu", "f0_nvp", "f0_dsmu", "f0_dvp"],
)
Internally this still builds ordinary one-step read plans at logical step 0
and executes them through xgc_analysis.catalog.execute_read_plans(), so
directory and campaign reads use the same source-reader boundary. Static
classes now require the requested products to be present in the catalog. Direct
hard-coded local BP fallbacks are disabled so missing catalog metadata fails
early and visibly.
Simulation is the preferred owner for shared static interpretation state.
When a catalog is supplied, Simulation passes it into
xgc_analysis.mesh.Mesh,
xgc_analysis.magnetic_field.MagneticField, and
xgc_analysis.velocity_grid.VelocityGrid. The velocity grid is then
available as simulation.velocity_grid and should be reused by
distribution-function workflows instead of being rediscovered by each reader.
Raw catalog reads
The low-level raw reader for finite source reads is
xgc_analysis.adios_file_reader.read_adios_file_steps(). It returns plain values
keyed by ADIOS step and variable name, and is the implementation used by the
catalog source-reader classes.
The catalog convenience method SimulationCatalog.read_arrays reads
advertised catalog variables directly into NumPy arrays without constructing
Simulation or a product-specific reader. This supports lightweight GUI and
scripting workflows that only need to inspect data values, dimensions, metadata,
or quick plots. The helper sits below product-specific wrapping and uses the
same read-plan/source-reader backend boundary:
arrays = catalog.read_arrays(
product_key="xgc.2d.bp",
variables=["eden", "time"],
steps=[10, 20],
)
The returned xgc_analysis.catalog.CatalogArrayRead contains
arrays[var][logical_step], raw read-plan records for source/step
provenance, and missing_steps_by_variable. Specialized readers continue to
return higher-level objects such as PlaneData, MeshData, and
DistributionFunctionField.
The low-level source read result is step-first because it mirrors physical ADIOS iteration:
source_data = {
0: {
"eden": np.ndarray(...),
"time": np.ndarray(...),
},
1: {
"eden": np.ndarray(...),
"time": np.ndarray(...),
},
}
This structure is local to source reading. It is not the final reader storage layout.
The executor converts the source data into one
xgc_analysis.catalog.ReadPlanRecord per variable and logical step:
ReadPlanRecord(
logical_step=10,
source_id="xgc.3d.bp",
source_path=Path(".../xgc.3d.bp"),
adios_step=0,
variable="pot",
value=np.ndarray(...),
)
The final xgc_analysis.catalog.ReadPlanExecution contains:
ReadPlanExecution(
plan=plan,
records=[ReadPlanRecord(...), ...],
missing_steps=plan.missing_steps,
)
Convenience views are available:
execution.records_by_logical_step()
execution.values_by_logical_step()
execution.records_in_requested_order()
Existing reader storage
Existing reader classes store loaded values in a variable-first dictionary:
reader.data[var_name][step_index] = value
For field-like products, value may be a
xgc_analysis.plane_data.PlaneData,
xgc_analysis.mesh_data.MeshData, scalar value, or NumPy array.
Distribution-function products additionally use
xgc_analysis.distribution_function_data.DistributionFunctionField,
which depends on both the mesh and simulation.velocity_grid for physical
interpretation.
For example:
field.data["eden"][0] = PlaneData(...)
field.data["time"][0] = 1.234
Reader-local step metadata are stored separately:
field.step_index_info[0] = {
"file_index": 10,
"bp_step": 0,
}
The disabled legacy direct-file path used to perform these steps:
Initialize
BPReaderMixinstate:self.requested_vars self.read_all_steps self.step_index_info = {} self._requested_var_templates = {} self.data = {}
Construct filenames such as
xgc.2d.00000.bporxgc.3d.00000.bp.Open each BP source directly through a reader-local helper.
Receive physical source data:
file_data: dict[adios_step, dict[var_name, value]]
Register each loaded ADIOS step:
step_index = self._register_step(file_index, bp_step)
Wrap arrays as product-specific objects.
Store the wrapped values in:
self.data[var_name][step_index] = wrapped_value
This path is retained only as historical context. Converted readers now raise
clear errors from private direct-file helpers and read through catalog
ReadPlan execution instead.
The catalog path preserves the same final reader storage layout:
ReadPlan
-> execute_read_plan(plan)
-> ReadPlanExecution.records
-> reader.data[var_name][step_index]
The corresponding step metadata can then include both logical and physical coordinates:
reader.step_index_info[step_index] = {
"logical_step": record.logical_step,
"source_id": record.source_id,
"source_path": record.source_path,
"bp_step": record.adios_step,
}
Distribution-function reader ownership follows the same principle. The preferred construction style is:
f0 = DistributionFunctionData(
simulation=simulation,
steps=[2000],
variables=["ion_f", "time"],
)
The reader inherits simulation.mesh, simulation.velocity_grid, and
simulation.catalog unless explicit overrides are supplied. Legacy
file_indices are still accepted as a selector and are interpreted as logical
steps when steps is omitted. This keeps velocity-grid interpretation
centralized in Simulation while still allowing tests and specialized
workflows to pass separate objects.
Backend boundary
The executor accepts a source_reader callable with this shape:
source_reader(source_path, variables, adios_steps) -> {
adios_step: {
variable: value,
},
}
The default implementation reads regular BP files with FileReader. The
campaign-backed implementation uses the same callable interface with an open ACA
FileReader and campaign-qualified variable names. Code above the executor can
therefore consume ReadPlanExecution or BatchedReadPlanExecution records
without knowing whether the data came from local BP files, a mixed local layout,
or an HPC-Campaign source.