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.
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: OneFieldSpeclist 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. AFILTER_FAILED_REASON_COLUMNis 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 viacombine_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 todate.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 (frombuild_eligibility_filters).column_provenance: Source-column →CriterionProvenancemap built at frame assembly; a column absent from it resolves toUNKNOWN.catalog: The aggregated{name: FieldSpec}field catalog units are read from. Optional: without it every outcome'sunitisNone.
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 genericcolumn_criteriagrain, orNonefor an empty catalog.column_provenance: Source-column → provenance map, used as the last-resort grain source for acolumn_criteriacolumn the catalog does not carry (anEHRcolumn resolves toPATIENT).None/absent columns fall through to theSCANdefault.
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 todate.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 viacombine_appointment_dates).months_threshold: Recency window in months; an appointment on or aftertoday - months_thresholdqualifies.today: Reference "now"; defaults todate.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: IfTruebuild an inclusion filter (a match makes the patient eligible); ifFalsebuild 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: IfTruebuild an inclusion filter (a match makes the subject eligible); ifFalsebuild 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 ascolumn 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_datetimefor conditions,performed_datetimefor procedures).laterality_targets: Scope-appropriate laterality inference callable (condition_laterality_targetsorprocedure_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_targetsorprocedure_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 Thicknesscolumn behaves identically to a NaN value, because the column is not inrequired_columns: listing it there would letbuild_eligibility_filtersclaim it as a typed-field column and silently drop a user's genericcolumn_criteriaon 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_dfreindexes a missing expected column into existence as NaN, which defeatsMethodFilter'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, orNone.provenance: The column's resolved provenance, orNoneif 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.