Validation Framework
VoxelOps includes a comprehensive validation framework that ensures data quality and pipeline integrity through automated pre- and post-execution checks. This framework validates inputs before running procedures and verifies outputs after completion.
Overview
The validation framework provides:
Pre-validation: Check inputs exist and meet requirements before execution
Post-validation: Verify expected outputs were created successfully
Audit logging: Track all validation results with timestamps
Orchestration: Integrated execution with automatic validation
Extensibility: Easy to add validation for new procedures
Architecture
The validation framework consists of several key components:
validation/
├── base.py # Core ValidationRule and ValidationResult classes
├── context.py # ValidationContext for passing data to rules
├── validators/ # Procedure-specific validators
│ ├── base.py # Base Validator class
│ ├── heudiconv.py # HeudiConv validator
│ ├── qsiprep.py # QSIPrep validator
│ ├── qsirecon.py # QSIRecon validator
│ └── qsiparc.py # QSIParc validator
└── rules/ # Reusable validation rules
└── common.py # Common rules (DirectoryExistsRule, etc.)
Core Concepts
ValidationRule
A ValidationRule is the base class for all validation checks. Each rule:
Has a
nameanddescriptionImplements a
check()method that returns aValidationResultCan be configured with
severity(“error” or “warning”)Belongs to a
phase(“pre” or “post”)
Example:
from voxelops.validation.base import ValidationRule, ValidationResult
from voxelops.validation.context import ValidationContext
class DirectoryExistsRule(ValidationRule):
"""Check that a directory exists."""
name = "directory_exists"
description = "Verify directory exists"
severity = "error"
phase = "pre"
def __init__(self, path_attr: str, dir_type: str = "Input"):
self.path_attr = path_attr
self.dir_type = dir_type
def check(self, context: ValidationContext) -> ValidationResult:
path = getattr(context.inputs, self.path_attr)
if not path.exists():
return self._fail(
f"{self.dir_type} directory not found: {path}",
{"path": str(path), "exists": False}
)
return self._pass(
f"{self.dir_type} directory exists: {path}",
{"path": str(path), "exists": True}
)
ValidationContext
The ValidationContext carries all data needed for validation:
from voxelops.validation.context import ValidationContext
context = ValidationContext(
procedure_name="qsiprep",
participant="01",
session="baseline", # Optional
inputs=qsiprep_inputs, # Procedure inputs
config=qsiprep_config, # Procedure config
expected_outputs=outputs, # Post-validation only
execution_result=result, # Post-validation only
)
ValidationResult
Each validation check returns a ValidationResult with:
passed: Boolean success/failuremessage: Human-readable descriptiondetails: Dictionary with additional informationseverity: “error” or “warning”timestamp: When the check ran
Validator
A Validator groups multiple rules for a specific procedure:
from voxelops.validation.validators.base import Validator
from voxelops.validation.rules.common import DirectoryExistsRule
class MyToolValidator(Validator):
"""Validator for MyTool procedure."""
procedure_name = "mytool"
pre_rules = [
DirectoryExistsRule("bids_dir", "BIDS directory"),
ParticipantExistsRule(),
# More pre-validation rules...
]
post_rules = [
OutputDirectoryExistsRule("output_dir", "Output directory"),
# More post-validation rules...
]
Using the Validation Framework
Standalone Validation
You can run validation directly:
from pathlib import Path
from voxelops.schemas.qsiprep import QSIPrepInputs
from voxelops.validation.validators import QSIPrepValidator
from voxelops.validation.context import ValidationContext
# Setup inputs
inputs = QSIPrepInputs(
bids_dir=Path("/data/bids"),
participant="01",
)
# Create validator and context
validator = QSIPrepValidator()
context = ValidationContext(
procedure_name="qsiprep",
participant="01",
inputs=inputs,
)
# Run pre-validation
pre_report = validator.validate_pre(context)
if not pre_report.passed:
print("Pre-validation failed!")
for error in pre_report.errors:
print(f" - {error}")
else:
print("Pre-validation passed!")
# Proceed with execution...
Integrated with Orchestrator
The recommended approach is to use the run_procedure() orchestrator, which automatically handles validation:
from pathlib import Path
from voxelops import run_procedure
from voxelops.schemas.qsiprep import QSIPrepInputs
inputs = QSIPrepInputs(
bids_dir=Path("/data/bids"),
participant="01",
)
# Run with automatic validation
result = run_procedure(
procedure="qsiprep",
inputs=inputs,
log_dir=Path("/data/logs"),
)
# Check result
if result.success:
print(f"✓ Success! {result.status}")
else:
print(f"✗ Failed: {result.get_failure_reason()}")
# Access validation reports
if result.pre_validation and not result.pre_validation.passed:
print("\nPre-validation errors:")
for error in result.pre_validation.errors:
print(f" - {error}")
if result.post_validation and not result.post_validation.passed:
print("\nPost-validation errors:")
for error in result.post_validation.errors:
print(f" - {error}")
Available Common Rules
The framework provides several reusable validation rules in voxelops.validation.rules.common:
DirectoryExistsRule
Check that a directory exists in inputs.
DirectoryExistsRule(
path_attr="bids_dir", # Attribute name on inputs
dir_type="BIDS directory", # Human-readable type
severity="error", # "error" or "warning"
)
FileExistsRule
Check that a file exists in inputs or config.
FileExistsRule(
path_attr="heuristic", # Attribute name
file_type="Heuristic file", # Human-readable type
severity="error",
on_config=False, # Look on config instead of inputs?
)
ParticipantExistsRule
Check that participant directory exists in input directory.
ParticipantExistsRule(prefix="sub-") # Participant directory prefix
GlobFilesExistRule
Check that files matching a glob pattern exist.
GlobFilesExistRule(
base_dir_attr="bids_dir", # Base directory attribute
pattern="**/dwi/*.nii.gz", # Glob pattern
min_count=1, # Minimum number of files required
file_type="DWI files", # Human-readable type
severity="error",
phase="pre", # "pre" or "post"
participant_level=True, # Search in participant subdir?
)
participant_level behavior:
True: Searches inbase_dir/sub-{participant}/ses-{session}(if session exists)False: Searches inbase_dirdirectly (for outputs or non-BIDS structures)
OutputDirectoryExistsRule
Post-validation: Check that output directory was created.
OutputDirectoryExistsRule(
output_attr="qsiprep_dir", # Attribute on expected_outputs
output_type="QSIPrep output", # Human-readable type
)
ExpectedOutputsExistRule
Generic post-validation rule for checking various output structures:
# Single file
ExpectedOutputsExistRule(
outputs_attr="html_report", # Attribute on expected_outputs
item_type="HTML report", # Human-readable type
)
# Flat dictionary {key: path}
ExpectedOutputsExistRule(
outputs_attr="workflow_outputs",
item_type="workflow outputs",
flatten_nested=False,
)
# Nested dictionary {workflow: {session: path}}
ExpectedOutputsExistRule(
outputs_attr="workflow_reports",
item_type="workflow HTML reports",
flatten_nested=True, # Handle nested structure
)
This rule automatically handles:
Single
PathobjectsFlat dictionaries
{key: path}Nested dictionaries
{key1: {key2: path}}
Adding Validation to a New Procedure
When adding a new procedure to VoxelOps, follow these steps to include validation:
Step 1: Create Validator Class
Create a new file src/voxelops/validation/validators/mytool.py:
"""MyTool validator with pre and post validation rules."""
from voxelops.validation.rules.common import (
DirectoryExistsRule,
ExpectedOutputsExistRule,
GlobFilesExistRule,
OutputDirectoryExistsRule,
ParticipantExistsRule,
)
from voxelops.validation.validators.base import Validator
class MyToolValidator(Validator):
"""Validator for MyTool procedure."""
procedure_name = "mytool"
pre_rules = [
# Check input directory exists
DirectoryExistsRule("bids_dir", "BIDS directory"),
# Check participant exists in input directory
ParticipantExistsRule(),
# Check required files exist
GlobFilesExistRule(
base_dir_attr="bids_dir",
pattern="**/anat/*_T1w.nii.gz",
min_count=1,
file_type="T1w images",
participant_level=True,
),
]
post_rules = [
# Check output directory was created
OutputDirectoryExistsRule("output_dir", "Output directory"),
# Check expected files were created
GlobFilesExistRule(
base_dir_attr="output_dir",
pattern="**/*.nii.gz",
min_count=1,
file_type="Output images",
phase="post",
participant_level=False, # output_dir is already participant-specific
),
]
Step 2: Export Validator
Add to src/voxelops/validation/validators/__init__.py:
from .mytool import MyToolValidator
__all__ = [
...,
"MyToolValidator",
]
Step 3: Register in Orchestrator
Add to src/voxelops/procedures/orchestrator.py:
from voxelops.validation.validators import (
...,
MyToolValidator,
)
# Registry of validators by procedure name
VALIDATORS = {
...,
"mytool": MyToolValidator(),
}
Step 4: Update Expected Outputs
Ensure your outputs schema in src/voxelops/schemas/mytool.py includes all paths needed for validation:
@dataclass
class MyToolOutputs:
"""Expected outputs from MyTool."""
output_dir: Path # For OutputDirectoryExistsRule
participant_dir: Path # If needed
html_report: Path # For ExpectedOutputsExistRule
@classmethod
def from_inputs(cls, inputs: MyToolInputs, output_dir: Path):
"""Generate expected output paths from inputs."""
return cls(
output_dir=output_dir,
participant_dir=output_dir / f"sub-{inputs.participant}",
html_report=output_dir / f"sub-{inputs.participant}.html",
)
Step 5: Write Tests
Create tests/validation/test_validators_mytool.py:
"""Tests for MyTool validator."""
from pathlib import Path
import pytest
from voxelops.validation.context import ValidationContext
from voxelops.validation.validators import MyToolValidator
class TestMyToolValidator:
"""Tests for MyToolValidator."""
def test_validator_attributes(self):
"""Test validator has correct attributes."""
validator = MyToolValidator()
assert validator.procedure_name == "mytool"
assert len(validator.pre_rules) == 3
assert len(validator.post_rules) == 2
def test_pre_validation_success(self, tmp_path):
"""Test successful pre-validation."""
# Setup test environment
bids_dir = tmp_path / "bids"
sub_dir = bids_dir / "sub-01"
anat_dir = sub_dir / "anat"
anat_dir.mkdir(parents=True)
(anat_dir / "sub-01_T1w.nii.gz").touch()
# Create mock inputs
from dataclasses import dataclass
@dataclass
class MockInputs:
bids_dir: Path
inputs = MockInputs(bids_dir=bids_dir)
# Run validation
validator = MyToolValidator()
context = ValidationContext(
procedure_name="mytool",
participant="01",
inputs=inputs,
)
report = validator.validate_pre(context)
assert report.passed is True
assert len(report.results) == 3
Custom Validation Rules
If the common rules don’t meet your needs, create custom rules:
from pathlib import Path
from voxelops.validation.base import ValidationRule, ValidationResult
from voxelops.validation.context import ValidationContext
class BIDSValidatorRule(ValidationRule):
"""Run BIDS validator on input directory."""
name = "bids_validator"
description = "Run BIDS validator"
severity = "warning" # Non-critical
phase = "pre"
def check(self, context: ValidationContext) -> ValidationResult:
"""Run bids-validator command."""
import subprocess
bids_dir = context.inputs.bids_dir
try:
result = subprocess.run(
["bids-validator", str(bids_dir)],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode == 0:
return self._pass(
"BIDS validation passed",
{"bids_dir": str(bids_dir)}
)
else:
return self._fail(
f"BIDS validation failed:\n{result.stderr}",
{
"bids_dir": str(bids_dir),
"errors": result.stderr,
}
)
except FileNotFoundError:
return self._fail(
"bids-validator not found in PATH",
{"bids_dir": str(bids_dir)}
)
except subprocess.TimeoutExpired:
return self._fail(
"BIDS validation timed out",
{"bids_dir": str(bids_dir)}
)
Then use it in your validator:
from .custom_rules import BIDSValidatorRule
class MyToolValidator(Validator):
procedure_name = "mytool"
pre_rules = [
BIDSValidatorRule(),
# ... other rules
]
Best Practices
Keep Rules Focused
Each rule should check one specific thing. This makes debugging easier and rules more reusable.
Use Appropriate Severity
"error": Must pass for execution to proceed"warning": Logged but doesn’t block execution
Provide Useful Messages
Include enough detail in messages and details dict for users to diagnose issues:
return self._fail( f"Missing required files in {search_dir}", { "pattern": self.pattern, "search_dir": str(search_dir), "found_count": len(found_files), "required_count": self.min_count, "found_files": [str(f.name) for f in found_files], } )
Test Edge Cases
Test your validators with:
Missing directories
Empty directories
Malformed file names
Missing sessions (for multi-session support)
Document Expected Outputs
Clearly document what your
ExpectedOutputsschema should contain in docstrings.Use participant_level Correctly
Pre-validation of BIDS inputs:
participant_level=TruePost-validation of procedure outputs:
participant_level=False(output_dir is already participant-specific)
Validation Reports
Validation results are stored in ValidationReport objects:
@dataclass
class ValidationReport:
phase: str # "pre" or "post"
procedure: str # "qsiprep", etc.
participant: str # "01"
session: Optional[str] # "baseline" or None
timestamp: datetime # When validation ran
results: List[ValidationResult] # All check results
@property
def passed(self) -> bool:
"""True if all error-level checks passed."""
return all(
r.passed for r in self.results
if r.severity == "error"
)
@property
def errors(self) -> List[str]:
"""List of error messages."""
return [
r.message for r in self.results
if not r.passed and r.severity == "error"
]
@property
def warnings(self) -> List[str]:
"""List of warning messages."""
return [
r.message for r in self.results
if not r.passed and r.severity == "warning"
]
Access reports from ProcedureResult:
result = run_procedure("qsiprep", inputs=inputs)
# Check pre-validation
if result.pre_validation:
print(f"Pre-validation: {result.pre_validation.passed}")
for error in result.pre_validation.errors:
print(f" ERROR: {error}")
for warning in result.pre_validation.warnings:
print(f" WARNING: {warning}")
# Check post-validation
if result.post_validation:
print(f"Post-validation: {result.post_validation.passed}")
for result_item in result.post_validation.results:
status = "✓" if result_item.passed else "✗"
print(f" {status} {result_item.rule_description}")
Integration with Audit Logging
All validation results are automatically logged to JSON files when using run_procedure():
{
"timestamp": "2024-01-15T10:30:00",
"event_type": "validation_pre",
"data": {
"phase": "pre",
"procedure": "qsiprep",
"participant": "01",
"passed": true,
"results": [
{
"rule_name": "bids_dir_exists",
"passed": true,
"message": "BIDS directory exists: /data/bids",
"severity": "error"
}
]
}
}
Find these logs in the audit log directory (default: output_dir/logs).
Troubleshooting
Validation Failed but Files Exist
Problem: Post-validation fails even though you can see the files.
Solutions:
Check the exact paths in
expected_outputsEnsure
participant_levelis set correctlyCheck for typos in glob patterns
Verify file permissions
Example debugging:
# Print expected paths
print("Expected outputs:")
print(f" qsiprep_dir: {result.execution['expected_outputs'].qsiprep_dir}")
print(f" Exists: {result.execution['expected_outputs'].qsiprep_dir.exists()}")
# Check post-validation details
for check in result.post_validation.results:
if not check.passed:
print(f"\nFailed check: {check.rule_name}")
print(f" Message: {check.message}")
print(f" Details: {check.details}")
Session Detection Not Working
Problem: Validator expects sessions but doesn’t find them.
Solutions:
Check session discovery functions (
_discover_sessionsin schemas)Verify directory structure matches expected pattern
Ensure session parameter is passed to
ValidationContext
Glob Pattern Not Matching
Problem: GlobFilesExistRule finds 0 files.
Solutions:
Test the pattern manually:
from pathlib import Path files = list(Path("/data/bids/sub-01").glob("**/dwi/*.nii.gz")) print(f"Found {len(files)} files: {files}")
Check
participant_levelsettingVerify the
base_dir_attrpoints to the correct directory
Summary
The VoxelOps validation framework provides:
Automated Quality Checks: Pre- and post-validation ensure data integrity
Reusable Rules: Common validation patterns available out-of-the-box
Easy Extension: Simple to add validation for new procedures
Comprehensive Logging: All validation results tracked in audit logs
User-Friendly Reports: Clear error messages guide troubleshooting
For more examples, see:
examples/validation/01_validation_framework_intro.ipynbExisting validators in
src/voxelops/validation/validators/Tests in
tests/validation/