diff --git a/README.md b/README.md index 4d0488b20..c6c27b66d 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,10 @@ This will show the list of validation options. -v, --version TEXT Standard version to validate against [required] -ss, --substandard TEXT Substandard to validate against + "SDTM", "SEND", "ADaM", or "CDASH" + [required for TIG] + -uc, --use-case TEXT Use Case for TIG Validation + "INDH", "PROD", "NONCLIN", or "ANALYSIS" [required for TIG] -ct, --controlled-terminology-package TEXT Controlled terminology package to validate diff --git a/TestRule/__init__.py b/TestRule/__init__.py index 3aa56cd28..0ccac2d0d 100644 --- a/TestRule/__init__.py +++ b/TestRule/__init__.py @@ -88,6 +88,7 @@ def main(req: func.HttpRequest, context: func.Context) -> func.HttpResponse: # standard = standards_data.get("product") standard_version = standards_data.get("version") standard_substandard = standards_data.get("substandard") + use_case = standards_data.get("use_case") standard, standard_version = normalize_adam_input(standard, standard_version) codelists = json_data.get("codelists", []) cache = InMemoryCacheService() @@ -117,6 +118,7 @@ def main(req: func.HttpRequest, context: func.Context) -> func.HttpResponse: # standard, standard_version, standard_substandard, + use_case, codelists, ) result = convert_numpy_types(result) diff --git a/cdisc_rules_engine/constants/use_cases.py b/cdisc_rules_engine/constants/use_cases.py deleted file mode 100644 index fdd8488c0..000000000 --- a/cdisc_rules_engine/constants/use_cases.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -Constants for use cases and their allowed domains. -""" - -SDTM = "SDTM" -SEND = "SEND" -ADAM = "ADAM" -CDASH = "CDASH" - -INDH = "INDH" -PROD = "PROD" -NONCLIN = "NONCLIN" -ANALYSIS = "ANALYSIS" - -# NOTE: this may need to be expanded after the pilot re: custom domains, other applicable domains, etc. The -USE_CASE_DOMAINS = { - SDTM: { # only prod and individual health are allowed for sdtm - INDH: [ - "AE", - "CO", - "CM", - "DM", - "DI", - "DU", - "DO", - "DS", - "EG", - "EX", - "EC", - "FA", - "IE", - "LB", - "MH", - "PC", - "PP", - "DA", - "DV", - "QS", - "RELREC", - "RE", - "SC", - "SE", - "SV", - "SU", - "EM", - "TA", - "TE", - "TI", - "TS", - "TV", - "VS", - ], - PROD: ["TO", "PD", "PT", "IT", "IN", "IQ", "ES"], - NONCLIN: [], - ANALYSIS: [], - }, - SEND: { # only nonclin allowed for send - INDH: [], - PROD: [], - NONCLIN: [ - "BW", - "CV", - "CL", - "CO", - "DD", - "DM", - "DI", - "DU", - "DS", - "EG", - "EX", - "FW", - "GT", - "LB", - "MA", - "MI", - "OM", - "PM", - "PK", - "PP", - "POOLDEF", - "RELREC", - "RELREF", - "RE", - "SC", - "SE", - "TA", - "TE", - "TF", - "TX", - "TS", - "VS", - ], - ANALYSIS: [], - }, - ADAM: { # only analysis allowed for adam, ADAM AD-- prefix check is done elsewhere. This is here for completeness. - INDH: [], - PROD: [], - NONCLIN: [], - ANALYSIS: [], - }, - CDASH: { # no conformance rules for CDASH Presently - INDH: [], - PROD: [], - NONCLIN: [], - ANALYSIS: [], - }, -} diff --git a/cdisc_rules_engine/models/validation_args.py b/cdisc_rules_engine/models/validation_args.py index 961ac2bf4..03954bdba 100644 --- a/cdisc_rules_engine/models/validation_args.py +++ b/cdisc_rules_engine/models/validation_args.py @@ -11,6 +11,7 @@ "standard", "version", "substandard", + "use_case", "controlled_terminology_package", "output", "output_format", diff --git a/cdisc_rules_engine/rules_engine.py b/cdisc_rules_engine/rules_engine.py index e67c56b12..1c50f3493 100644 --- a/cdisc_rules_engine/rules_engine.py +++ b/cdisc_rules_engine/rules_engine.py @@ -70,6 +70,7 @@ def __init__( self.standard = kwargs.get("standard") self.standard_version = (kwargs.get("standard_version") or "").replace(".", "-") self.standard_substandard = kwargs.get("standard_substandard") or None + self.use_case = kwargs.get("use_case") or None self.library_metadata = kwargs.get("library_metadata") self.max_dataset_size = kwargs.get("max_dataset_size") self.dataset_paths = kwargs.get("dataset_paths") @@ -220,6 +221,7 @@ def validate_single_dataset( datasets, self.standard, self.standard_substandard, + self.use_case, ) if is_suitable: result: List[Union[dict, str]] = self.validate_rule( diff --git a/cdisc_rules_engine/services/reporting/sdtm_report_data.py b/cdisc_rules_engine/services/reporting/sdtm_report_data.py index b9a258ab2..a6af212f8 100644 --- a/cdisc_rules_engine/services/reporting/sdtm_report_data.py +++ b/cdisc_rules_engine/services/reporting/sdtm_report_data.py @@ -67,12 +67,14 @@ def __init__( substandard = ( self._args.substandard if hasattr(self._args, "substandard") else None ) + use_case = self._args.use_case if hasattr(self._args, "use_case") else None self.data_sheets = { "Conformance Details": self.get_conformance_details_data( define_version, controlled_terminology, dictionary_versions, substandard=substandard, + use_case=use_case, ), "Dataset Details": self.get_dataset_details_data(), "Issue Summary": self.get_summary_data(), @@ -80,7 +82,7 @@ def __init__( "Rules Report": self.get_rules_report_data(), } - def get_conformance_details_data( + def get_conformance_details_data( # noqa self, define_version, cdiscCt, @@ -132,11 +134,15 @@ def get_conformance_details_data( conformance_details.append( ReportMetadataItem("Version", 11, f"V{self._version}") ) + if "use_case" in kwargs and kwargs["use_case"] is not None: + conformance_details.append( + ReportMetadataItem("TIG Use Case", 12, kwargs["use_case"]) + ) if cdiscCt: conformance_details.append( ReportMetadataItem( "CT Version", - 12, + 13, ( ", ".join(cdiscCt) if isinstance(cdiscCt, (list, tuple, set)) @@ -145,41 +151,41 @@ def get_conformance_details_data( ) ) else: - conformance_details.append(ReportMetadataItem("CT Version", 12, "")) + conformance_details.append(ReportMetadataItem("CT Version", 13, "")) conformance_details.append( - ReportMetadataItem("Define-XML Version", 13, define_version) + ReportMetadataItem("Define-XML Version", 14, define_version) ) # Populate external dictionary versions unii_version = dictionary_versions.get(DictionaryTypes.UNII.value) if unii_version is not None: conformance_details.append( - ReportMetadataItem("UNII Version", 16, unii_version) + ReportMetadataItem("UNII Version", 15, unii_version) ) medrt_version = dictionary_versions.get(DictionaryTypes.MEDRT.value) if medrt_version is not None: conformance_details.append( - ReportMetadataItem("Med-RT Version", 17, medrt_version) + ReportMetadataItem("Med-RT Version", 16, medrt_version) ) meddra_version = dictionary_versions.get(DictionaryTypes.MEDDRA.value) if meddra_version is not None: conformance_details.append( - ReportMetadataItem("MedDRA Version", 18, meddra_version) + ReportMetadataItem("MedDRA Version", 17, meddra_version) ) whodrug_version = dictionary_versions.get(DictionaryTypes.WHODRUG.value) if whodrug_version is not None: conformance_details.append( - ReportMetadataItem("WHODRUG Version", 19, whodrug_version) + ReportMetadataItem("WHODRUG Version", 18, whodrug_version) ) snomed_version = dictionary_versions.get(DictionaryTypes.SNOMED.value) if snomed_version is not None: conformance_details.append( - ReportMetadataItem("SNOMED Version", 20, snomed_version) + ReportMetadataItem("SNOMED Version", 19, snomed_version) ) loinc_version = dictionary_versions.get(DictionaryTypes.LOINC.value) if loinc_version is not None: conformance_details.append( - ReportMetadataItem("LOINC Version", 21, loinc_version) + ReportMetadataItem("LOINC Version", 20, loinc_version) ) return conformance_details diff --git a/cdisc_rules_engine/utilities/rule_processor.py b/cdisc_rules_engine/utilities/rule_processor.py index b72dc9b44..d7c834321 100644 --- a/cdisc_rules_engine/utilities/rule_processor.py +++ b/cdisc_rules_engine/utilities/rule_processor.py @@ -24,7 +24,6 @@ SUPPLEMENTARY_DOMAINS, ) from cdisc_rules_engine.constants.rule_constants import ALL_KEYWORD -from cdisc_rules_engine.constants.use_cases import USE_CASE_DOMAINS from cdisc_rules_engine.interfaces import ConditionInterface from cdisc_rules_engine.models.operation_params import OperationParams from cdisc_rules_engine.models.rule_conditions import AllowedConditionsKeys @@ -274,6 +273,7 @@ def rule_applies_to_use_case( rule: dict, standard: str, standard_substandard: str, + use_case: str, ) -> bool: if standard.lower() != "tig": return True @@ -281,25 +281,7 @@ def rule_applies_to_use_case( if not use_cases: return True use_cases = [uc.strip() for uc in use_cases.split(",")] - substandard = standard_substandard.upper() - if substandard not in USE_CASE_DOMAINS: - return False - - domain_to_check = dataset_metadata.domain - if dataset_metadata.is_supp and dataset_metadata.rdomain: - domain_to_check = dataset_metadata.rdomain - - # Handle ADaM datasets with AD prefix - if substandard == "ADAM" and domain_to_check.startswith("AD"): - return "ANALYSIS" in use_cases - - allowed_domains = set() - for use_case in use_cases: - if use_case in USE_CASE_DOMAINS[substandard]: - allowed_domains.update(USE_CASE_DOMAINS[substandard][use_case]) - if domain_to_check in allowed_domains: - return True - return False + return use_case in use_cases @classmethod def rule_applies_to_entity( @@ -684,6 +666,7 @@ def is_suitable_for_validation( datasets: Iterable[SDTMDatasetMetadata], standard, standard_substandard: str, + use_case: str, ) -> Tuple[bool, str]: """Check if rule is suitable and return reason if not""" rule_id = rule.get("core_id", "unknown") @@ -698,7 +681,11 @@ def is_suitable_for_validation( ): return self.log_suitable_for_validation(rule_id, dataset_name) if not self.rule_applies_to_use_case( - dataset_metadata, rule, standard, standard_substandard + dataset_metadata, + rule, + standard, + standard_substandard, + use_case, ): reason = ( f"Rule skipped - doesn't apply to use case for " diff --git a/cdisc_rules_engine/utilities/utils.py b/cdisc_rules_engine/utilities/utils.py index f3b8906a5..2b4a47163 100644 --- a/cdisc_rules_engine/utilities/utils.py +++ b/cdisc_rules_engine/utilities/utils.py @@ -178,7 +178,7 @@ def get_standard_details_cache_key( if not standard_substandard: return f"standards/{standard_type}/{standard_version}" else: - return f"standards/{standard_type}/{standard_version}/{standard_substandard}" + return f"standards/{standard_type}/{standard_version}/{standard_substandard.lower()}" def normalize_adam_input(standard: str, version: str) -> tuple: diff --git a/core.py b/core.py index dd1ab05b3..ef104df6b 100644 --- a/core.py +++ b/core.py @@ -233,7 +233,19 @@ def _validate_no_arguments(logger) -> None: "-ss", "--substandard", default=None, - help="CDISC Substandard to validate against", + type=click.Choice(["sdtm", "send", "adam", "cdash"], case_sensitive=False), + help="CDISC Substandard to validate against. Any of SDTM, SEND, ADaM, CDASH", +) +@click.option( + "-uc", + "--use-case", + required=False, + default=None, + type=click.Choice(["INDH", "PROD", "NONCLIN", "ANALYSIS"], case_sensitive=True), + help=( + "CDISC TIG Use Case for scoping a TIG Validation." + "Any of INDH, PROD, NONCLIN, or ANALYSIS." + ), ) @click.option( "-ct", @@ -368,7 +380,7 @@ def _validate_no_arguments(logger) -> None: ), ) @click.pass_context -def validate( +def validate( # noqa ctx, cache: str, pool_size: int, @@ -380,6 +392,7 @@ def validate( standard: str, version: str, substandard: str, + use_case: str, controlled_terminology_package: tuple[str], output: str, output_format: tuple[str], @@ -431,6 +444,12 @@ def validate( cache_path: str = os.path.join(os.path.dirname(__file__), cache) + if standard.lower() == "tig": + if not substandard or not use_case: + logger.error( + "Standard 'tig' requires both --substandard and --use-case to be specified." + ) + ctx.exit(2) # Construct ExternalDictionariesContainer: external_dictionaries = ExternalDictionariesContainer( { @@ -474,6 +493,7 @@ def validate( standard, version, substandard, + use_case, set(controlled_terminology_package), # avoiding duplicates output, set(output_format), # avoiding duplicates @@ -840,6 +860,7 @@ def test_validate(filetype): standard, version, None, + None, set(), output, output_format, diff --git a/resources/templates/report-template.xlsx b/resources/templates/report-template.xlsx index 2e805f055..e2e47a628 100644 Binary files a/resources/templates/report-template.xlsx and b/resources/templates/report-template.xlsx differ diff --git a/scripts/run_validation.py b/scripts/run_validation.py index 8ca1990e5..3f6df90bb 100644 --- a/scripts/run_validation.py +++ b/scripts/run_validation.py @@ -84,6 +84,7 @@ def validate_single_rule( standard=args.standard, standard_version=args.version.replace(".", "-"), standard_substandard=args.substandard, + use_case=args.use_case, external_dictionaries=args.external_dictionaries, ct_packages=args.controlled_terminology_package, define_xml_path=args.define_xml_path, @@ -234,6 +235,7 @@ def run_single_rule_validation( standard: str = None, standard_version: str = "", standard_substandard: str = None, + use_case: str = None, codelists=[], ) -> dict: datasets = [DummyDataset(dataset_data) for dataset_data in datasets] @@ -290,6 +292,7 @@ def run_single_rule_validation( standard=standard, standard_version=standard_version, standard_substandard=standard_substandard, + use_case=use_case, library_metadata=library_metadata, ) engine.rule_processor = RuleProcessor(data_service, cache, library_metadata) diff --git a/tests/unit/test_services/test_data_service/test_data_service.py b/tests/unit/test_services/test_data_service/test_data_service.py index 4d7ded960..31ae87a91 100644 --- a/tests/unit/test_services/test_data_service/test_data_service.py +++ b/tests/unit/test_services/test_data_service/test_data_service.py @@ -190,6 +190,7 @@ def test_get_dataset_class(dataset_metadata, data, expected_class): "sdtmig", "3-4", None, + None, "", "", "", @@ -274,6 +275,7 @@ def test_get_dataset_class_associated_domains(): "sdtmig", "3-4", None, + None, "", "", "", diff --git a/tests/unit/test_services/test_reporting/test_excel_export.py b/tests/unit/test_services/test_reporting/test_excel_export.py index 5cd9aef6b..aa305b49a 100644 --- a/tests/unit/test_services/test_reporting/test_excel_export.py +++ b/tests/unit/test_services/test_reporting/test_excel_export.py @@ -30,6 +30,7 @@ def test_get_export(mock_validation_results): mock_args.standard = "sdtmig" mock_args.substandard = None mock_args.version = "3.4" + mock_args.use_case = None mock_args.dictionary_versions = {} datasets = [ SDTMDatasetMetadata( @@ -57,10 +58,11 @@ def test_get_export(mock_validation_results): assert wb["Conformance Details"]["B9"].value == "SDTMIG" assert wb["Conformance Details"]["B10"].value == "NAP" assert wb["Conformance Details"]["B11"].value == "V3.4" - assert wb["Conformance Details"]["B12"].value == ", ".join( + assert wb["Conformance Details"]["B12"].value == "NAP" + assert wb["Conformance Details"]["B13"].value == ", ".join( mock_args.controlled_terminology_package ) - assert wb["Conformance Details"]["B13"].value == "2.1" + assert wb["Conformance Details"]["B14"].value == "2.1" # Check dataset details tab assert wb["Dataset Details"]["A2"].value == "test" # filename diff --git a/tests/unit/test_utilities/test_rule_processor.py b/tests/unit/test_utilities/test_rule_processor.py index f2356f73d..25b17a808 100644 --- a/tests/unit/test_utilities/test_rule_processor.py +++ b/tests/unit/test_utilities/test_rule_processor.py @@ -379,34 +379,35 @@ def test_rule_applies_to_class( @pytest.mark.parametrize( - "dataset_name, domain, rdomain, rule_use_case, standard, standard_substandard, outcome", + "dataset_name, domain, rdomain, rule_use_case, use_case, standard, standard_substandard, outcome", [ - # Basic use case tests with string format "INDH, PROD" - ("AE", "AE", None, "INDH, PROD", "tig", "SDTM", True), - ("CM", "CM", None, "INDH", "tig", "SDTM", True), - ("TS", "TS", None, "INDH", "tig", "SDTM", True), - ("ES", "ES", None, "PROD", "tig", "SDTM", True), - ("ES", "ES", None, "INDH", "tig", "SDTM", False), - ("BW", "BW", None, "NONCLIN", "tig", "SEND", True), - ("BW", "BW", None, "INDH", "tig", "SEND", False), + # Basic use case tests - user provides "INDH" or "PROD" + ("AE", "AE", None, "INDH, PROD", "INDH", "tig", "SDTM", True), + ("AE", "AE", None, "INDH, PROD", "PROD", "tig", "SDTM", True), + ("CM", "CM", None, "INDH", "INDH", "tig", "SDTM", True), + ("TS", "TS", None, "INDH", "INDH", "tig", "SDTM", True), + ("ES", "ES", None, "PROD", "PROD", "tig", "SDTM", True), + ("ES", "ES", None, "PROD", "INDH", "tig", "SDTM", False), + ("BW", "BW", None, "NONCLIN", "NONCLIN", "tig", "SEND", True), + ("BW", "BW", None, "NONCLIN", "INDH", "tig", "SEND", False), # Tests for ADaM datasets - ("ADSL", "ADSL", None, "ANALYSIS", "tig", "ADAM", True), - ("ADAE", "ADAE", None, "ANALYSIS", "tig", "ADAM", True), - ("ADAE", "ADAE", None, "INDH", "tig", "ADAM", False), + ("ADSL", "ADSL", None, "ANALYSIS", "ANALYSIS", "tig", "ADAM", True), + ("ADAE", "ADAE", None, "ANALYSIS", "ANALYSIS", "tig", "ADAM", True), + ("ADAE", "ADAE", None, "ANALYSIS", "INDH", "tig", "ADAM", False), # Tests for supplementary datasets - ("SUPPAE", None, "AE", "INDH", "tig", "SDTM", True), - ("SUPPQS", None, "QS", "INDH", "tig", "SDTM", True), - ("SUPPEC", None, "EC", "INDH", "tig", "SDTM", True), - ("SUPP--", None, "AE", "INDH", "tig", "SDTM", True), - ("SUPPPT", None, "PT", "PROD", "tig", "SDTM", True), - # Tests for empty/None use cases (should always return True) - ("AE", "AE", None, "", "tig", "SDTM", True), - ("AE", "AE", None, None, "tig", "SDTM", True), + ("SUPPAE", None, "AE", "INDH", "INDH", "tig", "SDTM", True), + ("SUPPQS", None, "QS", "INDH", "INDH", "tig", "SDTM", True), + ("SUPPEC", None, "EC", "INDH", "INDH", "tig", "SDTM", True), + ("SUPP--", None, "AE", "INDH", "INDH", "tig", "SDTM", True), + ("SUPPPT", None, "PT", "PROD", "PROD", "tig", "SDTM", True), + # Tests for empty/None use cases in rule (should always return True) + ("AE", "AE", None, "", "INDH", "tig", "SDTM", True), + ("AE", "AE", None, None, "INDH", "tig", "SDTM", True), # Tests for non-TIG standard (should always return True) - ("AE", "AE", None, "INDH", "sdtmig", "SDTM", True), - ("BW", "BW", None, "NONCLIN", "sendct", "SEND", True), - # Tests for substandards not in USE_CASE_DOMAINS - ("AE", "AE", None, "INDH", "tig", "UNKNOWN", False), + ("AE", "AE", None, "INDH", "INDH", "sdtmig", "SDTM", True), + ("BW", "BW", None, "NONCLIN", "NONCLIN", "sendct", "SEND", True), + # Test case mismatch + ("AE", "AE", None, "INDH, PROD", "SAFETY", "tig", "SDTM", False), ], ) def test_rule_applies_to_use_case( @@ -417,6 +418,7 @@ def test_rule_applies_to_use_case( rule_use_case, standard, standard_substandard, + use_case, outcome, ): processor = RuleProcessor(mock_data_service, InMemoryCacheService()) @@ -429,7 +431,7 @@ def test_rule_applies_to_use_case( ) assert ( processor.rule_applies_to_use_case( - dataset_metadata, rule, standard, standard_substandard + dataset_metadata, rule, standard, standard_substandard, use_case ) == outcome )