diff --git a/_to_delete/_stage/examples_bids.tgz b/_to_delete/_stage/examples_bids.tgz new file mode 100644 index 00000000..4c649237 Binary files /dev/null and b/_to_delete/_stage/examples_bids.tgz differ diff --git a/apps/backend/app/routers/reports.py b/apps/backend/app/routers/reports.py index 5b80ab8e..5202697e 100644 --- a/apps/backend/app/routers/reports.py +++ b/apps/backend/app/routers/reports.py @@ -101,7 +101,7 @@ async def get_report_dicom( with open(f"{base_dir}/_aslcontext.tsv", "w") as f: f.write("volume_type\n") for value in asl_context: - f.write(f'"{value.lower()}"\n') + f.write(f"{value.lower()}\n") bids_data = { "modality": data["modality"], diff --git a/package/src/pyaslreport/io/readers/file_reader.py b/package/src/pyaslreport/io/readers/file_reader.py index 55484406..038abc55 100644 --- a/package/src/pyaslreport/io/readers/file_reader.py +++ b/package/src/pyaslreport/io/readers/file_reader.py @@ -1,6 +1,24 @@ import json +def _strip_surrounding_quotes(token: str) -> str: + """Remove one matching pair of surrounding quotes from a TSV token. + + BIDS ``aslcontext.tsv`` values are unquoted, but some producers wrap them + in quotes; normalizing both keeps downstream volume-type matching working. + + Args: + token: A whitespace-stripped TSV field. + + Returns: + The token without a single pair of matching surrounding single or + double quotes, or unchanged when it is not quoted. + """ + if len(token) >= 2 and token[0] == token[-1] and token[0] in ('"', "'"): + return token[1:-1] + return token + + class FileReader: @staticmethod @@ -14,11 +32,11 @@ def read(file_path): Parsed content of the file. """ try: - file_stream = open(file_path, 'r') - if file_path.endswith('.json'): + file_stream = open(file_path, "r") + if file_path.endswith(".json"): return FileReader._read_json(file_stream) - elif file_path.endswith('.tsv'): + elif file_path.endswith(".tsv"): return FileReader._read_tsv(file_stream) else: @@ -41,7 +59,9 @@ def _read_json(file_stream): Parsed JSON data. """ with file_stream as f: - content = f.read().strip() # Read the content and strip any leading/trailing whitespace + content = ( + f.read().strip() + ) # Read the content and strip any leading/trailing whitespace if content: # Check if the file is not empty data = json.loads(content) return data @@ -62,10 +82,10 @@ def _read_tsv(file_stream): if not lines: return None - header = lines[0].strip() + header = _strip_surrounding_quotes(lines[0].strip()) - if header != 'volume_type': - raise RuntimeError("Invalid TSV header, not \"volume_type\"") + if header != "volume_type": + raise RuntimeError('Invalid TSV header, not "volume_type"') - data = [line.strip() for line in lines[1:]] + data = [_strip_surrounding_quotes(line.strip()) for line in lines[1:]] return data diff --git a/package/src/pyaslreport/main.py b/package/src/pyaslreport/main.py index 553e7e5e..a03df4d2 100644 --- a/package/src/pyaslreport/main.py +++ b/package/src/pyaslreport/main.py @@ -1,10 +1,22 @@ -from pyaslreport.modalities import asl, testdsc -from .modalities.registry import get_processor -from pyaslreport.sequences.factory import get_sequence +import logging as log +import os +import warnings +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass + import pydicom +import pydicom.config as pydicom_config from pydicom.errors import InvalidDicomError -import os -import logging as log + +from pyaslreport.modalities import asl, testdsc +from pyaslreport.sequences.factory import get_sequence +from pyaslreport.utils import dicom_tags_utils as dcm_tags +from pyaslreport.utils.dicom_repair_utils import repair_dicom_dataset_in_memory +from pyaslreport.utils.metadata_override_utils import apply_sidecar_overrides + +from .modalities.registry import get_processor + def generate_report(data): """ @@ -24,46 +36,247 @@ def get_bids_metadata(data): """ Converts the provided data to BIDS format. :param data: Dictionary containing modality data. - :return: BIDS-formatted data. + :return: A (metadata, asl_context) pair for every vendor. """ modality = data.get("modality") dicom_dir = data.get("dicom_dir") - dicom_header = get_dicom_header(dicom_dir) + dicom_header = get_dicom_header(dicom_dir, modality=modality) sequence = get_sequence(modality, dicom_header) - + if sequence is None: - raise ValueError(f"No matching sequence found for modality '{modality}' with the provided DICOM header") - - return sequence.extract_bids_metadata() + raise ValueError( + f"No matching sequence found for modality '{modality}' with the " + "provided DICOM header" + ) + + metadata = sequence.extract_bids_metadata() + metadata, _ = apply_sidecar_overrides(metadata, dicom_dir) + + return _normalize_metadata_shape(metadata) + +def _normalize_metadata_shape(metadata): + """Return a ``(metadata, asl_context)`` pair for every vendor. -def get_dicom_header(dicom_dir: str): + GE already returns the pair; Siemens returns a bare metadata dict, from which + the ASL context is taken from a sidecar ``ASLContext`` string (routed out of + the metadata) or a sensible default. This keeps ``get_bids_metadata``'s + contract uniform so callers can always unpack two values. """ - Extracts the DICOM header from the provided DICOM files. - :param dicom_dir: Directory containing DICOM files. - :return: DICOM header from the first valid DICOM file found. + if isinstance(metadata, (list, tuple)) and len(metadata) == 2: + return metadata[0], metadata[1] + if isinstance(metadata, dict): + context = metadata.pop("ASLContext", None) + if context: + asl_context = [v.strip() for v in str(context).split(",") if v.strip()] + else: + asl_context = ["deltaM", "m0scan"] + return metadata, asl_context + raise TypeError(f"Unexpected metadata shape: {type(metadata).__name__}") + + +@dataclass +class DicomHeaderCandidate: + """A readable DICOM header and its source path.""" + + path: str + header: pydicom.Dataset + + +def get_dicom_header(dicom_dir: str, modality: object | None = None) -> pydicom.Dataset: + """Extract a representative DICOM header from a directory. + + Args: + dicom_dir: Directory containing DICOM files. + modality: Optional modality used to choose a better ASL header. + + Returns: + DICOM header selected for metadata extraction. + + Raises: + ValueError: If no readable DICOM files are found. """ - # Get all files in the directory - all_files = [f for f in os.listdir(dicom_dir) if os.path.isfile(os.path.join(dicom_dir, f))] - - # Try to find DICOM files by attempting to read them with pydicom - dcm_files = [] + all_files = sorted( + f for f in os.listdir(dicom_dir) if os.path.isfile(os.path.join(dicom_dir, f)) + ) + + candidates: list[DicomHeaderCandidate] = [] for file in all_files: file_path = os.path.join(dicom_dir, file) try: - # Try to read the file as DICOM - pydicom.dcmread(file_path, stop_before_pixels=True) - dcm_files.append(file) + with _silence_pydicom_invalid_vr_warnings(): + dcm_header = pydicom.dcmread(file_path, stop_before_pixels=True) + repair_dicom_dataset_in_memory(dcm_header) + candidates.append(DicomHeaderCandidate(file_path, dcm_header)) except (InvalidDicomError, OSError, PermissionError): - # File is not a valid DICOM file or cannot be read continue - log.info(f"Found {len(dcm_files)} DICOM files in {dicom_dir}") + log.info(f"Found {len(candidates)} DICOM files in {dicom_dir}") - if not dcm_files: + if not candidates: raise ValueError(f"No DICOM files found in directory: {dicom_dir}") - - # Read the first valid DICOM file - dcm_header = pydicom.dcmread(os.path.join(dicom_dir, dcm_files[0])) - - return dcm_header \ No newline at end of file + + return _select_best_dicom_header(candidates, modality) + + +@contextmanager +def _silence_pydicom_invalid_vr_warnings() -> Iterator[None]: + """Silence known invalid-VR warning noise while repair checks a header.""" + pydicom_logger = log.getLogger("pydicom") + previous_level = pydicom_logger.level + previous_validation_mode = pydicom_config.settings.reading_validation_mode + pydicom_logger.setLevel(log.ERROR) + pydicom_config.settings.reading_validation_mode = pydicom_config.IGNORE + try: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + yield + finally: + pydicom_config.settings.reading_validation_mode = previous_validation_mode + pydicom_logger.setLevel(previous_level) + + +def _select_best_dicom_header( + candidates: list[DicomHeaderCandidate], modality: object | None = None +) -> pydicom.Dataset: + """Choose a deterministic representative header for metadata extraction.""" + if _is_asl_modality(modality): + ge_candidates = [ + candidate + for candidate in candidates + if _is_ge_manufacturer(candidate.header) + ] + if ge_candidates: + return _select_best_ge_asl_header(ge_candidates) + + return sorted(candidates, key=_candidate_sort_key)[0].header + + +def _select_best_ge_asl_header( + candidates: list[DicomHeaderCandidate], +) -> pydicom.Dataset: + """Choose the most complete representative header for a GE ASL series.""" + internal_names = [ + _ge_internal_sequence_name(candidate.header) for candidate in candidates + ] + non_empty_names = [name for name in internal_names if name] + + # A missing internal sequence name is not fatal: per GE guidance, absence of + # the tag (or any value other than 'easl') is treated as basic single-PLD. + sequence_kind = ( + "ge_easl_multi_pld" if "easl" in non_empty_names else "ge_basic_single_pld" + ) + return max( + candidates, + key=lambda candidate: ( + _score_ge_asl_header(candidate.header, sequence_kind), + -_candidate_sort_key(candidate)[0], + _candidate_sort_key(candidate)[1], + ), + ).header + + +def _score_ge_asl_header(dicom_header: pydicom.Dataset, sequence_kind: str) -> int: + """Score a GE ASL header by sequence-specific metadata completeness.""" + score = 0 + + if _is_ge_manufacturer(dicom_header): + score += 100 + + internal_name = _ge_internal_sequence_name(dicom_header) + if internal_name: + score += 20 + + if sequence_kind == "ge_easl_multi_pld": + if internal_name == "easl": + score += 500 + else: + score -= 1000 + essential_tags = [ + dcm_tags.GE_PRIVATE_CV4, + dcm_tags.GE_PRIVATE_CV5, + dcm_tags.GE_PRIVATE_CV6, + dcm_tags.GE_PRIVATE_CV7, + ] + else: + if internal_name != "easl": + score += 500 + else: + score -= 1000 + essential_tags = [ + dcm_tags.GE_LABEL_DURATION, + dcm_tags.GE_INVERSION_TIME, + ] + + for tag in essential_tags: + if _has_non_empty_value(dicom_header, tag): + score += 100 + else: + score -= 100 + + for tag in [ + dcm_tags.ECHO_TIME, + dcm_tags.MAGNETIC_FIELD_STRENGTH, + dcm_tags.MR_ACQUISITION_TYPE, + dcm_tags.FLIP_ANGLE, + dcm_tags.GE_ASSET_R_FACTOR, + dcm_tags.GE_NUMBER_OF_EXCITATIONS, + ]: + if _has_non_empty_value(dicom_header, tag): + score += 10 + + return score + + +def _candidate_sort_key(candidate: DicomHeaderCandidate) -> tuple[int, str]: + """Return a deterministic ordering key for DICOM header candidates.""" + instance = _value(candidate.header, dcm_tags.INSTANCE_NUMBER) + try: + instance_number = int(instance) + except (TypeError, ValueError): + instance_number = 10**9 + return instance_number, os.path.basename(candidate.path) + + +def _is_asl_modality(modality: object | None) -> bool: + """Return True when a modality object or string represents ASL.""" + if modality is None: + return False + name = getattr(modality, "name", None) + value = getattr(modality, "value", None) + return modality == "asl" or name == "ASL" or value == "asl" + + +def _is_ge_manufacturer(dicom_header: pydicom.Dataset) -> bool: + """Return True when the DICOM manufacturer identifies GE.""" + manufacturer = _value(dicom_header, dcm_tags.MANUFACTURER, "") + manufacturer = str(manufacturer).strip().upper() + return "GE" in manufacturer or "GENERAL ELECTRIC" in manufacturer + + +def _ge_internal_sequence_name(dicom_header: pydicom.Dataset) -> str | None: + """Return the normalized GE internal sequence name, if present.""" + value = _value(dicom_header, dcm_tags.GE_INTERNAL_SEQUENCE_NAME) + if value is None or str(value).strip() == "": + return None + return str(value).strip().lower() + + +def _has_non_empty_value(dicom_header: pydicom.Dataset, tag: object) -> bool: + """Return True when a DICOM tag exists and carries a non-empty value.""" + value = _value(dicom_header, tag) + if value is None: + return False + if isinstance(value, str): + return value.strip() != "" + return True + + +def _value( + dicom_header: pydicom.Dataset, tag: object, default: object | None = None +) -> object | None: + """Return a DICOM element value or a caller-provided default.""" + elem = dicom_header.get(tag) + if elem is None: + return default + return getattr(elem, "value", elem) diff --git a/package/src/pyaslreport/modalities/asl/processor.py b/package/src/pyaslreport/modalities/asl/processor.py index 4ec31318..7edf7f16 100644 --- a/package/src/pyaslreport/modalities/asl/processor.py +++ b/package/src/pyaslreport/modalities/asl/processor.py @@ -1,24 +1,25 @@ import json import math import os -from typing import Any, Dict, List, Tuple, Optional from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple from pyaslreport.converters.dicom_to_nifti_converter import DICOM2NiFTIConverter -from pyaslreport.io.readers.nifti_reader import NiftiReader +from pyaslreport.core.config import config from pyaslreport.io.readers.file_reader import FileReader +from pyaslreport.io.readers.nifti_reader import NiftiReader +from pyaslreport.modalities.asl.constants import DURATION_OF_EACH_RFBLOCK from pyaslreport.modalities.asl.report_generator import ReportGenerator from pyaslreport.modalities.asl.utils import ASLUtils from pyaslreport.modalities.asl.validator import ASLValidator from pyaslreport.modalities.base_processor import BaseProcessor -from pyaslreport.modalities.asl.constants import DURATION_OF_EACH_RFBLOCK from pyaslreport.utils.unit_conversion_utils import UnitConverterUtils -from pyaslreport.core.config import config @dataclass class ProcessingContext: """Data class to hold processing context and state.""" + asl_json_data: List[Dict[str, Any]] m0_prep_times_collection: List[Any] errors: List[str] @@ -55,27 +56,27 @@ def __init__(self, data: Dict[str, Any]) -> None: def process(self) -> Dict[str, Any]: """ Main processing method that orchestrates the ASL data processing workflow. - + Returns: Dictionary containing processing results including reports, errors, and parameters. """ self._validate_input_data() - + # Step 1: Convert DICOM files if present (in-place, no temp storage) nifti_file, file_format = self._convert_dicom_files() - + # Step 2: Group and organize files using user-provided paths grouped_files = self._group_files(file_format) - + # Step 3: Read NIfTI file and get slice information nifti_slice_number = self._get_nifti_slice_number(nifti_file) - + # Step 4: Process ASL JSON data context = self._process_asl_json_data(grouped_files, nifti_slice_number) - + # Step 5: Validate M0 data and TSV files self._validate_m0_and_tsv_data(grouped_files, context, file_format) - + # Step 6: Run validation and generate reports return self._generate_reports_and_results(context) @@ -88,22 +89,22 @@ def _convert_dicom_files(self) -> Tuple[str, str]: """ Convert DICOM files to NIfTI format if present. Uses user-provided paths without creating temporary storage. - + Returns: Tuple of (nifti_file_path, file_format) """ dcm_files = self.data.get("dcm_files", []) nifti_file = self.data.get("nifti_file") - + if not dcm_files: # No DICOM files, use existing NIfTI file if not self.data.get("files"): raise RuntimeError("Neither DICOM nor NIfTI files were found.") return str(nifti_file), "nifti" - + # Convert DICOM files using the converter - converted_files, new_filenames, nifti_file, file_format, error = DICOM2NiFTIConverter.convert( - dcm_files, nifti_file + converted_files, new_filenames, nifti_file, file_format, error = ( + DICOM2NiFTIConverter.convert(dcm_files, nifti_file) ) if converted_files: @@ -120,39 +121,46 @@ def _group_files(self, file_format: str) -> List[Dict[str, Any]]: """ Group files by type using user-provided paths. Extracts filenames from paths when needed. - + Args: file_format: Format of the input files. - + Returns: List of grouped files with their data loaded. """ files = self.data.get("files", []) - + grouped_files = [] - current_group = {'asl_json': None, 'm0_json': None, 'tsv': None} + current_group = {"asl_json": None, "m0_json": None, "tsv": None} for filepath in files: # Extract filename from path filename = os.path.basename(filepath) - - if not filename.endswith(('.json', '.tsv')): + + if not filename.endswith((".json", ".tsv")): raise ValueError(f"Unsupported file format: {filename}") # Read file data directly from user-provided path data = FileReader.read(filepath) - if filename.endswith('m0scan.json') or (('m0' in filename) and file_format == "dicom"): - current_group['m0_json'] = (filename, data) - elif (filename.endswith('asl.json') and file_format == "nifti") or ( - filename.endswith('.json') and file_format == "dicom"): - if current_group['asl_json']: + if filename.endswith("m0scan.json") or ( + ("m0" in filename) and file_format == "dicom" + ): + current_group["m0_json"] = (filename, data) + elif (filename.endswith("asl.json") and file_format == "nifti") or ( + filename.endswith(".json") and file_format == "dicom" + ): + if current_group["asl_json"]: grouped_files.append(current_group) - current_group = {'asl_json': (filename, data), 'm0_json': None, 'tsv': None} - elif filename.endswith('.tsv'): - current_group['tsv'] = (filename, data) + current_group = { + "asl_json": (filename, data), + "m0_json": None, + "tsv": None, + } + elif filename.endswith(".tsv"): + current_group["tsv"] = (filename, data) - if current_group['asl_json']: + if current_group["asl_json"]: grouped_files.append(current_group) return grouped_files @@ -160,24 +168,26 @@ def _group_files(self, file_format: str) -> List[Dict[str, Any]]: def _get_nifti_slice_number(self, nifti_file: str) -> int: """ Read NIfTI file and extract slice number. - + Args: nifti_file: Path to the NIfTI file. - + Returns: Number of slices in the NIfTI file. """ nifti_img = NiftiReader.read(nifti_file) return nifti_img.shape[2] - def _process_asl_json_data(self, grouped_files: List[Dict[str, Any]], nifti_slice_number: int) -> ProcessingContext: + def _process_asl_json_data( + self, grouped_files: List[Dict[str, Any]], nifti_slice_number: int + ) -> ProcessingContext: """ Process ASL JSON data and extract metadata. - + Args: grouped_files: List of grouped files. nifti_slice_number: Number of slices in NIfTI file. - + Returns: ProcessingContext containing extracted data and metadata. """ @@ -187,8 +197,8 @@ def _process_asl_json_data(self, grouped_files: List[Dict[str, Any]], nifti_slic # Extract ASL JSON data from grouped files for group in grouped_files: - if group['asl_json'] is not None: - asl_filename, asl_data = group['asl_json'] + if group["asl_json"] is not None: + asl_filename, asl_data = group["asl_json"] asl_json_data.append(asl_data) # Update metadata flags @@ -211,94 +221,116 @@ def _process_asl_json_data(self, grouped_files: List[Dict[str, Any]], nifti_slic m0_type=None, global_pattern=None, total_acquired_pairs=None, - nifti_slice_number=nifti_slice_number + nifti_slice_number=nifti_slice_number, ) def _normalize_asl_data(self, asl_json_data: List[Dict[str, Any]]) -> None: """ Normalize ASL data by converting units and renaming fields. - + Args: asl_json_data: List of ASL JSON data dictionaries. """ for session in asl_json_data: self._rename_fields(session) self._convert_units_to_milliseconds(session) - session['PLDType'] = ASLUtils.determine_pld_type(session) + session["PLDType"] = ASLUtils.determine_pld_type(session) def _rename_fields(self, session: Dict[str, Any]) -> None: """ Rename fields in ASL session data to standard names. - + Args: session: ASL session data dictionary. """ field_mappings = { - 'RepetitionTime': 'RepetitionTimePreparation', - 'InversionTime': 'PostLabelingDelay', - 'BolusDuration': 'BolusCutOffDelayTime', - 'InitialPostLabelDelay': 'PostLabelingDelay' + "RepetitionTime": "RepetitionTimePreparation", + "InversionTime": "PostLabelingDelay", + "BolusDuration": "BolusCutOffDelayTime", + "InitialPostLabelDelay": "PostLabelingDelay", } - + for old_key, new_key in field_mappings.items(): if old_key in session: session[new_key] = session[old_key] del session[old_key] # Handle NumRFBlocks special case - if 'NumRFBlocks' in session: - session['LabelingDuration'] = session['NumRFBlocks'] * DURATION_OF_EACH_RFBLOCK + if "NumRFBlocks" in session: + session["LabelingDuration"] = ( + session["NumRFBlocks"] * DURATION_OF_EACH_RFBLOCK + ) def _convert_units_to_milliseconds(self, session: Dict[str, Any]) -> None: """ Convert time-related fields from seconds to milliseconds. - + Args: session: ASL session data dictionary. """ time_fields = [ - 'EchoTime', 'RepetitionTimePreparation', 'LabelingDuration', - 'BolusCutOffDelayTime', 'BackgroundSuppressionPulseTime', 'PostLabelingDelay' + "EchoTime", + "RepetitionTimePreparation", + "LabelingDuration", + "BolusCutOffDelayTime", + "BackgroundSuppressionPulseTime", + "PostLabelingDelay", ] - + for key in time_fields: if key in session: session[key] = UnitConverterUtils.convert_to_milliseconds(session[key]) - def _validate_m0_and_tsv_data(self, grouped_files: List[Dict[str, Any]], context: ProcessingContext, file_format: str) -> None: + def _validate_m0_and_tsv_data( + self, + grouped_files: List[Dict[str, Any]], + context: ProcessingContext, + file_format: str, + ) -> None: """ Validate M0 data and TSV files, updating context with results. - + Args: grouped_files: List of grouped files. context: Processing context to update. file_format: Format of input files. """ for i, group in enumerate(grouped_files): - if group['asl_json'] is not None: - asl_filename, asl_data = group['asl_json'] + if group["asl_json"] is not None: + asl_filename, asl_data = group["asl_json"] context.m0_type = asl_data.get("M0Type") self._validate_m0_data(group, context, asl_filename, asl_data) - self._validate_tsv_data(group, context, asl_filename, asl_data, file_format) + self._validate_tsv_data( + group, context, asl_filename, asl_data, file_format + ) + self._warn_if_voxel_geometry_missing(context, asl_filename, asl_data) - def _validate_m0_data(self, group: Dict[str, Any], context: ProcessingContext, asl_filename: str, asl_data: Dict[str, Any]) -> None: + def _validate_m0_data( + self, + group: Dict[str, Any], + context: ProcessingContext, + asl_filename: str, + asl_data: Dict[str, Any], + ) -> None: """ Validate M0 data and check for inconsistencies. - + Args: group: Group of files containing M0 data. context: Processing context to update. asl_filename: Name of ASL file. asl_data: ASL data dictionary. """ - if group['m0_json'] is not None: - m0_filename, m0_data = group['m0_json'] - + if group["m0_json"] is not None: + m0_filename, m0_data = group["m0_json"] + # Convert M0 time units - for key in ['EchoTime', 'RepetitionTimePreparation', 'RepetitionTime']: + for key in ["EchoTime", "RepetitionTimePreparation", "RepetitionTime"]: if key in m0_data: - m0_data[key] = UnitConverterUtils.convert_to_milliseconds(m0_data[key]) + m0_data[key] = UnitConverterUtils.convert_to_milliseconds( + m0_data[key] + ) # Validate M0 type consistency if context.m0_type == "Absent": @@ -311,8 +343,12 @@ def _validate_m0_data(self, group: Dict[str, Any], context: ProcessingContext, a ) # Compare parameters between ASL and M0 - params_asl, params_m0 = ASLUtils.extract_params(asl_data), ASLUtils.extract_params(m0_data) - comparison_errors, comparison_warnings = ASLUtils.compare_params(params_asl, params_m0, asl_filename, m0_filename) + params_asl, params_m0 = ASLUtils.extract_params( + asl_data + ), ASLUtils.extract_params(m0_data) + comparison_errors, comparison_warnings = ASLUtils.compare_params( + params_asl, params_m0, asl_filename, m0_filename + ) context.errors.extend(comparison_errors) context.warnings.extend(comparison_warnings) @@ -325,10 +361,17 @@ def _validate_m0_data(self, group: Dict[str, Any], context: ProcessingContext, a f"Error: M0 type specified as 'Separate' for '{asl_filename}', but m0scan.json is not provided." ) - def _validate_tsv_data(self, group: Dict[str, Any], context: ProcessingContext, asl_filename: str, asl_data: Dict[str, Any], file_format: str) -> None: + def _validate_tsv_data( + self, + group: Dict[str, Any], + context: ProcessingContext, + asl_filename: str, + asl_data: Dict[str, Any], + file_format: str, + ) -> None: """ Validate TSV data and analyze volume types. - + Args: group: Group of files containing TSV data. context: Processing context to update. @@ -336,19 +379,30 @@ def _validate_tsv_data(self, group: Dict[str, Any], context: ProcessingContext, asl_data: ASL data dictionary. file_format: Format of input files. """ - if group['tsv'] is not None: - tsv_filename, tsv_data = group['tsv'] - self._analyze_tsv_volume_types(tsv_data, context, asl_filename, asl_data, tsv_filename) + if group["tsv"] is not None: + tsv_filename, tsv_data = group["tsv"] + self._analyze_tsv_volume_types( + tsv_data, context, asl_filename, asl_data, tsv_filename + ) elif file_format == "nifti": - context.errors.append(f"Error: 'aslcontext.tsv' is missing for {asl_filename}") + context.errors.append( + f"Error: 'aslcontext.tsv' is missing for {asl_filename}" + ) else: # Handle DICOM input case self._analyze_dicom_repetitions(asl_data, context) - def _analyze_tsv_volume_types(self, tsv_data: List[str], context: ProcessingContext, asl_filename: str, asl_data: Dict[str, Any], tsv_filename: str) -> None: + def _analyze_tsv_volume_types( + self, + tsv_data: List[str], + context: ProcessingContext, + asl_filename: str, + asl_data: Dict[str, Any], + tsv_filename: str, + ) -> None: """ Analyze volume types in TSV data and validate M0 scans. - + Args: tsv_data: TSV data as list of strings. context: Processing context to update. @@ -359,7 +413,7 @@ def _analyze_tsv_volume_types(self, tsv_data: List[str], context: ProcessingCont m0scan_count = sum(1 for line in tsv_data if line.strip() == "m0scan") volume_types = [line.strip() for line in tsv_data if line.strip()] pattern, total_acquired_pairs = ASLUtils.analyze_volume_types(volume_types) - asl_data['TotalAcquiredPairs'] = total_acquired_pairs + asl_data["TotalAcquiredPairs"] = total_acquired_pairs context.total_acquired_pairs = total_acquired_pairs # Update global pattern @@ -369,12 +423,21 @@ def _analyze_tsv_volume_types(self, tsv_data: List[str], context: ProcessingCont context.global_pattern = "control-label (there's no consistent control-label or label-control order)" # Validate M0 scan consistency - self._validate_m0scan_consistency(m0scan_count, context, asl_filename, tsv_filename, asl_data) + self._validate_m0scan_consistency( + m0scan_count, context, asl_filename, tsv_filename, asl_data + ) - def _validate_m0scan_consistency(self, m0scan_count: int, context: ProcessingContext, asl_filename: str, tsv_filename: str, asl_data: Dict[str, Any]) -> None: + def _validate_m0scan_consistency( + self, + m0scan_count: int, + context: ProcessingContext, + asl_filename: str, + tsv_filename: str, + asl_data: Dict[str, Any], + ) -> None: """ Validate consistency between M0 scan count and M0 type. - + Args: m0scan_count: Number of M0 scans found in TSV. context: Processing context to update. @@ -392,14 +455,23 @@ def _validate_m0scan_consistency(self, m0scan_count: int, context: ProcessingCon f"Error: m0 type is specified as 'Separate' for '{asl_filename}', but '{tsv_filename}' contains m0scan." ) else: - self._handle_m0scan_timing(asl_data, m0scan_count, context, asl_filename, tsv_filename) + self._handle_m0scan_timing( + asl_data, m0scan_count, context, asl_filename, tsv_filename + ) else: self._handle_no_m0scan_warnings(context, asl_filename, asl_data) - def _handle_m0scan_timing(self, asl_data: Dict[str, Any], m0scan_count: int, context: ProcessingContext, asl_filename: str, tsv_filename: str) -> None: + def _handle_m0scan_timing( + self, + asl_data: Dict[str, Any], + m0scan_count: int, + context: ProcessingContext, + asl_filename: str, + tsv_filename: str, + ) -> None: """ Handle timing calculations for M0 scans. - + Args: asl_data: ASL data dictionary. m0scan_count: Number of M0 scans. @@ -408,6 +480,25 @@ def _handle_m0scan_timing(self, asl_data: Dict[str, Any], m0scan_count: int, con tsv_filename: Name of TSV file. """ repetition_times = asl_data.get("RepetitionTimePreparation", []) + if repetition_times in (None, []): + # Per BIDS, RepetitionTimePreparation is preferred because it allows a + # vector of TRs. When it is absent we fall back to the plain + # RepetitionTime rather than dropping M0 timing entirely. + fallback_tr = asl_data.get("RepetitionTime", []) + if fallback_tr in (None, []): + context.warnings.append( + f"Warning: Cannot determine M0 preparation timing for ASL file " + f"'{asl_filename}' because neither 'RepetitionTimePreparation' " + f"nor 'RepetitionTime' is present, but TSV file '{tsv_filename}' " + f"contains m0scan." + ) + return + context.warnings.append( + f"Warning: 'RepetitionTimePreparation' is missing for ASL file " + f"'{asl_filename}'; using 'RepetitionTime' for M0 preparation timing." + ) + repetition_times = fallback_tr + if not isinstance(repetition_times, list): repetition_times = [repetition_times] @@ -426,10 +517,12 @@ def _handle_m0scan_timing(self, asl_data: Dict[str, Any], m0scan_count: int, con f"than the number of 'm0scan' in TSV file '{tsv_filename}'" ) - def _handle_no_m0scan_warnings(self, context: ProcessingContext, asl_filename: str, asl_data: Dict[str, Any]) -> None: + def _handle_no_m0scan_warnings( + self, context: ProcessingContext, asl_filename: str, asl_data: Dict[str, Any] + ) -> None: """ Handle warnings when no M0 scan is provided but background suppression is enabled. - + Args: context: Processing context to update. asl_filename: Name of ASL file. @@ -447,26 +540,53 @@ def _handle_no_m0scan_warnings(self, context: ProcessingContext, asl_filename: s f"only a relative quantification is possible." ) - def _analyze_dicom_repetitions(self, asl_data: Dict[str, Any], context: ProcessingContext) -> None: + def _warn_if_voxel_geometry_missing( + self, context: ProcessingContext, asl_filename: str, asl_data: Dict[str, Any] + ) -> None: + """Warn when acquisition voxel geometry cannot be determined. + + The report drops the in-plane resolution and slice-thickness clause when + 'AcquisitionVoxelSize' is absent; this surfaces that omission to the user + instead of dropping it silently. + + Args: + context: Processing context to update. + asl_filename: Name of ASL file. + asl_data: ASL data dictionary. + """ + voxel_size = asl_data.get("AcquisitionVoxelSize") + if not isinstance(voxel_size, (list, tuple)) or len(voxel_size) < 3: + context.warnings.append( + f"Warning: Acquisition voxel geometry is missing for ASL file " + f"'{asl_filename}'; 'AcquisitionVoxelSize' is absent or " + f"incomplete, so in-plane resolution and slice thickness are " + f"omitted from the report." + ) + + def _analyze_dicom_repetitions( + self, asl_data: Dict[str, Any], context: ProcessingContext + ) -> None: """ Analyze repetitions for DICOM input. - + Args: asl_data: ASL data dictionary. context: Processing context to update. """ - if 'lRepetitions' in asl_data: - context.total_acquired_pairs = math.ceil(int(asl_data['lRepetitions']) / 2) - asl_data['TotalAcquiredPairs'] = context.total_acquired_pairs + if "lRepetitions" in asl_data: + context.total_acquired_pairs = math.ceil(int(asl_data["lRepetitions"]) / 2) + asl_data["TotalAcquiredPairs"] = context.total_acquired_pairs context.global_pattern = "control-label" - def _generate_reports_and_results(self, context: ProcessingContext) -> Dict[str, Any]: + def _generate_reports_and_results( + self, context: ProcessingContext + ) -> Dict[str, Any]: """ Generate validation reports and final results. - + Args: context: Processing context containing all extracted data. - + Returns: Dictionary containing all processing results. """ @@ -478,55 +598,89 @@ def _generate_reports_and_results(self, context: ProcessingContext) -> Dict[str, # Run validation validation_results = ASLValidator().validate(validation_data) - combined_major_errors, combined_major_errors_concise, combined_errors, combined_errors_concise, \ - combined_warnings, combined_warnings_concise, combined_values = validation_results + ( + combined_major_errors, + combined_major_errors_concise, + combined_errors, + combined_errors_concise, + combined_warnings, + combined_warnings_concise, + combined_values, + ) = validation_results # Add M0-specific errors and warnings ASLUtils.ensure_keys_and_append(combined_errors, "m0_error", context.errors) - ASLUtils.ensure_keys_and_append(combined_warnings, "m0_warning", context.warnings) + ASLUtils.ensure_keys_and_append( + combined_warnings, "m0_warning", context.warnings + ) # Generate concise error and warning texts - error_texts = self._generate_concise_texts(combined_major_errors_concise, combined_errors_concise, combined_warnings_concise) + error_texts = self._generate_concise_texts( + combined_major_errors_concise, + combined_errors_concise, + combined_warnings_concise, + ) # Extract inconsistencies - inconsistencies = self._extract_inconsistencies(combined_errors_concise, combined_major_errors_concise, combined_warnings_concise) + inconsistencies = self._extract_inconsistencies( + combined_errors_concise, + combined_major_errors_concise, + combined_warnings_concise, + ) # Generate M0-specific concise errors and warnings - m0_concise_error, m0_concise_error_params = ASLUtils.condense_and_reformat_discrepancies(context.errors) - m0_concise_warning, _ = ASLUtils.condense_and_reformat_discrepancies(context.warnings) + m0_concise_error, m0_concise_error_params = ( + ASLUtils.condense_and_reformat_discrepancies(context.errors) + ) + m0_concise_warning, _ = ASLUtils.condense_and_reformat_discrepancies( + context.warnings + ) # Determine M0 TR and generate reports M0_TR, report_line_on_M0 = ASLUtils.determine_m0_tr_and_report( - context.m0_prep_times_collection, context.all_absent, context.bs_all_off, - context.errors, m0_type=context.m0_type, inconsistent_params=m0_concise_error_params + context.m0_prep_times_collection, + context.all_absent, + context.bs_all_off, + context.errors, + m0_type=context.m0_type, + inconsistent_params=m0_concise_error_params, ) # Generate ASL and M0 reports reports = self._generate_reports( - combined_values, combined_major_errors, combined_errors, context, M0_TR, report_line_on_M0 + combined_values, + combined_major_errors, + combined_errors, + context, + M0_TR, + report_line_on_M0, ) # Prepare parameters parameters = self._prepare_parameters(context, M0_TR, reports) - required_condition_schema = config['schemas']['required_condition_schema'] + required_condition_schema = config["schemas"]["required_condition_schema"] missing_required_parameters: Dict[str, str] = {} for idx, session in enumerate(context.asl_json_data): - asl_type = session.get('ArterialSpinLabelingType', None) + asl_type = session.get("ArterialSpinLabelingType", None) for param, condition in required_condition_schema.items(): # Determine if this param is required for this ASL type is_required = False - if condition == 'all': + if condition == "all": is_required = True elif isinstance(condition, dict): - asl_type_list = condition.get('ArterialSpinLabelingType', []) + asl_type_list = condition.get("ArterialSpinLabelingType", []) if isinstance(asl_type_list, str): asl_type_list = [asl_type_list] if asl_type and asl_type in asl_type_list: is_required = True if is_required and param not in session: - param_units = config['schemas'].get('param_units', {}) or {} - unit = param_units.get(param, '-') if isinstance(param_units, dict) else '-' + param_units = config["schemas"].get("param_units", {}) or {} + unit = ( + param_units.get(param, "-") + if isinstance(param_units, dict) + else "-" + ) missing_required_parameters[param] = unit return { @@ -550,50 +704,73 @@ def _generate_reports_and_results(self, context: ProcessingContext) -> Dict[str, "asl_parameters": parameters["asl"], "m0_parameters": parameters["m0"], "extended_parameters": parameters["extended"], - "missing_required_parameters": missing_required_parameters + "missing_required_parameters": missing_required_parameters, } - def _generate_concise_texts(self, combined_major_errors_concise: Dict, combined_errors_concise: Dict, combined_warnings_concise: Dict) -> Dict[str, str]: + def _generate_concise_texts( + self, + combined_major_errors_concise: Dict, + combined_errors_concise: Dict, + combined_warnings_concise: Dict, + ) -> Dict[str, str]: """ Generate concise error and warning texts. - + Args: combined_major_errors_concise: Concise major errors. combined_errors_concise: Concise errors. combined_warnings_concise: Concise warnings. - + Returns: Dictionary containing concise texts. """ return { - "major_errors": ASLUtils.extract_concise_error(combined_major_errors_concise), + "major_errors": ASLUtils.extract_concise_error( + combined_major_errors_concise + ), "errors": ASLUtils.extract_concise_error(combined_errors_concise), - "warnings": ASLUtils.extract_concise_error(combined_warnings_concise) + "warnings": ASLUtils.extract_concise_error(combined_warnings_concise), } - def _extract_inconsistencies(self, combined_errors_concise: Dict, combined_major_errors_concise: Dict, combined_warnings_concise: Dict) -> Dict[str, List[str]]: + def _extract_inconsistencies( + self, + combined_errors_concise: Dict, + combined_major_errors_concise: Dict, + combined_warnings_concise: Dict, + ) -> Dict[str, List[str]]: """ Extract inconsistencies from validation results. - + Args: combined_errors_concise: Concise errors. combined_major_errors_concise: Concise major errors. combined_warnings_concise: Concise warnings. - + Returns: Dictionary containing inconsistency lists. """ return { "errors": ReportGenerator.extract_inconsistencies(combined_errors_concise), - "major_errors": ReportGenerator.extract_inconsistencies(combined_major_errors_concise), - "warnings": ReportGenerator.extract_inconsistencies(combined_warnings_concise) + "major_errors": ReportGenerator.extract_inconsistencies( + combined_major_errors_concise + ), + "warnings": ReportGenerator.extract_inconsistencies( + combined_warnings_concise + ), } - def _generate_reports(self, combined_values: Dict, combined_major_errors: Dict, combined_errors: Dict, - context: ProcessingContext, M0_TR: Any, report_line_on_M0: str) -> Dict[str, Any]: + def _generate_reports( + self, + combined_values: Dict, + combined_major_errors: Dict, + combined_errors: Dict, + context: ProcessingContext, + M0_TR: Any, + report_line_on_M0: str, + ) -> Dict[str, Any]: """ Generate ASL and M0 reports. - + Args: combined_values: Combined validation values. combined_major_errors: Combined major errors. @@ -601,16 +778,20 @@ def _generate_reports(self, combined_values: Dict, combined_major_errors: Dict, context: Processing context. M0_TR: M0 TR value. report_line_on_M0: M0 report line. - + Returns: Dictionary containing basic and extended reports. """ asl_report, asl_parameters = ReportGenerator.generate_asl_report( - combined_values, combined_major_errors, combined_errors, context.global_pattern, - context.m0_type, total_acquired_pairs=context.total_acquired_pairs, - slice_number=context.nifti_slice_number + combined_values, + combined_major_errors, + combined_errors, + context.global_pattern, + context.m0_type, + total_acquired_pairs=context.total_acquired_pairs, + slice_number=context.nifti_slice_number, ) - + m0_report = ReportGenerator.generate_m0_report(report_line_on_M0, M0_TR) basic_report = asl_report + m0_report @@ -623,18 +804,20 @@ def _generate_reports(self, combined_values: Dict, combined_major_errors: Dict, "basic_report": basic_report, "extended_report": extended_report, "asl_parameters": asl_parameters, - "extended_parameters": extended_parameters + "extended_parameters": extended_parameters, } - def _prepare_parameters(self, context: ProcessingContext, M0_TR: Any, reports: Dict) -> Dict[str, List]: + def _prepare_parameters( + self, context: ProcessingContext, M0_TR: Any, reports: Dict + ) -> Dict[str, List]: """ Prepare parameter lists for the result. - + Args: context: Processing context. M0_TR: M0 TR value. reports: Dictionary containing report parameters. - + Returns: Dictionary containing parameter lists. """ @@ -644,13 +827,17 @@ def _prepare_parameters(self, context: ProcessingContext, M0_TR: Any, reports: D m0_parameters.append(("M0 TR", M0_TR)) # Convert boolean values to strings for ASL and extended parameters - asl_parameters = [(key, "True" if isinstance(value, bool) and value else value) - for key, value in reports["asl_parameters"]] - extended_parameters = [(key, "True" if isinstance(value, bool) and value else value) - for key, value in reports["extended_parameters"]] + asl_parameters = [ + (key, "True" if isinstance(value, bool) and value else value) + for key, value in reports["asl_parameters"] + ] + extended_parameters = [ + (key, "True" if isinstance(value, bool) and value else value) + for key, value in reports["extended_parameters"] + ] return { "asl": asl_parameters, "m0": m0_parameters, - "extended": extended_parameters + "extended": extended_parameters, } diff --git a/package/src/pyaslreport/modalities/asl/report_generator.py b/package/src/pyaslreport/modalities/asl/report_generator.py index 5887d786..06a3b281 100644 --- a/package/src/pyaslreport/modalities/asl/report_generator.py +++ b/package/src/pyaslreport/modalities/asl/report_generator.py @@ -1,181 +1,449 @@ +import math from collections import Counter +from typing import Any class ReportGenerator: from collections import Counter - # Function to generate the ASL report based on the provided values and error data. - # The function constructs a detailed text report and also extracts key parameters for further use. + # ===================================================================== + # Tier 1 helpers (readability / cohesion) + # ===================================================================== @staticmethod - def generate_asl_report(values, combined_major_errors, combined_errors, global_pattern, m0_type, - total_acquired_pairs, slice_number): - report_lines = [] - asl_parameters = [] - - pld_type = ReportGenerator.handle_bolus_cutoff_technique(values, 'PLDType', combined_major_errors) - # total_acquired_pairs = ReportGenerator.extract_value(values, "TotalAcquiredPairs", combined_errors) + def _is_missing(value: object) -> bool: + """Return whether a value carries no usable report information. - extended_pld_text = ReportGenerator.handle_pld_values(values, combined_errors, 'PostLabelingDelay', - global_pattern, m0_type) + Args: + value: Extracted parameter value to inspect. - magnetic_field_strength = ReportGenerator.extract_value(values, "MagneticFieldStrength", combined_errors) - manufacturer = ReportGenerator.extract_value(values, "Manufacturer", combined_errors) - manufacturers_model_name = ReportGenerator.extract_and_format_unique_string_values(values, - "ManufacturersModelName") - asl_type = ReportGenerator.extract_value(values, "ArterialSpinLabelingType", combined_major_errors) - mr_acq_type = ReportGenerator.extract_value(values, "MRAcquisitionType", combined_major_errors) - pulse_seq_type = ReportGenerator.extract_value(values, "PulseSequenceType", combined_major_errors) + Returns: + True when the value is None or a blank / "N/A" string. + """ + if value is None: + return True + if isinstance(value, str) and value.strip() in ("", "N/A"): + return True + return False - if pulse_seq_type == "3Dgrase": - pulse_seq_type = "GRASE" + @staticmethod + def _join_and(parts: list[object]) -> str: + """Join items into natural-language prose with an Oxford comma. + + Args: + parts: Items to join; falsy items are dropped. + + Returns: + "" for no items, "a", "a and b", or "a, b, and c". + """ + text_parts = [str(part) for part in parts if part] + if not text_parts: + return "" + if len(text_parts) == 1: + return text_parts[0] + if len(text_parts) == 2: + return f"{text_parts[0]} and {text_parts[1]}" + return ", ".join(text_parts[:-1]) + ", and " + text_parts[-1] - echo_time = ReportGenerator.extract_value(values, "EchoTime", combined_errors) - repetition_time = ReportGenerator.handle_pld_values(values, combined_errors, 'RepetitionTimePreparation') + @staticmethod + def _fmt_num(value: object, sig: int = 4) -> object: + """Round a number to `sig` significant figures for display. + + Non-numeric values (including booleans) are returned unchanged. A whole + result is returned as an int so it renders without a trailing ".0". + + Args: + value: Value to format; only ints and floats are rounded. + sig: Number of significant figures to keep. + + Returns: + The rounded number (int when whole), or the value unchanged. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + return value + if value == 0 or not math.isfinite(value): + return value + digits = sig - 1 - math.floor(math.log10(abs(value))) + rounded = round(value, digits) + return int(rounded) if rounded == int(rounded) else rounded - flip_angle = ReportGenerator.extract_value(values, "FlipAngle", combined_errors) - labeling_duration = ReportGenerator.extract_value(values, "LabelingDuration", combined_errors) + @staticmethod + def _within_tolerance(numbers: list[float], tol: float = 0.01) -> bool: + """Return whether all numbers lie within a fractional tolerance. + + Args: + numbers: Numeric values to compare (must be non-empty). + tol: Maximum fractional spread, e.g. 0.01 for 1%. + + Returns: + True when the spread (max - min) relative to the mean is <= `tol`. + """ + lo, hi = min(numbers), max(numbers) + if lo == hi: + return True + mean = sum(numbers) / len(numbers) + if mean == 0: + return False + return (hi - lo) / abs(mean) <= tol - voxel_size_1_2, voxel_size_3 = ReportGenerator.handle_voxel_size(values, combined_errors) + @staticmethod + def _format_duration_list(value: object, unit: str = "ms") -> str: + """Format a duration value or list, collapsing near-identical entries. + + Args: + value: Scalar duration, list/tuple of durations, or a missing value. + unit: Unit suffix appended to each rendered number. + + Returns: + "" when the value is missing, a single "" when a list holds + values that agree within 1%, otherwise the comma-joined values. + """ + if ReportGenerator._is_missing(value): + return "" + if isinstance(value, (list, tuple)): + numbers = [ + v + for v in value + if isinstance(v, (int, float)) and not isinstance(v, bool) + ] + if ( + numbers + and len(numbers) == len(value) + and ReportGenerator._within_tolerance(numbers) + ): + mean = sum(numbers) / len(numbers) + return f"{ReportGenerator._fmt_num(mean)}{unit}" + formatted = [ReportGenerator._fmt_num(v) for v in value] + uniq = list(dict.fromkeys(formatted)) # de-duplicate, preserve order + if len(uniq) == 1: + return f"{uniq[0]}{unit}" + return ", ".join(f"{v}{unit}" for v in formatted) + return f"{ReportGenerator._fmt_num(value)}{unit}" + + # Raw internal pattern tokens -> human-readable words + _PATTERN_DISPLAY = { + # GE performs control/label subtraction on the scanner and stores only + # deltaM volumes, but each represents one control-label pair, so it is + # reported as such (meeting notes, Jan). + "deltam": "control-label", + "controllabel": "control-label", + "labelcontrol": "label-control", + } - bolus_cutoff_flag = ReportGenerator.handle_bolus_cutoff_flag(values, 'BolusCutOffFlag', combined_errors) - bolus_cutoff_technique = ReportGenerator.handle_bolus_cutoff_technique(values, 'BolusCutOffTechnique', - combined_errors) - bolus_cutoff_delay_time = ReportGenerator.handle_bolus_cutoff_delay_time(values, combined_errors) + @staticmethod + def _pattern_words(global_pattern: str, plural: bool) -> tuple[str, str]: + """Map an internal volume pattern to a display label and count noun. - background_suppression = ReportGenerator.handle_bolus_cutoff_flag(values, 'BackgroundSuppression', - combined_errors) - background_suppression_number_pulses = ReportGenerator.extract_value(values, 'BackgroundSuppressionNumberPulses', - combined_errors, report_range=True) - background_suppression_pulse_time = ReportGenerator.extract_value(values, 'BackgroundSuppressionPulseTime', - combined_errors) + Every ASL volume pattern is reported in control-label "pair" terms; the + GE "deltam" case is treated as a control-label pair rather than a + standalone deltaM volume. - acquisition_duration = ReportGenerator.extract_value(values, "AcquisitionDuration", combined_errors, - format_duration=True) - pasl_type = ReportGenerator.extract_value(values, "PASLType", combined_errors, recommneded=True) - labeling_slab_thickness = ReportGenerator.extract_value(values, "LabelingSlabThickness", combined_errors, - recommneded=True) + Args: + global_pattern: Internal token (e.g. "deltam", "controllabel"). + plural: Whether the count noun should be pluralized. - report_lines.append( - f"ASL was acquired on a {magnetic_field_strength}T {manufacturer}" - f" {manufacturers_model_name} scanner using {pld_type} " - ) - asl_parameters.append(("Magnetic Field Strength", f"{magnetic_field_strength}T")) - asl_parameters.append(("Manufacturer", manufacturer)) - asl_parameters.append(("Manufacturer's Model Name", manufacturers_model_name)) - asl_parameters.append(("PLD Type", pld_type)) + Returns: + A (label, noun) pair, e.g. ("control-label", "pairs") or + ("label-control", "pair"). + """ + label = ReportGenerator._PATTERN_DISPLAY.get(global_pattern, global_pattern) + noun = "pairs" if plural else "pair" + return label, noun - if pasl_type != "": - report_lines.append(f"{pasl_type} ") - asl_parameters.append(("PASL Type", pasl_type)) + @staticmethod + def generate_asl_report( + values: dict[str, Any], + combined_major_errors: dict[str, Any], + combined_errors: dict[str, Any], + global_pattern: str | None, + m0_type: str | None, + total_acquired_pairs: int | None, + slice_number: int, + ) -> tuple[str, list[tuple[str, Any]]]: + """Build the ASL Methods paragraph and its structured parameter list. + + Rewritten as a modular, multi-sentence builder: scanner/family, + timing/suppression, and geometry/repetitions. Clauses whose values are + missing drop out entirely instead of emitting placeholders. + + Args: + values: Extracted BIDS parameter values keyed by field name. + combined_major_errors: Major-error data keyed by field name. + combined_errors: Non-major error/inconsistency data keyed by field. + global_pattern: ASL volume pattern token, or None. + m0_type: M0 acquisition type, or None. + total_acquired_pairs: Number of acquired control-label pairs, or None. + slice_number: Number of slices from the NIfTI header. + + Returns: + A (report_paragraph, asl_parameters) tuple, where asl_parameters is + a list of (label, value) pairs. + """ + asl_parameters = [] + is_missing = ReportGenerator._is_missing + fmt_num = ReportGenerator._fmt_num - report_lines.append( - f"{asl_type} labeling and a {mr_acq_type} {pulse_seq_type} readout with the following parameters: " + # ---- extraction (unchanged from original) ---- + pld_type = ReportGenerator.handle_bolus_cutoff_technique( + values, "PLDType", combined_major_errors ) - asl_parameters.append(("ASL Type", asl_type)) - asl_parameters.append(("MR Acquisition Type", mr_acq_type)) - asl_parameters.append(("Pulse Sequence Type", pulse_seq_type)) - - report_lines.append( - f"TE = {echo_time}ms, TR = {repetition_time}, " - f"flip angle {flip_angle} degrees, " + extended_pld_text = ReportGenerator.handle_pld_values( + values, combined_errors, "PostLabelingDelay", global_pattern, m0_type ) - asl_parameters.append(("Echo Time", f"{echo_time}ms")) - asl_parameters.append(("Repetition Time", repetition_time)) - asl_parameters.append(("Flip Angle", flip_angle)) - - report_lines.append( - f"in-plane resolution {voxel_size_1_2}mm^2, " + magnetic_field_strength = ReportGenerator.extract_value( + values, "MagneticFieldStrength", combined_errors + ) + manufacturer = ReportGenerator.extract_value( + values, "Manufacturer", combined_errors + ) + manufacturers_model_name = ( + ReportGenerator.extract_and_format_unique_string_values( + values, "ManufacturersModelName" + ) + ) + asl_type = ReportGenerator.extract_value( + values, "ArterialSpinLabelingType", combined_major_errors + ) + mr_acq_type = ReportGenerator.extract_value( + values, "MRAcquisitionType", combined_major_errors + ) + pulse_seq_type = ReportGenerator.extract_value( + values, "PulseSequenceType", combined_major_errors + ) + if pulse_seq_type == "3Dgrase": + pulse_seq_type = "GRASE" + echo_time = ReportGenerator.extract_value(values, "EchoTime", combined_errors) + repetition_time = ReportGenerator.handle_pld_values( + values, combined_errors, "RepetitionTimePreparation" + ) + flip_angle = ReportGenerator.extract_value(values, "FlipAngle", combined_errors) + labeling_duration = ReportGenerator.extract_value( + values, "LabelingDuration", combined_errors + ) + voxel_size_1_2, voxel_size_3 = ReportGenerator.handle_voxel_size( + values, combined_errors + ) + bolus_cutoff_flag = ReportGenerator.handle_bolus_cutoff_flag( + values, "BolusCutOffFlag", combined_errors + ) + bolus_cutoff_technique = ReportGenerator.handle_bolus_cutoff_technique( + values, "BolusCutOffTechnique", combined_errors + ) + bolus_cutoff_delay_time = ReportGenerator.handle_bolus_cutoff_delay_time( + values, combined_errors + ) + background_suppression = ReportGenerator.handle_bolus_cutoff_flag( + values, "BackgroundSuppression", combined_errors + ) + background_suppression_number_pulses = ReportGenerator.extract_value( + values, + "BackgroundSuppressionNumberPulses", + combined_errors, + report_range=True, + ) + background_suppression_pulse_time = ReportGenerator.extract_value( + values, "BackgroundSuppressionPulseTime", combined_errors + ) + acquisition_duration = ReportGenerator.extract_value( + values, "AcquisitionDuration", combined_errors, format_duration=True + ) + pasl_type = ReportGenerator.extract_value( + values, "PASLType", combined_errors, recommneded=True + ) + labeling_slab_thickness = ReportGenerator.extract_value( + values, "LabelingSlabThickness", combined_errors, recommneded=True ) - asl_parameters.append(("In-plane Resolution", f"{voxel_size_1_2}mm^2")) - report_lines.append( - f"{slice_number} slices with {voxel_size_3}mm thickness, " + is_pasl = str(asl_type).upper() == "PASL" + is_pcasl = str(asl_type).upper() in ("PCASL", "CASL") + + # ================================================================= + # Sentence 1: scanner + acquisition family + readout + # ================================================================= + scanner = "" + if not is_missing(magnetic_field_strength): + scanner += f"{magnetic_field_strength}T " + for part in (manufacturer, manufacturers_model_name): + if not is_missing(part): + scanner += f"{part} " + scanner = scanner.strip() + s1 = "ASL was acquired on a " + ( + f"{scanner} scanner." if scanner else "scanner." ) - asl_parameters.append(("Slice Thickness", f"{voxel_size_3}mm")) - if asl_type == 'PCASL': - report_lines.append( - f"labeling duration {labeling_duration}ms, " - ) - asl_parameters.append(("Labeling Duration", labeling_duration)) + # labeling phrase (drop 'N/A' ASL type instead of writing 'N/A labeling') + family = ReportGenerator._join_and([]) or " ".join( + [ + p + for p in ( + pld_type if not is_missing(pld_type) else "", + pasl_type if not is_missing(pasl_type) else "", + asl_type if not is_missing(asl_type) else "", + ) + if p + ] + ) + # readout: de-duplicate dimensionality vs readout type ('3D 3D' -> '3D') + readout_parts = [] + if not is_missing(mr_acq_type): + readout_parts.append(mr_acq_type) + if not is_missing(pulse_seq_type) and pulse_seq_type != mr_acq_type: + readout_parts.append(pulse_seq_type) + readout = " ".join(readout_parts) + + family_cap = (family[0].upper() + family[1:]) if family else family + if family and readout: + s1 += f" {family_cap} labeling was performed with a {readout} readout." + elif family: + s1 += f" {family_cap} labeling was performed." + elif readout: + s1 += f" A {readout} readout was used." - report_lines.append( - f"PLD {extended_pld_text}, " - ) - asl_parameters.append(("PLD", extended_pld_text)) + asl_parameters.append( + ("Magnetic Field Strength", f"{magnetic_field_strength}T") + ) + asl_parameters.append(("Manufacturer", manufacturer)) + asl_parameters.append(("Manufacturer's Model Name", manufacturers_model_name)) + asl_parameters.append(("PLD Type", pld_type)) + if not is_missing(pasl_type): + asl_parameters.append(("PASL Type", pasl_type)) + asl_parameters.append(("ASL Type", asl_type)) + asl_parameters.append(("MR Acquisition Type", mr_acq_type)) + asl_parameters.append(("Pulse Sequence Type", pulse_seq_type)) - if asl_type.upper() == 'PASL': - report_lines.append( - f"inversion time {extended_pld_text}, " + # ================================================================= + # Sentence 2: ASL timing + background suppression + # ================================================================= + timing = "" + if is_pcasl: + ld = ReportGenerator._format_duration_list(labeling_duration, "ms") + pld = "" if is_missing(extended_pld_text) else str(extended_pld_text) + if ld: + asl_parameters.append(("Labeling Duration", labeling_duration)) + if pld: + asl_parameters.append(("PLD", extended_pld_text)) + pld_word = "a PLD of" if ("," not in pld) else "PLDs of" + if ld and pld: + timing = f"The labeling duration was {ld}, followed by {pld_word} {pld}" + elif ld: + timing = f"The labeling duration was {ld}" + elif pld: + timing = f"{pld_word[0].upper() + pld_word[1:]} {pld}" + elif is_pasl: + inv = "" if is_missing(extended_pld_text) else str(extended_pld_text) + inv_word = ( + "an inversion time of" if ("," not in inv) else "inversion times of" ) - asl_parameters.append(("Inversion Time", extended_pld_text)) - - if labeling_slab_thickness != "": - report_lines.append( - f"labeling slab thickness {labeling_slab_thickness}mm, " + if inv: + timing = f"Labeling used {inv_word} {inv}" + asl_parameters.append(("Inversion Time", extended_pld_text)) + if not is_missing(labeling_slab_thickness): + clause = f"a labeling slab thickness of {labeling_slab_thickness}mm" + timing = ( + f"{timing}, with {clause}" + if timing + else clause[0].upper() + clause[1:] ) - asl_parameters.append(("Labeling Slab Thickness", f"{labeling_slab_thickness}mm")) - - if bolus_cutoff_flag is not None: - report_lines.append( - f"{bolus_cutoff_flag} bolus saturation " + asl_parameters.append( + ("Labeling Slab Thickness", f"{labeling_slab_thickness}mm") ) - asl_parameters.append(("Bolus Cutoff Flag", bolus_cutoff_flag)) - if bolus_cutoff_flag == "with": - report_lines.append( - f"using {bolus_cutoff_technique} pulse " + if bolus_cutoff_flag == "with": + bolus = "with bolus saturation" + if not is_missing(bolus_cutoff_technique): + bolus += f" using a {bolus_cutoff_technique} pulse" + asl_parameters.append( + ("Bolus Cutoff Technique", bolus_cutoff_technique) ) - asl_parameters.append(("Bolus Cutoff Technique", bolus_cutoff_technique)) - report_lines.append( - f"applied {bolus_cutoff_delay_time} after the labeling, " + if not is_missing(bolus_cutoff_delay_time): + bolus += f" applied {bolus_cutoff_delay_time} after labeling" + asl_parameters.append( + ("Bolus Cutoff Delay Time", bolus_cutoff_delay_time) ) - asl_parameters.append(("Bolus Cutoff Delay Time", bolus_cutoff_delay_time)) - - if background_suppression is not None: - report_lines.append(f"{background_suppression} background suppression") - asl_parameters.append(("Background Suppression", background_suppression)) + timing = ( + f"{timing}, {bolus}" if timing else bolus[0].upper() + bolus[1:] + ) + asl_parameters.append(("Bolus Cutoff Flag", bolus_cutoff_flag)) + elif bolus_cutoff_flag == "without": + asl_parameters.append(("Bolus Cutoff Flag", bolus_cutoff_flag)) - if background_suppression_number_pulses is not None and background_suppression_number_pulses != "N/A": - report_lines.append( - f" with {background_suppression_number_pulses} pulses") - asl_parameters.append( - ("Background Suppression Number of Pulses", background_suppression_number_pulses)) - if background_suppression_pulse_time is not None and background_suppression_pulse_time != "N/A": - report_lines.append( - f" at {ReportGenerator.format_background_suppression(background_suppression_pulse_time)} after the start of labeling") + suppression = "" + if background_suppression == "with": + suppression = "Background suppression was applied" + if not is_missing(background_suppression_number_pulses): + suppression += f" using {background_suppression_number_pulses} pulses" asl_parameters.append( - ("Background Suppression Pulse Time", - ReportGenerator.format_background_suppression(background_suppression_pulse_time))) - report_lines.append(".") - - if total_acquired_pairs == 1: - if (global_pattern == "deltam"): - classifer = "volume" - else: - classifer = "pair" + ( + "Background Suppression Number of Pulses", + background_suppression_number_pulses, + ) + ) + if not is_missing(background_suppression_pulse_time): + times = ReportGenerator.format_background_suppression( + background_suppression_pulse_time + ) + suppression += f", with pulses at {times} after the start of labeling" + asl_parameters.append(("Background Suppression Pulse Time", times)) + elif background_suppression == "without": + suppression = "Background suppression was not applied" + elif not is_missing(background_suppression): + suppression = f"Background suppression was {background_suppression}" + if not is_missing(background_suppression): + asl_parameters.append(("Background Suppression", background_suppression)) - report_lines.append( - f" In total, {total_acquired_pairs} {global_pattern} {classifer} was acquired" - ) - else: - if (global_pattern == "deltam"): - classifer = "volumes" - else: - classifer = "pairs" + s2 = ". ".join([c for c in (timing, suppression) if c]) + if s2: + s2 += "." + + # ================================================================= + # Sentence 3: imaging geometry + repetitions + # ================================================================= + geo = [] + te_ok, tr_ok = not is_missing(echo_time), not is_missing(repetition_time) + if te_ok and tr_ok: + geo.append(f"TR/TE = {repetition_time}/{fmt_num(echo_time)}ms") + elif te_ok: + geo.append(f"TE = {fmt_num(echo_time)}ms") + elif tr_ok: + geo.append(f"TR = {repetition_time}") + if not is_missing(voxel_size_1_2): + geo.append(f"an in-plane resolution of {voxel_size_1_2}mm^2") + if slice_number: + geo.append(f"{slice_number} slices") + if not is_missing(voxel_size_3): + geo.append(f"a slice thickness of {voxel_size_3}mm") + if not is_missing(flip_angle): + geo.append(f"a flip angle of {fmt_num(flip_angle)} degrees") - report_lines.append( - f" In total, {total_acquired_pairs} {global_pattern} {classifer} were acquired" - ) + asl_parameters.append(("Echo Time", f"{echo_time}ms")) + asl_parameters.append(("Repetition Time", repetition_time)) + asl_parameters.append(("Flip Angle", flip_angle)) + asl_parameters.append(("In-plane Resolution", f"{voxel_size_1_2}mm^2")) + asl_parameters.append(("Slice Thickness", f"{voxel_size_3}mm")) - asl_parameters.append( - ("Total Acquired Pairs", total_acquired_pairs)) + s3 = ( + f"Images were acquired with {ReportGenerator._join_and(geo)}." + if geo + else "" + ) - if acquisition_duration != "N/A": - report_lines.append(f" in a {acquisition_duration} time.") - asl_parameters.append(("Acquisition Duration", acquisition_duration)) - else: - report_lines.append(".") - report_paragraph = "".join(line for line in report_lines) + # repetitions (guarded so a missing count never prints 'None') + rep = "" + pattern_known = global_pattern not in (None, "", "pattern error") + if ( + not is_missing(total_acquired_pairs) + and total_acquired_pairs != 0 + and pattern_known + ): + plural = total_acquired_pairs != 1 + label, noun = ReportGenerator._pattern_words(global_pattern, plural) + verb = "were" if plural else "was" + rep = f"In total, {total_acquired_pairs} {label} {noun} {verb} acquired" + if acquisition_duration != "N/A": + rep += f" over a {acquisition_duration} acquisition" + asl_parameters.append(("Acquisition Duration", acquisition_duration)) + rep += "." + asl_parameters.append(("Total Acquired Pairs", total_acquired_pairs)) + + report_paragraph = " ".join([s for s in (s1, s2, s3, rep) if s]) return report_paragraph, asl_parameters # Function to generate a report for M0 scans based on provided parameters. @@ -196,25 +464,33 @@ def generate_m0_report(report_line_on_M0, M0_TR): def generate_extended_report(values, combined_major_errors, combined_errors): report_lines = [] extended_parameters = [] - vascular_crushing = ReportGenerator.extract_value(values, "VascularCrushing", combined_errors, recommneded=True) - vascular_crushing_VENC = ReportGenerator.extract_value(values, "VascularCrushingVENC", combined_errors, - recommneded=True) - PCASL_type = ReportGenerator.extract_value(values, "PCASLType", combined_errors, - recommneded=True) - labeling_pulse_average_gradient = ReportGenerator.extract_value(values, "LabelingPulseAverageGradient", - combined_errors, - recommneded=True) - labeling_pulse_maximum_gradient = ReportGenerator.extract_value(values, "LabelingPulseMaximumGradient", - combined_errors, - recommneded=True) - labeling_pulse_average_B1 = ReportGenerator.extract_value(values, "LabelingPulseAverageB1", combined_errors, - recommneded=True) - labeling_pulse_flip_angle = ReportGenerator.extract_value(values, "LabelingPulseFlipAngle", combined_errors, - recommneded=True) - labeling_pulse_interval = ReportGenerator.extract_value(values, "LabelingPulseInterval", combined_errors, - recommneded=True) - labeling_pulse_duration = ReportGenerator.extract_value(values, "LabelingPulseDuration", combined_errors, - recommneded=True) + vascular_crushing = ReportGenerator.extract_value( + values, "VascularCrushing", combined_errors, recommneded=True + ) + vascular_crushing_VENC = ReportGenerator.extract_value( + values, "VascularCrushingVENC", combined_errors, recommneded=True + ) + PCASL_type = ReportGenerator.extract_value( + values, "PCASLType", combined_errors, recommneded=True + ) + labeling_pulse_average_gradient = ReportGenerator.extract_value( + values, "LabelingPulseAverageGradient", combined_errors, recommneded=True + ) + labeling_pulse_maximum_gradient = ReportGenerator.extract_value( + values, "LabelingPulseMaximumGradient", combined_errors, recommneded=True + ) + labeling_pulse_average_B1 = ReportGenerator.extract_value( + values, "LabelingPulseAverageB1", combined_errors, recommneded=True + ) + labeling_pulse_flip_angle = ReportGenerator.extract_value( + values, "LabelingPulseFlipAngle", combined_errors, recommneded=True + ) + labeling_pulse_interval = ReportGenerator.extract_value( + values, "LabelingPulseInterval", combined_errors, recommneded=True + ) + labeling_pulse_duration = ReportGenerator.extract_value( + values, "LabelingPulseDuration", combined_errors, recommneded=True + ) if isinstance(vascular_crushing, bool) and vascular_crushing: report_lines.append(" Vascular crushing was applied") extended_parameters.append(("Vascular Crushing", vascular_crushing)) @@ -222,7 +498,9 @@ def generate_extended_report(values, combined_major_errors, combined_errors): report_lines.append(".") else: report_lines.append(f" with a {vascular_crushing_VENC}cm/s threshold.") - extended_parameters.append(("Vascular Crushing VENC", vascular_crushing_VENC)) + extended_parameters.append( + ("Vascular Crushing VENC", vascular_crushing_VENC) + ) elif isinstance(vascular_crushing, str) and vascular_crushing: report_lines.append(f" Vascular crushing was {vascular_crushing}") extended_parameters.append(("Vascular Crushing", vascular_crushing)) @@ -230,7 +508,9 @@ def generate_extended_report(values, combined_major_errors, combined_errors): report_lines.append(".") else: report_lines.append(f" with a {vascular_crushing_VENC}cm/s threshold.") - extended_parameters.append(("Vascular Crushing VENC", vascular_crushing_VENC)) + extended_parameters.append( + ("Vascular Crushing VENC", vascular_crushing_VENC) + ) if PCASL_type: if PCASL_type == "balanced": @@ -238,47 +518,89 @@ def generate_extended_report(values, combined_major_errors, combined_errors): elif PCASL_type == "unbalanced": report_lines.append(f" Unbalanced") extended_parameters.append(("PCASL Type", PCASL_type)) - if (labeling_pulse_average_gradient or labeling_pulse_maximum_gradient or - labeling_pulse_average_B1 or labeling_pulse_flip_angle or labeling_pulse_interval - or labeling_pulse_duration): - report_lines.append(f" PCASL labeling was applied with the following pulse parameters: ") + if ( + labeling_pulse_average_gradient + or labeling_pulse_maximum_gradient + or labeling_pulse_average_B1 + or labeling_pulse_flip_angle + or labeling_pulse_interval + or labeling_pulse_duration + ): + report_lines.append( + f" PCASL labeling was applied with the following pulse parameters: " + ) if labeling_pulse_average_gradient: if not labeling_pulse_maximum_gradient: - report_lines.append(f"average pulse gradient {labeling_pulse_average_gradient}mT/m") + report_lines.append( + f"average pulse gradient {labeling_pulse_average_gradient}mT/m" + ) else: - report_lines.append(f"average {labeling_pulse_average_gradient}mT/m and ") + report_lines.append( + f"average {labeling_pulse_average_gradient}mT/m and " + ) extended_parameters.append( - ("Labeling Pulse Average Gradient", f"{labeling_pulse_average_gradient}mT/m")) + ( + "Labeling Pulse Average Gradient", + f"{labeling_pulse_average_gradient}mT/m", + ) + ) if labeling_pulse_maximum_gradient: - report_lines.append(f"maximum pulse gradient {labeling_pulse_maximum_gradient}mT/m") + report_lines.append( + f"maximum pulse gradient {labeling_pulse_maximum_gradient}mT/m" + ) extended_parameters.append( - ("Labeling Pulse Maximum Gradient", f"{labeling_pulse_maximum_gradient}mT/m")) - if ((labeling_pulse_average_gradient or labeling_pulse_maximum_gradient) and - (labeling_pulse_duration or labeling_pulse_interval or labeling_pulse_average_B1 - or labeling_pulse_flip_angle)): + ( + "Labeling Pulse Maximum Gradient", + f"{labeling_pulse_maximum_gradient}mT/m", + ) + ) + if (labeling_pulse_average_gradient or labeling_pulse_maximum_gradient) and ( + labeling_pulse_duration + or labeling_pulse_interval + or labeling_pulse_average_B1 + or labeling_pulse_flip_angle + ): report_lines.append(", ") if labeling_pulse_duration: report_lines.append(f"with {labeling_pulse_duration}ms pulses") - extended_parameters.append(("Labeling Pulse Duration", f"{labeling_pulse_duration}ms")) + extended_parameters.append( + ("Labeling Pulse Duration", f"{labeling_pulse_duration}ms") + ) if labeling_pulse_interval: report_lines.append(f" applied at {labeling_pulse_interval}ms intervals") - extended_parameters.append(("Labeling Pulse Interval", f"{labeling_pulse_interval}ms")) - if (labeling_pulse_duration or labeling_pulse_interval) and (labeling_pulse_average_B1 - or labeling_pulse_flip_angle): + extended_parameters.append( + ("Labeling Pulse Interval", f"{labeling_pulse_interval}ms") + ) + if (labeling_pulse_duration or labeling_pulse_interval) and ( + labeling_pulse_average_B1 or labeling_pulse_flip_angle + ): report_lines.append(", ") if labeling_pulse_average_B1: - report_lines.append(f"average B1-field strength {labeling_pulse_average_B1}mT") + report_lines.append( + f"average B1-field strength {labeling_pulse_average_B1}mT" + ) extended_parameters.append( - ("Labeling Pulse Average B1-field Strength", f"{labeling_pulse_average_B1}mT")) + ( + "Labeling Pulse Average B1-field Strength", + f"{labeling_pulse_average_B1}mT", + ) + ) elif labeling_pulse_flip_angle: report_lines.append(f"with {labeling_pulse_flip_angle} degree flip angle") - extended_parameters.append(("Labeling Pulse Flip Angle", labeling_pulse_flip_angle)) + extended_parameters.append( + ("Labeling Pulse Flip Angle", labeling_pulse_flip_angle) + ) - if (labeling_pulse_average_gradient or labeling_pulse_maximum_gradient or - labeling_pulse_average_B1 or labeling_pulse_flip_angle or labeling_pulse_interval - or labeling_pulse_duration): + if ( + labeling_pulse_average_gradient + or labeling_pulse_maximum_gradient + or labeling_pulse_average_B1 + or labeling_pulse_flip_angle + or labeling_pulse_interval + or labeling_pulse_duration + ): report_lines.append(".") report_paragraph = "".join(line for line in report_lines) return report_paragraph, extended_parameters @@ -286,21 +608,39 @@ def generate_extended_report(values, combined_major_errors, combined_errors): # Function to extract a specific value from the provided values dictionary. # This function also handles inconsistencies and formats the value based on the context (e.g., reporting range, duration). @staticmethod - def extract_value(values, key, combined_errors, report_range=False, format_duration=False, - recommneded=False): - status, most_common_value, value_range = ReportGenerator.handle_inconsistency(values, key, combined_errors) + def extract_value( + values, + key, + combined_errors, + report_range=False, + format_duration=False, + recommneded=False, + ): + status, most_common_value, value_range = ReportGenerator.handle_inconsistency( + values, key, combined_errors + ) if format_duration and isinstance(most_common_value, (int, float)): - most_common_value = ReportGenerator.format_acquisition_duration(most_common_value) + most_common_value = ReportGenerator.format_acquisition_duration( + most_common_value + ) if status == "consistent": if most_common_value == "N/A" and recommneded: return "" return most_common_value elif status == "inconsistent_common": - return f"(inconsistent, {most_common_value} is the most common data)" if not report_range else f"(inconsistent, {most_common_value} is the most common data, {value_range})" + return ( + f"(inconsistent, {most_common_value} is the most common data)" + if not report_range + else f"(inconsistent, {most_common_value} is the most common data, {value_range})" + ) else: - return f"(inconsistent, no common data, {value_range})" if report_range else "(inconsistent, no common data)" + return ( + f"(inconsistent, no common data, {value_range})" + if report_range + else "(inconsistent, no common data)" + ) # Helper function to handle inconsistencies in the extracted values. # This function checks for inconsistencies across different sources and identifies the most common value or range. @@ -327,8 +667,11 @@ def handle_inconsistency(values, key, combined_errors): counter = Counter(normalized_values) most_common_value, count = counter.most_common(1)[0] - flattened_values = [item for sublist in normalized_values for item in - (sublist if isinstance(sublist, tuple) else [sublist])] + flattened_values = [ + item + for sublist in normalized_values + for item in (sublist if isinstance(sublist, tuple) else [sublist]) + ] value_range = f"Range: {min(flattened_values)}-{max(flattened_values)}" if count > len(normalized_values) // 2: @@ -336,11 +679,15 @@ def handle_inconsistency(values, key, combined_errors): else: return "inconsistent_no_common", None, value_range else: - first_value = values.get(key, [['N/A']]) - if first_value and isinstance(first_value[0], list) and len(first_value[0]) > 1: + first_value = values.get(key, [["N/A"]]) + if ( + first_value + and isinstance(first_value[0], list) + and len(first_value[0]) > 1 + ): value = tuple(first_value[0]) else: - value = first_value[0][1] if first_value else 'N/A' + value = first_value[0][1] if first_value else "N/A" return "consistent", value, value_range @@ -349,7 +696,9 @@ def handle_inconsistency(values, key, combined_errors): # whether the data is consistent across multiple sources. @staticmethod def handle_bolus_cutoff_technique(values, key, combined_errors): - status, technique = ReportGenerator.handle_string_inconsistency(values, key, combined_errors) + status, technique = ReportGenerator.handle_string_inconsistency( + values, key, combined_errors + ) if status == "consistent": return technique elif status == "inconsistent_common": @@ -374,39 +723,59 @@ def handle_string_inconsistency(values, key, combined_errors): else: return "inconsistent_no_common", None else: - first_value = values.get(key, [['N/A']]) - return "consistent", str(first_value[0][1]) if first_value else 'N/A' + first_value = values.get(key, [["N/A"]]) + return "consistent", str(first_value[0][1]) if first_value else "N/A" # Helper function to handle voxel size extraction and format the results. # It accounts for inconsistencies in the voxel sizes and formats them appropriately for reporting. @staticmethod def handle_voxel_size(values, combined_errors): - status, acquisition_voxel_size, _ = ReportGenerator.handle_inconsistency(values, 'AcquisitionVoxelSize', - combined_errors) + status, acquisition_voxel_size, _ = ReportGenerator.handle_inconsistency( + values, "AcquisitionVoxelSize", combined_errors + ) if status == "consistent": - if isinstance(acquisition_voxel_size, (list, tuple)) and len(acquisition_voxel_size) >= 3: - voxel_size_1_2 = f"{acquisition_voxel_size[0]}x{acquisition_voxel_size[1]}" + if ( + isinstance(acquisition_voxel_size, (list, tuple)) + and len(acquisition_voxel_size) >= 3 + ): + voxel_size_1_2 = ( + f"{acquisition_voxel_size[0]}x{acquisition_voxel_size[1]}" + ) voxel_size_3 = acquisition_voxel_size[2] else: - voxel_size_1_2 = 'N/A' - voxel_size_3 = 'N/A' + voxel_size_1_2 = "N/A" + voxel_size_3 = "N/A" elif status == "inconsistent_common": - if isinstance(acquisition_voxel_size, (list, tuple)) and len(acquisition_voxel_size) >= 3: + if ( + isinstance(acquisition_voxel_size, (list, tuple)) + and len(acquisition_voxel_size) >= 3 + ): voxel_size_1_2 = f"(inconsistent, {acquisition_voxel_size[0]}x{acquisition_voxel_size[1]} is the most common)" - voxel_size_3 = f"(inconsistent, {acquisition_voxel_size[2]} is the most common)" + voxel_size_3 = ( + f"(inconsistent, {acquisition_voxel_size[2]} is the most common)" + ) else: - voxel_size_1_2 = 'N/A' - voxel_size_3 = 'N/A' + voxel_size_1_2 = "N/A" + voxel_size_3 = "N/A" else: - voxel_size_1_2 = 'N/A (inconsistent, with no common data.)' - voxel_size_3 = 'N/A (inconsistent, with no common data.)' + voxel_size_1_2 = "N/A (inconsistent, with no common data.)" + voxel_size_3 = "N/A (inconsistent, with no common data.)" return voxel_size_1_2, voxel_size_3 # Helper function to handle PLD (Post-Labeling Delay) values and format them for reporting. # The function manages cases where the PLD values are consistent or inconsistent across different sources. @staticmethod - def handle_pld_values(values, combined_errors, key, global_pattern=False, m0_type=""): - status, pld_values, _ = ReportGenerator.handle_inconsistency(values, key, combined_errors) + def handle_pld_values( + values, combined_errors, key, global_pattern=False, m0_type="" + ): + status, pld_values, _ = ReportGenerator.handle_inconsistency( + values, key, combined_errors + ) + # Round PLD values to 4 significant figures for display. + if isinstance(pld_values, (list, tuple)): + pld_values = [ReportGenerator._fmt_num(v) for v in pld_values] + else: + pld_values = ReportGenerator._fmt_num(pld_values) @staticmethod def format_pld_array(pld_array): @@ -414,14 +783,18 @@ def format_pld_array(pld_array): pld_counter = Counter(filtered_array) if global_pattern != "deltam": - formatted_pld = ', '.join( - [f"{pld}ms ({count // 2} {'repeat' if (count // 2) == 1 else 'repeats'})" for pld, count in - sorted(pld_counter.items())] + formatted_pld = ", ".join( + [ + f"{pld}ms ({count // 2} {'repeat' if (count // 2) == 1 else 'repeats'})" + for pld, count in sorted(pld_counter.items()) + ] ) else: - formatted_pld = ', '.join( - [f"{pld}ms ({count} {'volume' if count == 1 else 'volumes'})" for pld, count in - sorted(pld_counter.items())] + formatted_pld = ", ".join( + [ + f"{pld}ms ({count} {'volume' if count == 1 else 'volumes'})" + for pld, count in sorted(pld_counter.items()) + ] ) return formatted_pld @@ -435,18 +808,20 @@ def format_pld_array(pld_array): elif isinstance(pld_values, (int, float)): extended_pld_text = f"{pld_values}ms" else: - extended_pld_text = 'N/A' + extended_pld_text = "N/A" elif status == "inconsistent_common": if isinstance(pld_values, (list, tuple)): unique_values = set(pld_values) if len(unique_values) == 1: - extended_pld_text = f"(inconsistent, {unique_values.pop()}ms is the most common)" + extended_pld_text = ( + f"(inconsistent, {unique_values.pop()}ms is the most common)" + ) else: extended_pld_text = f"(inconsistent, {format_pld_array(pld_values)} is the most common)" elif isinstance(pld_values, (int, float)): extended_pld_text = f"(inconsistent, {pld_values}ms is the most common)" else: - extended_pld_text = 'N/A' + extended_pld_text = "N/A" else: extended_pld_text = "(inconsistent, no common data)" @@ -456,7 +831,9 @@ def format_pld_array(pld_array): # This function formats the bolus cutoff flag for reporting. @staticmethod def handle_bolus_cutoff_flag(values, key, combined_errors): - status, flag = ReportGenerator.handle_boolean_inconsistency(values, key, combined_errors) + status, flag = ReportGenerator.handle_boolean_inconsistency( + values, key, combined_errors + ) if status == "consistent": return "without" if not flag else "with" elif status == "inconsistent_common": @@ -481,24 +858,28 @@ def handle_boolean_inconsistency(values, key, combined_errors): else: return "inconsistent_no_common", None else: - first_value = values.get(key, [['N/A']]) - return "consistent", bool(first_value[0][1]) if first_value else 'N/A' + first_value = values.get(key, [["N/A"]]) + return "consistent", bool(first_value[0][1]) if first_value else "N/A" # Helper function to format bolus cutoff delay time. # It accounts for consistency or inconsistency in the values across different sources. @staticmethod def handle_bolus_cutoff_delay_time(values, combined_errors): - status, bolus_cutoff_delay_time, _ = ReportGenerator.handle_inconsistency(values, 'BolusCutOffDelayTime', - combined_errors) + status, bolus_cutoff_delay_time, _ = ReportGenerator.handle_inconsistency( + values, "BolusCutOffDelayTime", combined_errors + ) if status == "consistent": - if isinstance(bolus_cutoff_delay_time, (list, tuple)) and len(bolus_cutoff_delay_time) >= 2: + if ( + isinstance(bolus_cutoff_delay_time, (list, tuple)) + and len(bolus_cutoff_delay_time) >= 2 + ): return f"from {bolus_cutoff_delay_time[0]}ms to {bolus_cutoff_delay_time[len(bolus_cutoff_delay_time) - 1]}ms" elif isinstance(bolus_cutoff_delay_time, (list, tuple)): return f"at {bolus_cutoff_delay_time[0]}ms" else: return f"at {bolus_cutoff_delay_time}ms" elif status == "inconsistent_common": - return f"(inconsistent, {bolus_cutoff_delay_time}ms is the most common" + return f"(inconsistent, {bolus_cutoff_delay_time}ms is the most common)" else: return "(inconsistent, no common data)" @@ -507,16 +888,16 @@ def handle_bolus_cutoff_delay_time(values, combined_errors): @staticmethod def format_background_suppression(values): if not values: - return '' + return "" if all(isinstance(val, (int, float)) for val in values): values = list(map(str, values)) if len(values) == 1: - return values[0] + 'ms' + return values[0] + "ms" elif len(values) == 2: - return 'ms and '.join(values) + 'ms' + return "ms and ".join(values) + "ms" else: - return 'ms, '.join(values[:-1]) + 'ms, and ' + values[-1] + 'ms' + return "ms, ".join(values[:-1]) + "ms, and " + values[-1] + "ms" else: return values @@ -528,7 +909,7 @@ def format_acquisition_duration(duration): minutes = int(duration // 60) seconds = int(duration % 60) return f"{minutes}:{seconds:02d}min" - return 'N/A' + return "N/A" # Helper function to extract and format unique string values for reporting. # Combines all unique values into a single string, separated by slashes. @@ -545,7 +926,7 @@ def extract_and_format_unique_string_values(values, key): @staticmethod def extract_unique_values_from_array(values): unique_values = sorted(set(values)) - return ', '.join(map(str, unique_values)) + return ", ".join(map(str, unique_values)) # Function to extract inconsistencies from the error map and clean up the error dictionary. # This helps isolate specific inconsistency issues for better reporting. @@ -557,7 +938,9 @@ def extract_inconsistencies(error_map): for field, errors in error_map.items(): for error in errors: if "INCONSISTENCY" in error: - inconsistency_errors.append(f"{field}: {error.replace('INCONSISTENCY: ', '')}\n") + inconsistency_errors.append( + f"{field}: {error.replace('INCONSISTENCY: ', '')}\n" + ) errors.remove(error) if not errors: diff --git a/package/src/pyaslreport/modalities/asl/utils.py b/package/src/pyaslreport/modalities/asl/utils.py index 1c55d1f9..5877018c 100644 --- a/package/src/pyaslreport/modalities/asl/utils.py +++ b/package/src/pyaslreport/modalities/asl/utils.py @@ -1,14 +1,15 @@ import os -from pyaslreport.core.config import config +from pyaslreport.core.config import config from pyaslreport.io.readers.file_reader import FileReader + class ASLUtils: @staticmethod def determine_pld_type(session): # Check if any of the specified keys contain arrays with different unique values - for key in ['PostLabelingDelay', 'EchoTime', 'LabelingDuration']: + for key in ["PostLabelingDelay", "EchoTime", "LabelingDuration"]: if key in session and isinstance(session[key], list): unique_values = set(session[key]) if len(unique_values) > 1: @@ -22,12 +23,12 @@ def extract_params(data): "FlipAngle": data.get("FlipAngle"), "MagneticFieldStrength": data.get("MagneticFieldStrength"), "MRAcquisitionType": data.get("MRAcquisitionType"), - "PulseSequenceType": data.get("PulseSequenceType") + "PulseSequenceType": data.get("PulseSequenceType"), } @staticmethod def compare_params(params_asl, params_m0, asl_filename, m0_filename): - consistency_schema = config['schemas']['consistency_schema'] + consistency_schema = config["schemas"]["consistency_schema"] errors = [] warnings = [] @@ -38,57 +39,70 @@ def compare_params(params_asl, params_m0, asl_filename, m0_filename): if not schema: continue - validation_type = schema.get('validation_type') - warning_variation = schema.get('warning_variation', 1e-5) - error_variation = schema.get('error_variation', 1e-4) + validation_type = schema.get("validation_type") + warning_variation = schema.get("warning_variation", 1e-5) + error_variation = schema.get("error_variation", 1e-4) if validation_type == "string": if asl_value != m0_value: errors.append( f"Discrepancy in '{param}' for ASL file '{asl_filename}' and M0 file '{m0_filename}': " - f"ASL value = {asl_value}, M0 value = {m0_value}") + f"ASL value = {asl_value}, M0 value = {m0_value}" + ) elif validation_type == "floatOrArray": - if isinstance(asl_value, (int, float)) and isinstance(m0_value, (int, float)): + if isinstance(asl_value, (int, float)) and isinstance( + m0_value, (int, float) + ): difference = abs(asl_value - m0_value) difference_formatted = f"{difference:.2f}" if difference > error_variation: errors.append( f"ERROR: Discrepancy in '{param}' for ASL file '{asl_filename}' and M0 file '{m0_filename}': " - f"ASL value = {asl_value}, M0 value = {m0_value}, difference = {difference_formatted}, exceeds error threshold {error_variation}") + f"ASL value = {asl_value}, M0 value = {m0_value}, difference = {difference_formatted}, exceeds error threshold {error_variation}" + ) elif difference > warning_variation: warnings.append( f"WARNING: Discrepancy in '{param}' for ASL file '{asl_filename}' and M0 file '{m0_filename}': " - f"ASL value = {asl_value}, M0 value = {m0_value}, difference = {difference_formatted}, exceeds warning threshold {warning_variation}") + f"ASL value = {asl_value}, M0 value = {m0_value}, difference = {difference_formatted}, exceeds warning threshold {warning_variation}" + ) return errors, warnings @staticmethod def analyze_volume_types(volume_types): - first_non_m0type = next((vt for vt in volume_types if vt in {'control', 'label', 'deltam'}), None) + first_non_m0type = next( + (vt for vt in volume_types if vt in {"control", "label", "deltam"}), None + ) pattern = "pattern error" control_label_pairs = 0 label_control_pairs = 0 - if first_non_m0type == 'control': - pattern = 'control-label' - elif first_non_m0type == 'label': - pattern = 'label-control' - elif first_non_m0type == 'deltam': - pattern = 'deltam' - deltam_count = volume_types.count('deltam') + if first_non_m0type == "control": + pattern = "control-label" + elif first_non_m0type == "label": + pattern = "label-control" + elif first_non_m0type == "deltam": + pattern = "deltam" + deltam_count = volume_types.count("deltam") return pattern, deltam_count i = 0 while i < len(volume_types): - if volume_types[i] == 'control' and i + 1 < len(volume_types) and volume_types[ - i + 1] == 'label': + if ( + volume_types[i] == "control" + and i + 1 < len(volume_types) + and volume_types[i + 1] == "label" + ): control_label_pairs += 1 i += 2 - elif volume_types[i] == 'label' and i + 1 < len(volume_types) and volume_types[ - i + 1] == 'control': + elif ( + volume_types[i] == "label" + and i + 1 < len(volume_types) + and volume_types[i + 1] == "control" + ): label_control_pairs += 1 i += 2 else: i += 1 - if pattern == 'control-label': + if pattern == "control-label": return pattern, control_label_pairs else: return pattern, label_control_pairs @@ -108,19 +122,19 @@ def extract_concise_error(issue_dict): if isinstance(issue, dict): for sub_issue, details in issue.items(): if isinstance(details, list): - details_str = ', '.join(map(str, details)) + details_str = ", ".join(map(str, details)) report.append(f'{sub_issue} for "{field}": {details_str}') else: report.append(f'{sub_issue} for "{field}": {details}') - return '\n'.join(report) + return "\n".join(report) @staticmethod def condense_and_reformat_discrepancies(error_list): if not error_list: return [], [] - + condensed_errors = {} param_names = [] @@ -129,10 +143,12 @@ def condense_and_reformat_discrepancies(error_list): # Extract the key part of the error message start_idx = error.index("Discrepancy in '") end_idx = error.index("'", start_idx + len("Discrepancy in '")) - param_name = error[start_idx + len("Discrepancy in '"):end_idx] + param_name = error[start_idx + len("Discrepancy in '") : end_idx] # Reformat the message in the desired format - reformatted_error = f"{param_name} (M0): Discrepancy between ASL JSON and M0 JSON" + reformatted_error = ( + f"{param_name} (M0): Discrepancy between ASL JSON and M0 JSON" + ) # If the parameter is already in the dictionary, skip adding it again if param_name not in condensed_errors: @@ -146,16 +162,27 @@ def condense_and_reformat_discrepancies(error_list): return list(condensed_errors.values()), param_names @staticmethod - def determine_m0_tr_and_report(m0_prep_times_collection, all_absent, bs_all_off, discrepancies, - m0_type, inconsistent_params): + def determine_m0_tr_and_report( + m0_prep_times_collection, + all_absent, + bs_all_off, + discrepancies, + m0_type, + inconsistent_params, + ): M0_TR = None if m0_type == "Estimate": return M0_TR, "A single M0 scaling value is provided for CBF quantification" if m0_prep_times_collection and all(m0_prep_times_collection): - if all(abs(x - m0_prep_times_collection[0]) < 1e-5 for x in m0_prep_times_collection): + if all( + abs(x - m0_prep_times_collection[0]) < 1e-5 + for x in m0_prep_times_collection + ): M0_TR = m0_prep_times_collection[0] else: - discrepancies.append("Different `RepetitionTimePreparation` parameters for M0") + discrepancies.append( + "Different `RepetitionTimePreparation` parameters for M0" + ) if all_absent and bs_all_off: report_line_on_M0 = "No m0-scan was acquired, a control image without background suppression was used for M0 estimation." @@ -165,7 +192,15 @@ def determine_m0_tr_and_report(m0_prep_times_collection, all_absent, bs_all_off, if not discrepancies: report_line_on_M0 = "M0 was acquired with the same readout and without background suppression." else: - inconsistent_params_str = ", ".join(inconsistent_params) - report_line_on_M0 = f"There is inconsistency in {inconsistent_params_str} between M0 and ASL scans." - - return M0_TR, report_line_on_M0 \ No newline at end of file + inconsistent_params_str = ", ".join(p for p in inconsistent_params if p) + if inconsistent_params_str: + report_line_on_M0 = ( + "There is an inconsistency in " + f"{inconsistent_params_str} between the M0 and ASL scans." + ) + else: + report_line_on_M0 = ( + "There is an inconsistency between the M0 and ASL scans." + ) + + return M0_TR, report_line_on_M0 diff --git a/package/src/pyaslreport/sequences/base_sequence.py b/package/src/pyaslreport/sequences/base_sequence.py index 44304fab..53137f51 100644 --- a/package/src/pyaslreport/sequences/base_sequence.py +++ b/package/src/pyaslreport/sequences/base_sequence.py @@ -58,6 +58,22 @@ def _extract_common_metadata(self) -> dict: repetition_time = dataset.get(dcm_tags.REPETITION_TIME, None).value bids["RepetitionTimePreparation"] = UnitConverterUtils.convert_milliseconds_to_seconds(repetition_time) + # AcquisitionVoxelSize [in-plane, in-plane, slice thickness] in mm, from + # DICOM geometry tags (PixelSpacing + SliceThickness). This is the + # reconstructed grid; the acquired resolution would need the acquisition + # matrix and FOV. + if dcm_tags.PIXEL_SPACING in dataset and dcm_tags.SLICE_THICKNESS in dataset: + pixel_spacing = dataset.get(dcm_tags.PIXEL_SPACING, None).value + slice_thickness = dataset.get(dcm_tags.SLICE_THICKNESS, None).value + try: + bids["AcquisitionVoxelSize"] = [ + float(pixel_spacing[0]), + float(pixel_spacing[1]), + float(slice_thickness), + ] + except (TypeError, ValueError, IndexError): + pass + return bids def convert_to_bids(self, dicom_dir: str, output_dir: str, bids_basename: str = "sub-01_asl", overwrite: bool = False): diff --git a/package/src/pyaslreport/sequences/ge/asl/ge_asl_base.py b/package/src/pyaslreport/sequences/ge/asl/ge_asl_base.py index 694f0dfe..a0495663 100644 --- a/package/src/pyaslreport/sequences/ge/asl/ge_asl_base.py +++ b/package/src/pyaslreport/sequences/ge/asl/ge_asl_base.py @@ -1,74 +1,118 @@ from operator import truediv + from pyaslreport.sequences.ge.ge_base import GEBaseSequence +from pyaslreport.utils import UnitConverterUtils from pyaslreport.utils import dicom_tags_utils as dcm_tags + class GEASLBase(GEBaseSequence): - + @classmethod + def get_internal_sequence_name(cls, dicom_header: object) -> str | None: + """Return the GE internal sequence name (0019,109E) lowercased, or None. + + A missing or empty tag is NOT an error. Per GE guidance, a missing tag + (or any value other than 'easl') can be safely treated as a basic GE + single-PLD sequence; callers decide the fallback. + """ + elem = dicom_header.get(dcm_tags.GE_INTERNAL_SEQUENCE_NAME, None) + if ( + elem is None + or getattr(elem, "value", None) is None + or str(elem.value).strip() == "" + ): + return None + return str(elem.value).strip().lower() + + @staticmethod + def _ms_to_seconds( + value: int | float | list[int | float], + ) -> int | float | list[int | float]: + """Convert GE timing values from milliseconds to BIDS seconds. + + GE private tags store label duration, inversion time and the eASL + CV4/CV5 timings in milliseconds, whereas the rest of the pipeline + emits BIDS seconds (matching ``EchoTime`` and + ``RepetitionTimePreparation``). The processor later multiplies these + BIDS seconds back to milliseconds for the report, so normalizing GE + to seconds here avoids a spurious factor-of-1000 inflation. + + Args: + value: Timing value(s) in milliseconds. + + Returns: + The same value(s) expressed in seconds. + """ + return UnitConverterUtils.convert_milliseconds_to_seconds(value) + def _extract_ge_common_asl_metadata(self): bids_ge_asl = {} bids_ge_asl["BackgroundSuppression"] = True bids_ge_asl["BackgroundSuppressionNumberPulses"] = 4 - + # M0 scan detection and ASL context handling # For GE: Control/label subtraction is executed on scanner, only deltaM images saved # M0 scan is included by default in all acquisitions bids_ge_asl["M0Type"] = "Included" - + # Check if CBF images are provided instead of deltaM/M0 # This would indicate M0 scan is absent if self._is_cbf_image(): bids_ge_asl["M0Type"] = "Absent" - + return bids_ge_asl def _get_volume_type(self) -> str: """ Determine the volume type based on DICOM ImageType tag. - + Returns: str: "deltaM", "m0scan", or "cbf" """ dataset = self.dicom_header - image_type = dataset.get(dcm_tags.IMAGE_TYPE, "").value if dcm_tags.IMAGE_TYPE in dataset else "" - + image_type = ( + dataset.get(dcm_tags.IMAGE_TYPE, "").value + if dcm_tags.IMAGE_TYPE in dataset + else "" + ) + if isinstance(image_type, (list, tuple)): image_type_str = " ".join(str(x).upper() for x in image_type) else: image_type_str = str(image_type).upper() - + # CBF patterns cbf_patterns = [ ["DERIVED", "PRIMARY", "CBF", "CBF"], - ["DERIVED", "PRIMARY", "CBF", "CBF", "REAL"] + ["DERIVED", "PRIMARY", "CBF", "CBF", "REAL"], ] - - # M0 scan patterns + + # M0 scan patterns m0_patterns = [ ["ORIGINAL", "PRIMARY", "ASL", "REAL"], - ["ORIGINAL", "PRIMARY", "ASL"] + ["ORIGINAL", "PRIMARY", "ASL"], ] - + # deltaM patterns deltam_patterns = [ ["DERIVED", "PRIMARY", "ASL", "PERFUSION", "ASL"], ["DERIVED", "PRIMARY", "ASL", "PERFUSION", "ASL", "REAL"], - ["DERIVED", "PRIMARY", "ASL", "PERFUSION_ASL"] + ["DERIVED", "PRIMARY", "ASL", "PERFUSION_ASL"], ] - + # Check patterns for pattern in cbf_patterns: if all(tag in image_type_str for tag in pattern): return "cbf" - + for pattern in m0_patterns: if all(tag in image_type_str for tag in pattern): return "m0scan" - + for pattern in deltam_patterns: if all(tag in image_type_str for tag in pattern): return "deltaM" - + # Default assumption for GE return "deltaM" @@ -76,25 +120,25 @@ def _is_cbf_image(self) -> bool: """ Check if the current image is a CBF (Cerebral Blood Flow) image. CBF images indicate processed data where M0 scan would be absent. - + Returns: bool: True if this appears to be a CBF image """ return self._get_volume_type() == "cbf" - + def _generate_asl_context(self, npld: int) -> list: """ Generate ASLContext based on volume type and number of PLDs. Normal order: deltaM followed by M0 scan for each PLD. - + Args: npld: Number of post-labeling delays - + Returns: list: ASLContext array """ volume_type = self._get_volume_type() - + if volume_type == "cbf": # CBF images don't have M0 scans return ["deltaM"] * npld @@ -105,4 +149,4 @@ def _generate_asl_context(self, npld: int) -> list: return ["deltaM", "m0scan"] else: # Multi-PLD: all deltaM (summed), last one is m0scan - return ["deltaM"] * (npld - 1) + ["m0scan"] \ No newline at end of file + return ["deltaM"] * (npld - 1) + ["m0scan"] diff --git a/package/src/pyaslreport/sequences/ge/asl/ge_basic_single_pld.py b/package/src/pyaslreport/sequences/ge/asl/ge_basic_single_pld.py index bc40610d..4448861b 100644 --- a/package/src/pyaslreport/sequences/ge/asl/ge_basic_single_pld.py +++ b/package/src/pyaslreport/sequences/ge/asl/ge_basic_single_pld.py @@ -1,11 +1,15 @@ from pyaslreport.sequences.ge.asl.ge_asl_base import GEASLBase from pyaslreport.utils import dicom_tags_utils as dcm_tags + class GEBasicSinglePLD(GEASLBase): @classmethod def matches(cls, dicom_header): + # Basic GE single-PLD is the fallback for any GE ASL header that is not + # eASL, including headers missing the internal sequence name (0019,109E). + # The factory checks the more specific eASL matcher first. return cls.is_ge_manufacturer(dicom_header) - + @classmethod def get_specificity_score(cls) -> int: """Lower specificity score because it only checks for manufacturer.""" @@ -18,12 +22,14 @@ def extract_bids_metadata(self): dicom_header = self.dicom_header if dcm_tags.GE_LABEL_DURATION in dicom_header: - bids["LabelingDuration"] = dicom_header.get(dcm_tags.GE_LABEL_DURATION, None).value + bids["LabelingDuration"] = self._ms_to_seconds( + dicom_header.get(dcm_tags.GE_LABEL_DURATION, None).value + ) if dcm_tags.GE_INVERSION_TIME in dicom_header: - bids["PostLabelingDelay"] = dicom_header.get(dcm_tags.GE_INVERSION_TIME, None).value - + bids["PostLabelingDelay"] = self._ms_to_seconds( + dicom_header.get(dcm_tags.GE_INVERSION_TIME, None).value + ) asl_context = self._generate_asl_context(1) return bids, asl_context - diff --git a/package/src/pyaslreport/sequences/ge/asl/ge_easl_multi_pld.py b/package/src/pyaslreport/sequences/ge/asl/ge_easl_multi_pld.py index f544e4f4..f4937f9e 100644 --- a/package/src/pyaslreport/sequences/ge/asl/ge_easl_multi_pld.py +++ b/package/src/pyaslreport/sequences/ge/asl/ge_easl_multi_pld.py @@ -1,14 +1,15 @@ import math + from pyaslreport.sequences.ge.asl.ge_asl_base import GEASLBase from pyaslreport.utils import dicom_tags_utils as dcm_tags + class GEMultiPLD(GEASLBase): @classmethod def matches(cls, dicom_header): - return ( - cls.is_ge_manufacturer(dicom_header) and - dicom_header.get(dcm_tags.GE_INTERNAL_SEQUENCE_NAME, "").value.strip().lower() == "easl" - ) + if not cls.is_ge_manufacturer(dicom_header): + return False + return cls.get_internal_sequence_name(dicom_header) == "easl" @classmethod def get_specificity_score(cls) -> int: @@ -16,7 +17,7 @@ def get_specificity_score(cls) -> int: return 10 def extract_bids_metadata(self): - + bids = self._extract_common_metadata() bids.update(self._extract_ge_common_metadata()) bids.update(self._extract_ge_common_asl_metadata()) @@ -32,30 +33,42 @@ def extract_bids_metadata(self): npld = int(npld) except Exception: npld = None - + if npld == 1: # Single-PLD - bids["LabelingDuration"] = dataset.get(dcm_tags.GE_PRIVATE_CV5, None).value - bids["PostLabelingDelay"] = dataset.get(dcm_tags.GE_PRIVATE_CV4, None).value + bids["LabelingDuration"] = self._ms_to_seconds( + dataset.get(dcm_tags.GE_PRIVATE_CV5, None).value + ) + bids["PostLabelingDelay"] = self._ms_to_seconds( + dataset.get(dcm_tags.GE_PRIVATE_CV4, None).value + ) elif npld and npld > 1: # Multi-PLD cv4 = float(dataset.get(dcm_tags.GE_PRIVATE_CV4, 0).value) cv5 = float(dataset.get(dcm_tags.GE_PRIVATE_CV5, 0).value) cv7 = float(dataset.get(dcm_tags.GE_PRIVATE_CV7, 1).value) - magnetic_field_strength = float(dataset.get(dcm_tags.MAGNETIC_FIELD_STRENGTH, 3).value) - + + # CV4 (initial PLD) and CV5 (total label duration) are stored in + # milliseconds; normalize to BIDS seconds before any timing math + # so the exponential T1 model and the emitted arrays are correct. + cv4 = self._ms_to_seconds(cv4) + cv5 = self._ms_to_seconds(cv5) + magnetic_field_strength = float( + dataset.get(dcm_tags.MAGNETIC_FIELD_STRENGTH, 3).value + ) + # T1 for blood T1 = 1.65 if magnetic_field_strength == 3 else 1.4 - + # Linear calculation LD_lin = [cv5 / npld] * npld PLD_lin = [cv4 + i * LD_lin[0] for i in range(npld)] - + # Exponential calculation LD_exp = [] PLD_exp = [cv4] Starget = npld * (1 - math.exp(-cv5 / T1)) * math.exp(-cv4 / T1) - + # Check if exponential calculation is mathematically valid exp_calculation_valid = True try: @@ -67,7 +80,7 @@ def extract_bids_metadata(self): break LD_exp.append(-T1 * math.log(1 - exp_factor)) else: - PLD_exp.append(PLD_exp[i-1] + LD_exp[i-1]) + PLD_exp.append(PLD_exp[i - 1] + LD_exp[i - 1]) exp_factor = Starget * math.exp(PLD_exp[i] / T1) if exp_factor >= 1: exp_calculation_valid = False @@ -75,7 +88,7 @@ def extract_bids_metadata(self): LD_exp.append(-T1 * math.log(1 - exp_factor)) except (ValueError, OverflowError): exp_calculation_valid = False - + if cv7 == 1: bids["LabelingDuration"] = LD_lin bids["PostLabelingDelay"] = PLD_lin @@ -88,14 +101,20 @@ def extract_bids_metadata(self): bids["PostLabelingDelay"] = PLD_lin elif exp_calculation_valid: # Linear combination - bids["LabelingDuration"] = [ld_lin * cv7 + ld_exp * (1 - cv7) for ld_lin, ld_exp in zip(LD_lin, LD_exp)] - bids["PostLabelingDelay"] = [pld_lin * cv7 + pld_exp * (1 - cv7) for pld_lin, pld_exp in zip(PLD_lin, PLD_exp)] + bids["LabelingDuration"] = [ + ld_lin * cv7 + ld_exp * (1 - cv7) + for ld_lin, ld_exp in zip(LD_lin, LD_exp) + ] + bids["PostLabelingDelay"] = [ + pld_lin * cv7 + pld_exp * (1 - cv7) + for pld_lin, pld_exp in zip(PLD_lin, PLD_exp) + ] else: # Fall back to linear calculation if exponential fails bids["LabelingDuration"] = LD_lin bids["PostLabelingDelay"] = PLD_lin - + # ASLcontext: all deltaM, last one is m0scan asl_context = self._generate_asl_context(npld if npld else 2) - return bids, asl_context \ No newline at end of file + return bids, asl_context diff --git a/package/src/pyaslreport/sequences/ge/ge_base.py b/package/src/pyaslreport/sequences/ge/ge_base.py index 8c278e4f..36997451 100644 --- a/package/src/pyaslreport/sequences/ge/ge_base.py +++ b/package/src/pyaslreport/sequences/ge/ge_base.py @@ -1,19 +1,24 @@ from pyaslreport.sequences.base_sequence import BaseSequence from pyaslreport.utils import dicom_tags_utils as dcm_tags +from pyaslreport.utils.dicom_repair_utils import dicom_value_to_python + class GEBaseSequence(BaseSequence): @classmethod def is_ge_manufacturer(cls, dicom_header): """ Check if the manufacturer contains GE or General Electric. - + Args: dicom_header: DICOM header dictionary - + Returns: bool: True if manufacturer contains GE or General Electric """ - manufacturer = dicom_header.get(dcm_tags.MANUFACTURER, "").value.strip().upper() + elem = dicom_header.get(dcm_tags.MANUFACTURER, None) + if elem is None or getattr(elem, "value", None) is None: + return False + manufacturer = str(elem.value).strip().upper() return "GE" in manufacturer or "GENERAL ELECTRIC" in manufacturer def _extract_ge_common_metadata(self) -> dict: @@ -21,17 +26,28 @@ def _extract_ge_common_metadata(self) -> dict: bids_ge = {} # Direct GE-specific mappings if dcm_tags.GE_ASSET_R_FACTOR in dataset: - bids_ge["AssetRFactor"] = dataset.get(dcm_tags.GE_ASSET_R_FACTOR, None).value + bids_ge["AssetRFactor"] = dicom_value_to_python( + dataset.get(dcm_tags.GE_ASSET_R_FACTOR, None).value + ) if dcm_tags.GE_ACQUISITION_MATRIX in dataset: - bids_ge["AcquisitionMatrix"] = dataset.get(dcm_tags.GE_ACQUISITION_MATRIX, None).value + bids_ge["AcquisitionMatrix"] = dicom_value_to_python( + dataset.get(dcm_tags.GE_ACQUISITION_MATRIX, None).value + ) if dcm_tags.GE_NUMBER_OF_EXCITATIONS in dataset: - bids_ge["TotalAcquiredPairs"] = dataset.get(dcm_tags.GE_NUMBER_OF_EXCITATIONS, None).value - + bids_ge["TotalAcquiredPairs"] = dicom_value_to_python( + dataset.get(dcm_tags.GE_NUMBER_OF_EXCITATIONS, None).value + ) + # Derived fields # EffectiveEchoSpacing = GE EffectiveEchoSpacing * AssetRFactor * 1e-6 - if dcm_tags.GE_EFFECTIVE_ECHO_SPACING in dataset and dcm_tags.GE_ASSET_R_FACTOR in dataset: + if ( + dcm_tags.GE_EFFECTIVE_ECHO_SPACING in dataset + and dcm_tags.GE_ASSET_R_FACTOR in dataset + ): try: - effective_echo_spacing = float(dataset.get(dcm_tags.GE_EFFECTIVE_ECHO_SPACING, None).value) + effective_echo_spacing = float( + dataset.get(dcm_tags.GE_EFFECTIVE_ECHO_SPACING, None).value + ) asset = float(dataset.get(dcm_tags.GE_ASSET_R_FACTOR, None).value) bids_ge["EffectiveEchoSpacing"] = effective_echo_spacing * asset * 1e-6 except Exception: @@ -39,10 +55,12 @@ def _extract_ge_common_metadata(self) -> dict: # TotalReadoutTime = (AcquisitionMatrix[0] - 1) * EffectiveEchoSpacing if ( - dcm_tags.GE_ACQUISITION_MATRIX in dataset and - isinstance(dataset.get(dcm_tags.GE_ACQUISITION_MATRIX, None).value, (list, tuple)) and - len(dataset.get(dcm_tags.GE_ACQUISITION_MATRIX, None).value) > 0 and - dcm_tags.GE_EFFECTIVE_ECHO_SPACING in bids_ge + dcm_tags.GE_ACQUISITION_MATRIX in dataset + and isinstance( + dataset.get(dcm_tags.GE_ACQUISITION_MATRIX, None).value, (list, tuple) + ) + and len(dataset.get(dcm_tags.GE_ACQUISITION_MATRIX, None).value) > 0 + and dcm_tags.GE_EFFECTIVE_ECHO_SPACING in bids_ge ): try: acq_matrix = dataset.get(dcm_tags.GE_ACQUISITION_MATRIX, None).value[0] @@ -50,54 +68,64 @@ def _extract_ge_common_metadata(self) -> dict: bids_ge["TotalReadoutTime"] = (acq_matrix - 1) * effective_echo_spacing except Exception: pass - + # MRAcquisitionType default is 3D if not present if dcm_tags.MR_ACQUISITION_TYPE in dataset: - bids_ge["MRAcquisitionType"] = dataset.get(dcm_tags.MR_ACQUISITION_TYPE, None).value + bids_ge["MRAcquisitionType"] = dataset.get( + dcm_tags.MR_ACQUISITION_TYPE, None + ).value else: bids_ge["MRAcquisitionType"] = "3D" # PulseSequenceType default is spiral if not present if dcm_tags.MR_ACQUISITION_TYPE in dataset: - bids_ge["PulseSequenceType"] = dataset.get(dcm_tags.MR_ACQUISITION_TYPE, None).value + bids_ge["PulseSequenceType"] = dataset.get( + dcm_tags.MR_ACQUISITION_TYPE, None + ).value else: bids_ge["PulseSequenceType"] = "spiral" - - + # Virtal Parameters that applies to all GE sequences and not specifiied in the DICOM header bids_ge["BackgroundSuppression"] = True bids_ge["BackgroundSuppressionNumberPulses"] = 4 - - + # M0 scan detection and ASL context handling # For GE: Control/label subtraction is executed on scanner, only deltaM images saved # M0 scan is included by default in all acquisitions bids_ge["M0Type"] = "Included" - + # Check if CBF images are provided instead of deltaM/M0 # This would indicate M0 scan is absent if self._is_cbf_image(): bids_ge["M0Type"] = "Absent" - + return bids_ge - + def _is_cbf_image(self) -> bool: """ Check if the current image is a CBF (Cerebral Blood Flow) image. CBF images indicate processed data where M0 scan would be absent. - + Returns: bool: True if this appears to be a CBF image """ dataset = self.dicom_header - + # Check image type or series description for CBF indicators - image_type = dataset.get(dcm_tags.IMAGE_TYPE, "").value if dcm_tags.IMAGE_TYPE in dataset else "" - series_desc = dataset.get(dcm_tags.SERIES_DESCRIPTION, "").value if dcm_tags.SERIES_DESCRIPTION in dataset else "" - + image_type = ( + dataset.get(dcm_tags.IMAGE_TYPE, "").value + if dcm_tags.IMAGE_TYPE in dataset + else "" + ) + series_desc = ( + dataset.get(dcm_tags.SERIES_DESCRIPTION, "").value + if dcm_tags.SERIES_DESCRIPTION in dataset + else "" + ) + # Common CBF indicators in GE sequences cbf_indicators = ["CBF", "PERFUSION", "FLOW", "ML/100G/MIN"] - + # Check if any CBF indicators are present combined_text = f"{image_type} {series_desc}".upper() return any(indicator in combined_text for indicator in cbf_indicators) diff --git a/package/src/pyaslreport/utils/__init__.py b/package/src/pyaslreport/utils/__init__.py index de4aca8e..67635da0 100644 --- a/package/src/pyaslreport/utils/__init__.py +++ b/package/src/pyaslreport/utils/__init__.py @@ -1,5 +1,6 @@ from .dicom_tags_utils import * -from .unit_conversion_utils import * +from .general_utils import * from .math_utils import * +from .metadata_override_utils import * +from .unit_conversion_utils import * from .validation_utils import * -from .general_utils import * \ No newline at end of file diff --git a/package/src/pyaslreport/utils/dicom_repair_utils.py b/package/src/pyaslreport/utils/dicom_repair_utils.py new file mode 100644 index 00000000..7c566ae6 --- /dev/null +++ b/package/src/pyaslreport/utils/dicom_repair_utils.py @@ -0,0 +1,262 @@ +"""Utilities for repairing anonymization-damaged DICOM metadata in memory. + +Some anonymizers preserve a DICOM element's numeric-string VR (DS/IS) but write +raw binary floating-point bytes into the value. The helpers here only repair a +small allowlist of GE ASL tags used by the extractor, and only mutate the +already-loaded pydicom Dataset. The source DICOM files are never written. +""" + +from __future__ import annotations + +import logging +import math +import struct +from collections.abc import Iterable +from dataclasses import dataclass, field +from typing import Any + +import pydicom +from pydicom.dataset import Dataset + +from pyaslreport.utils import dicom_tags_utils as dcm_tags + +LOGGER = logging.getLogger(__name__) + +NUMERIC_STRING_VRS = frozenset({"DS", "IS"}) + +# Conservative bounds for MR metadata values such as times, counts, factors, +# angles, and field strength. Values outside this range are likely mis-decodes. +_MAX_PLAUSIBLE = 1e7 +_MIN_PLAUSIBLE = 1e-6 + +GE_ASL_REPAIR_TAGS = frozenset( + { + dcm_tags.ECHO_TIME, + dcm_tags.GE_INVERSION_TIME, + dcm_tags.MAGNETIC_FIELD_STRENGTH, + dcm_tags.FLIP_ANGLE, + dcm_tags.GE_LABEL_DURATION, + dcm_tags.GE_PRIVATE_CV4, + dcm_tags.GE_PRIVATE_CV5, + dcm_tags.GE_PRIVATE_CV6, + dcm_tags.GE_PRIVATE_CV7, + dcm_tags.GE_ASSET_R_FACTOR, + dcm_tags.GE_EFFECTIVE_ECHO_SPACING, + dcm_tags.GE_NUMBER_OF_EXCITATIONS, + } +) + +GE_ASL_ESSENTIAL_REPAIR_TAGS = frozenset( + { + dcm_tags.GE_INVERSION_TIME, + dcm_tags.GE_LABEL_DURATION, + dcm_tags.GE_PRIVATE_CV4, + dcm_tags.GE_PRIVATE_CV5, + dcm_tags.GE_PRIVATE_CV6, + dcm_tags.GE_PRIVATE_CV7, + } +) + + +@dataclass(frozen=True) +class DicomTagRepair: + """One in-memory DICOM element repair.""" + + tag: Any + vr: str + decoded: list[float] + + +@dataclass +class DicomRepairReport: + """Summary of repairs applied to a Dataset.""" + + repaired: list[DicomTagRepair] = field(default_factory=list) + skipped_auxiliary: list[Any] = field(default_factory=list) + + +class DicomAnonymizationRepairError(ValueError): + """Raised when an essential GE ASL tag is damaged but cannot be decoded.""" + + +def _plausible(values: Iterable[float]) -> bool: + """Return whether decoded numeric values are plausible MR metadata values. + + Args: + values: Numeric values decoded from a damaged DICOM element. + + Returns: + True when every value is finite and inside conservative bounds. + """ + for value in values: + if not math.isfinite(value): + return False + if value != 0.0 and not (_MIN_PLAUSIBLE <= abs(value) <= _MAX_PLAUSIBLE): + return False + return True + + +def _raw_bytes(value: Any) -> bytes | None: + """Return raw bytes from pydicom values that may contain binary payloads. + + Args: + value: DICOM element value to inspect. + + Returns: + Raw bytes when the value can be interpreted losslessly, otherwise None. + """ + if isinstance(value, bytes): + return value + if type(value) is str: + try: + return value.encode("latin-1") + except UnicodeEncodeError: + return None + return None + + +def _is_text_number(value: str) -> bool: + """Return whether a string is already a valid numeric text value. + + Args: + value: DICOM DS/IS string value. + + Returns: + True when all separated components parse as floats. + """ + parts = [part.strip() for part in value.replace("\\", " ").split() if part.strip()] + if not parts: + return False + try: + for part in parts: + float(part) + except ValueError: + return False + return True + + +def _looks_binary_damaged(value: Any) -> bool: + """Return whether a DICOM value looks like binary data in a text VR. + + Args: + value: DICOM element value to inspect. + + Returns: + True when the value should be considered for binary numeric repair. + """ + if not isinstance(value, (bytes, str)) or value == "": + return False + if type(value) is str: + if _is_text_number(value): + return False + if all(32 <= ord(char) <= 126 for char in value): + return False + return True + + +def decode_binary_numeric(value: Any) -> list[float] | None: + """Decode binary bytes accidentally stored in a DS/IS value. + + Args: + value: DICOM value that may contain little-endian float/double bytes. + + Returns: + Decoded finite numeric values, or None when decoding is unsafe. + """ + + raw = _raw_bytes(value) + if not raw: + return None + + for width, fmt in ((8, "d"), (4, "f")): + if len(raw) % width != 0: + continue + try: + decoded = list(struct.unpack(f"<{len(raw) // width}{fmt}", raw)) + except struct.error: + continue + if _plausible(decoded): + return decoded + return None + + +def repair_dicom_dataset_in_memory(ds: Dataset) -> DicomRepairReport: + """Repair allowlisted GE ASL numeric tags in a loaded Dataset. + + The Dataset is mutated in memory only. No DICOM file is written. + + Args: + ds: Loaded DICOM dataset to inspect and repair. + + Returns: + Summary of applied repairs and skipped auxiliary tags. + + Raises: + DicomAnonymizationRepairError: If an essential damaged GE ASL tag cannot + be decoded safely. + """ + + report = DicomRepairReport() + for elem in ds: + if elem.VR == "SQ": + for item in elem.value: + nested = repair_dicom_dataset_in_memory(item) + report.repaired.extend(nested.repaired) + report.skipped_auxiliary.extend(nested.skipped_auxiliary) + continue + + if elem.tag not in GE_ASL_REPAIR_TAGS or elem.VR not in NUMERIC_STRING_VRS: + continue + + value = elem.value + if not _looks_binary_damaged(value): + continue + + decoded = decode_binary_numeric(value) + if decoded is None: + if elem.tag in GE_ASL_ESSENTIAL_REPAIR_TAGS: + raise DicomAnonymizationRepairError( + "Cannot repair anonymization-damaged essential GE ASL tag " + f"{elem.tag}: value is not safely decodable." + ) + report.skipped_auxiliary.append(elem.tag) + LOGGER.warning( + "Skipping anonymization-damaged auxiliary GE ASL tag %s; " + "value is not safely decodable.", + elem.tag, + ) + continue + + if elem.VR == "IS": + new_value: Any = [int(round(item)) for item in decoded] + else: + new_value = decoded + + elem.value = new_value[0] if len(new_value) == 1 else new_value + report.repaired.append(DicomTagRepair(elem.tag, elem.VR, decoded)) + LOGGER.info( + "Repaired anonymization-damaged GE ASL tag %s in memory: %s", + elem.tag, + decoded[0] if len(decoded) == 1 else decoded, + ) + + return report + + +def dicom_value_to_python(value: Any) -> Any: + """Convert common pydicom value containers to JSON-friendly Python values. + + Args: + value: Value returned by pydicom. + + Returns: + A scalar or list made from built-in Python value types where possible. + """ + + if isinstance(value, (list, tuple, pydicom.multival.MultiValue)): + return [dicom_value_to_python(item) for item in value] + if isinstance(value, pydicom.valuerep.IS): + return int(value) + if isinstance(value, pydicom.valuerep.DSfloat): + return float(value) + return value diff --git a/package/src/pyaslreport/utils/dicom_tags_utils.py b/package/src/pyaslreport/utils/dicom_tags_utils.py index 77e7d2c0..50800b39 100644 --- a/package/src/pyaslreport/utils/dicom_tags_utils.py +++ b/package/src/pyaslreport/utils/dicom_tags_utils.py @@ -1,6 +1,5 @@ from pydicom.tag import Tag - # Basic Tags MANUFACTURER = Tag(0x0008, 0x0070) MANUFACTURERS_MODEL_NAME = Tag(0x0008, 0x1090) @@ -11,22 +10,26 @@ FLIP_ANGLE = Tag(0x0018, 0x1314) REPETITION_TIME = Tag(0x0020, 0x0110) IMAGE_TYPE = Tag(0x0008, 0x0008) +SERIES_DESCRIPTION = Tag(0x0008, 0x103E) +INSTANCE_NUMBER = Tag(0x0020, 0x0013) +PIXEL_SPACING = Tag(0x0028, 0x0030) +SLICE_THICKNESS = Tag(0x0018, 0x0050) # GE Commoon Tags GE_ASSET_R_FACTOR = Tag(0x0043, 0x1083) -GE_EFFECTIVE_ECHO_SPACING = Tag(0x0043, 0x192c) +GE_EFFECTIVE_ECHO_SPACING = Tag(0x0043, 0x192C) GE_ACQUISITION_MATRIX = Tag(0x0018, 0x1310) GE_NUMBER_OF_EXCITATIONS = Tag(0x0027, 0x1062) -GE_INTERNAL_SEQUENCE_NAME = Tag(0x0019,0x109E) +GE_INTERNAL_SEQUENCE_NAME = Tag(0x0019, 0x109E) # GE Mlti PLD Tags -GE_PRIVATE_CV4 = Tag(0x0019, 0x10ab) -GE_PRIVATE_CV5 = Tag(0x0019, 0x10ac) -GE_PRIVATE_CV6 = Tag(0x0019, 0x10ad) -GE_PRIVATE_CV7 = Tag(0x0019, 0x10ae) -GE_PRIVATE_CV8 = Tag(0x0019, 0x10af) -GE_PRIVATE_CV9 = Tag(0x0019, 0x10b0) +GE_PRIVATE_CV4 = Tag(0x0019, 0x10AB) +GE_PRIVATE_CV5 = Tag(0x0019, 0x10AC) +GE_PRIVATE_CV6 = Tag(0x0019, 0x10AD) +GE_PRIVATE_CV7 = Tag(0x0019, 0x10AE) +GE_PRIVATE_CV8 = Tag(0x0019, 0x10AF) +GE_PRIVATE_CV9 = Tag(0x0019, 0x10B0) # GE Single PLD Tags GE_LABEL_DURATION = Tag(0x0043, 0x10A5) @@ -44,4 +47,4 @@ SIEMENS_SEQUENCE_NAME = Tag(0x0018, 0x0024) SIEMENS_INPLANE_PHASE_ENCODING_DIRECTION = Tag(0x0018, 0x1312) SIEMENS_ROWS = Tag(0x0028, 0x0010) -SIEMENS_COLUMNS = Tag(0x0028, 0x0011) \ No newline at end of file +SIEMENS_COLUMNS = Tag(0x0028, 0x0011) diff --git a/package/src/pyaslreport/utils/metadata_override_utils.py b/package/src/pyaslreport/utils/metadata_override_utils.py new file mode 100644 index 00000000..251a41b5 --- /dev/null +++ b/package/src/pyaslreport/utils/metadata_override_utils.py @@ -0,0 +1,285 @@ +"""In-memory overlay of a BIDS-style JSON sidecar onto DICOM-derived metadata. + +This module loads a BIDS-style JSON "sidecar" that ships alongside the DICOMs +(for example ``studyPar.json``) and overlays its fields onto the metadata +extracted from the DICOM headers. The overlay happens entirely in memory; the +source DICOM files are never modified, consistent with the anonymization-repair +philosophy used elsewhere in the package. + +The sidecar is treated as authoritative ("JSON wins"): when a field is present +in both the extracted metadata and the sidecar, the sidecar value replaces the +extracted one. Replacing a *differing* extracted value is recorded as a conflict +so the caller can surface a warning instead of changing a value silently. + +Discovery, field scope, and name-aliasing are driven by module-level constants +so behaviour can be adjusted without touching call sites. +""" + +from __future__ import annotations + +import glob +import json +import logging as log +import os +from dataclasses import dataclass, field +from typing import Any + +__all__ = [ + "SIDECAR_GLOBS", + "SIDECAR_NAME_EXCLUDES", + "SEARCH_PARENT", + "OVERRIDE_ALLOWLIST", + "FIELD_ALIASES", + "OverrideReport", + "discover_sidecars", + "load_sidecar", + "apply_overrides", + "apply_sidecar_overrides", +] + +# --------------------------------------------------------------------------- +# Configurable knobs +# --------------------------------------------------------------------------- + +#: Filename patterns that count as a sidecar, searched in the given order. +SIDECAR_GLOBS: tuple[str, ...] = ("*.json",) + +#: Case-insensitive substrings that DISQUALIFY a JSON from being treated as a +#: sidecar. Guards against report/golden files (e.g. ``expected_output.json``) +#: that sit next to the DICOMs, especially in integration example folders. +SIDECAR_NAME_EXCLUDES: tuple[str, ...] = ("output",) + +#: Whether to also search the parent (session) folder of the DICOM directory. +SEARCH_PARENT: bool = True + +#: When not ``None``, only these post-alias keys are allowed to be overlaid. +OVERRIDE_ALLOWLIST: set[str] | None = None + +#: Sidecar field names mapped onto the tool's internal metadata keys. +FIELD_ALIASES: dict[str, str] = { + "LabelingType": "ArterialSpinLabelingType", + "M0": "M0Type", +} + + +# --------------------------------------------------------------------------- +# Result type +# --------------------------------------------------------------------------- +@dataclass +class OverrideReport: + """Record of what a sidecar overlay changed. + + Attributes: + sidecar_path: Path of the sidecar that was applied, or ``None`` when no + sidecar was found or applied. + applied: Post-alias keys newly added to the metadata (absent before). + conflicts: ``(key, dicom_value, sidecar_value)`` for each field whose + differing extracted value was overridden by the sidecar. + aliased: ``(sidecar_name, internal_key)`` for each remapped field. + skipped: Sidecar field names skipped because of the allowlist. + """ + + sidecar_path: str | None = None + applied: list[str] = field(default_factory=list) + conflicts: list[tuple[str, Any, Any]] = field(default_factory=list) + aliased: list[tuple[str, str]] = field(default_factory=list) + skipped: list[str] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Discovery and loading +# --------------------------------------------------------------------------- +def discover_sidecars(dicom_dir: str | None) -> list[str]: + """Find sidecar JSON files next to a DICOM directory. + + Searches ``dicom_dir`` and, when :data:`SEARCH_PARENT` is set, its parent + (session) folder for files matching :data:`SIDECAR_GLOBS`. + + Args: + dicom_dir: Directory the DICOM files were read from. + + Returns: + Absolute sidecar paths, de-duplicated and in a stable (sorted) order, + excluding any whose name matches :data:`SIDECAR_NAME_EXCLUDES`. Empty when + ``dicom_dir`` is falsy or nothing matches. + """ + if not dicom_dir: + return [] + + roots = [dicom_dir] + if SEARCH_PARENT: + roots.append(os.path.dirname(os.path.normpath(dicom_dir))) + + found: list[str] = [] + seen: set[str] = set() + for root in roots: + for pattern in SIDECAR_GLOBS: + for path in sorted(glob.glob(os.path.join(root, pattern))): + name = os.path.basename(path).lower() + if any(excl in name for excl in SIDECAR_NAME_EXCLUDES): + continue + abs_path = os.path.abspath(path) + if abs_path not in seen: + seen.add(abs_path) + found.append(abs_path) + return found + + +def load_sidecar(path: str) -> dict[str, Any]: + """Load a sidecar JSON file into a dictionary. + + Args: + path: Path to the JSON sidecar. + + Returns: + The parsed JSON object, or an empty dict when the file does not contain + a JSON object (for example a top-level array). + + Raises: + OSError: If the file cannot be read. + json.JSONDecodeError: If the file is not valid JSON. + """ + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + + if not isinstance(data, dict): + log.warning("Sidecar %s is not a JSON object; ignoring its contents.", path) + return {} + return data + + +# --------------------------------------------------------------------------- +# Overlay +# --------------------------------------------------------------------------- +def _resolve_metadata_dict(metadata: Any) -> dict[str, Any] | None: + """Return the mutable metadata mapping within an extraction result. + + ``extract_bids_metadata`` returns a plain ``dict`` for some sequences and a + ``[dict, asl_context]`` pair for others. This returns the first ``dict`` to + overlay onto, or ``None`` when no mapping is present. + + Args: + metadata: The result of ``extract_bids_metadata``. + + Returns: + The metadata mapping to mutate, or ``None``. + """ + if isinstance(metadata, dict): + return metadata + if isinstance(metadata, (list, tuple)): + for item in metadata: + if isinstance(item, dict): + return item + return None + + +def apply_overrides( + metadata: Any, + sidecar: dict[str, Any], + *, + allowlist: set[str] | None = None, + aliases: dict[str, str] | None = None, + sidecar_path: str | None = None, +) -> tuple[Any, OverrideReport]: + """Overlay a loaded sidecar onto extracted metadata in memory. + + The sidecar is authoritative: each field replaces the corresponding + extracted value. Replacing a *differing* extracted value is recorded as a + conflict (and logged at WARNING level) so nothing changes silently. + + Args: + metadata: The extraction result to mutate in place. Either a ``dict`` or + a ``[dict, asl_context]`` sequence. + sidecar: Parsed sidecar fields to overlay. + allowlist: When not ``None``, only these post-alias keys are overlaid; + all other sidecar keys are skipped. + aliases: Sidecar-to-internal key remapping. Defaults to + :data:`FIELD_ALIASES`. + sidecar_path: Path recorded on the returned report, for diagnostics. + + Returns: + A ``(metadata, report)`` tuple. ``metadata`` is the same object passed + in (mutated); ``report`` is an :class:`OverrideReport` describing the + changes. + """ + if aliases is None: + aliases = FIELD_ALIASES + + report = OverrideReport(sidecar_path=sidecar_path) + target = _resolve_metadata_dict(metadata) + if target is None: + log.warning("No metadata mapping found to overlay the sidecar onto; skipping.") + return metadata, report + + for raw_key, new_value in sidecar.items(): + key = aliases.get(raw_key, raw_key) + if key != raw_key: + report.aliased.append((raw_key, key)) + + if allowlist is not None and key not in allowlist: + report.skipped.append(raw_key) + continue + + if key in target: + old_value = target[key] + if old_value != new_value: + report.conflicts.append((key, old_value, new_value)) + log.warning( + "Sidecar override: '%s' %r -> %r (DICOM-derived value replaced).", + key, + old_value, + new_value, + ) + else: + report.applied.append(key) + + target[key] = new_value + + return metadata, report + + +def apply_sidecar_overrides( + metadata: Any, + dicom_dir: str | None, + *, + allowlist: set[str] | None = OVERRIDE_ALLOWLIST, +) -> tuple[Any, OverrideReport]: + """Discover a sidecar for a DICOM directory and overlay it onto metadata. + + Locates a sidecar next to ``dicom_dir`` (see :data:`SIDECAR_GLOBS` and + :data:`SEARCH_PARENT`), loads it, and overlays it onto ``metadata`` in + memory. When several sidecars are found the first (stable order) is applied + and the rest are logged and ignored. + + Args: + metadata: The extraction result to mutate in place. + dicom_dir: Directory the DICOMs were read from. ``None`` or empty + disables the overlay (no-op). + allowlist: Optional post-alias key allowlist. Defaults to + :data:`OVERRIDE_ALLOWLIST`. + + Returns: + A ``(metadata, report)`` tuple, as described in :func:`apply_overrides`. + When no sidecar is found, ``metadata`` is returned unchanged with an + empty report. + """ + report = OverrideReport() + if not dicom_dir: + return metadata, report + + sidecars = discover_sidecars(dicom_dir) + if not sidecars: + return metadata, report + + if len(sidecars) > 1: + log.warning( + "Multiple sidecar JSON files found for %s; applying '%s' and ignoring %s.", + dicom_dir, + sidecars[0], + sidecars[1:], + ) + + sidecar = load_sidecar(sidecars[0]) + return apply_overrides( + metadata, sidecar, allowlist=allowlist, sidecar_path=sidecars[0] + ) diff --git a/package/tests/integration/_dicom_synth.py b/package/tests/integration/_dicom_synth.py new file mode 100644 index 00000000..71c00396 --- /dev/null +++ b/package/tests/integration/_dicom_synth.py @@ -0,0 +1,96 @@ +"""Synthetic DICOM writers for the DICOM integration harness tests. + +Lets the harness build minimal in-memory ASL DICOMs with no committed fixtures, +so the DICOM machinery is exercised on every push. Adapted from +``tests/test_ge_dicom_metadata.py``. +""" + +from __future__ import annotations + +import struct +from pathlib import Path +from typing import Any + +import pydicom +import pydicom.config as pydicom_config +from pydicom.dataelem import DataElement +from pydicom.dataset import Dataset, FileMetaDataset +from pydicom.uid import ExplicitVRLittleEndian, generate_uid + +from pyaslreport.utils import dicom_tags_utils as dcm_tags + + +def _binary_double(value: float) -> str: + """Encode a float as the anonymization-damaged raw-double string.""" + return struct.pack(" Path: + """Write a minimal DICOM file from a ``{tag: (VR, value)}`` mapping.""" + dataset = Dataset() + dataset.SpecificCharacterSet = "ISO_IR 100" + + old_read = pydicom_config.settings.reading_validation_mode + old_write = pydicom_config.settings.writing_validation_mode + pydicom_config.settings.reading_validation_mode = pydicom_config.IGNORE + pydicom_config.settings.writing_validation_mode = pydicom_config.IGNORE + try: + for tag, (vr, value) in tags.items(): + if ( + vr in {"DS", "IS"} + and isinstance(value, str) + and any(ord(char) < 32 or ord(char) > 126 for char in value) + ): + dataset.add(DataElement(tag, vr, value, already_converted=True)) + else: + dataset.add_new(tag, vr, value) + finally: + pydicom_config.settings.reading_validation_mode = old_read + pydicom_config.settings.writing_validation_mode = old_write + + file_meta = FileMetaDataset() + file_meta.MediaStorageSOPClassUID = generate_uid() + file_meta.MediaStorageSOPInstanceUID = generate_uid() + file_meta.TransferSyntaxUID = ExplicitVRLittleEndian + dataset.file_meta = file_meta + + old_write = pydicom_config.settings.writing_validation_mode + pydicom_config.settings.writing_validation_mode = pydicom_config.IGNORE + try: + pydicom.dcmwrite(str(path), dataset, enforce_file_format=True) + finally: + pydicom_config.settings.writing_validation_mode = old_write + return path + + +def write_ge_asl_dicom( + path: Path, + *, + sequence_name: str = "3dpcasl", + label_duration: str = "1450", + inversion_time: str = "2025", +) -> Path: + """Write a minimal GE single-PLD ASL DICOM (yields a (metadata, context) pair).""" + tags = { + dcm_tags.MANUFACTURER: ("LO", "GE MEDICAL SYSTEMS"), + dcm_tags.MR_ACQUISITION_TYPE: ("CS", "3D"), + dcm_tags.MAGNETIC_FIELD_STRENGTH: ("DS", "3"), + dcm_tags.ECHO_TIME: ("DS", "10.5"), + dcm_tags.FLIP_ANGLE: ("DS", "111"), + dcm_tags.GE_INTERNAL_SEQUENCE_NAME: ("LO", sequence_name), + dcm_tags.GE_LABEL_DURATION: ("IS", label_duration), + dcm_tags.GE_INVERSION_TIME: ("DS", inversion_time), + } + return write_dicom(path, tags) + + +def write_siemens_asl_dicom(path: Path) -> Path: + """Write a minimal Siemens PASL DICOM (yields a bare dict today).""" + tags = { + dcm_tags.MANUFACTURER: ("LO", "SIEMENS"), + dcm_tags.MR_ACQUISITION_TYPE: ("CS", "2D"), + dcm_tags.MAGNETIC_FIELD_STRENGTH: ("DS", "3"), + dcm_tags.ECHO_TIME: ("DS", "12"), + dcm_tags.FLIP_ANGLE: ("DS", "90"), + } + return write_dicom(path, tags) diff --git a/package/tests/integration/compare.py b/package/tests/integration/compare.py new file mode 100644 index 00000000..c06bd04a --- /dev/null +++ b/package/tests/integration/compare.py @@ -0,0 +1,82 @@ +"""Readable comparison for integration goldens. + +Used by ``tests/test_integration.py``. Reports EVERY differing key at once +(not just the first), and renders a unified line diff for report prose so a +single changed sentence shows as one ``-``/``+`` pair instead of a wall of text. +The same output appears locally under ``pytest -v`` and in the CI log. +""" + +from __future__ import annotations + +import difflib +import json +from typing import Any + +_TEXT_KEYS = frozenset({"basic_report", "extended_report"}) + + +def _is_text_key(key: str, expected: Any, actual: Any) -> bool: + """Return True when a key's values should be diffed as text lines.""" + if key in _TEXT_KEYS or key.endswith("_text"): + return True + return isinstance(expected, str) and isinstance(actual, str) + + +def _text_diff(key: str, expected: Any, actual: Any) -> str: + """Return a unified line diff (old vs new) for two string values.""" + return "\n".join( + difflib.unified_diff( + str(expected).splitlines(), + str(actual).splitlines(), + fromfile=f"{key} (expected)", + tofile=f"{key} (actual)", + lineterm="", + ) + ) + + +def _struct_diff(expected: Any, actual: Any) -> str: + """Return an expected/actual block for a differing structured value.""" + exp = json.dumps(expected, indent=2, sort_keys=True, default=str) + act = json.dumps(actual, indent=2, sort_keys=True, default=str) + return f" expected: {exp}\n actual: {act}" + + +def assert_report_matches( + expected: dict[str, Any], actual: dict[str, Any], case: str +) -> None: + """Assert two reports are equal, reporting all differences at once. + + Args: + expected: The committed golden (``expected_output.json``), JSON-normalized. + actual: The freshly generated report, JSON-normalized. + case: The example name, used to label the failure message. + + Raises: + AssertionError: If the key sets differ or any value differs. The message + lists key-set drift first, then one block per differing key: a + unified line diff for report prose (and any string value), or an + expected/actual dump for structured values. + """ + blocks: list[str] = [] + + only_expected = sorted(set(expected) - set(actual)) + only_actual = sorted(set(actual) - set(expected)) + if only_expected: + blocks.append(f"keys only in expected: {only_expected}") + if only_actual: + blocks.append(f"keys only in actual: {only_actual}") + + for key in sorted(set(expected) & set(actual)): + if expected[key] == actual[key]: + continue + if _is_text_key(key, expected[key], actual[key]): + body = _text_diff(key, expected[key], actual[key]) + else: + body = _struct_diff(expected[key], actual[key]) + blocks.append(f"[{key}] differs:\n{body}") + + if blocks: + raise AssertionError( + f"{case}: {len(blocks)} difference(s)\n\n" + "\n\n".join(blocks) + ) diff --git a/package/tests/integration/examples/bids_ge_pcasl_spiral/expected_output.json b/package/tests/integration/examples/bids_ge_pcasl_spiral/expected_output.json new file mode 100644 index 00000000..45ecd85e --- /dev/null +++ b/package/tests/integration/examples/bids_ge_pcasl_spiral/expected_output.json @@ -0,0 +1,105 @@ +{ + "major_errors": {}, + "major_errors_concise": {}, + "errors": {}, + "errors_concise": {}, + "warnings": {}, + "warnings_concise": {}, + "basic_report": "ASL was acquired on a 3T GE DISCOVERY_MR750 scanner. Single-PLD PCASL labeling was performed with a 3D spiral readout. The labeling duration was 1450ms, followed by a PLD of 2025ms. Background suppression was applied using 4 pulses, with pulses at 1965ms, 2600ms, 3100ms, and 3380ms after the start of labeling. Images were acquired with TR/TE = 4886ms/10.53ms, an in-plane resolution of 4x4mm^2, 20 slices, a slice thickness of 8mm, and a flip angle of 111 degrees. In total, 1 control-label pair was acquired. M0 was acquired with the same readout and without background suppression. TR for M0 is 4886ms.", + "extended_report": "ASL was acquired on a 3T GE DISCOVERY_MR750 scanner. Single-PLD PCASL labeling was performed with a 3D spiral readout. The labeling duration was 1450ms, followed by a PLD of 2025ms. Background suppression was applied using 4 pulses, with pulses at 1965ms, 2600ms, 3100ms, and 3380ms after the start of labeling. Images were acquired with TR/TE = 4886ms/10.53ms, an in-plane resolution of 4x4mm^2, 20 slices, a slice thickness of 8mm, and a flip angle of 111 degrees. In total, 1 control-label pair was acquired. M0 was acquired with the same readout and without background suppression. TR for M0 is 4886ms.", + "nifti_slice_number": 20, + "major_errors_concise_text": "", + "errors_concise_text": "", + "warnings_concise_text": "", + "inconsistencies": "", + "major_inconsistencies": "", + "warning_inconsistencies": "", + "m0_concise_error": "", + "m0_concise_warning": "", + "asl_parameters": [ + [ + "Magnetic Field Strength", + "3T" + ], + [ + "Manufacturer", + "GE" + ], + [ + "Manufacturer's Model Name", + "DISCOVERY_MR750" + ], + [ + "PLD Type", + "single-PLD" + ], + [ + "ASL Type", + "PCASL" + ], + [ + "MR Acquisition Type", + "3D" + ], + [ + "Pulse Sequence Type", + "spiral" + ], + [ + "Labeling Duration", + 1450 + ], + [ + "PLD", + "2025ms" + ], + [ + "Background Suppression Number of Pulses", + 4 + ], + [ + "Background Suppression Pulse Time", + "1965ms, 2600ms, 3100ms, and 3380ms" + ], + [ + "Background Suppression", + "with" + ], + [ + "Echo Time", + "10.528ms" + ], + [ + "Repetition Time", + "4886ms" + ], + [ + "Flip Angle", + 111 + ], + [ + "In-plane Resolution", + "4x4mm^2" + ], + [ + "Slice Thickness", + "8mm" + ], + [ + "Total Acquired Pairs", + 1 + ] + ], + "m0_parameters": [ + [ + "M0 Type", + "Included" + ], + [ + "M0 TR", + 4886 + ] + ], + "extended_parameters": [], + "missing_required_parameters": {} +} diff --git a/package/tests/integration/examples/bids_ge_pcasl_spiral/sub-Sub103/perf/sub-Sub103_asl.json b/package/tests/integration/examples/bids_ge_pcasl_spiral/sub-Sub103/perf/sub-Sub103_asl.json new file mode 100644 index 00000000..91331392 --- /dev/null +++ b/package/tests/integration/examples/bids_ge_pcasl_spiral/sub-Sub103/perf/sub-Sub103_asl.json @@ -0,0 +1,26 @@ +{"Manufacturer":"GE", +"ManufacturersModelName":"DISCOVERY_MR750", +"SoftwareVersions":"24_LX_MR_Software_release:DV24.0_R02_1607.b", +"MagneticFieldStrength":3, +"ReceiveCoilName":"32Ch_Head", +"MRAcquisitionType":"3D", +"PulseSequenceType":"spiral", +"PulseSequenceDetails":"GE 3d spiral pcasl product sequence: 24_LX_MR_Software_release", +"ScanningSequence":"RM", +"SequenceVariant":"NONE", +"ScanOptions":"EDR_GEMS_SPIRAL_GEMS", +"EchoTime":0.010528, +"FlipAngle":111, +"RepetitionTimePreparation":4.886, +"ArterialSpinLabelingType":"PCASL", +"PostLabelingDelay":2.025, +"BackgroundSuppression":true, +"M0Type":"Included", +"TotalAcquiredPairs":3, +"VascularCrushing":false, +"AcquisitionVoxelSize":[4,4,8], +"BackgroundSuppressionNumberPulses":4, +"BackgroundSuppressionPulseTime":[1.965,2.6,3.1,3.38], +"LabelingLocationDescription":"~8 cm below the circle of Willis, through the proximal V3 segment of the vertebral arteries", +"LabelingDistance":40, +"LabelingDuration":1.450} diff --git a/package/tests/integration/examples/bids_ge_pcasl_spiral/sub-Sub103/perf/sub-Sub103_asl.nii.gz b/package/tests/integration/examples/bids_ge_pcasl_spiral/sub-Sub103/perf/sub-Sub103_asl.nii.gz new file mode 100644 index 00000000..92233e3c Binary files /dev/null and b/package/tests/integration/examples/bids_ge_pcasl_spiral/sub-Sub103/perf/sub-Sub103_asl.nii.gz differ diff --git a/package/tests/integration/examples/bids_ge_pcasl_spiral/sub-Sub103/perf/sub-Sub103_aslcontext.tsv b/package/tests/integration/examples/bids_ge_pcasl_spiral/sub-Sub103/perf/sub-Sub103_aslcontext.tsv new file mode 100644 index 00000000..e2fd9564 --- /dev/null +++ b/package/tests/integration/examples/bids_ge_pcasl_spiral/sub-Sub103/perf/sub-Sub103_aslcontext.tsv @@ -0,0 +1,3 @@ +volume_type +m0scan +deltam diff --git a/package/tests/integration/examples/bids_pasl/expected_output.json b/package/tests/integration/examples/bids_pasl/expected_output.json new file mode 100644 index 00000000..cf1494d6 --- /dev/null +++ b/package/tests/integration/examples/bids_pasl/expected_output.json @@ -0,0 +1,133 @@ +{ + "major_errors": {}, + "major_errors_concise": {}, + "errors": { + "m0_error": [ + [ + "ERROR: Discrepancy in 'EchoTime' for ASL file 'sub-Sub1_asl.json' and M0 file 'sub-Sub1_m0scan.json': ASL value = 11.92, M0 value = 16.14, difference = 4.22, exceeds error threshold 0.1" + ] + ] + }, + "errors_concise": {}, + "warnings": { + "m0_warning": [ + [ + "For sub-Sub1_asl.json, no M0 is provided and BS pulses with known timings are on. BS-pulse efficiency has to be calculated to enable absolute quantification." + ] + ] + }, + "warnings_concise": {}, + "basic_report": "ASL was acquired on a 3T Siemens TrioTim scanner. Multi-PLD FAIR PASL labeling was performed with a 3D GRASE readout. Labeling used inversion times of 300ms (1 repeat), 600ms (1 repeat), 900ms (1 repeat), 1200ms (1 repeat), 1500ms (1 repeat), 1800ms (1 repeat), 2100ms (1 repeat), 2400ms (1 repeat), 2700ms (1 repeat), 3000ms (1 repeat), with a labeling slab thickness of 115.5mm, with bolus saturation using a Q2TIPS pulse applied from 700ms to 1600ms after labeling. Background suppression was applied using 2 pulses, with pulses at 150ms and 200ms after the start of labeling. Images were acquired with TR/TE = 3500ms/11.92ms, an in-plane resolution of 8x4mm^2, 20 slices, a slice thickness of 6mm, and a flip angle of 180 degrees. In total, 10 label-control pairs were acquired. There is an inconsistency in EchoTime between the M0 and ASL scans. TR for M0 is 6000ms.", + "extended_report": "ASL was acquired on a 3T Siemens TrioTim scanner. Multi-PLD FAIR PASL labeling was performed with a 3D GRASE readout. Labeling used inversion times of 300ms (1 repeat), 600ms (1 repeat), 900ms (1 repeat), 1200ms (1 repeat), 1500ms (1 repeat), 1800ms (1 repeat), 2100ms (1 repeat), 2400ms (1 repeat), 2700ms (1 repeat), 3000ms (1 repeat), with a labeling slab thickness of 115.5mm, with bolus saturation using a Q2TIPS pulse applied from 700ms to 1600ms after labeling. Background suppression was applied using 2 pulses, with pulses at 150ms and 200ms after the start of labeling. Images were acquired with TR/TE = 3500ms/11.92ms, an in-plane resolution of 8x4mm^2, 20 slices, a slice thickness of 6mm, and a flip angle of 180 degrees. In total, 10 label-control pairs were acquired. There is an inconsistency in EchoTime between the M0 and ASL scans. TR for M0 is 6000ms.", + "nifti_slice_number": 20, + "major_errors_concise_text": "", + "errors_concise_text": "", + "warnings_concise_text": "", + "inconsistencies": "", + "major_inconsistencies": "", + "warning_inconsistencies": "", + "m0_concise_error": "EchoTime (M0): Discrepancy between ASL JSON and M0 JSON", + "m0_concise_warning": "For sub-Sub1_asl.json, no M0 is provided and BS pulses with known timings are on. BS-pulse efficiency has to be calculated to enable absolute quantification.", + "asl_parameters": [ + [ + "Magnetic Field Strength", + "3T" + ], + [ + "Manufacturer", + "Siemens" + ], + [ + "Manufacturer's Model Name", + "TrioTim" + ], + [ + "PLD Type", + "multi-PLD" + ], + [ + "PASL Type", + "FAIR" + ], + [ + "ASL Type", + "PASL" + ], + [ + "MR Acquisition Type", + "3D" + ], + [ + "Pulse Sequence Type", + "GRASE" + ], + [ + "Inversion Time", + "300ms (1 repeat), 600ms (1 repeat), 900ms (1 repeat), 1200ms (1 repeat), 1500ms (1 repeat), 1800ms (1 repeat), 2100ms (1 repeat), 2400ms (1 repeat), 2700ms (1 repeat), 3000ms (1 repeat)" + ], + [ + "Labeling Slab Thickness", + "115.5mm" + ], + [ + "Bolus Cutoff Technique", + "Q2TIPS" + ], + [ + "Bolus Cutoff Delay Time", + "from 700ms to 1600ms" + ], + [ + "Bolus Cutoff Flag", + "with" + ], + [ + "Background Suppression Number of Pulses", + 2 + ], + [ + "Background Suppression Pulse Time", + "150ms and 200ms" + ], + [ + "Background Suppression", + "with" + ], + [ + "Echo Time", + "11.92ms" + ], + [ + "Repetition Time", + "3500ms" + ], + [ + "Flip Angle", + 180 + ], + [ + "In-plane Resolution", + "8x4mm^2" + ], + [ + "Slice Thickness", + "6mm" + ], + [ + "Total Acquired Pairs", + 10 + ] + ], + "m0_parameters": [ + [ + "M0 Type", + "Separate" + ], + [ + "M0 TR", + 6000 + ] + ], + "extended_parameters": [], + "missing_required_parameters": {} +} diff --git a/package/tests/integration/examples/bids_pasl/sub-Sub1/perf/sub-Sub1_asl.json b/package/tests/integration/examples/bids_pasl/sub-Sub1/perf/sub-Sub1_asl.json new file mode 100644 index 00000000..dc96df05 --- /dev/null +++ b/package/tests/integration/examples/bids_pasl/sub-Sub1/perf/sub-Sub1_asl.json @@ -0,0 +1,37 @@ +{"Manufacturer":"Siemens", +"ManufacturersModelName":"TrioTim", +"SoftwareVersions":"N4_VB17A_LATEST_20090307", +"MagneticFieldStrength":3, +"ReceiveCoilName":"32Ch_Head", +"ReceiveCoilActiveElements":"C:HEA;HEP", +"MRAcquisitionType":"3D", +"PulseSequenceType":"3Dgrase", +"PulseSequenceDetails":"Bremen sequence: fme_ASL_Collection_002A for TrioTim-syngo_MR_B17", +"NumberShots":2, +"ScanningSequence":"RM", +"SequenceVariant":"SK", +"ScanOptions":"SAT1_FS", +"SequenceName":"grs3d3d1_512t0", +"PartialFourier":1, +"PhaseEncodingDirection":"j-", +"EffectiveEchoSpacing":0.0005, +"EchoTime":0.01192, +"DwellTime":3.4e-06, +"FlipAngle":180, +"RepetitionTimePreparation":3.5, +"ArterialSpinLabelingType":"PASL", +"PostLabelingDelay":[0.3,0.3,0.6,0.6,0.9,0.9,1.2,1.2,1.5,1.5,1.8,1.8,2.1,2.1,2.4,2.4,2.7,2.7,3,3], +"BackgroundSuppression":true, +"M0Type":"Separate", +"TotalAcquiredPairs":10, +"VascularCrushing":false, +"AcquisitionVoxelSize":[8,4,6], +"BackgroundSuppressionNumberPulses":2, +"BackgroundSuppressionPulseTime":[0.15,0.2], +"LabelingLocationDescription":"Labeling slab parallel to the imaging volume with a 2cm gap", +"LabelingDistance":10, +"PASLType":"FAIR", +"LabelingSlabThickness":115.5, +"BolusCutOffFlag":true, +"BolusCutOffDelayTime":[0.7,1.6], +"BolusCutOffTechnique":"Q2TIPS"} diff --git a/package/tests/integration/examples/bids_pasl/sub-Sub1/perf/sub-Sub1_asl.nii.gz b/package/tests/integration/examples/bids_pasl/sub-Sub1/perf/sub-Sub1_asl.nii.gz new file mode 100644 index 00000000..92233e3c Binary files /dev/null and b/package/tests/integration/examples/bids_pasl/sub-Sub1/perf/sub-Sub1_asl.nii.gz differ diff --git a/package/tests/integration/examples/bids_pasl/sub-Sub1/perf/sub-Sub1_aslcontext.tsv b/package/tests/integration/examples/bids_pasl/sub-Sub1/perf/sub-Sub1_aslcontext.tsv new file mode 100644 index 00000000..5c4e787c --- /dev/null +++ b/package/tests/integration/examples/bids_pasl/sub-Sub1/perf/sub-Sub1_aslcontext.tsv @@ -0,0 +1,21 @@ +volume_type +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control diff --git a/package/tests/integration/examples/bids_pasl/sub-Sub1/perf/sub-Sub1_m0scan.json b/package/tests/integration/examples/bids_pasl/sub-Sub1/perf/sub-Sub1_m0scan.json new file mode 100644 index 00000000..cd127a16 --- /dev/null +++ b/package/tests/integration/examples/bids_pasl/sub-Sub1/perf/sub-Sub1_m0scan.json @@ -0,0 +1,23 @@ +{"Manufacturer":"Siemens", +"ManufacturersModelName":"TrioTim", +"SoftwareVersions":"N4_VB17A_LATEST_20090307", +"MagneticFieldStrength":3, +"ReceiveCoilName":"32Ch_Head", +"ReceiveCoilActiveElements":"C:HEA;HEP", +"MRAcquisitionType":"3D", +"PulseSequenceType":"3Dgrase", +"ScanningSequence":"RM", +"SequenceVariant":"SK", +"ScanOptions":"SAT1_FS", +"SequenceName":"grs3d3d1_1152t0", +"PulseSequenceDetails":"Bremen sequence: fme_ASL_Collection_002A", +"PartialFourier":1, +"PhaseEncodingDirection":"j-", +"EffectiveEchoSpacing":0.00052, +"TotalReadoutTime":0.0104, +"EchoTime":0.01614, +"DwellTime":3.4e-06, +"FlipAngle":180, +"RepetitionTimePreparation":6, +"IntendedFor":"perf/sub-Sub1_asl.nii.gz", +"AcquisitionVoxelsize":[2,2,5]} \ No newline at end of file diff --git a/package/tests/integration/examples/bids_pcasl_3dgrase/expected_output.json b/package/tests/integration/examples/bids_pcasl_3dgrase/expected_output.json new file mode 100644 index 00000000..a1c728c4 --- /dev/null +++ b/package/tests/integration/examples/bids_pcasl_3dgrase/expected_output.json @@ -0,0 +1,111 @@ +{ + "major_errors": {}, + "major_errors_concise": {}, + "errors": {}, + "errors_concise": {}, + "warnings": { + "m0_warning": [ + [ + "For sub-Sub103_asl.json, no M0 is provided and BS pulses with known timings are on. BS-pulse efficiency has to be calculated to enable absolute quantification." + ] + ] + }, + "warnings_concise": {}, + "basic_report": "ASL was acquired on a 3T Siemens Prisma_fit scanner. Single-PLD PCASL labeling was performed with a 3D GRASE readout. The labeling duration was 1800ms, followed by a PLD of 2000ms. Background suppression was applied using 4 pulses, with pulses at 2290ms, 2925ms, 3425ms, and 3705ms after the start of labeling. Images were acquired with TR/TE = 4950ms/13.28ms, an in-plane resolution of 3.4x3.4mm^2, 20 slices, a slice thickness of 4mm, and a flip angle of 130 degrees. In total, 8 control-label pairs were acquired. M0 was acquired with the same readout and without background suppression. TR for M0 is 4950ms.", + "extended_report": "ASL was acquired on a 3T Siemens Prisma_fit scanner. Single-PLD PCASL labeling was performed with a 3D GRASE readout. The labeling duration was 1800ms, followed by a PLD of 2000ms. Background suppression was applied using 4 pulses, with pulses at 2290ms, 2925ms, 3425ms, and 3705ms after the start of labeling. Images were acquired with TR/TE = 4950ms/13.28ms, an in-plane resolution of 3.4x3.4mm^2, 20 slices, a slice thickness of 4mm, and a flip angle of 130 degrees. In total, 8 control-label pairs were acquired. M0 was acquired with the same readout and without background suppression. TR for M0 is 4950ms.", + "nifti_slice_number": 20, + "major_errors_concise_text": "", + "errors_concise_text": "", + "warnings_concise_text": "", + "inconsistencies": "", + "major_inconsistencies": "", + "warning_inconsistencies": "", + "m0_concise_error": "", + "m0_concise_warning": "For sub-Sub103_asl.json, no M0 is provided and BS pulses with known timings are on. BS-pulse efficiency has to be calculated to enable absolute quantification.", + "asl_parameters": [ + [ + "Magnetic Field Strength", + "3T" + ], + [ + "Manufacturer", + "Siemens" + ], + [ + "Manufacturer's Model Name", + "Prisma_fit" + ], + [ + "PLD Type", + "single-PLD" + ], + [ + "ASL Type", + "PCASL" + ], + [ + "MR Acquisition Type", + "3D" + ], + [ + "Pulse Sequence Type", + "GRASE" + ], + [ + "Labeling Duration", + 1800 + ], + [ + "PLD", + "2000ms" + ], + [ + "Background Suppression Number of Pulses", + 4 + ], + [ + "Background Suppression Pulse Time", + "2290ms, 2925ms, 3425ms, and 3705ms" + ], + [ + "Background Suppression", + "with" + ], + [ + "Echo Time", + "13.28ms" + ], + [ + "Repetition Time", + "4950ms" + ], + [ + "Flip Angle", + 130 + ], + [ + "In-plane Resolution", + "3.4x3.4mm^2" + ], + [ + "Slice Thickness", + "4mm" + ], + [ + "Total Acquired Pairs", + 8 + ] + ], + "m0_parameters": [ + [ + "M0 Type", + "Separate" + ], + [ + "M0 TR", + 4950 + ] + ], + "extended_parameters": [], + "missing_required_parameters": {} +} diff --git a/package/tests/integration/examples/bids_pcasl_3dgrase/sub-Sub103/perf/sub-Sub103_asl.json b/package/tests/integration/examples/bids_pcasl_3dgrase/sub-Sub103/perf/sub-Sub103_asl.json new file mode 100644 index 00000000..9abe004e --- /dev/null +++ b/package/tests/integration/examples/bids_pcasl_3dgrase/sub-Sub103/perf/sub-Sub103_asl.json @@ -0,0 +1,33 @@ +{"Manufacturer":"Siemens", +"ManufacturersModelName":"Prisma_fit", +"SoftwareVersions":"syngo_MR_E11", +"MagneticFieldStrength":3, +"ReceiveCoilName":"HeadNeck_64", +"ReceiveCoilActiveElements":"HC1-6", +"MRAcquisitionType":"3D", +"PulseSequenceType":"3Dgrase", +"ScanningSequence":"EP", +"SequenceVariant":"SK", +"ScanOptions":"SAT1_FS", +"SequenceName":"tg818I3d1_6720", +"PulseSequenceDetails":"Siemens 3D GRASE product sequence (WIP): tgse_csl_818", +"NumberShots":4, +"PartialFourier":1, +"EchoTime":0.01328, +"DwellTime":3.20E-06, +"FlipAngle":130, +"RepetitionTimePreparation":4.95, +"ArterialSpinLabelingType":"PCASL", +"PostLabelingDelay":2.000, +"BackgroundSuppression":true, +"M0Type":"Separate", +"TotalAcquiredPairs":8, +"VascularCrushing":false, +"AcquisitionVoxelSize":[3.4,3.4,4], +"BackgroundSuppressionNumberPulses":4, +"BackgroundSuppressionPulseTime":[2.29,2.925,3.425,3.705], +"LabelingLocationDescription":"~8 cm below the circle of Willis, through the proximal V3 segment of the vertebral arteries", +"LabelingDistance":40, +"LookLocker":false, +"LabelingDuration":1.800 +} diff --git a/package/tests/integration/examples/bids_pcasl_3dgrase/sub-Sub103/perf/sub-Sub103_asl.nii.gz b/package/tests/integration/examples/bids_pcasl_3dgrase/sub-Sub103/perf/sub-Sub103_asl.nii.gz new file mode 100644 index 00000000..92233e3c Binary files /dev/null and b/package/tests/integration/examples/bids_pcasl_3dgrase/sub-Sub103/perf/sub-Sub103_asl.nii.gz differ diff --git a/package/tests/integration/examples/bids_pcasl_3dgrase/sub-Sub103/perf/sub-Sub103_aslcontext.tsv b/package/tests/integration/examples/bids_pcasl_3dgrase/sub-Sub103/perf/sub-Sub103_aslcontext.tsv new file mode 100644 index 00000000..cdd6a79c --- /dev/null +++ b/package/tests/integration/examples/bids_pcasl_3dgrase/sub-Sub103/perf/sub-Sub103_aslcontext.tsv @@ -0,0 +1,17 @@ +volume_type +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label diff --git a/package/tests/integration/examples/bids_pcasl_3dgrase/sub-Sub103/perf/sub-Sub103_m0scan.json b/package/tests/integration/examples/bids_pcasl_3dgrase/sub-Sub103/perf/sub-Sub103_m0scan.json new file mode 100644 index 00000000..7241e94f --- /dev/null +++ b/package/tests/integration/examples/bids_pcasl_3dgrase/sub-Sub103/perf/sub-Sub103_m0scan.json @@ -0,0 +1,19 @@ +{"Manufacturer":"Siemens", +"ManufacturersModelName":"Prisma_fit", +"SoftwareVersions":"syngo_MR_E11", +"MagneticFieldStrength":3, +"ReceiveCoilName":"HeadNeck_64", +"ReceiveCoilActiveElements":"HC1-6", +"MRAcquisitionType":"3D", +"PulseSequenceType":"3Dgrase", +"ScanningSequence":"EP", +"SequenceVariant":"SK", +"ScanOptions":"SAT1_FS", +"SequenceName":"tg818I3d1_6720", +"PulseSequenceDetails":"Siemens 3D GRASE product sequence (WIP): tgse_csl_818", +"PartialFourier":1, +"EchoTime":0.01328, +"DwellTime":3.2e-06, +"FlipAngle":130, +"RepetitionTimePreparation":4.95, +"IntendedFor":"perf/sub-Sub103_asl.nii.gz"} \ No newline at end of file diff --git a/package/tests/integration/examples/bids_pcasl_multipld/expected_output.json b/package/tests/integration/examples/bids_pcasl_multipld/expected_output.json new file mode 100644 index 00000000..5403795b --- /dev/null +++ b/package/tests/integration/examples/bids_pcasl_multipld/expected_output.json @@ -0,0 +1,136 @@ +{ + "major_errors": {}, + "major_errors_concise": {}, + "errors": {}, + "errors_concise": {}, + "warnings": { + "m0_warning": [ + [ + "For sub-Sub1_asl.json, no M0 is provided and BS pulses with known timings are on. BS-pulse efficiency has to be calculated to enable absolute quantification." + ] + ] + }, + "warnings_concise": {}, + "basic_report": "ASL was acquired on a 3T Siemens TrioTim scanner. Multi-PLD PCASL labeling was performed with a 2D EPI readout. The labeling duration was 1400ms, followed by PLDs of 250ms (8 repeats), 500ms (8 repeats), 750ms (8 repeats), 1000ms (8 repeats), 1250ms (8 repeats), 1500ms (8 repeats). Background suppression was applied using 2 pulses, with pulses at 1428ms and 1604ms after the start of labeling. Images were acquired with TR/TE = 4050ms/14ms, an in-plane resolution of 3.4375x3.4375mm^2, 20 slices, a slice thickness of 4.05mm, and a flip angle of 90 degrees. In total, 48 label-control pairs were acquired. M0 was acquired with the same readout and without background suppression. TR for M0 is 4800ms.", + "extended_report": "ASL was acquired on a 3T Siemens TrioTim scanner. Multi-PLD PCASL labeling was performed with a 2D EPI readout. The labeling duration was 1400ms, followed by PLDs of 250ms (8 repeats), 500ms (8 repeats), 750ms (8 repeats), 1000ms (8 repeats), 1250ms (8 repeats), 1500ms (8 repeats). Background suppression was applied using 2 pulses, with pulses at 1428ms and 1604ms after the start of labeling. Images were acquired with TR/TE = 4050ms/14ms, an in-plane resolution of 3.4375x3.4375mm^2, 20 slices, a slice thickness of 4.05mm, and a flip angle of 90 degrees. In total, 48 label-control pairs were acquired. Balanced PCASL labeling was applied with the following pulse parameters: average 0.8mT/m and maximum pulse gradient 6mT/m, with 0.6ms pulses applied at 1ms intervals, with 20 degree flip angle. M0 was acquired with the same readout and without background suppression. TR for M0 is 4800ms.", + "nifti_slice_number": 20, + "major_errors_concise_text": "", + "errors_concise_text": "", + "warnings_concise_text": "", + "inconsistencies": "", + "major_inconsistencies": "", + "warning_inconsistencies": "", + "m0_concise_error": "", + "m0_concise_warning": "For sub-Sub1_asl.json, no M0 is provided and BS pulses with known timings are on. BS-pulse efficiency has to be calculated to enable absolute quantification.", + "asl_parameters": [ + [ + "Magnetic Field Strength", + "3T" + ], + [ + "Manufacturer", + "Siemens" + ], + [ + "Manufacturer's Model Name", + "TrioTim" + ], + [ + "PLD Type", + "multi-PLD" + ], + [ + "ASL Type", + "PCASL" + ], + [ + "MR Acquisition Type", + "2D" + ], + [ + "Pulse Sequence Type", + "EPI" + ], + [ + "Labeling Duration", + 1400 + ], + [ + "PLD", + "250ms (8 repeats), 500ms (8 repeats), 750ms (8 repeats), 1000ms (8 repeats), 1250ms (8 repeats), 1500ms (8 repeats)" + ], + [ + "Background Suppression Number of Pulses", + 2 + ], + [ + "Background Suppression Pulse Time", + "1428ms and 1604ms" + ], + [ + "Background Suppression", + "with" + ], + [ + "Echo Time", + "14ms" + ], + [ + "Repetition Time", + "4050ms" + ], + [ + "Flip Angle", + 90 + ], + [ + "In-plane Resolution", + "3.4375x3.4375mm^2" + ], + [ + "Slice Thickness", + "4.05mm" + ], + [ + "Total Acquired Pairs", + 48 + ] + ], + "m0_parameters": [ + [ + "M0 Type", + "Separate" + ], + [ + "M0 TR", + 4800 + ] + ], + "extended_parameters": [ + [ + "PCASL Type", + "balanced" + ], + [ + "Labeling Pulse Average Gradient", + "0.8mT/m" + ], + [ + "Labeling Pulse Maximum Gradient", + "6mT/m" + ], + [ + "Labeling Pulse Duration", + "0.6ms" + ], + [ + "Labeling Pulse Interval", + "1ms" + ], + [ + "Labeling Pulse Flip Angle", + 20 + ] + ], + "missing_required_parameters": {} +} diff --git a/package/tests/integration/examples/bids_pcasl_multipld/sub-Sub1/perf/sub-Sub1_asl.json b/package/tests/integration/examples/bids_pcasl_multipld/sub-Sub1/perf/sub-Sub1_asl.json new file mode 100644 index 00000000..7794502d --- /dev/null +++ b/package/tests/integration/examples/bids_pcasl_multipld/sub-Sub1/perf/sub-Sub1_asl.json @@ -0,0 +1,31 @@ +{"Manufacturer":"Siemens", +"ManufacturersModelName":"TrioTim", +"MagneticFieldStrength":3, +"MRAcquisitionType":"2D", +"PulseSequenceType":"EPI", +"PhaseEncodingDirection":"j-", +"TotalReadoutTime":0.06, +"EchoTime":0.014, +"SliceTiming":[0,0.0452,0.0904,0.1356,0.1808,0.226,0.2712,0.3164,0.3616,0.4068,0.452,0.4972,0.5424,0.5876,0.6328,0.678,0.7232,0.7684,0.8136,0.8588,0.904,0.9492,0.9944,1.0396], +"FlipAngle":90, +"RepetitionTimePreparation":4.05, +"ArterialSpinLabelingType":"PCASL", +"PostLabelingDelay":[0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.5,0.75,0.75,0.75,0.75,0.75,0.75,0.75,0.75,0.75,0.75,0.75,0.75,0.75,0.75,0.75,0.75,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.25,1.25,1.25,1.25,1.25,1.25,1.25,1.25,1.25,1.25,1.25,1.25,1.25,1.25,1.25,1.25,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5,1.5], +"BackgroundSuppression":true, +"M0Type":"Separate", +"TotalAcquiredPairs":48, +"VascularCrushing":false, +"AcquisitionVoxelSize":[3.4375,3.4375,4.05], +"BackgroundSuppressionNumberPulses":2, +"BackgroundSuppressionPulseTime":[1.428,1.604], +"LabelingLocationDescription":"~8 cm below the circle of Willis, through the proximal V3 segment of the vertebral arteries", +"LookLocker":false, +"LabelingEfficiency":0.88, +"PCASLType":"balanced", +"LabelingDuration":1.400, +"LabelingPulseAverageGradient":0.8, +"LabelingPulseMaximumGradient":6, +"LabelingPulseDuration":0.6, +"LabelingPulseFlipAngle":20, +"LabelingPulseInterval":1 +} diff --git a/package/tests/integration/examples/bids_pcasl_multipld/sub-Sub1/perf/sub-Sub1_asl.nii.gz b/package/tests/integration/examples/bids_pcasl_multipld/sub-Sub1/perf/sub-Sub1_asl.nii.gz new file mode 100644 index 00000000..92233e3c Binary files /dev/null and b/package/tests/integration/examples/bids_pcasl_multipld/sub-Sub1/perf/sub-Sub1_asl.nii.gz differ diff --git a/package/tests/integration/examples/bids_pcasl_multipld/sub-Sub1/perf/sub-Sub1_aslcontext.tsv b/package/tests/integration/examples/bids_pcasl_multipld/sub-Sub1/perf/sub-Sub1_aslcontext.tsv new file mode 100644 index 00000000..c9bb987d --- /dev/null +++ b/package/tests/integration/examples/bids_pcasl_multipld/sub-Sub1/perf/sub-Sub1_aslcontext.tsv @@ -0,0 +1,98 @@ +volume_type +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control +label +control + diff --git a/package/tests/integration/examples/bids_pcasl_multipld/sub-Sub1/perf/sub-Sub1_m0scan.json b/package/tests/integration/examples/bids_pcasl_multipld/sub-Sub1/perf/sub-Sub1_m0scan.json new file mode 100644 index 00000000..c7e72a30 --- /dev/null +++ b/package/tests/integration/examples/bids_pcasl_multipld/sub-Sub1/perf/sub-Sub1_m0scan.json @@ -0,0 +1,12 @@ +{"Manufacturer":"Siemens", +"ManufacturersModelName":"TrioTim", +"MagneticFieldStrength":3, +"MRAcquisitionType":"2D", +"PulseSequenceType":"EPI", +"PhaseEncodingDirection":"j-", +"TotalReadoutTime":0.06, +"EchoTime":0.014, +"SliceTiming":[0,0.0452,0.0904,0.1356,0.1808,0.226,0.2712,0.3164,0.3616,0.4068,0.452,0.4972,0.5424,0.5876,0.6328,0.678,0.7232,0.7684,0.8136,0.8588,0.904,0.9492,0.9944,1.0396], +"FlipAngle":90, +"RepetitionTimePreparation":4.8, +"IntendedFor":"perf/sub-Sub1_asl.nii.gz"} \ No newline at end of file diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00001.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00001.dcm new file mode 100644 index 00000000..df636910 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00001.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00002.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00002.dcm new file mode 100644 index 00000000..5695b6dc Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00002.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00003.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00003.dcm new file mode 100644 index 00000000..29a9c6cc Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00003.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00004.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00004.dcm new file mode 100644 index 00000000..08d2ca24 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00004.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00005.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00005.dcm new file mode 100644 index 00000000..58edc36e Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00005.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00006.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00006.dcm new file mode 100644 index 00000000..befaa215 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00006.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00007.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00007.dcm new file mode 100644 index 00000000..e3e59591 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00007.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00008.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00008.dcm new file mode 100644 index 00000000..a5264a45 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00008.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00009.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00009.dcm new file mode 100644 index 00000000..d7772a47 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00009.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00010.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00010.dcm new file mode 100644 index 00000000..fdf63ac1 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00010.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00011.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00011.dcm new file mode 100644 index 00000000..13a181fa Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00011.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00012.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00012.dcm new file mode 100644 index 00000000..6ec27584 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00012.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00013.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00013.dcm new file mode 100644 index 00000000..0edaa47a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00013.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00014.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00014.dcm new file mode 100644 index 00000000..94c993a3 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00014.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00015.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00015.dcm new file mode 100644 index 00000000..50c7846a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00015.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00016.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00016.dcm new file mode 100644 index 00000000..cbf9fb27 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00016.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00017.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00017.dcm new file mode 100644 index 00000000..f29c6938 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00017.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00018.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00018.dcm new file mode 100644 index 00000000..039dd623 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00018.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00019.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00019.dcm new file mode 100644 index 00000000..9fccd38c Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00019.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00020.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00020.dcm new file mode 100644 index 00000000..4cff660e Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00020.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00021.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00021.dcm new file mode 100644 index 00000000..4b9aa365 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00021.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00022.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00022.dcm new file mode 100644 index 00000000..d0adccda Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00022.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00023.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00023.dcm new file mode 100644 index 00000000..d4666342 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00023.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00024.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00024.dcm new file mode 100644 index 00000000..e0f03850 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00024.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00025.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00025.dcm new file mode 100644 index 00000000..d7dd174f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00025.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00026.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00026.dcm new file mode 100644 index 00000000..c91f3625 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00026.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00027.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00027.dcm new file mode 100644 index 00000000..bcfbb9d6 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00027.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00028.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00028.dcm new file mode 100644 index 00000000..3cc7aa26 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00028.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00029.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00029.dcm new file mode 100644 index 00000000..f9b55ad7 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00029.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00030.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00030.dcm new file mode 100644 index 00000000..aebcc0df Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00030.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00031.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00031.dcm new file mode 100644 index 00000000..f924fec2 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00031.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00032.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00032.dcm new file mode 100644 index 00000000..715696e9 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00032.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00033.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00033.dcm new file mode 100644 index 00000000..1e213348 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00033.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00034.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00034.dcm new file mode 100644 index 00000000..31f697be Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00034.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00035.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00035.dcm new file mode 100644 index 00000000..a382ccd4 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00035.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00036.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00036.dcm new file mode 100644 index 00000000..57692b4b Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00036.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00037.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00037.dcm new file mode 100644 index 00000000..7f7e95ea Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00037.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00038.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00038.dcm new file mode 100644 index 00000000..e0780252 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00038.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00039.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00039.dcm new file mode 100644 index 00000000..574fbce7 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00039.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00040.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00040.dcm new file mode 100644 index 00000000..3e69938e Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00040.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00041.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00041.dcm new file mode 100644 index 00000000..1638044a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00041.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00042.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00042.dcm new file mode 100644 index 00000000..646ab9e6 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00042.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00043.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00043.dcm new file mode 100644 index 00000000..83ee4ec3 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00043.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00044.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00044.dcm new file mode 100644 index 00000000..24750955 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00044.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00045.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00045.dcm new file mode 100644 index 00000000..d5392a4a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00045.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00046.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00046.dcm new file mode 100644 index 00000000..c612a0b6 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00046.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00047.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00047.dcm new file mode 100644 index 00000000..2311d3e4 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00047.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00048.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00048.dcm new file mode 100644 index 00000000..e610c81f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00048.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00049.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00049.dcm new file mode 100644 index 00000000..69627618 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00049.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00050.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00050.dcm new file mode 100644 index 00000000..20f9bd19 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00050.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00051.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00051.dcm new file mode 100644 index 00000000..50d127d1 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00051.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00052.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00052.dcm new file mode 100644 index 00000000..3bb61665 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00052.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00053.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00053.dcm new file mode 100644 index 00000000..3f424bca Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00053.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00054.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00054.dcm new file mode 100644 index 00000000..7bb4c0d3 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00054.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00055.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00055.dcm new file mode 100644 index 00000000..a14e01fd Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00055.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00056.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00056.dcm new file mode 100644 index 00000000..035ff283 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00056.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00057.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00057.dcm new file mode 100644 index 00000000..d6336562 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00057.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00058.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00058.dcm new file mode 100644 index 00000000..b3e68bd4 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00058.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00059.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00059.dcm new file mode 100644 index 00000000..aa2001ed Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00059.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00060.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00060.dcm new file mode 100644 index 00000000..b57f00d6 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00060.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00061.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00061.dcm new file mode 100644 index 00000000..c0d3b821 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00061.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00062.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00062.dcm new file mode 100644 index 00000000..873eec68 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00062.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00063.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00063.dcm new file mode 100644 index 00000000..0bec1a72 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00063.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00064.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00064.dcm new file mode 100644 index 00000000..145c1439 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00064.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00065.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00065.dcm new file mode 100644 index 00000000..a430a8f7 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00065.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00066.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00066.dcm new file mode 100644 index 00000000..ef7fe06e Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00066.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00067.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00067.dcm new file mode 100644 index 00000000..04be16e8 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00067.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00068.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00068.dcm new file mode 100644 index 00000000..0a8224c1 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00068.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00069.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00069.dcm new file mode 100644 index 00000000..580792bf Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00069.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00070.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00070.dcm new file mode 100644 index 00000000..50646ad5 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00070.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00071.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00071.dcm new file mode 100644 index 00000000..eb8cd481 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00071.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00072.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00072.dcm new file mode 100644 index 00000000..41e99fb1 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00072.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00073.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00073.dcm new file mode 100644 index 00000000..16ed79cc Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00073.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00074.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00074.dcm new file mode 100644 index 00000000..c6ae276a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00074.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00075.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00075.dcm new file mode 100644 index 00000000..268e7109 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00075.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00076.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00076.dcm new file mode 100644 index 00000000..80d2b49b Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00076.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00077.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00077.dcm new file mode 100644 index 00000000..bcb703fc Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00077.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00078.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00078.dcm new file mode 100644 index 00000000..47266b73 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00078.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00079.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00079.dcm new file mode 100644 index 00000000..de8338c9 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00079.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/ASL/00080.dcm b/package/tests/integration/examples/ge_pcasl_basic/ASL/00080.dcm new file mode 100644 index 00000000..c73b6b1f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_basic/ASL/00080.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_basic/expected_output.json b/package/tests/integration/examples/ge_pcasl_basic/expected_output.json new file mode 100644 index 00000000..47931e56 --- /dev/null +++ b/package/tests/integration/examples/ge_pcasl_basic/expected_output.json @@ -0,0 +1,133 @@ +{ + "major_errors": { + "ArterialSpinLabelingType": [ + { + "Missing in files": [ + "asl_0.json" + ] + } + ] + }, + "major_errors_concise": { + "ArterialSpinLabelingType": [ + { + "Missing in files": [ + "asl_0.json" + ] + } + ] + }, + "errors": { + "RepetitionTimePreparation": [ + { + "Missing in files": [ + "asl_0.json" + ] + } + ] + }, + "errors_concise": { + "RepetitionTimePreparation": [ + { + "Missing in files": [ + "asl_0.json" + ] + } + ] + }, + "warnings": { + "m0_warning": [ + [ + "Warning: Cannot determine M0 preparation timing for ASL file '_asl.json' because neither 'RepetitionTimePreparation' nor 'RepetitionTime' is present, but TSV file '_aslcontext.tsv' contains m0scan." + ] + ] + }, + "warnings_concise": {}, + "basic_report": "ASL was acquired on a 3.0T GE MEDICAL SYSTEMS DISCOVERY MR750 scanner. Single-PLD labeling was performed with a 3D readout. Background suppression was applied using 4 pulses, with pulses at 1465ms, 2100ms, 2600ms, and 2880ms after the start of labeling. Images were acquired with TE = 10.53ms, an in-plane resolution of 1.875x1.875mm^2, 20 slices, a slice thickness of 4.0mm, and a flip angle of 111 degrees. In total, 1 control-label pair was acquired. M0 was acquired with the same readout and without background suppression.", + "extended_report": "ASL was acquired on a 3.0T GE MEDICAL SYSTEMS DISCOVERY MR750 scanner. Single-PLD labeling was performed with a 3D readout. Background suppression was applied using 4 pulses, with pulses at 1465ms, 2100ms, 2600ms, and 2880ms after the start of labeling. Images were acquired with TE = 10.53ms, an in-plane resolution of 1.875x1.875mm^2, 20 slices, a slice thickness of 4.0mm, and a flip angle of 111 degrees. In total, 1 control-label pair was acquired. M0 was acquired with the same readout and without background suppression.", + "nifti_slice_number": 20, + "major_errors_concise_text": "Missing in files for \"ArterialSpinLabelingType\": asl_0.json", + "errors_concise_text": "Missing in files for \"RepetitionTimePreparation\": asl_0.json", + "warnings_concise_text": "", + "inconsistencies": "", + "major_inconsistencies": "", + "warning_inconsistencies": "", + "m0_concise_error": "", + "m0_concise_warning": "Warning: Cannot determine M0 preparation timing for ASL file '_asl.json' because neither 'RepetitionTimePreparation' nor 'RepetitionTime' is present, but TSV file '_aslcontext.tsv' contains m0scan.", + "asl_parameters": [ + [ + "Magnetic Field Strength", + "3.0T" + ], + [ + "Manufacturer", + "GE MEDICAL SYSTEMS" + ], + [ + "Manufacturer's Model Name", + "DISCOVERY MR750" + ], + [ + "PLD Type", + "single-PLD" + ], + [ + "ASL Type", + "N/A" + ], + [ + "MR Acquisition Type", + "3D" + ], + [ + "Pulse Sequence Type", + "3D" + ], + [ + "Background Suppression Number of Pulses", + 4 + ], + [ + "Background Suppression Pulse Time", + "1465ms, 2100ms, 2600ms, and 2880ms" + ], + [ + "Background Suppression", + "with" + ], + [ + "Echo Time", + "10.528ms" + ], + [ + "Repetition Time", + "N/A" + ], + [ + "Flip Angle", + 111.0 + ], + [ + "In-plane Resolution", + "1.875x1.875mm^2" + ], + [ + "Slice Thickness", + "4.0mm" + ], + [ + "Total Acquired Pairs", + 1 + ] + ], + "m0_parameters": [ + [ + "M0 Type", + "Included" + ] + ], + "extended_parameters": [], + "missing_required_parameters": { + "RepetitionTimePreparation": "s" + } +} diff --git a/package/tests/integration/examples/ge_pcasl_basic/studyPar.json b/package/tests/integration/examples/ge_pcasl_basic/studyPar.json new file mode 100644 index 00000000..6beff48a --- /dev/null +++ b/package/tests/integration/examples/ge_pcasl_basic/studyPar.json @@ -0,0 +1,3 @@ +{"BackgroundSuppression":true, +"BackgroundSuppressionNumberPulses":4, +"BackgroundSuppressionPulseTime":[1.465,2.1,2.6,2.88]} diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00001.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00001.dcm new file mode 100644 index 00000000..41d12695 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00001.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00002.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00002.dcm new file mode 100644 index 00000000..342e9229 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00002.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00003.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00003.dcm new file mode 100644 index 00000000..a3ff78df Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00003.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00004.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00004.dcm new file mode 100644 index 00000000..3491879f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00004.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00005.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00005.dcm new file mode 100644 index 00000000..e9edf4eb Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00005.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00006.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00006.dcm new file mode 100644 index 00000000..d351ff0b Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00006.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00007.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00007.dcm new file mode 100644 index 00000000..e576ba7f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00007.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00008.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00008.dcm new file mode 100644 index 00000000..3155b24f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00008.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00009.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00009.dcm new file mode 100644 index 00000000..d9450dc5 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00009.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00010.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00010.dcm new file mode 100644 index 00000000..a9e39534 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00010.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00011.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00011.dcm new file mode 100644 index 00000000..5187320d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00011.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00012.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00012.dcm new file mode 100644 index 00000000..efbd39eb Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00012.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00013.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00013.dcm new file mode 100644 index 00000000..babcf93a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00013.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00014.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00014.dcm new file mode 100644 index 00000000..8c302454 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00014.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00015.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00015.dcm new file mode 100644 index 00000000..1a84178e Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00015.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00016.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00016.dcm new file mode 100644 index 00000000..0da834f9 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00016.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00017.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00017.dcm new file mode 100644 index 00000000..2c182466 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00017.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00018.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00018.dcm new file mode 100644 index 00000000..7d2e9c61 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00018.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00019.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00019.dcm new file mode 100644 index 00000000..f98e9925 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00019.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00020.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00020.dcm new file mode 100644 index 00000000..4265b0d4 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00020.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00021.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00021.dcm new file mode 100644 index 00000000..62c55ba4 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00021.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00022.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00022.dcm new file mode 100644 index 00000000..0d322018 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00022.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00023.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00023.dcm new file mode 100644 index 00000000..980c9b8a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00023.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00024.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00024.dcm new file mode 100644 index 00000000..b035d911 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00024.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00025.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00025.dcm new file mode 100644 index 00000000..72292319 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00025.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00026.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00026.dcm new file mode 100644 index 00000000..c31600c3 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00026.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00027.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00027.dcm new file mode 100644 index 00000000..dfb04a0d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00027.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00028.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00028.dcm new file mode 100644 index 00000000..c8711396 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00028.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00029.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00029.dcm new file mode 100644 index 00000000..8e7223b0 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00029.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00030.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00030.dcm new file mode 100644 index 00000000..bd146628 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00030.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00031.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00031.dcm new file mode 100644 index 00000000..aff1c00f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00031.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00032.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00032.dcm new file mode 100644 index 00000000..b8b46219 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00032.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00033.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00033.dcm new file mode 100644 index 00000000..37d8ef7f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00033.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00034.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00034.dcm new file mode 100644 index 00000000..bed2064f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00034.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00035.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00035.dcm new file mode 100644 index 00000000..b9933383 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00035.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00036.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00036.dcm new file mode 100644 index 00000000..47bc4514 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00036.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00037.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00037.dcm new file mode 100644 index 00000000..02134acb Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00037.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00038.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00038.dcm new file mode 100644 index 00000000..ef665ced Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00038.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00039.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00039.dcm new file mode 100644 index 00000000..d00a0f7c Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00039.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00040.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00040.dcm new file mode 100644 index 00000000..f6bbd4b2 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00040.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00041.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00041.dcm new file mode 100644 index 00000000..024c871a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00041.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00042.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00042.dcm new file mode 100644 index 00000000..2ad95444 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00042.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00043.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00043.dcm new file mode 100644 index 00000000..02ee022b Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00043.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00044.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00044.dcm new file mode 100644 index 00000000..bd2f573d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00044.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00045.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00045.dcm new file mode 100644 index 00000000..0d5a9bea Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00045.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00046.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00046.dcm new file mode 100644 index 00000000..97a6fb87 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00046.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00047.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00047.dcm new file mode 100644 index 00000000..2510343a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00047.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00048.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00048.dcm new file mode 100644 index 00000000..71f731e2 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00048.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00049.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00049.dcm new file mode 100644 index 00000000..f56f4a74 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00049.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00050.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00050.dcm new file mode 100644 index 00000000..0f884781 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00050.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00051.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00051.dcm new file mode 100644 index 00000000..f2ef1a4b Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00051.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00052.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00052.dcm new file mode 100644 index 00000000..2df9b2fa Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00052.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00053.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00053.dcm new file mode 100644 index 00000000..0ca99c72 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00053.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00054.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00054.dcm new file mode 100644 index 00000000..48b76b31 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00054.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00055.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00055.dcm new file mode 100644 index 00000000..b6286f72 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00055.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00056.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00056.dcm new file mode 100644 index 00000000..437c4ac7 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00056.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00057.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00057.dcm new file mode 100644 index 00000000..059ae387 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00057.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00058.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00058.dcm new file mode 100644 index 00000000..b5b7620e Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00058.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00059.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00059.dcm new file mode 100644 index 00000000..d6f698b7 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00059.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00060.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00060.dcm new file mode 100644 index 00000000..7c3f0f97 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00060.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00061.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00061.dcm new file mode 100644 index 00000000..427192d4 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00061.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00062.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00062.dcm new file mode 100644 index 00000000..195ac1b7 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00062.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00063.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00063.dcm new file mode 100644 index 00000000..e404e52b Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00063.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00064.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00064.dcm new file mode 100644 index 00000000..dc8c9418 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00064.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00065.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00065.dcm new file mode 100644 index 00000000..331c5a01 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00065.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00066.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00066.dcm new file mode 100644 index 00000000..bcc1ca33 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00066.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00067.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00067.dcm new file mode 100644 index 00000000..be8d7790 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00067.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00068.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00068.dcm new file mode 100644 index 00000000..2883eaa0 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00068.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00069.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00069.dcm new file mode 100644 index 00000000..031cd878 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00069.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00070.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00070.dcm new file mode 100644 index 00000000..a6a28fc6 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00070.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00071.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00071.dcm new file mode 100644 index 00000000..60064740 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00071.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00072.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00072.dcm new file mode 100644 index 00000000..e8ea719f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00072.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00073.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00073.dcm new file mode 100644 index 00000000..c77724e3 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00073.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00074.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00074.dcm new file mode 100644 index 00000000..e04bfc0c Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00074.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00075.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00075.dcm new file mode 100644 index 00000000..0e90a9fc Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00075.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00076.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00076.dcm new file mode 100644 index 00000000..26baccf6 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00076.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00077.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00077.dcm new file mode 100644 index 00000000..977c415d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00077.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00078.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00078.dcm new file mode 100644 index 00000000..7aeef573 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00078.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00079.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00079.dcm new file mode 100644 index 00000000..a4170bc0 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00079.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00080.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00080.dcm new file mode 100644 index 00000000..d410dec3 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00080.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00081.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00081.dcm new file mode 100644 index 00000000..3e268af2 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00081.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00082.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00082.dcm new file mode 100644 index 00000000..a77e5a0c Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00082.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00083.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00083.dcm new file mode 100644 index 00000000..93c31011 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00083.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00084.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00084.dcm new file mode 100644 index 00000000..47d6db5d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00084.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00085.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00085.dcm new file mode 100644 index 00000000..7b9ef6f1 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00085.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00086.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00086.dcm new file mode 100644 index 00000000..3436e626 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00086.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00087.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00087.dcm new file mode 100644 index 00000000..253209d0 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00087.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00088.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00088.dcm new file mode 100644 index 00000000..1979fa5d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00088.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00089.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00089.dcm new file mode 100644 index 00000000..74925b22 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00089.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00090.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00090.dcm new file mode 100644 index 00000000..313fd58e Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00090.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00091.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00091.dcm new file mode 100644 index 00000000..6bf02e6d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00091.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00092.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00092.dcm new file mode 100644 index 00000000..f20f6f78 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00092.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00093.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00093.dcm new file mode 100644 index 00000000..be71f585 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00093.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00094.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00094.dcm new file mode 100644 index 00000000..92ee4e19 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00094.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00095.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00095.dcm new file mode 100644 index 00000000..694e157f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00095.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00096.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00096.dcm new file mode 100644 index 00000000..fd8416fc Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00096.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00097.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00097.dcm new file mode 100644 index 00000000..2151622f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00097.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00098.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00098.dcm new file mode 100644 index 00000000..94d77768 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00098.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00099.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00099.dcm new file mode 100644 index 00000000..f0d44afe Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00099.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00100.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00100.dcm new file mode 100644 index 00000000..f0f905a4 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00100.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00101.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00101.dcm new file mode 100644 index 00000000..8cfa2b15 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00101.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00102.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00102.dcm new file mode 100644 index 00000000..de862be2 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00102.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00103.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00103.dcm new file mode 100644 index 00000000..b343833d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00103.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00104.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00104.dcm new file mode 100644 index 00000000..74fa2e5d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00104.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00105.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00105.dcm new file mode 100644 index 00000000..767e5dc5 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00105.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00106.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00106.dcm new file mode 100644 index 00000000..15befdee Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00106.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00107.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00107.dcm new file mode 100644 index 00000000..4aea445d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00107.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00108.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00108.dcm new file mode 100644 index 00000000..8a3ef21e Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00108.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00109.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00109.dcm new file mode 100644 index 00000000..e66c13b8 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00109.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00110.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00110.dcm new file mode 100644 index 00000000..88cc818d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00110.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00111.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00111.dcm new file mode 100644 index 00000000..380ddd0d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00111.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00112.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00112.dcm new file mode 100644 index 00000000..7f24681a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00112.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00113.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00113.dcm new file mode 100644 index 00000000..4975932f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00113.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00114.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00114.dcm new file mode 100644 index 00000000..bb4bedce Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00114.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00115.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00115.dcm new file mode 100644 index 00000000..1f55968b Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00115.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00116.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00116.dcm new file mode 100644 index 00000000..aaca64d0 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00116.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00117.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00117.dcm new file mode 100644 index 00000000..e1a16e63 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00117.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00118.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00118.dcm new file mode 100644 index 00000000..7eb3971a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00118.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00119.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00119.dcm new file mode 100644 index 00000000..bb7d229f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00119.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00120.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00120.dcm new file mode 100644 index 00000000..d5691b16 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00120.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00121.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00121.dcm new file mode 100644 index 00000000..cf35537b Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00121.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00122.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00122.dcm new file mode 100644 index 00000000..521bdd21 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00122.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00123.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00123.dcm new file mode 100644 index 00000000..ec6dd2bd Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00123.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00124.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00124.dcm new file mode 100644 index 00000000..5d54263a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00124.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00125.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00125.dcm new file mode 100644 index 00000000..e86d33b9 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00125.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00126.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00126.dcm new file mode 100644 index 00000000..602054f8 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00126.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00127.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00127.dcm new file mode 100644 index 00000000..3835ff4b Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00127.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00128.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00128.dcm new file mode 100644 index 00000000..3eeaaa42 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00128.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00129.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00129.dcm new file mode 100644 index 00000000..57e8cb3a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00129.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00130.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00130.dcm new file mode 100644 index 00000000..53953cf6 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00130.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00131.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00131.dcm new file mode 100644 index 00000000..fe3e9d8f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00131.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00132.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00132.dcm new file mode 100644 index 00000000..bbd6cd1e Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00132.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00133.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00133.dcm new file mode 100644 index 00000000..43aee43d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00133.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00134.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00134.dcm new file mode 100644 index 00000000..f83556d3 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00134.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00135.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00135.dcm new file mode 100644 index 00000000..96247bbb Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00135.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00136.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00136.dcm new file mode 100644 index 00000000..1e291bd1 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00136.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00137.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00137.dcm new file mode 100644 index 00000000..76320213 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00137.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00138.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00138.dcm new file mode 100644 index 00000000..f4409fd8 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00138.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00139.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00139.dcm new file mode 100644 index 00000000..c2c26caf Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00139.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00140.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00140.dcm new file mode 100644 index 00000000..635f0252 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00140.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00141.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00141.dcm new file mode 100644 index 00000000..48db9e7e Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00141.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00142.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00142.dcm new file mode 100644 index 00000000..b0c1c26a Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00142.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00143.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00143.dcm new file mode 100644 index 00000000..fbad6780 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00143.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00144.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00144.dcm new file mode 100644 index 00000000..1b9da868 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00144.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00145.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00145.dcm new file mode 100644 index 00000000..15aedf97 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00145.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00146.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00146.dcm new file mode 100644 index 00000000..601c03d1 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00146.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00147.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00147.dcm new file mode 100644 index 00000000..163ff9a7 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00147.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00148.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00148.dcm new file mode 100644 index 00000000..1fa0c143 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00148.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00149.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00149.dcm new file mode 100644 index 00000000..31faaefd Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00149.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00150.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00150.dcm new file mode 100644 index 00000000..685e5ed9 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00150.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00151.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00151.dcm new file mode 100644 index 00000000..e23c5610 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00151.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00152.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00152.dcm new file mode 100644 index 00000000..5ca7465c Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00152.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00153.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00153.dcm new file mode 100644 index 00000000..85e3bfe0 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00153.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00154.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00154.dcm new file mode 100644 index 00000000..859087d4 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00154.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00155.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00155.dcm new file mode 100644 index 00000000..d9fdebcd Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00155.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00156.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00156.dcm new file mode 100644 index 00000000..b8a3bd14 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00156.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00157.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00157.dcm new file mode 100644 index 00000000..9df6a636 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00157.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00158.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00158.dcm new file mode 100644 index 00000000..ad1e27a7 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00158.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00159.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00159.dcm new file mode 100644 index 00000000..37d309a0 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00159.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00160.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00160.dcm new file mode 100644 index 00000000..807119f6 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00160.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00161.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00161.dcm new file mode 100644 index 00000000..9d630f66 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00161.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00162.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00162.dcm new file mode 100644 index 00000000..6af4126d Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00162.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00163.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00163.dcm new file mode 100644 index 00000000..f2c5f981 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00163.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00164.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00164.dcm new file mode 100644 index 00000000..b1e0c063 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00164.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00165.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00165.dcm new file mode 100644 index 00000000..ed1d1b55 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00165.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00166.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00166.dcm new file mode 100644 index 00000000..6d750ae9 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00166.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00167.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00167.dcm new file mode 100644 index 00000000..bcefd2bb Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00167.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00168.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00168.dcm new file mode 100644 index 00000000..198e0685 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00168.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00169.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00169.dcm new file mode 100644 index 00000000..382b506f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00169.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00170.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00170.dcm new file mode 100644 index 00000000..06b4c759 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00170.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00171.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00171.dcm new file mode 100644 index 00000000..dacff818 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00171.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00172.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00172.dcm new file mode 100644 index 00000000..df2deb78 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00172.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00173.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00173.dcm new file mode 100644 index 00000000..bcdd1d35 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00173.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00174.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00174.dcm new file mode 100644 index 00000000..6d1d43db Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00174.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00175.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00175.dcm new file mode 100644 index 00000000..80057c75 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00175.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00176.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00176.dcm new file mode 100644 index 00000000..19941da1 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00176.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00177.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00177.dcm new file mode 100644 index 00000000..e2ba430f Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00177.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00178.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00178.dcm new file mode 100644 index 00000000..b813a855 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00178.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00179.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00179.dcm new file mode 100644 index 00000000..296ff3e6 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00179.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/ASL/00180.dcm b/package/tests/integration/examples/ge_pcasl_easl/ASL/00180.dcm new file mode 100644 index 00000000..e9ecc1e2 Binary files /dev/null and b/package/tests/integration/examples/ge_pcasl_easl/ASL/00180.dcm differ diff --git a/package/tests/integration/examples/ge_pcasl_easl/expected_output.json b/package/tests/integration/examples/ge_pcasl_easl/expected_output.json new file mode 100644 index 00000000..6212f655 --- /dev/null +++ b/package/tests/integration/examples/ge_pcasl_easl/expected_output.json @@ -0,0 +1,125 @@ +{ + "major_errors": {}, + "major_errors_concise": {}, + "errors": { + "RepetitionTimePreparation": [ + { + "Missing in files": [ + "asl_0.json" + ] + } + ] + }, + "errors_concise": { + "RepetitionTimePreparation": [ + { + "Missing in files": [ + "asl_0.json" + ] + } + ] + }, + "warnings": { + "m0_warning": [ + [ + "Warning: Cannot determine M0 preparation timing for ASL file '_asl.json' because neither 'RepetitionTimePreparation' nor 'RepetitionTime' is present, but TSV file '_aslcontext.tsv' contains m0scan." + ] + ] + }, + "warnings_concise": {}, + "basic_report": "ASL was acquired on a 3.0T GE MEDICAL SYSTEMS DISCOVERY MR750 scanner. Multi-PLD PCASL labeling was performed with a 3D readout. The labeling duration was 1167ms, followed by PLDs of 1000ms (1 volume), 2167ms (1 volume), 3333ms (1 volume). Background suppression was applied using 4 pulses. Images were acquired with TE = 11.24ms, an in-plane resolution of 1.875x1.875mm^2, 20 slices, a slice thickness of 4.0mm, and a flip angle of 111 degrees. In total, 2 control-label pairs were acquired. M0 was acquired with the same readout and without background suppression.", + "extended_report": "ASL was acquired on a 3.0T GE MEDICAL SYSTEMS DISCOVERY MR750 scanner. Multi-PLD PCASL labeling was performed with a 3D readout. The labeling duration was 1167ms, followed by PLDs of 1000ms (1 volume), 2167ms (1 volume), 3333ms (1 volume). Background suppression was applied using 4 pulses. Images were acquired with TE = 11.24ms, an in-plane resolution of 1.875x1.875mm^2, 20 slices, a slice thickness of 4.0mm, and a flip angle of 111 degrees. In total, 2 control-label pairs were acquired. M0 was acquired with the same readout and without background suppression.", + "nifti_slice_number": 20, + "major_errors_concise_text": "", + "errors_concise_text": "Missing in files for \"RepetitionTimePreparation\": asl_0.json", + "warnings_concise_text": "", + "inconsistencies": "", + "major_inconsistencies": "", + "warning_inconsistencies": "", + "m0_concise_error": "", + "m0_concise_warning": "Warning: Cannot determine M0 preparation timing for ASL file '_asl.json' because neither 'RepetitionTimePreparation' nor 'RepetitionTime' is present, but TSV file '_aslcontext.tsv' contains m0scan.", + "asl_parameters": [ + [ + "Magnetic Field Strength", + "3.0T" + ], + [ + "Manufacturer", + "GE MEDICAL SYSTEMS" + ], + [ + "Manufacturer's Model Name", + "DISCOVERY MR750" + ], + [ + "PLD Type", + "multi-PLD" + ], + [ + "ASL Type", + "PCASL" + ], + [ + "MR Acquisition Type", + "3D" + ], + [ + "Pulse Sequence Type", + "3D" + ], + [ + "Labeling Duration", + [ + 1166.667, + 1166.667, + 1166.667 + ] + ], + [ + "PLD", + "1000ms (1 volume), 2167ms (1 volume), 3333ms (1 volume)" + ], + [ + "Background Suppression Number of Pulses", + 4 + ], + [ + "Background Suppression", + "with" + ], + [ + "Echo Time", + "11.24ms" + ], + [ + "Repetition Time", + "N/A" + ], + [ + "Flip Angle", + 111.0 + ], + [ + "In-plane Resolution", + "1.875x1.875mm^2" + ], + [ + "Slice Thickness", + "4.0mm" + ], + [ + "Total Acquired Pairs", + 2 + ] + ], + "m0_parameters": [ + [ + "M0 Type", + "Included" + ] + ], + "extended_parameters": [], + "missing_required_parameters": { + "RepetitionTimePreparation": "s" + } +} diff --git a/package/tests/integration/examples/ge_pcasl_easl/studyPar.json b/package/tests/integration/examples/ge_pcasl_easl/studyPar.json new file mode 100644 index 00000000..d2b5e899 --- /dev/null +++ b/package/tests/integration/examples/ge_pcasl_easl/studyPar.json @@ -0,0 +1 @@ +{"BackgroundSuppression":true} diff --git a/package/tests/integration/fixtures/sample_asl.nii.gz b/package/tests/integration/fixtures/sample_asl.nii.gz new file mode 100644 index 00000000..92233e3c Binary files /dev/null and b/package/tests/integration/fixtures/sample_asl.nii.gz differ diff --git a/package/tests/integration/runner.py b/package/tests/integration/runner.py index bd63c245..e1d4876d 100644 --- a/package/tests/integration/runner.py +++ b/package/tests/integration/runner.py @@ -50,11 +50,15 @@ from __future__ import annotations import json +import tempfile import warnings from pathlib import Path from typing import Any, Optional -from pyaslreport import generate_report +import numpy as np +import pydicom + +from pyaslreport import generate_report, get_bids_metadata from pyaslreport.enums import ModalityTypeValues EXPECTED_FILENAME = "expected_output.json" @@ -65,6 +69,8 @@ _NII_SUFFIXES = ("_asl.nii.gz", "_asl.nii") _SKIP_DIRS = {"derivatives", "sourcedata"} +SAMPLE_NIFTI = Path(__file__).parent / "fixtures" / "sample_asl.nii.gz" + class ExampleStructureError(ValueError): """Raised when a case folder can't be turned into a valid call at all.""" @@ -236,27 +242,22 @@ def build_inputs(case: Path) -> tuple[list[str], str]: def discover_examples(base: Path) -> list[Path]: - """Return case dirs under `base` (immediate children with a perf/*_asl.json). + """Return case dirs under `base` (immediate children the runner can run). - Keys on inputs, not on expected_output.json, so --update-expected can - bootstrap a brand-new case. Missing/empty base yields []. + A child is a case when it classifies as DICOM or BIDS (see + ``classify_example``). Keys on inputs, not on expected_output.json, so + --update-expected can bootstrap a brand-new case. Missing/empty base yields []. """ if not base.is_dir(): return [] - cases = [] - for child in base.iterdir(): - if not child.is_dir(): - continue - if any( - p.name.endswith(_ASL_JSON) - for perf in _perf_dirs(child) - for p in perf.iterdir() - ): - cases.append(child) - return sorted(cases) + return sorted( + child + for child in base.iterdir() + if child.is_dir() and classify_example(child) != "none" + ) -def run_example(case: Path) -> dict[str, Any]: +def _run_bids_example(case: Path) -> dict[str, Any]: """Run the BIDS report path on one example case directory.""" files, nifti = build_inputs(case) return generate_report( @@ -269,6 +270,142 @@ def run_example(case: Path) -> dict[str, Any]: ) +def default_serializer(obj: Any) -> Any: + """Serialize values ``json`` can't handle, matching the backend helper. + + Mirrors ``apps/backend/app/utils/lib.py`` so the ``_asl.json`` written for a + DICOM example is byte-compatible with what ``get_report_dicom`` produces. + """ + if isinstance(obj, pydicom.multival.MultiValue): + return list(obj) + if isinstance(obj, np.generic): + return obj.item() + if isinstance(obj, np.ndarray): + return obj.tolist() + return str(obj) + + +def _is_readable_dicom(path: Path) -> bool: + """Return True when ``path`` parses as a DICOM header.""" + try: + pydicom.dcmread(str(path), stop_before_pixels=True) + return True + except Exception: + return False + + +def _find_dicom_dir(case: Path) -> Optional[Path]: + """Locate a case's DICOM directory, or None if it isn't a DICOM case. + + Resolution: an ``example.json`` ``dicom_subdir`` override; any ``*.dcm`` under + the case (its parent is the DICOM dir); otherwise, for extension-less DICOMs, + the first subfolder whose first file parses as DICOM. A BIDS layout + (perf/*_asl.json) short-circuits to None. + """ + manifest = case / "example.json" + if manifest.is_file(): + try: + sub = json.loads(manifest.read_text()).get("dicom_subdir") + except (json.JSONDecodeError, OSError): + sub = None + if sub: + candidate = case / sub + return candidate if candidate.is_dir() else None + + dcms = sorted(case.rglob("*.dcm")) + if dcms: + return dcms[0].parent + + if any( + p.name.endswith(_ASL_JSON) for perf in _perf_dirs(case) for p in perf.iterdir() + ): + return None + + for directory in sorted(p for p in case.rglob("*") if p.is_dir()): + files = sorted(f for f in directory.iterdir() if f.is_file()) + if files and _is_readable_dicom(files[0]): + return directory + return None + + +def classify_example(case: Path) -> str: + """Return ``"dicom"``, ``"bids"``, or ``"none"`` for a case directory. + + An ``example.json`` ``type`` field overrides detection; otherwise a DICOM + directory wins, then a perf/*_asl.json BIDS layout. + """ + manifest = case / "example.json" + if manifest.is_file(): + try: + declared = json.loads(manifest.read_text()).get("type") + except (json.JSONDecodeError, OSError): + declared = None + if declared in ("dicom", "bids"): + return declared + + if _find_dicom_dir(case) is not None: + return "dicom" + if any( + p.name.endswith(_ASL_JSON) for perf in _perf_dirs(case) for p in perf.iterdir() + ): + return "bids" + return "none" + + +def _write_dicom_inputs( + out_dir: Path, metadata: Any, asl_context: Any +) -> tuple[str, str]: + """Write ``_asl.json`` and ``_aslcontext.tsv`` exactly as the backend does. + + Returns the (asl_json, aslcontext_tsv) paths. The tsv header is ``volume_type`` + followed by one lower-cased, quoted value per row. + """ + asl_json = out_dir / "_asl.json" + asl_json.write_text(json.dumps(metadata, indent=2, default=default_serializer)) + tsv = out_dir / "_aslcontext.tsv" + with tsv.open("w", newline="\n") as handle: + handle.write("volume_type\n") + for value in asl_context: + handle.write(f'"{str(value).lower()}"\n') + return str(asl_json), str(tsv) + + +def _run_dicom_example(case: Path) -> dict[str, Any]: + """Run the DICOM report path on one case, mirroring ``get_report_dicom``. + + Extracts BIDS metadata + context from the DICOMs (applying any C12 sidecar), + writes them to a temporary ``_asl.json`` / ``_aslcontext.tsv``, and runs + ``generate_report`` against the committed placeholder NIfTI (D-1a). The + ``metadata, asl_context`` unpack mirrors production: a vendor returning a bare + dict (Siemens today) raises here, by design. + + Raises: + ExampleStructureError: If the case has no DICOM directory. + """ + dicom_dir = _find_dicom_dir(case) + if dicom_dir is None: + raise ExampleStructureError(f"{case}: no DICOM directory found") + metadata, asl_context = get_bids_metadata( + {"modality": ModalityTypeValues.ASL, "dicom_dir": str(dicom_dir)} + ) + with tempfile.TemporaryDirectory() as tmp: + asl_json, tsv = _write_dicom_inputs(Path(tmp), metadata, asl_context) + return generate_report( + { + "modality": ModalityTypeValues.ASL, + "files": [asl_json, tsv], + "nifti_file": str(SAMPLE_NIFTI), + } + ) + + +def run_example(case: Path) -> dict[str, Any]: + """Run one example case, auto-detecting DICOM vs BIDS.""" + if classify_example(case) == "dicom": + return _run_dicom_example(case) + return _run_bids_example(case) + + def normalize(obj: Any) -> Any: """Round-trip through JSON so tuples (e.g. m0_parameters) compare as lists.""" return json.loads(json.dumps(obj, default=str)) diff --git a/package/tests/test_file_reader.py b/package/tests/test_file_reader.py new file mode 100644 index 00000000..1cd582ea --- /dev/null +++ b/package/tests/test_file_reader.py @@ -0,0 +1,25 @@ +"""Tests for FileReader TSV parsing (aslcontext volume types).""" + +from __future__ import annotations + +from pathlib import Path + +from pyaslreport.io.readers.file_reader import FileReader + + +class TestReadTsv: + """Reading a single-column aslcontext.tsv into volume-type tokens.""" + + def test_strips_surrounding_quotes(self, tmp_path: Path) -> None: + """Quoted values are unwrapped so downstream matching works.""" + path = tmp_path / "_aslcontext.tsv" + path.write_text('volume_type\n"deltam"\n"m0scan"\n', encoding="utf-8") + + assert FileReader.read(str(path)) == ["deltam", "m0scan"] + + def test_unquoted_values_unchanged(self, tmp_path: Path) -> None: + """Standard unquoted BIDS values pass through untouched.""" + path = tmp_path / "_aslcontext.tsv" + path.write_text("volume_type\ncontrol\nlabel\n", encoding="utf-8") + + assert FileReader.read(str(path)) == ["control", "label"] diff --git a/package/tests/test_ge_dicom_metadata.py b/package/tests/test_ge_dicom_metadata.py new file mode 100644 index 00000000..76226cd8 --- /dev/null +++ b/package/tests/test_ge_dicom_metadata.py @@ -0,0 +1,253 @@ +"""Tests for GE DICOM header repair and representative-header selection.""" + +from __future__ import annotations + +import struct +from pathlib import Path +from typing import Any + +import pydicom +import pydicom.config as pydicom_config +import pytest +from pydicom.dataelem import DataElement +from pydicom.dataset import Dataset, FileMetaDataset +from pydicom.tag import Tag +from pydicom.uid import ExplicitVRLittleEndian, generate_uid + +from pyaslreport import get_bids_metadata +from pyaslreport.enums import ModalityTypeValues +from pyaslreport.modalities.asl.processor import ASLProcessor +from pyaslreport.utils import dicom_tags_utils as dcm_tags +from pyaslreport.utils.dicom_repair_utils import GE_ASL_REPAIR_TAGS + + +def _binary_double(value: float) -> str: + """Encode a float as the anonymization-damaged value used in fixtures. + + Args: + value: Numeric value to encode as a little-endian double. + + Returns: + Latin-1 string preserving the raw double bytes. + """ + return struct.pack(" Path: + """Write a minimal DICOM file for metadata extraction tests. + + Args: + path: Destination file path. + tags: Mapping of DICOM tags to ``(VR, value)`` pairs. + + Returns: + The path that was written. + """ + ds = Dataset() + ds.SpecificCharacterSet = "ISO_IR 100" + + old_reading_mode = pydicom_config.settings.reading_validation_mode + old_writing_mode = pydicom_config.settings.writing_validation_mode + pydicom_config.settings.reading_validation_mode = pydicom_config.IGNORE + pydicom_config.settings.writing_validation_mode = pydicom_config.IGNORE + try: + for tag, (vr, value) in tags.items(): + if ( + vr in {"DS", "IS"} + and isinstance(value, str) + and any(ord(char) < 32 or ord(char) > 126 for char in value) + ): + ds.add(DataElement(tag, vr, value, already_converted=True)) + else: + ds.add_new(tag, vr, value) + finally: + pydicom_config.settings.reading_validation_mode = old_reading_mode + pydicom_config.settings.writing_validation_mode = old_writing_mode + + fm = FileMetaDataset() + fm.MediaStorageSOPClassUID = generate_uid() + fm.MediaStorageSOPInstanceUID = generate_uid() + fm.TransferSyntaxUID = ExplicitVRLittleEndian + ds.file_meta = fm + + old_writing_mode = pydicom_config.settings.writing_validation_mode + pydicom_config.settings.writing_validation_mode = pydicom_config.IGNORE + try: + pydicom.dcmwrite(str(path), ds, enforce_file_format=True) + finally: + pydicom_config.settings.writing_validation_mode = old_writing_mode + return path + + +def _base_ge_tags( + sequence_name: str | None = "3dpcasl", +) -> dict[Any, tuple[str, Any]]: + """Return common GE tags shared by synthetic ASL DICOM fixtures. + + Args: + sequence_name: Optional value for the GE internal sequence-name tag. + + Returns: + Mapping of DICOM tags to ``(VR, value)`` pairs. + """ + tags = { + dcm_tags.MANUFACTURER: ("LO", "GE MEDICAL SYSTEMS"), + dcm_tags.MR_ACQUISITION_TYPE: ("CS", "3D"), + dcm_tags.MAGNETIC_FIELD_STRENGTH: ("DS", "3"), + dcm_tags.ECHO_TIME: ("DS", "10.5"), + } + if sequence_name is not None: + tags[dcm_tags.GE_INTERNAL_SEQUENCE_NAME] = ("LO", sequence_name) + return tags + + +def test_ge_asl_missing_internal_sequence_name_falls_back_to_single_pld( + tmp_path: Path, +) -> None: + """A missing GE internal sequence name (0019,109E) is treated as single-PLD. + + Per GE guidance, absence of the tag (or any value other than 'easl') can be + safely handled as a basic GE single-PLD sequence instead of raising. + """ + tags = _base_ge_tags(sequence_name=None) + tags[dcm_tags.GE_LABEL_DURATION] = ("IS", "1450") + tags[dcm_tags.GE_INVERSION_TIME] = ("DS", "2025") + _write_dicom(tmp_path / "0001.dcm", tags) + + metadata, asl_context = get_bids_metadata( + {"modality": ModalityTypeValues.ASL, "dicom_dir": str(tmp_path)} + ) + + # Single-PLD extractor path: GE milliseconds are normalized to BIDS seconds + # (1450 ms -> 1.45 s, 2025 ms -> 2.025 s); single deltaM + m0scan context. + assert metadata["LabelingDuration"] == pytest.approx(1.45) + assert float(metadata["PostLabelingDelay"]) == pytest.approx(2.025) + assert asl_context == ["deltaM", "m0scan"] + + +def test_single_pld_repairs_binary_label_duration_and_selects_pld_header( + tmp_path: Path, +) -> None: + """The chosen single-PLD header should include InversionTime when available.""" + tags_without_pld = _base_ge_tags("3dpcasl") + tags_without_pld.update( + { + dcm_tags.INSTANCE_NUMBER: ("IS", "1"), + dcm_tags.GE_LABEL_DURATION: ("IS", _binary_double(1450.0)), + } + ) + _write_dicom(tmp_path / "0001.dcm", tags_without_pld) + + tags_with_pld = _base_ge_tags("3dpcasl") + tags_with_pld.update( + { + dcm_tags.INSTANCE_NUMBER: ("IS", "2"), + dcm_tags.GE_LABEL_DURATION: ("IS", _binary_double(1450.0)), + dcm_tags.GE_INVERSION_TIME: ("DS", "2025"), + } + ) + _write_dicom(tmp_path / "0002.dcm", tags_with_pld) + + metadata, asl_context = get_bids_metadata( + {"modality": ModalityTypeValues.ASL, "dicom_dir": str(tmp_path)} + ) + + # Extractor emits BIDS seconds: 1450 ms -> 1.45 s, 2025 ms -> 2.025 s. + assert metadata["LabelingDuration"] == pytest.approx(1.45) + assert float(metadata["PostLabelingDelay"]) == pytest.approx(2.025) + assert asl_context == ["deltaM", "m0scan"] + + +def test_easl_repairs_binary_cv_tags_and_generates_multi_pld(tmp_path: Path) -> None: + """Damaged eASL CV tags should be repaired before PLD arrays are computed.""" + tags = _base_ge_tags("easl") + tags.update( + { + dcm_tags.GE_PRIVATE_CV4: ("DS", _binary_double(1000.0)), + dcm_tags.GE_PRIVATE_CV5: ("DS", _binary_double(3500.0)), + dcm_tags.GE_PRIVATE_CV6: ("DS", _binary_double(3.0)), + dcm_tags.GE_PRIVATE_CV7: ("DS", _binary_double(0.2)), + } + ) + _write_dicom(tmp_path / "0001.dcm", tags) + + metadata, asl_context = get_bids_metadata( + {"modality": ModalityTypeValues.ASL, "dicom_dir": str(tmp_path)} + ) + + # CV4/CV5 are normalized ms -> s before the eASL timing math, so the + # emitted arrays are in BIDS seconds (previously x1000 too large). + assert metadata["LabelingDuration"] == pytest.approx( + [1.1666667, 1.1666667, 1.1666667] + ) + assert metadata["PostLabelingDelay"] == pytest.approx([1.0, 2.1666667, 3.3333333]) + assert asl_context == ["deltaM", "deltaM", "m0scan"] + + +def test_ge_asl_repair_allowlist_covers_extractor_numeric_tags() -> None: + """Numeric GE ASL tags read by the extractors must be repair-aware.""" + extractor_tags = { + Tag(0x0018, 0x0081), + Tag(0x0018, 0x0082), + Tag(0x0018, 0x0087), + Tag(0x0018, 0x1314), + Tag(0x0043, 0x10A5), + Tag(0x0019, 0x10AB), + Tag(0x0019, 0x10AC), + Tag(0x0019, 0x10AD), + Tag(0x0019, 0x10AE), + Tag(0x0043, 0x1083), + Tag(0x0043, 0x192C), + Tag(0x0027, 0x1062), + } + + assert extractor_tags <= GE_ASL_REPAIR_TAGS + + +def test_acquisition_voxel_size_from_dicom_geometry(tmp_path: Path) -> None: + """PixelSpacing + SliceThickness populate AcquisitionVoxelSize in mm.""" + tags = _base_ge_tags("3dpcasl") + tags[dcm_tags.PIXEL_SPACING] = ("DS", [3.4, 3.4]) + tags[dcm_tags.SLICE_THICKNESS] = ("DS", "4.0") + tags[dcm_tags.GE_LABEL_DURATION] = ("IS", "1450") + tags[dcm_tags.GE_INVERSION_TIME] = ("DS", "2025") + _write_dicom(tmp_path / "0001.dcm", tags) + + metadata, _ = get_bids_metadata( + {"modality": ModalityTypeValues.ASL, "dicom_dir": str(tmp_path)} + ) + + assert metadata["AcquisitionVoxelSize"] == pytest.approx([3.4, 3.4, 4.0]) + + +def test_ge_single_pld_timing_survives_processor_conversion_once( + tmp_path: Path, +) -> None: + """GE timing round-trips to report milliseconds exactly once. + + The GE extractor normalizes LabelingDuration/PostLabelingDelay to BIDS + seconds, and the ASL processor multiplies the time fields back to + milliseconds for the report. Chaining both must reproduce the original + GE millisecond values (1450 / 2025) rather than inflating them by 1000, + proving the conversion is applied a single time end to end. + """ + tags = _base_ge_tags(sequence_name=None) + tags[dcm_tags.GE_LABEL_DURATION] = ("IS", "1450") + tags[dcm_tags.GE_INVERSION_TIME] = ("DS", "2025") + _write_dicom(tmp_path / "0001.dcm", tags) + + metadata, _ = get_bids_metadata( + {"modality": ModalityTypeValues.ASL, "dicom_dir": str(tmp_path)} + ) + + # Extractor output is in BIDS seconds. + assert metadata["LabelingDuration"] == pytest.approx(1.45) + assert float(metadata["PostLabelingDelay"]) == pytest.approx(2.025) + + processor = ASLProcessor.__new__(ASLProcessor) + session = dict(metadata) + processor._convert_units_to_milliseconds(session) + + # Processor converts BIDS seconds back to milliseconds exactly once. + assert session["LabelingDuration"] == pytest.approx(1450) + assert session["PostLabelingDelay"] == pytest.approx(2025) diff --git a/package/tests/test_integration.py b/package/tests/test_integration.py index 79a4fa15..ac7575d6 100644 --- a/package/tests/test_integration.py +++ b/package/tests/test_integration.py @@ -24,12 +24,21 @@ import nibabel as nib import numpy as np +import pydicom import pytest +from tests.integration._dicom_synth import ( + write_ge_asl_dicom, + write_siemens_asl_dicom, +) +from tests.integration.compare import assert_report_matches from tests.integration.runner import ( ExampleStructureError, PairingFallbackWarning, + _find_dicom_dir, + _write_dicom_inputs, build_inputs, + classify_example, discover_examples, load_expected, normalize, @@ -65,11 +74,7 @@ def test_example_matches_expected( expected = load_expected(integration_case) actual = normalize(report) - assert set(actual.keys()) == set( - expected.keys() - ), f"{integration_case.name}: key set drift" - for key in sorted(expected.keys()): - assert actual[key] == expected[key], f"{integration_case.name}: '{key}' differs" + assert_report_matches(expected, actual, integration_case.name) # --------------------------------------------------------------------------- @@ -261,3 +266,123 @@ def test_slice_count_tracks_nifti(self, tmp_path: Path) -> None: """nifti_slice_number reflects the synthesized slice axis.""" case = _make_example(tmp_path, slices=27) assert run_example(case)["nifti_slice_number"] == 27 + + +class TestAssertReportMatches: + """Self-tests for the golden comparison helper (unmarked; runs in unit job).""" + + def test_identical_reports_do_not_raise(self) -> None: + """Equal reports produce no error.""" + report = {"basic_report": "hello", "nifti_slice_number": 20} + assert_report_matches(dict(report), dict(report), "case") + + def test_prose_difference_shows_unified_diff(self) -> None: + """A changed report sentence appears as -old / +new lines.""" + expected = {"basic_report": "TE = 10ms.\nSlices: 18."} + actual = {"basic_report": "TE = 10ms.\nSlices: 20."} + with pytest.raises(AssertionError) as exc: + assert_report_matches(expected, actual, "case") + message = str(exc.value) + assert "basic_report" in message + assert "-Slices: 18." in message + assert "+Slices: 20." in message + + def test_structured_difference_shows_both_values(self) -> None: + """A differing structured field shows both expected and actual.""" + with pytest.raises(AssertionError) as exc: + assert_report_matches( + {"nifti_slice_number": 18}, {"nifti_slice_number": 20}, "case" + ) + message = str(exc.value) + assert "18" in message + assert "20" in message + + def test_key_set_drift_is_reported(self) -> None: + """Keys only in expected or only in actual are both reported.""" + with pytest.raises(AssertionError) as exc: + assert_report_matches({"a": 1, "b": 2}, {"a": 1, "c": 3}, "case") + message = str(exc.value) + assert "only in expected" in message and "b" in message + assert "only in actual" in message and "c" in message + + def test_all_differences_reported_at_once(self) -> None: + """Every differing key is reported, not just the first.""" + with pytest.raises(AssertionError) as exc: + assert_report_matches( + {"basic_report": "a", "nifti_slice_number": 18}, + {"basic_report": "b", "nifti_slice_number": 20}, + "case", + ) + message = str(exc.value) + assert "basic_report" in message + assert "nifti_slice_number" in message + + +class TestDicomExampleRunner: + """Self-tests for the DICOM example branch (unmarked; runs in unit job).""" + + def _ge_case(self, root: Path, *, with_sidecar: bool = True) -> Path: + """Build a minimal GE single-PLD DICOM case under root/case/ASL.""" + case = root / "case" + asl_dir = case / "ASL" + asl_dir.mkdir(parents=True) + write_ge_asl_dicom(asl_dir / "0001.dcm") + if with_sidecar: + (case / "studyPar.json").write_text( + json.dumps( + { + "BackgroundSuppression": True, + "BackgroundSuppressionNumberPulses": 4, + "BackgroundSuppressionPulseTime": [1.465, 2.1, 2.6, 2.88], + } + ) + ) + return case + + def test_classify_detects_dicom_and_bids(self, tmp_path: Path) -> None: + """A .dcm folder classifies as dicom; a perf/*_asl.json folder as bids.""" + assert classify_example(self._ge_case(tmp_path / "d")) == "dicom" + assert classify_example(_make_example(tmp_path / "b")) == "bids" + + def test_locate_dicom_dir_finds_asl_subfolder(self, tmp_path: Path) -> None: + """The DICOM directory is the ASL subfolder holding the .dcm files.""" + assert _find_dicom_dir(self._ge_case(tmp_path)).name == "ASL" + + def test_dicom_example_runs_full_report(self, tmp_path: Path) -> None: + """A GE case runs end to end to a 21-key report; slice count from the fixture.""" + report = run_example(self._ge_case(tmp_path)) + assert len(report) == 21 + assert report["nifti_slice_number"] == 20 + + def test_c12_sidecar_reaches_report_prose(self, tmp_path: Path) -> None: + """studyPar pulse times reach the prose only when the sidecar is present.""" + with_sidecar = run_example(self._ge_case(tmp_path / "with")) + without = run_example(self._ge_case(tmp_path / "without", with_sidecar=False)) + assert "1465ms" in with_sidecar["basic_report"] + assert "1465ms" not in without["basic_report"] + + def test_serialization_matches_backend_format(self, tmp_path: Path) -> None: + """_write_dicom_inputs serializes numpy/MultiValue and quotes tsv values.""" + metadata = { + "arr": np.array([1, 2, 3]), + "mv": pydicom.multival.MultiValue(float, [4.0, 5.0]), + } + asl_json, tsv = _write_dicom_inputs(tmp_path, metadata, ["deltaM", "m0scan"]) + loaded = json.loads(Path(asl_json).read_text()) + assert loaded["arr"] == [1, 2, 3] + assert loaded["mv"] == [4.0, 5.0] + assert Path(tsv).read_text() == 'volume_type\n"deltam"\n"m0scan"\n' + + def test_siemens_runs_full_report(self, tmp_path: Path) -> None: + """Siemens now returns a (metadata, asl_context) pair and runs to a report. + + get_bids_metadata normalizes the Siemens bare dict into a pair (routing any + sidecar ASLContext into the context), so the DICOM path no longer raises. + """ + case = tmp_path / "case" + asl_dir = case / "ASL" + asl_dir.mkdir(parents=True) + write_siemens_asl_dicom(asl_dir / "0001.dcm") + report = run_example(case) + assert len(report) == 21 + assert report["nifti_slice_number"] == 20 diff --git a/package/tests/test_m0_tsv_validation.py b/package/tests/test_m0_tsv_validation.py index 3cf1ed85..fbb1eb16 100644 --- a/package/tests/test_m0_tsv_validation.py +++ b/package/tests/test_m0_tsv_validation.py @@ -143,6 +143,29 @@ def test_total_acquired_pairs_set( ) assert asl_data["TotalAcquiredPairs"] == 2 # two control-label pairs + def test_m0scan_with_missing_repetition_time_warns_without_crashing( + self, + make_processor: Callable[..., ASLProcessor], + make_context: Callable[..., ProcessingContext], + ) -> None: + """M0 timing cannot be assessed without RepetitionTimePreparation.""" + proc = make_processor() + ctx = make_context(m0_type="Included") + asl_data = {"M0Type": "Included", "BackgroundSuppression": False} + tsv_data = ["deltaM", "m0scan"] + + proc._analyze_tsv_volume_types( + tsv_data, ctx, "asl.json", asl_data, "context.tsv" + ) + + expected_warning = ( + "Warning: Cannot determine M0 preparation timing for ASL file " + "'asl.json' because neither 'RepetitionTimePreparation' nor " + "'RepetitionTime' is present, but TSV file 'context.tsv' contains " + "m0scan." + ) + assert ctx.warnings == [expected_warning] + # ---------- _handle_no_m0scan_warnings ---------- @@ -186,3 +209,57 @@ def test_bs_on_no_pulse_time_warns_about_relative_quantification( asl_data = {"BackgroundSuppression": True} proc._handle_no_m0scan_warnings(ctx, "asl.json", asl_data) assert any("relative quantification" in w for w in ctx.warnings) + + +# ---------- _warn_if_voxel_geometry_missing ---------- + + +class TestVoxelGeometryWarnings: + def test_missing_voxel_geometry_warns( + self, + make_processor: Callable[..., ASLProcessor], + make_context: Callable[..., ProcessingContext], + ) -> None: + """Absent AcquisitionVoxelSize surfaces a warning to the user.""" + proc = make_processor() + ctx = make_context() + asl_data = {"M0Type": "Included"} + + proc._warn_if_voxel_geometry_missing(ctx, "asl.json", asl_data) + + expected = ( + "Warning: Acquisition voxel geometry is missing for ASL file " + "'asl.json'; 'AcquisitionVoxelSize' is absent or incomplete, so " + "in-plane resolution and slice thickness are omitted from the " + "report." + ) + assert ctx.warnings == [expected] + + def test_incomplete_voxel_geometry_warns( + self, + make_processor: Callable[..., ASLProcessor], + make_context: Callable[..., ProcessingContext], + ) -> None: + """A voxel array with fewer than three values still warns.""" + proc = make_processor() + ctx = make_context() + asl_data = {"AcquisitionVoxelSize": [3.0, 3.0]} + + proc._warn_if_voxel_geometry_missing(ctx, "asl.json", asl_data) + + assert len(ctx.warnings) == 1 + assert "voxel geometry is missing" in ctx.warnings[0] + + def test_present_voxel_geometry_no_warning( + self, + make_processor: Callable[..., ASLProcessor], + make_context: Callable[..., ProcessingContext], + ) -> None: + """A complete AcquisitionVoxelSize produces no warning.""" + proc = make_processor() + ctx = make_context() + asl_data = {"AcquisitionVoxelSize": [3.0, 3.0, 4.0]} + + proc._warn_if_voxel_geometry_missing(ctx, "asl.json", asl_data) + + assert ctx.warnings == [] diff --git a/package/tests/test_metadata_override.py b/package/tests/test_metadata_override.py new file mode 100644 index 00000000..2dee6fc2 --- /dev/null +++ b/package/tests/test_metadata_override.py @@ -0,0 +1,219 @@ +"""Tests for the sidecar-JSON metadata override utility (C12).""" + +from __future__ import annotations + +import os +from pathlib import Path + +from pyaslreport.utils.metadata_override_utils import ( + apply_overrides, + apply_sidecar_overrides, + discover_sidecars, + load_sidecar, +) + + +def _make_session(tmp_path: Path, sidecar_json: str | None) -> Path: + """Build a session/ASL layout and return the DICOM (ASL) directory. + + Args: + tmp_path: Pytest-provided per-test temporary directory. + sidecar_json: Raw JSON text to write as ``session/studyPar.json``, or + ``None`` to create no sidecar. + + Returns: + Path to the ``ASL`` subfolder, mirroring the real layout where the + sidecar sits in the session folder one level above the DICOMs. + """ + session = tmp_path / "GE_PCASL_DV25.0" + asl_dir = session / "ASL" + asl_dir.mkdir(parents=True) + if sidecar_json is not None: + (session / "studyPar.json").write_text(sidecar_json, encoding="utf-8") + return asl_dir + + +class TestDiscoverSidecars: + """Discovery of sidecar JSON files next to a DICOM directory.""" + + def test_finds_sidecar_in_session_folder(self, tmp_path: Path) -> None: + """A studyPar.json one level above the DICOMs is discovered.""" + asl_dir = _make_session(tmp_path, "{}") + + found = discover_sidecars(str(asl_dir)) + + assert len(found) == 1 + assert os.path.basename(found[0]) == "studyPar.json" + + def test_returns_empty_when_no_json(self, tmp_path: Path) -> None: + """No JSON anywhere yields an empty list.""" + asl_dir = _make_session(tmp_path, None) + + assert discover_sidecars(str(asl_dir)) == [] + + def test_empty_dicom_dir_returns_empty(self) -> None: + """A falsy dicom_dir disables discovery.""" + assert discover_sidecars("") == [] + assert discover_sidecars(None) == [] + + def test_excludes_output_named_json(self, tmp_path: Path) -> None: + """A report/golden JSON (name contains 'output') is not a sidecar.""" + asl_dir = _make_session(tmp_path, "{}") # writes studyPar.json in session + (asl_dir.parent / "expected_output.json").write_text("{}", encoding="utf-8") + + found = [os.path.basename(f) for f in discover_sidecars(str(asl_dir))] + + assert "studyPar.json" in found + assert "expected_output.json" not in found + + +class TestLoadSidecar: + """Loading and shape-checking of sidecar files.""" + + def test_loads_json_object(self, tmp_path: Path) -> None: + """A JSON object is returned as a dict.""" + path = tmp_path / "s.json" + path.write_text('{"BackgroundSuppression": true}', encoding="utf-8") + + assert load_sidecar(str(path)) == {"BackgroundSuppression": True} + + def test_non_object_returns_empty_dict(self, tmp_path: Path) -> None: + """A top-level JSON array is ignored and yields an empty dict.""" + path = tmp_path / "s.json" + path.write_text("[1, 2, 3]", encoding="utf-8") + + assert load_sidecar(str(path)) == {} + + +class TestApplyOverrides: + """In-memory overlay semantics: JSON wins, conflicts recorded.""" + + def test_adds_absent_field(self) -> None: + """A DICOM-absent field (pulse times) is added to the metadata.""" + metadata = {"BackgroundSuppression": True} + + _, report = apply_overrides( + metadata, + {"BackgroundSuppressionPulseTime": [1.465, 2.1, 2.6, 2.88]}, + ) + + assert metadata["BackgroundSuppressionPulseTime"] == [1.465, 2.1, 2.6, 2.88] + assert report.applied == ["BackgroundSuppressionPulseTime"] + assert report.conflicts == [] + + def test_json_wins_and_conflict_recorded(self) -> None: + """A differing present value is overridden and recorded as a conflict.""" + metadata = {"BackgroundSuppressionNumberPulses": 4} + + _, report = apply_overrides(metadata, {"BackgroundSuppressionNumberPulses": 2}) + + assert metadata["BackgroundSuppressionNumberPulses"] == 2 + assert report.conflicts == [("BackgroundSuppressionNumberPulses", 4, 2)] + assert report.applied == [] + + def test_matching_value_is_not_a_conflict(self) -> None: + """Overriding with an identical value records neither applied nor conflict.""" + metadata = {"BackgroundSuppression": True} + + _, report = apply_overrides(metadata, {"BackgroundSuppression": True}) + + assert report.conflicts == [] + assert report.applied == [] + + def test_alias_maps_labeling_type(self) -> None: + """LabelingType is remapped onto the internal ArterialSpinLabelingType.""" + metadata: dict[str, object] = {} + + _, report = apply_overrides(metadata, {"LabelingType": "PCASL"}) + + assert metadata == {"ArterialSpinLabelingType": "PCASL"} + assert report.aliased == [("LabelingType", "ArterialSpinLabelingType")] + assert report.applied == ["ArterialSpinLabelingType"] + + def test_allowlist_skips_unlisted_keys(self) -> None: + """With an allowlist, only listed post-alias keys are overlaid.""" + metadata: dict[str, object] = {} + + _, report = apply_overrides( + metadata, + {"SliceTiming": 0.0374, "BackgroundSuppression": True}, + allowlist={"BackgroundSuppression"}, + ) + + assert metadata == {"BackgroundSuppression": True} + assert report.skipped == ["SliceTiming"] + + def test_accept_all_passes_unknown_keys(self) -> None: + """With no allowlist, DICOM-unknown keys pass through unchanged.""" + metadata: dict[str, object] = {} + + apply_overrides(metadata, {"SliceTiming": 0.0374, "NumberSegments": 4}) + + assert metadata == {"SliceTiming": 0.0374, "NumberSegments": 4} + + def test_overlays_first_dict_of_list_result(self) -> None: + """For a [dict, asl_context] result, only the dict is overlaid.""" + metadata = [{"BackgroundSuppression": True}, ["deltaM", "m0scan"]] + + apply_overrides( + metadata, + {"BackgroundSuppressionPulseTime": [1.465, 2.1, 2.6, 2.88]}, + ) + + assert metadata[0]["BackgroundSuppressionPulseTime"] == [1.465, 2.1, 2.6, 2.88] + assert metadata[1] == ["deltaM", "m0scan"] + + def test_no_metadata_mapping_is_noop(self) -> None: + """A result with no dict is left untouched with an empty report.""" + metadata = ["only", "strings"] + + result, report = apply_overrides(metadata, {"BackgroundSuppression": True}) + + assert result == ["only", "strings"] + assert report.applied == [] + assert report.conflicts == [] + + +class TestApplySidecarOverrides: + """End-to-end discovery + overlay driven from a DICOM directory.""" + + def test_discovers_and_applies(self, tmp_path: Path) -> None: + """A real studyPar.json is found and its fields reach the metadata.""" + asl_dir = _make_session( + tmp_path, + '{"BackgroundSuppression": true,' + ' "BackgroundSuppressionNumberPulses": 4,' + ' "BackgroundSuppressionPulseTime": [1.465, 2.1, 2.6, 2.88]}', + ) + metadata = {"BackgroundSuppression": True} + + _, report = apply_sidecar_overrides(metadata, str(asl_dir)) + + assert metadata["BackgroundSuppressionPulseTime"] == [1.465, 2.1, 2.6, 2.88] + assert metadata["BackgroundSuppressionNumberPulses"] == 4 + assert report.applied == [ + "BackgroundSuppressionNumberPulses", + "BackgroundSuppressionPulseTime", + ] + assert report.sidecar_path is not None + + def test_none_dicom_dir_is_noop(self) -> None: + """A None dicom_dir leaves the metadata untouched.""" + metadata = {"BackgroundSuppression": True} + + result, report = apply_sidecar_overrides(metadata, None) + + assert result == {"BackgroundSuppression": True} + assert report.sidecar_path is None + assert report.applied == [] + + def test_no_sidecar_found_is_noop(self, tmp_path: Path) -> None: + """When no sidecar exists, the metadata is unchanged.""" + asl_dir = _make_session(tmp_path, None) + metadata = {"BackgroundSuppression": True} + + _, report = apply_sidecar_overrides(metadata, str(asl_dir)) + + assert metadata == {"BackgroundSuppression": True} + assert report.applied == [] + assert report.sidecar_path is None diff --git a/package/tests/test_report_generator_prose.py b/package/tests/test_report_generator_prose.py new file mode 100644 index 00000000..378a61d6 --- /dev/null +++ b/package/tests/test_report_generator_prose.py @@ -0,0 +1,161 @@ +"""Unit tests for the Tier-1 report-prose helpers and small text fixes. + +These lock the readability behavior introduced in the report-text rewrite: +missing-value handling, natural-language joining, duration-list collapsing, +internal-token display, and the bolus-cutoff-delay closing parenthesis. +""" + +import pytest + +from pyaslreport.modalities.asl.report_generator import ReportGenerator as R + + +@pytest.mark.parametrize( + "value,expected", + [ + (None, True), + ("", True), + (" ", True), + ("N/A", True), + (" N/A ", True), + (0, False), + (0.0, False), + ("3D", False), + (11.24, False), + ], +) +def test_is_missing(value: object, expected: bool) -> None: + """Missing-value helper treats None, blanks, and N/A as absent.""" + assert R._is_missing(value) is expected + + +def test_join_and() -> None: + """Natural-language joining handles one, two, and many values.""" + assert R._join_and([]) == "" + assert R._join_and(["a"]) == "a" + assert R._join_and(["a", "b"]) == "a and b" + assert R._join_and(["a", "b", "c"]) == "a, b, and c" + # falsy parts are dropped + assert R._join_and(["a", "", None, "c"]) == "a and c" + + +def test_format_duration_list_collapses_identical() -> None: + """Identical duration arrays collapse to one displayed value.""" + assert R._format_duration_list([1450.0, 1450.0, 1450.0]) == "1450ms" + + +def test_format_duration_list_keeps_distinct() -> None: + """Distinct duration arrays preserve all displayed values.""" + assert R._format_duration_list([1000, 2000]) == "1000ms, 2000ms" + + +def test_format_duration_list_scalar_and_missing() -> None: + """Scalar values display with units while missing values disappear.""" + assert R._format_duration_list(1450) == "1450ms" + assert R._format_duration_list("N/A") == "" + assert R._format_duration_list(None) == "" + + +def test_fmt_num_caps_significant_figures() -> None: + """Numbers are rounded to at most four significant figures for display.""" + assert R._fmt_num(1166.6666667) == 1167 + assert R._fmt_num(2166.666666666667) == 2167 + assert R._fmt_num(3333.3333333333335) == 3333 + assert R._fmt_num(11.24) == 11.24 + assert R._fmt_num(0.2) == 0.2 + assert R._fmt_num(1000.0) == 1000 + + +def test_fmt_num_leaves_non_numbers_unchanged() -> None: + """Non-numeric values and booleans pass through untouched.""" + assert R._fmt_num("N/A") == "N/A" + assert R._fmt_num(None) is None + assert R._fmt_num(True) is True + assert R._fmt_num(0) == 0 + + +def test_format_duration_list_rounds_values() -> None: + """Long decimals are capped when a duration list is rendered.""" + assert R._format_duration_list([1166.6666667, 1166.6666667]) == "1167ms" + assert R._format_duration_list(2166.666666666667) == "2167ms" + + +def test_within_tolerance() -> None: + """The 1% tolerance check groups near-identical values.""" + assert R._within_tolerance([1160.0, 1170.0]) is True + assert R._within_tolerance([1000.0, 1100.0]) is False + assert R._within_tolerance([1450.0, 1450.0]) is True + + +def test_format_duration_list_collapses_near_identical() -> None: + """Timing arrays within 1% collapse to a single representative value.""" + assert R._format_duration_list([1160.0, 1170.0]) == "1165ms" + assert R._format_duration_list([1000.0, 1100.0]) == "1000ms, 1100ms" + + +def test_pattern_words_display_tokens() -> None: + """Every volume pattern is rendered in control-label pair terms.""" + assert R._pattern_words("deltam", plural=True) == ("control-label", "pairs") + assert R._pattern_words("deltam", plural=False) == ("control-label", "pair") + assert R._pattern_words("controllabel", plural=True) == ("control-label", "pairs") + assert R._pattern_words("labelcontrol", plural=False) == ("label-control", "pair") + + +def test_bolus_cutoff_delay_inconsistent_closes_parenthesis() -> None: + """Inconsistent bolus-cutoff delay prose closes its parenthesis.""" + values = {"BolusCutOffDelayTime": [["fileA", 800], ["fileB", 900]]} + combined_errors = { + "BolusCutOffDelayTime": ["INCONSISTENCY: differing bolus cutoff delay"] + } + out = R.handle_bolus_cutoff_delay_time(values, combined_errors) + assert out.startswith("(inconsistent") + assert out.endswith(")"), out + assert out.count("(") == out.count(")") + + +class _Values(dict): + """Mapping yielding a well-formed (file, 'N/A') pair for absent keys.""" + + def get(self, key, default=None): + return self[key] if key in self else [("f", "N/A")] + + +def _asl_report(global_pattern: str, total_acquired_pairs: object) -> str: + """Build an ASL report with all value-derived clauses empty. + + Args: + global_pattern: Volume-ordering token, passed straight through. + total_acquired_pairs: Pair count, passed straight through. + + Returns: + The basic ASL report paragraph. + """ + report, _ = R.generate_asl_report( + _Values(), + {}, + {}, + global_pattern, + "Included", + total_acquired_pairs=total_acquired_pairs, + slice_number=18, + ) + return report + + +def test_repetitions_clause_present_for_valid_count() -> None: + """A known pattern with a positive count yields the totals sentence.""" + assert "In total, 30 control-label pairs were acquired." in _asl_report( + "controllabel", 30 + ) + + +def test_repetitions_clause_omitted_when_zero() -> None: + """A zero count drops the totals sentence entirely.""" + assert "In total" not in _asl_report("controllabel", 0) + + +def test_repetitions_clause_omitted_for_unknown_pattern() -> None: + """The 'pattern error' sentinel never leaks into the prose.""" + report = _asl_report("pattern error", 1) + assert "In total" not in report + assert "pattern error" not in report