File size: 6,466 Bytes
1ddeb51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
"""Align optional patient age to age-banded OHIP premiums / add-ons.

Many Schedule codes carry an explicit age window in their description
(e.g. ``ages 0 to 15``, ``2 - 11 years of age``, ``aged 65 years and above``,
``under 2 years``). When the clinician supplies an optional age (in months),
we:

1. Steer premium retrieval with age-appropriate keywords.
2. Drop age-banded premiums that fall outside the patient's age.
3. Leave non-age-banded premiums (after-hours, immunization, chronic-disease,
   discharge, …) untouched.

When age is omitted, no age filtering is applied — physicians stay unblocked.
Age alone is clinical signal, not an identifier (unlike DOB / health card).
"""
from __future__ import annotations

import math
import re
from collections.abc import Callable
from dataclasses import dataclass
from re import Match

# Sentinel for open-ended upper bounds (e.g. 65+).
_INF_MONTHS = 200 * 12


@dataclass(frozen=True)
class AgeAlignment:
    age_months: int
    label: str
    keywords: str


@dataclass(frozen=True)
class AgeWindow:
    """Inclusive age window in whole months."""

    lo_months: int
    hi_months: int  # inclusive


def align_patient_age(age_months: int | None) -> AgeAlignment | None:
    """Build steering keywords / label for a patient age in months."""
    if age_months is None or age_months < 0:
        return None
    years = age_months / 12.0
    if age_months < 24:
        label = f"{age_months} months old"
        keywords = (
            "under 2 years of age infant toddler paediatric age premium "
            "newborn child ages 0 to 15"
        )
    elif years < 12:
        y = int(years)
        label = f"{y} years old"
        keywords = (
            f"{y} years of age child paediatric 2 - 11 years of age "
            "ages 0 to 15 aged 19 years and below"
        )
    elif years < 18:
        y = int(years)
        label = f"{y} years old"
        keywords = (
            f"{y} years of age adolescent 12 - 17 years of age "
            "ages 0 to 15 aged 19 years and below less than age 22"
        )
    elif years < 65:
        y = int(years)
        label = f"{y} years old"
        keywords = (
            f"adult age {y} years 18 to 64 inclusive aged 19 years and below"
            if y <= 19
            else f"adult age {y} years 18 to 64 inclusive"
        )
    else:
        y = int(years)
        label = f"{y} years old"
        keywords = f"adult {y} years of age and older aged 65 years and above"
    return AgeAlignment(age_months=age_months, label=label, keywords=keywords)


def premium_age_window(doc: dict) -> AgeWindow | None:
    """Extract an age window from a code's description, or None if not age-banded."""
    text = " ".join(
        [
            doc.get("description_text") or "",
            doc.get("description") or "",
            doc.get("rules_and_constraints") or "",
        ]
    )
    if not text.strip():
        return None
    return _parse_age_window(text)


def premium_applies_to_age(doc: dict, age_months: int) -> bool:
    """True if this premium is compatible with the patient's age.

    Non-age-banded premiums always apply. Age-banded ones must contain
    ``age_months`` in their inclusive window.
    """
    window = premium_age_window(doc)
    if window is None:
        return True
    return window.lo_months <= age_months <= window.hi_months


def filter_premiums_for_age(
    premiums: list[dict], age_months: int
) -> list[dict]:
    """Drop age-banded premiums outside the patient's age."""
    return [p for p in premiums if premium_applies_to_age(p, age_months)]


def months_from_years(years: float) -> int:
    """Convert a whole/fractional year value to whole months (floor)."""
    if years < 0:
        return 0
    return int(math.floor(years * 12))


# --- description parsers -----------------------------------------------------

def _year_hi(years_inclusive: int) -> int:
    """Last month index still inside an inclusive year upper bound."""
    return (years_inclusive + 1) * 12 - 1


def _parse_age_window(text: str) -> AgeWindow | None:
    # Skip exception clauses ("except for patients under 4 years…") — those
    # are carve-outs, not eligibility windows.
    scrubbed = re.sub(
        r"except\s+for\s+patients?\s+under\s+\d+\s+years?[^.]*",
        " ",
        text,
        flags=re.I,
    )
    patterns: list[tuple[re.Pattern[str], Callable[[Match[str]], AgeWindow]]] = [
        (
            re.compile(
                r"ages?\s+(\d+)\s*(?:to|-|–)\s*(\d+)(?:\s+inclusive)?",
                re.I,
            ),
            lambda m: AgeWindow(int(m.group(1)) * 12, _year_hi(int(m.group(2)))),
        ),
        (
            re.compile(
                r"(\d+)\s*[-–]\s*(\d+)\s*years?\s+of\s+age",
                re.I,
            ),
            lambda m: AgeWindow(int(m.group(1)) * 12, _year_hi(int(m.group(2)))),
        ),
        (
            re.compile(r"aged\s+(\d+)\s+years?\s+and\s+below", re.I),
            lambda m: AgeWindow(0, _year_hi(int(m.group(1)))),
        ),
        (
            re.compile(r"aged\s+(\d+)\s+years?\s+and\s+above", re.I),
            lambda m: AgeWindow(int(m.group(1)) * 12, _INF_MONTHS),
        ),
        (
            re.compile(r"(\d+)\s+years?\s+of\s+age\s+and\s+older", re.I),
            lambda m: AgeWindow(int(m.group(1)) * 12, _INF_MONTHS),
        ),
        (
            re.compile(r"less than age\s+(\d+)", re.I),
            lambda m: AgeWindow(0, int(m.group(1)) * 12 - 1),
        ),
        (
            re.compile(r"under\s+two\s+years?", re.I),
            lambda _m: AgeWindow(0, 23),
        ),
        (
            re.compile(r"under\s+(\d+)\s+years?", re.I),
            lambda m: AgeWindow(0, int(m.group(1)) * 12 - 1),
        ),
        (
            re.compile(r"\bnewborn\b|\binfant\b", re.I),
            lambda _m: AgeWindow(0, 11),
        ),
    ]
    for pattern, builder in patterns:
        m = pattern.search(scrubbed)
        if not m:
            continue
        # Newborn/infant fallback only when no numeric age cue exists.
        if pattern.pattern.startswith(r"\bnewborn"):
            if re.search(
                r"ages?\s+\d+|years?\s+of\s+age|aged\s+\d+|under\s+\d+|under\s+two",
                scrubbed,
                re.I,
            ):
                continue
        try:
            return builder(m)
        except (TypeError, ValueError):
            continue
    return None