Skip to main content

functions

Pure functions for trial inclusion criteria matching.

Extracted from bitfount.federated.algorithms.ophthalmology. ga_trial_inclusion_criteria_match_algorithm_base so that the same logic can be called from both the legacy protocol path and new Prefect-based pipelines.

Module

Functions

add_age_column

def add_age_column(    df: pd.DataFrame, dob_col: str = "Patient's Birth Date", age_col: str = 'Age (yrs)',)> pandas.core.frame.DataFrame:

Add an age column to df based on a date-of-birth column.

If age_col already exists the dataframe is returned unchanged.

note

dob_col is converted in-place to UTC-aware datetime64 as a side-effect of parsing. Unparseable DOB values (e.g. de-identification placeholders like "XXXX") are coerced to NaT; the original string values are therefore lost after this call. Age is computed only for rows with a valid DOB and left as NA for the rest.

If DOB parsing raises OverflowError (e.g. DOB set to Timestamp.min as a placeholder), a timedelta-based fallback is used.

Returns The same dataframe with the extra age column.

aggregate_field_specs

def aggregate_field_specs(    field_specs_lists: list[list[FieldSpec]],)> dict[str, FieldSpec]:

Merge per-source FieldSpec lists into one {name: FieldSpec} catalog.

Later sources win on a name clash (last-wins), matching the left-merge order used when assembling the match frame.

Arguments

  • field_specs_lists: One FieldSpec list per source, in merge order.

Returns The merged {name: FieldSpec} catalog.

apply_inclusion_filters

def apply_inclusion_filters(    df: pd.DataFrame, filters: list[ColumnFilter | MethodFilter],)> pandas.core.frame.DataFrame:

Apply a list of eligibility filters to df.

Runs each filter in turn (every filter appends its own eligibility / exclusion-reason columns), then trims the joined reason string.

Arguments

  • df: The assembled feature frame. A FILTER_FAILED_REASON_COLUMN is seeded when absent so every row carries a reason string.
  • filters: The eligibility filters to apply, in order.

Returns The same frame with each filter's eligibility/reason columns added and the aggregate reason column stripped of stray separators.

Extracted from BaseGATrialInclusionWorkerAlgorithmSingleEye._filter_by_criteria.

appointment_history_filter

def appointment_history_filter(    row: pd.Series[Any],    *,    years_of_history: int,    min_appointments_per_year: int,    today: date | None = None,    appointments_col: str = 'Previous Appointments Info',    encounters_col: str = 'Previous Encounters Info',)> tuple[bool | None, str | None]:

Check the patient's appointment history spans the required window.

UNKNOWN (None) when neither appointment column holds a list — the history was never fetched, so there is nothing to judge. FAILs when the history is present but no appointment falls on or before the years_of_history cutoff; otherwise defers to check_min_appointments_per_year over the dates from the cutoff onward.

A FAIL drawn from an incomplete history — only one source column fetched, or a record that could not be read — is reported as UNKNOWN instead, since the missing records could have satisfied the criterion. See _resolve_appointment_verdict.

Arguments

  • row: The patient row (appointment/encounter columns read via combine_appointment_dates).
  • years_of_history: Required length of history, in years.
  • min_appointments_per_year: Minimum appointments required per year of the window.
  • today: Reference "now"; defaults to date.today().
  • appointments_col: Column holding the previous-appointments records.
  • encounters_col: Column holding the previous-encounters records.

Returns (eligible, reason)eligible is None for UNKNOWN, and reason names the failure when ineligible.

build_criterion_outcomes

def build_criterion_outcomes(    df: pd.DataFrame,    filters: list[ColumnFilter | MethodFilter],    column_provenance: Mapping[str, CriterionProvenance],    catalog: Mapping[str, FieldSpec] | None = None,)> list[CriteriaEvaluation]:

Build one CriteriaEvaluation per df row from the eligibility filters.

Resolves each ColumnFilter's target column once (constant across rows), then evaluates every filter per row into a CriterionOutcome. Each outcome's provenance is stamped from column_provenance keyed by the filter's resolved target column — the source axis, distinct from the filter's grain.

A filter that reads more than one source column overrides that per row. The observation filter spans OBSERVATIONS_COLUMN and SUPPLIED_OBSERVATIONS_COLUMN, whose provenances differ, so no single filter-level column describes it and the constant resolves to UNKNOWN for every row. Such a filter reports CriterionOutcome.matched_column — the column that determined that row's verdict — and it is preferred here. Single-column filters set it to None and keep the constant. Its unit is stamped the same way, from the target column's catalogued FieldSpec, so the unit travels with the evidence the API serves rather than being knowable only from a display column name. Self-contained — does not require apply_inclusion_filters to have run.

Arguments

  • df: The assembled feature DataFrame (values + identity columns).
  • filters: The eligibility filters (from build_eligibility_filters).
  • column_provenance: Source-column → CriterionProvenance map built at frame assembly; a column absent from it resolves to UNKNOWN.
  • catalog: The aggregated {name: FieldSpec} field catalog units are read from. Optional: without it every outcome's unit is None.

Returns A list aligned to df row order, one CriteriaEvaluation each.

build_eligibility_filters

def build_eligibility_filters(    config: CriteriaMatchConfig,    catalog: dict[str, FieldSpec] | None = None,    column_provenance: Mapping[str, CriterionProvenance] | None = None,)> list[ColumnFilter | MethodFilter]:

Build the eligibility filter list from a criteria-matching config.

Every criterion is expressed as a filter so that eligibility (and its failure reasons) can be derived from a single pass over the data. A bound left as None contributes no filter, and an empty/None code list contributes no filter.

Bounds notes:

  • GA area / largest GA lesion: exclusive (> lower, < upper).
  • CNV: eligible only when strictly below the threshold (<).
  • Patient age: inclusive (>= lower, <= upper), per clinical protocol.
  • Distance from fovea: inclusive (>= lower, <= upper).
  • exclude_foveal_ga: when set, excludes rows whose lesion sits exactly at the fovea centre (distance == 0).

Inclusion code lists make a patient eligible when any one code matches; exclusion code lists make a patient ineligible when any one code matches.

Arguments

  • config: The criteria-matching config.
  • catalog: The aggregated field catalog used to resolve a generic column_criteria grain, or None for an empty catalog.
  • column_provenance: Source-column → provenance map, used as the last-resort grain source for a column_criteria column the catalog does not carry (an EHR column resolves to PATIENT). None/absent columns fall through to the SCAN default.

build_subject_identity

def build_subject_identity(row: pd.Series[Any])> SubjectIdentity:

Build the SubjectIdentity for one row from its identity columns.

All fields are read from columns criteria_matching already assembled. scan_id/study_date/laterality are None when absent (the EHR grain).

A patient-ID column holding no value gives None, and so does one holding the empty string: the two mean the same thing, and keeping both would leave consumers guarding against either. first_present already maps a null (including the pd.NA that generate_bitfount_patient_id writes for a name it will not key) to None, so the or None here only folds in the empty string.

Arguments

  • row: The df row.

Returns The row's SubjectIdentity.

check_min_appointments_per_year

def check_min_appointments_per_year(    appointment_dates_within_history: list[date],    years_of_history: int,    min_appointments_per_year: int,    today: date | None = None,)> bool:

Check every year of the history window meets the per-year minimum.

Buckets each date by whole years-ago from today and requires each year in range(years_of_history) (the trailing years_of_history years) to hold at least min_appointments_per_year appointments. Future-dated appointments are excluded.

Arguments

  • appointment_dates_within_history: Candidate appointment/encounter dates.
  • years_of_history: Number of trailing years, each of which must meet the minimum.
  • min_appointments_per_year: Minimum appointments required in every such year.
  • today: Reference "now"; defaults to date.today().

Returns True only when every year in the window meets the minimum.

combine_appointment_dates

def combine_appointment_dates(    row: pd.Series[Any],    prev_appointments_col: str = 'Previous Appointments Info',    prev_encounters_col: str = 'Previous Encounters Info',)> list[date]:

Combine appointment and encounter dates from EHR data in a row.

Arguments

  • row: The patient row.
  • prev_appointments_col: Column holding the previous-appointments JSON records.
  • prev_encounters_col: Column holding the previous-encounters JSON records.

Returns The merged appointment + encounter dates, ascending; empty when both columns are absent/empty.

Extracted from BaseGATrialInclusionWorkerAlgorithmSingleEye._combine_appointment_dates.

configured_scan_range_columns

def configured_scan_range_columns(config: CriteriaMatchConfig)> set[str]:

Columns whose CST/GCC/fluid range filter this config actually emits.

A column appears iff at least one of its bounds is set. Used to warn when a range field is configured but the calc step that produces its column is not wired, so the criterion would otherwise silently mark every patient ineligible with no diagnostic (unlike the GA path, which warns).

Arguments

  • config: The criteria-matching config carrying the range bounds.

Returns The set of target column names with at least one bound set.

convert_appointment_dates

def convert_appointment_dates(    list_appt_enct: list[Mapping[str, str]],)> list[datetime.date]:

Convert appointment JSON records to a sorted list of dates.

Date strings are parsed with datetime.fromisoformat, which handles both "YYYY-MM-DDTHH:MM:SS" and sub-second variants such as "YYYY-MM-DDTHH:MM:SS.fff" that are common in EHR exports. Records that cannot be read at all are skipped with a warning; use _convert_appointment_dates_lossy where that distinction matters.

Arguments

  • list_appt_enct: Appointment/encounter JSON records, each an {"Appointment Date": "<iso datetime>", ...} mapping. A record with no/empty "Appointment Date" is skipped.

Returns The parsed dates, ascending; empty when no record carries a date.

Extracted from BaseGATrialInclusionWorkerAlgorithmSingleEye._convert_appointment_dates.

current_patient_filter

def current_patient_filter(    row: pd.Series[Any],    *,    months_threshold: int,    today: date | None = None,    appointments_col: str = 'Previous Appointments Info',    encounters_col: str = 'Previous Encounters Info',)> tuple[bool | None, str | None]:

Check the patient has an appointment within the last months_threshold.

UNKNOWN (None) when neither appointment column holds a list — the history was never fetched, so there is nothing to judge — and also when the history on hand is incomplete and does not show a qualifying appointment, since a missing record could have been the recent one. See _resolve_appointment_verdict.

Arguments

  • row: The patient row (appointment/encounter columns read via combine_appointment_dates).
  • months_threshold: Recency window in months; an appointment on or after today - months_threshold qualifies.
  • today: Reference "now"; defaults to date.today().
  • appointments_col: Column holding the previous-appointments records.
  • encounters_col: Column holding the previous-encounters records.

Returns (is_current, reason)is_current is None for UNKNOWN, True when any appointment falls in the window, and False when there is none.

ehr_field_specs

def ehr_field_specs()> list[FieldSpec]:

The matchable PATIENT-grain FieldSpecs the EHR patient-data channel contributes.

patient_data sources otherwise contribute nothing to the field catalog (_build_catalog only aggregates scan_metrics sources' field_specs()), so a generic column_criteria entry targeting an EHR column with no explicit grain fell through resolve_grain's catalog lookup and defaulted to SCAN even though the column's provenance is EHR — the grain=SCAN/provenance=EHR gating hole. Advertising AGE_COL, CONDITIONS_COLUMN, and PROCEDURES_COLUMN here as PATIENT grain closes it for those columns.

The two appointment-record columns hold lists of dicts, so a generic column_criteria membership test against them is not meaningful. They are advertised anyway so their grain resolves correctly for anyone who writes one; the typed appointment_history and current_patient_months config fields are the intended surface. NEXT_APPOINTMENT_COL is "str" because the catalog has no date dtype, which also withholds ordering operators from what is a formatted string.

Returns One FieldSpec each for AGE_COL ("int"); CONDITIONS_COLUMN, PROCEDURES_COLUMN, PREV_APPOINTMENTS_COL, and PREV_ENCOUNTERS_COL (all "collection"); and NEXT_APPOINTMENT_COL ("str"). All are CriterionGrain.PATIENT.

make_appointment_history_filter

def make_appointment_history_filter(    *,    years: int,    min_per_year: int,    today: date,    appointments_col: str,    encounters_col: str,)> Callable[[pd.Series[Any]], tuple[bool | None, str | None]]:

Build the appointment-history filter with its bounds and date bound in.

today is fixed at build time rather than read per row so every patient in a run is judged against the same reference date, including a run that straddles midnight.

Arguments

  • years: Required length of history, in years.
  • min_per_year: Minimum appointments required in each year of the window.
  • today: Reference "now" for the whole run.
  • appointments_col: Column holding the previous-appointments records.
  • encounters_col: Column holding the previous-encounters records.

Returns A row predicate returning (state, context) where state is True/False/None (PASS/FAIL/UNKNOWN).

make_code_list_filter

def make_code_list_filter(    *, column: str, codes: list[str], inclusion: bool,)> Callable[[pd.Series[Any]], tuple[bool | None, str | None]]:

Build a MethodFilter-compatible callable matching EHR code lists.

The EHR-specific specialisation of collection membership: the cell holds a collection of code-bearing objects (e.g. Condition / Procedure), and matching reads a code_code attribute off each member rather than the member itself. codes are matched literally or as * glob wildcards.

Arguments

  • column: Name of the column holding the patient's code objects.
  • codes: Codes to match against; a single match is sufficient.
  • inclusion: If True build an inclusion filter (a match makes the patient eligible); if False build an exclusion filter (a match makes the patient ineligible).

Returns A callable taking a row and returning (eligible, context), where eligible is None when the patient's code data is absent (unknown).

make_collection_membership_filter

def make_collection_membership_filter(    *, column: str, values: list[str], inclusion: bool,)> Callable[[pd.Series[Any]], tuple[bool | None, str | None]]:

Build a MethodFilter callable testing membership in a cell's collection.

Use when the matched cell holds a collection and the criterion asks whether that collection contains any wanted value — the value in cell test (the collection lives in the cell). Dispatched for a collection-dtype column (ops in / not in). Each member is compared directly as a string; no member attribute is inspected. values are matched literally or as * glob wildcards.

Arguments

  • column: Name of the column holding the per-row collection.
  • values: Patterns to match against; a single member match is sufficient.
  • inclusion: If True build an inclusion filter (a match makes the subject eligible); if False build an exclusion filter (a match makes the subject ineligible).

Returns A callable taking a row and returning (eligible, context), where eligible is None when the collection data is absent (unknown).

make_current_patient_filter

def make_current_patient_filter(    *, months: int, today: date, appointments_col: str, encounters_col: str,)> Callable[[pd.Series[Any]], tuple[bool | None, str | None]]:

Build the current-patient filter with its window and date bound in.

today is fixed at build time rather than read per row so every patient in a run is judged against the same reference date.

Arguments

  • months: Recency window in months.
  • today: Reference "now" for the whole run.
  • appointments_col: Column holding the previous-appointments records.
  • encounters_col: Column holding the previous-encounters records.

Returns A row predicate returning (state, context) where state is True/False/None (PASS/FAIL/UNKNOWN).

make_derived_from_column_filter

def make_derived_from_column_filter(    column: str, op: str, derived: DerivedFromColumn,)> Callable[[pd.Series[Any]], tuple[bool | None, str | None]]:

Build a filter comparing column against a DerivedFromColumn-resolved value.

Generalises make_n_scan_biomarker_filter's missing-data handling to any column/operator pair. column's own absence resolves UNKNOWN first — the row cannot be evaluated at all. This matches every other generic criterion's missing-column convention. divisor_column being missing, NaN, or 0 resolves to derived.on_unusable_divisor instead, since no resolved value can be computed at all. Whether that counts as PASS, FAIL, or UNKNOWN is the criterion's own call, not this function's — see DerivedFromColumn.

Arguments

  • column: The column being compared (the filter's own left-hand side).
  • op: The comparison operator, applied as column op resolved_value.
  • derived: The right-hand side's resolution spec.

Returns A row predicate returning (state, context).

make_drusen_inclusion_filter

def make_drusen_inclusion_filter(    *,    drusen_threshold: float,    hard_drusen_column: str,    soft_drusen_column: str,    confluent_drusen_column: str,)> Callable[[pd.Series[Any]], tuple[bool | None, str | None]]:

Build the combined drusen-inclusion filter.

Passes when hard-, soft-, or confluent-drusen probability is at or above (>=) drusen_threshold on the scan. Each signal is guarded on its value being present (non-NaN). When all three probabilities are absent/NaN the result is UNKNOWN (None); when at least one is present but none reach the threshold the result is FAIL.

Arguments

  • drusen_threshold: >= threshold applied to each drusen probability.
  • hard_drusen_column: Max hard-drusen probability column name.
  • soft_drusen_column: Max soft-drusen probability column name.
  • confluent_drusen_column: Max confluent-drusen probability column name.

Returns A row predicate returning (state, context) where state is True/False/None (PASS/FAIL/UNKNOWN).

make_exclusion_code_filter

def make_exclusion_code_filter(    *,    column: str,    any_eye_codes: list[str] | None,    study_eye_codes: list[str] | None,    laterality_column: str,    lat_unknown_eligible: bool,    time_windows: Mapping[str, Mapping[str, int]] | None,    date_field: str,    laterality_targets: Callable[[Any], set[str] | None],)> Callable[[pd.Series[Any]], MethodOutcome]:

Build an exclusion code filter matching the v8 laterality+recency logic.

A matching any-eye code (checked first, mutually exclusive with study-eye) disqualifies the patient unless a configured recency window excludes the event by date. Study-eye codes disqualify only when the scan laterality is known and the code's laterality includes the scan eye. Study-eye matches whose laterality is indeterminate are kept eligible per lat_unknown_eligible and flagged; windowed matches with no event date are always kept eligible and flagged.

Arguments

  • column: Column holding the patient's code entries.
  • any_eye_codes: Exclusion codes for either eye.
  • study_eye_codes: Exclusion codes for the study eye only.
  • laterality_column: Column holding the scan laterality.
  • lat_unknown_eligible: Eligibility when a study-eye code's laterality is indeterminate.
  • time_windows: Optional per-code recency windows.
  • date_field: Entry attribute/key holding the event date (onset_datetime for conditions, performed_datetime for procedures).
  • laterality_targets: Scope-appropriate laterality inference callable (condition_laterality_targets or procedure_laterality_targets).

Returns A MethodFilter-compatible callable returning a MethodOutcome whose code_match names the disqualifying code (or the codes awaiting laterality / event-date review).

make_inclusion_code_filter

def make_inclusion_code_filter(    *,    column: str,    any_eye_codes: list[str] | None,    study_eye_codes: list[str] | None,    laterality_column: str,    lat_unknown_eligible: bool,    laterality_targets: Callable[[Any], set[str] | None],)> Callable[[pd.Series[Any]], MethodOutcome]:

Build an inclusion code filter matching the v8 3-tier logic.

A patient is included if any any-eye code matches (checked first), or a study-eye code matches and the scan laterality is known and the code's inferred laterality includes the scan eye. When a study-eye code matches but its own laterality is indeterminate (scan laterality known), the verdict is lat_unknown_eligible. Study-eye codes are skipped entirely when scan laterality is unknown.

Arguments

  • column: Column holding the patient's code entries.
  • any_eye_codes: Codes qualifying for either eye.
  • study_eye_codes: Codes qualifying only for the study eye.
  • laterality_column: Column holding the scan laterality.
  • lat_unknown_eligible: Verdict when a study-eye code matches but its laterality is indeterminate.
  • laterality_targets: Scope-appropriate laterality inference callable (condition_laterality_targets or procedure_laterality_targets).

Returns A MethodFilter-compatible callable returning a MethodOutcome whose code_match names the qualifying code (or the codes awaiting laterality review).

make_n_scan_biomarker_filter

def make_n_scan_biomarker_filter(    *,    biomarker_display_name: str,    typical_width_micrometers: float,    run_length_column: str,    slice_thickness_column: str,)> Callable[[pd.Series[Any]], tuple[bool | None, str | None]]:

Build the N-scan consecutive-run biomarker exclusion filter.

A scan is ineligible (FAIL) when the biomarker's longest consecutive-B-scan run reaches ceil(typical_width_micrometers / slice_thickness_micrometers) (default run threshold 1 when the width is 0).

The run-length check runs FIRST, because the two missing-data causes correlate: an absent Slice Thickness is itself a missing_data:* reason for the GA metrics, so testing thickness first would PASS a scan that has no run-length metric at all — reporting it eligible on this criterion while the degraded path simultaneously reports every GA metric unknown.

The two missing-data branches then diverge deliberately:

  • A missing/zero/NaN slice thickness PASSES: without a thickness the run cannot be converted to a width, so the criterion is unevaluable and does not exclude on this basis. An absent Slice Thickness column behaves identically to a NaN value, because the column is not in required_columns: listing it there would let build_eligibility_filters claim it as a typed-field column and silently drop a user's generic column_criteria on it.
  • A missing/NaN run-length value returns UNKNOWN, NOT PASS: the biomarker was never measured, so no verdict is available. UNKNOWN has to be explicit here because convert_metrics_to_df reindexes a missing expected column into existence as NaN, which defeats MethodFilter's own missing-column UNKNOWN guard; returning PASS would report every scan of a no-GA-data patient as eligible on this criterion.

Arguments

  • biomarker_display_name: Human-readable biomarker name, used verbatim in the user-facing context and failure messages (e.g. "Subretinal Hyperreflective Material (SHRM)"). Never the raw internal label.
  • typical_width_micrometers: The lesion width driving the scan-count threshold, in micrometers.
  • run_length_column: Column holding the biomarker's longest consecutive run (n_scan_run_<label>).
  • slice_thickness_column: Column holding the per-scan slice thickness (mm).

Returns A row predicate returning (state, context) where state is True (PASS), False (FAIL), or None (UNKNOWN, missing run-length data only).

make_scalar_match_filter

def make_scalar_match_filter(    *, column: str, values: list[Any], negate: bool,)> Callable[[pd.Series[Any]], tuple[bool | None, str | None]]:

Build a MethodFilter callable glob-matching a scalar string cell.

Use when the matched cell holds a single scalar and the criterion asks whether that scalar is one of the wanted values — the cell in values test (the collection lives in the config, not the cell). Dispatched for a str-dtype column carrying a * glob (ops == / != / in / not in); a plain *-less str comparison stays a vectorized ColumnFilter instead. The cell is coerced to str and matched against values as * globs (via split_literal_and_regex_codes); a match makes the row eligible, and negate=True (!= / not in) inverts.

Arguments

  • column: The scalar column to read.
  • values: Glob patterns / literals to match against.
  • negate: Invert the match (!= / not in).

Returns A callable (row) -> (eligible, context).

matches_literal_or_regex

def matches_literal_or_regex(    *, code: str | None, literal_codes: set[str], regex_pattern: re.Pattern[str] | None,)> bool:

Return whether code matches configured literals or regex.

Extracted from CodeFilterMixIn._matches_literal_or_regex. Uses fullmatch (not v8's prefix match): a glob such as "E11*" must match the whole code, so "E11" does not spuriously match "E110". This is a deliberate tightening over v8 — for the trailing-* globs configs use in practice the two are equivalent, and it only diverges on the (unusual) mid-string wildcard, where full-string matching is the safer reading.

reset_unparseable_date_warnings

def reset_unparseable_date_warnings()> None:

Clear the warn-once-per-field state kept by _coerce_event_datetime.

Call once at the start of a criteria_matching_task run so an unparseable date format already warned about on a previous run is still reported, rather than silenced forever within a long-lived process.

resolve_grain

def resolve_grain(    column: str,    catalog: dict[str, FieldSpec],    explicit: CriterionGrain | None,    provenance: CriterionProvenance | None = None,)> CriterionGrain:

Resolve a column's grain: explicit > catalog > provenance > SCAN default.

When the catalog does not carry the column (only three EHR columns are catalogued, so any other EHR-produced column misses), the column's provenance is used as a last resort: an EHR-sourced column defaults to PATIENT grain rather than SCAN. Without this a grain=SCAN / provenance=EHR outcome is skipped by the scan-evidence reducer (EHR provenance) and by the patient reducer (SCAN grain) — it gates nothing.

Arguments

  • column: The target column name.
  • catalog: The aggregated field catalog.
  • explicit: A grain set on the criterion, or None.
  • provenance: The column's resolved provenance, or None if unknown.

Returns The resolved CriterionGrain.

split_literal_and_regex_codes

def split_literal_and_regex_codes(    list_of_codes: list[str] | None,)> tuple[set[str], re.Pattern[str] | None]:

Split wildcard code config into a literal set and a compiled regex.

Only * is treated as a wildcard (glob-style); all other characters, including ? and [, are matched literally.

Extracted from CodeFilterMixIn._split_literal_and_regex_codes.

warn_on_missing_appointment_columns

def warn_on_missing_appointment_columns(    config: CriteriaMatchConfig, frame: pd.DataFrame,)> None:

Warn when appointment criteria are configured but no data reached them.

fetch_appointments lives on the ehr_query step's config, while the appointment-history criteria live on this step's config, so nothing else cross-checks them. A modeller who configures appointment_history and/or current_patient_months while the ehr_query step ran without fetch_appointments gets NULL appointment columns for every patient — both criteria evaluate to unknown for every row, and since matches_all requires PASS, no row can be eligible. This surfaces that misconfiguration explicitly.

Absent columns alone do not detect it. SqliteCacheAccessor.to_dataframe builds its column list from the ORM's mapped attributes, so every ehr_data column is in the frame whenever a patient_data source is wired, however empty. The fetch_appointments: false case therefore shows up as columns that are present and entirely NULL, which is what the second check below looks for; the missing-column check still catches a run with no EHR source at all.

Arguments

  • config: The criteria-matching config.
  • frame: The assembled match frame.