jeuko commited on
Commit
96dba57
·
verified ·
1 Parent(s): 629a216

Sync from GitHub (main)

Browse files
src/sentinel/risk_models/__init__.py CHANGED
@@ -9,6 +9,7 @@ from sentinel.risk_models.llpi import LLPiRiskModel
9
  from sentinel.risk_models.mrat import MRATRiskModel
10
  from sentinel.risk_models.pcpt import PCPTRiskModel
11
  from sentinel.risk_models.plcom2012 import PLCOm2012RiskModel
 
12
  from sentinel.risk_models.qcancer import QCancerRiskModel
13
 
14
  RISK_MODELS = [
@@ -19,6 +20,7 @@ RISK_MODELS = [
19
  CRCProRiskModel,
20
  ExtendedPBCGRiskModel,
21
  PCPTRiskModel,
 
22
  QCancerRiskModel,
23
  ClausRiskModel,
24
  MRATRiskModel,
 
9
  from sentinel.risk_models.mrat import MRATRiskModel
10
  from sentinel.risk_models.pcpt import PCPTRiskModel
11
  from sentinel.risk_models.plcom2012 import PLCOm2012RiskModel
12
+ from sentinel.risk_models.prostate_mortality import ProstateMortalityRiskModel
13
  from sentinel.risk_models.qcancer import QCancerRiskModel
14
 
15
  RISK_MODELS = [
 
20
  CRCProRiskModel,
21
  ExtendedPBCGRiskModel,
22
  PCPTRiskModel,
23
+ ProstateMortalityRiskModel,
24
  QCancerRiskModel,
25
  ClausRiskModel,
26
  MRATRiskModel,
src/sentinel/risk_models/prostate_mortality.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prostate cancer mortality prediction model.
2
+
3
+ This module implements a prostate cancer-specific mortality (PCSM) and
4
+ non-prostate cancer mortality (NPCM) prediction model for men diagnosed
5
+ with non-metastatic prostate cancer. The model uses competing risks
6
+ methodology to predict mortality outcomes at specified time horizons.
7
+ """
8
+
9
+ from math import exp, log
10
+ from typing import Annotated
11
+
12
+ from pydantic import Field
13
+
14
+ from sentinel.risk_models.base import RiskModel
15
+ from sentinel.user_input import (
16
+ ProstateBiopsyCoresInvolved,
17
+ ProstateCancerTreatment,
18
+ PSATest,
19
+ Sex,
20
+ UserInput,
21
+ )
22
+
23
+
24
+ class ProstateMortalityRiskModel(RiskModel):
25
+ """Predict prostate cancer-specific and non-prostate cancer mortality."""
26
+
27
+ def __init__(self) -> None:
28
+ super().__init__("prostate_mortality")
29
+
30
+ REQUIRED_INPUTS: dict[str, tuple[type, bool]] = {
31
+ "demographics.age_years": (Annotated[int, Field(ge=35, le=95)], True),
32
+ "demographics.sex": (Sex, True),
33
+ "clinical_tests.psa": (PSATest, True),
34
+ "personal_medical_history.prostate_cancer_grade_group": (
35
+ Annotated[int, Field(ge=1, le=5)],
36
+ True,
37
+ ),
38
+ "personal_medical_history.prostate_cancer_t_stage": (
39
+ Annotated[int, Field(ge=1, le=4)],
40
+ True,
41
+ ),
42
+ "personal_medical_history.charlson_comorbidity_score": (
43
+ Annotated[int, Field(ge=0, le=1)],
44
+ False,
45
+ ),
46
+ "personal_medical_history.prostate_cancer_treatment": (
47
+ ProstateCancerTreatment,
48
+ False,
49
+ ),
50
+ "personal_medical_history.prostate_biopsy_cores_involved": (
51
+ ProstateBiopsyCoresInvolved,
52
+ False,
53
+ ),
54
+ }
55
+
56
+ def absolute_risk(self, user: UserInput, years: int = 15) -> dict[str, float]:
57
+ """Compute prostate cancer-specific and non-prostate cancer mortality.
58
+
59
+ Args:
60
+ user: The user profile with clinical data.
61
+ years: Time horizon for predictions (default: 15 years).
62
+
63
+ Returns:
64
+ Dictionary with mortality percentages for PCSM, NPCM, and overall.
65
+
66
+ Raises:
67
+ ValueError: If PSA value is missing or out of range.
68
+ """
69
+ if user.clinical_tests.psa is None:
70
+ raise ValueError("PSA test is required.")
71
+
72
+ age = float(user.demographics.age_years)
73
+ psa = user.clinical_tests.psa.value_ng_ml
74
+
75
+ if psa < 0 or psa > 100:
76
+ raise ValueError("PSA must be between 0 and 100 ng/mL.")
77
+
78
+ grade_group = user.personal_medical_history.prostate_cancer_grade_group
79
+ t_stage = user.personal_medical_history.prostate_cancer_t_stage
80
+ charlson = user.personal_medical_history.charlson_comorbidity_score or 0
81
+
82
+ treatment_map = {
83
+ ProstateCancerTreatment.CONSERVATIVE: 0,
84
+ ProstateCancerTreatment.RADICAL: 1,
85
+ ProstateCancerTreatment.ADT_ALONE: 3,
86
+ None: 0,
87
+ }
88
+ treatment = treatment_map[
89
+ user.personal_medical_history.prostate_cancer_treatment
90
+ ]
91
+
92
+ biopsy_map = {
93
+ ProstateBiopsyCoresInvolved.UNKNOWN: 0,
94
+ ProstateBiopsyCoresInvolved.LESS_THAN_50_PERCENT: 1,
95
+ ProstateBiopsyCoresInvolved.FIFTY_PERCENT_OR_MORE: 2,
96
+ None: 0,
97
+ }
98
+ biopsy50 = biopsy_map[
99
+ user.personal_medical_history.prostate_biopsy_cores_involved
100
+ ]
101
+
102
+ pi_pcsm = (
103
+ 0.0026005 * ((age / 10) ** 3 - 341.155151)
104
+ + 0.185959 * (log((psa + 1) / 100) + 1.636423432)
105
+ + 0.1614922 * (1 if t_stage == 2 else 0)
106
+ + 0.39767881 * (1 if t_stage == 3 else 0)
107
+ + 0.6330977 * (1 if t_stage == 4 else 0)
108
+ + 0.2791641 * (1 if grade_group == 2 else 0)
109
+ + 0.5464889 * (1 if grade_group == 3 else 0)
110
+ + 0.7411321 * (1 if grade_group == 4 else 0)
111
+ + 1.367963 * (1 if grade_group == 5 else 0)
112
+ + -0.6837094 * (1 if treatment == 1 else 0)
113
+ + 0.9084921 * (1 if treatment == 3 else 0)
114
+ + -0.617722958 * (1 if biopsy50 == 1 else 0)
115
+ + 0.579225231 * (1 if biopsy50 == 2 else 0)
116
+ )
117
+
118
+ pi_npcm = 0.1226666 * (age - 69.87427439) + 0.6382002 * (
119
+ 1 if charlson == 1 else 0
120
+ )
121
+
122
+ time_days = 365 * years
123
+
124
+ pcsm_at_t = 1 - exp(
125
+ -exp(pi_pcsm)
126
+ * exp(-16.40532 + 1.653947 * log(time_days) + 1.89e-12 * (time_days**3))
127
+ )
128
+ npcm_at_t = 1 - exp(
129
+ -exp(pi_npcm)
130
+ * exp(-12.4841 + 1.32274 * log(time_days) + 2.90e-12 * (time_days**3))
131
+ )
132
+
133
+ pcs_survival = 1 - pcsm_at_t
134
+ npc_survival = 1 - npcm_at_t
135
+ overall_mortality = 1 - pcs_survival * npc_survival
136
+ pca_proportion = (
137
+ pcsm_at_t / (npcm_at_t + pcsm_at_t) if (npcm_at_t + pcsm_at_t) > 0 else 0
138
+ )
139
+ predicted_pcsm = pca_proportion * overall_mortality
140
+ predicted_npcm = (1 - pca_proportion) * overall_mortality
141
+
142
+ return {
143
+ "pcsm": predicted_pcsm * 100.0,
144
+ "npcm": predicted_npcm * 100.0,
145
+ "overall": overall_mortality * 100.0,
146
+ }
147
+
148
+ def compute_score(self, user: UserInput) -> str:
149
+ """Compute the mortality prediction score.
150
+
151
+ Args:
152
+ user: The user profile.
153
+
154
+ Returns:
155
+ Formatted string with 15-year mortality predictions.
156
+ """
157
+ is_valid, errors = self.validate_inputs(user)
158
+ if not is_valid:
159
+ return f"N/A: Invalid inputs - {'; '.join(errors)}"
160
+
161
+ if user.demographics.sex != Sex.MALE:
162
+ return "N/A: Prostate mortality model applies to male patients only."
163
+
164
+ try:
165
+ risk = self.absolute_risk(user, years=15)
166
+ except ValueError as exc:
167
+ return f"N/A: {exc}"
168
+
169
+ return (
170
+ f"15-year PCSM: {risk['pcsm']:.1f}%, "
171
+ f"NPCM: {risk['npcm']:.1f}%, "
172
+ f"Overall: {risk['overall']:.1f}%"
173
+ )
174
+
175
+ def cancer_type(self) -> str:
176
+ """Return the cancer type handled by this model.
177
+
178
+ Returns:
179
+ Cancer type label.
180
+ """
181
+ return "prostate"
182
+
183
+ def description(self) -> str:
184
+ """Return a description of the model.
185
+
186
+ Returns:
187
+ Human-readable model description.
188
+ """
189
+ return (
190
+ "The Predict Prostate model estimates prostate cancer-specific mortality (PCSM) "
191
+ "and non-prostate cancer mortality (NPCM) for men diagnosed with non-metastatic "
192
+ "prostate cancer. The model uses competing risks methodology to account for death "
193
+ "from other causes and provides personalized mortality predictions at 15 years "
194
+ "based on age, PSA, grade group, T stage, comorbidities, and treatment."
195
+ )
196
+
197
+ def interpretation(self) -> str:
198
+ """Return interpretation guidelines for the score.
199
+
200
+ Returns:
201
+ User-facing interpretation guidance.
202
+ """
203
+ return (
204
+ "Outputs three percentages: prostate cancer-specific mortality (PCSM), "
205
+ "non-prostate cancer mortality (NPCM), and overall mortality at 15 years. "
206
+ "These predictions help inform treatment decisions by comparing conservative "
207
+ "management with radical treatment outcomes. Results should be interpreted "
208
+ "alongside clinical judgment and patient preferences."
209
+ )
210
+
211
+ def references(self) -> list[str]:
212
+ """Return academic references for the model.
213
+
214
+ Returns:
215
+ List of references.
216
+ """
217
+ return [
218
+ "Thurtle D, Bratt O, Stattin P, et al. Comparative performance and external validation "
219
+ "of the multivariable Predict Prostate tool for non-metastatic prostate cancer: "
220
+ "a study in 69,206 men from Prostate Cancer data Base Sweden (PCBaSe). "
221
+ "BMC Med. 2019;17:144.",
222
+ "Predict Prostate: https://prostate.predict.cam/tool",
223
+ ]
src/sentinel/user_input.py CHANGED
@@ -360,13 +360,13 @@ class PSATest(StrictBaseModel):
360
  """PSA (Prostate-Specific Antigen) test result.
361
 
362
  Attributes:
363
- value_ng_ml: PSA value in ng/mL (valid range: 0-50)
364
  date: Date when test was performed
365
  """
366
 
367
  value_ng_ml: float = Field(
368
  ge=0,
369
- le=50,
370
  description="PSA value in ng/mL",
371
  examples=[2.5, 4.0, 8.5],
372
  )
@@ -545,6 +545,34 @@ class NSAIDUse(str, Enum):
545
  FORMER = "former"
546
 
547
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  # ---------------------------------------------------------------------------
549
  # Dermatologic Enums (All converted to str, Enum)
550
  # ---------------------------------------------------------------------------
@@ -1060,6 +1088,37 @@ class PersonalMedicalHistory(StrictBaseModel):
1060
  description="History of prior PSA screening tests (before current test)",
1061
  examples=[True, False],
1062
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1063
 
1064
 
1065
  # ---------------------------------------------------------------------------
 
360
  """PSA (Prostate-Specific Antigen) test result.
361
 
362
  Attributes:
363
+ value_ng_ml: PSA value in ng/mL (valid range: 0-100)
364
  date: Date when test was performed
365
  """
366
 
367
  value_ng_ml: float = Field(
368
  ge=0,
369
+ le=100,
370
  description="PSA value in ng/mL",
371
  examples=[2.5, 4.0, 8.5],
372
  )
 
545
  FORMER = "former"
546
 
547
 
548
+ class ProstateCancerTreatment(str, Enum):
549
+ """Primary treatment types for prostate cancer.
550
+
551
+ Attributes:
552
+ CONSERVATIVE: Conservative management or active surveillance
553
+ RADICAL: Radical treatment (surgery or radiotherapy)
554
+ ADT_ALONE: Androgen deprivation therapy alone
555
+ """
556
+
557
+ CONSERVATIVE = "conservative"
558
+ RADICAL = "radical"
559
+ ADT_ALONE = "adt_alone"
560
+
561
+
562
+ class ProstateBiopsyCoresInvolved(str, Enum):
563
+ """Percentage of prostate biopsy cores involved with cancer.
564
+
565
+ Attributes:
566
+ UNKNOWN: Unknown or not included
567
+ LESS_THAN_50_PERCENT: Less than 50% of cores involved
568
+ FIFTY_PERCENT_OR_MORE: 50% or more of cores involved
569
+ """
570
+
571
+ UNKNOWN = "unknown"
572
+ LESS_THAN_50_PERCENT = "less_than_50_percent"
573
+ FIFTY_PERCENT_OR_MORE = "50_percent_or_more"
574
+
575
+
576
  # ---------------------------------------------------------------------------
577
  # Dermatologic Enums (All converted to str, Enum)
578
  # ---------------------------------------------------------------------------
 
1088
  description="History of prior PSA screening tests (before current test)",
1089
  examples=[True, False],
1090
  )
1091
+ prostate_cancer_grade_group: int | None = Field(
1092
+ None,
1093
+ ge=1,
1094
+ le=5,
1095
+ description="Histological grade group at diagnosis (1-5)",
1096
+ examples=[1, 2, 3, 4, 5],
1097
+ )
1098
+ prostate_cancer_t_stage: int | None = Field(
1099
+ None,
1100
+ ge=1,
1101
+ le=4,
1102
+ description="Clinical T stage at diagnosis (1-4)",
1103
+ examples=[1, 2, 3, 4],
1104
+ )
1105
+ charlson_comorbidity_score: int | None = Field(
1106
+ None,
1107
+ ge=0,
1108
+ le=1,
1109
+ description="Charlson comorbidity score (0=none, 1=one or more comorbidities)",
1110
+ examples=[0, 1],
1111
+ )
1112
+ prostate_cancer_treatment: ProstateCancerTreatment | None = Field(
1113
+ None,
1114
+ description="Primary treatment received (conservative, radical, or ADT alone)",
1115
+ examples=["conservative", "radical", "adt_alone"],
1116
+ )
1117
+ prostate_biopsy_cores_involved: ProstateBiopsyCoresInvolved | None = Field(
1118
+ None,
1119
+ description="Percentage of biopsy cores with cancer",
1120
+ examples=["unknown", "less_than_50_percent", "50_percent_or_more"],
1121
+ )
1122
 
1123
 
1124
  # ---------------------------------------------------------------------------
tests/test_risk_models/test_prostate_mortality_model.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the Prostate Cancer Mortality prediction model.
2
+
3
+ Web calculator available at: https://prostate.predict.cam/tool
4
+
5
+ NOTE: This implementation is based on the Stata reference code from the published
6
+ model. There are discrepancies with the current web calculator, particularly for
7
+ cases with comorbidities (charlson=1), suggesting the web calculator may use an
8
+ updated version or different calibration. Our implementation matches the Stata
9
+ code exactly.
10
+ """
11
+
12
+ import pytest
13
+
14
+ from sentinel.risk_models.prostate_mortality import ProstateMortalityRiskModel
15
+ from sentinel.user_input import (
16
+ Anthropometrics,
17
+ ClinicalTests,
18
+ Demographics,
19
+ Ethnicity,
20
+ Lifestyle,
21
+ PersonalMedicalHistory,
22
+ ProstateCancerTreatment,
23
+ PSATest,
24
+ Sex,
25
+ SmokingHistory,
26
+ SmokingStatus,
27
+ UserInput,
28
+ )
29
+
30
+
31
+ def create_test_user(
32
+ age: int,
33
+ psa: float,
34
+ grade_group: int,
35
+ t_stage: int,
36
+ charlson: int = 0,
37
+ treatment: ProstateCancerTreatment = ProstateCancerTreatment.CONSERVATIVE,
38
+ ethnicity: Ethnicity = Ethnicity.WHITE,
39
+ ) -> UserInput:
40
+ """Create a test user with specified prostate cancer parameters.
41
+
42
+ Args:
43
+ age: Patient age in years.
44
+ psa: PSA value in ng/mL.
45
+ grade_group: Histological grade group (1-5).
46
+ t_stage: Clinical T stage (1-4).
47
+ charlson: Charlson comorbidity score (0-1).
48
+ treatment: Primary treatment received.
49
+ ethnicity: Patient ethnicity.
50
+
51
+ Returns:
52
+ UserInput instance configured for prostate mortality testing.
53
+ """
54
+ return UserInput(
55
+ demographics=Demographics(
56
+ age_years=age,
57
+ sex=Sex.MALE,
58
+ ethnicity=ethnicity,
59
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
60
+ ),
61
+ lifestyle=Lifestyle(
62
+ smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
63
+ ),
64
+ personal_medical_history=PersonalMedicalHistory(
65
+ prostate_cancer_grade_group=grade_group,
66
+ prostate_cancer_t_stage=t_stage,
67
+ charlson_comorbidity_score=charlson,
68
+ prostate_cancer_treatment=treatment,
69
+ ),
70
+ family_history=[],
71
+ clinical_tests=ClinicalTests(
72
+ psa=PSATest(value_ng_ml=psa),
73
+ ),
74
+ )
75
+
76
+
77
+ GROUND_TRUTH_CASES = [
78
+ {
79
+ "name": "low_risk_young",
80
+ "age": 55,
81
+ "psa": 5.0,
82
+ "grade_group": 1,
83
+ "t_stage": 1,
84
+ "charlson": 0,
85
+ "treatment": ProstateCancerTreatment.CONSERVATIVE,
86
+ "expected_pcsm_15yr": 7.0,
87
+ "expected_npcm_15yr": 8.0,
88
+ "expected_overall_15yr": 15.0,
89
+ },
90
+ {
91
+ "name": "medium_risk_example",
92
+ "age": 65,
93
+ "psa": 11.0,
94
+ "grade_group": 4,
95
+ "t_stage": 2,
96
+ "charlson": 0,
97
+ "treatment": ProstateCancerTreatment.CONSERVATIVE,
98
+ "expected_pcsm_15yr": 19.0,
99
+ "expected_npcm_15yr": 26.0,
100
+ "expected_overall_15yr": 45.0,
101
+ },
102
+ {
103
+ "name": "moderate_risk",
104
+ "age": 60,
105
+ "psa": 8.0,
106
+ "grade_group": 3,
107
+ "t_stage": 2,
108
+ "charlson": 0,
109
+ "treatment": ProstateCancerTreatment.CONSERVATIVE,
110
+ "expected_pcsm_15yr": 15.0,
111
+ "expected_npcm_15yr": 15.0,
112
+ "expected_overall_15yr": 30.0,
113
+ },
114
+ ]
115
+
116
+
117
+ class TestProstateMortalityRiskModel:
118
+ """Test suite for ProstateMortalityRiskModel."""
119
+
120
+ def setup_method(self) -> None:
121
+ """Set up test fixtures."""
122
+ self.model = ProstateMortalityRiskModel()
123
+
124
+ def test_metadata(self) -> None:
125
+ """Test model metadata including name, cancer type, and references."""
126
+ assert self.model.name == "prostate_mortality"
127
+ assert self.model.cancer_type() == "prostate"
128
+ assert "Predict Prostate" in self.model.description()
129
+ assert "PCSM" in self.model.description()
130
+ assert "mortality" in self.model.interpretation().lower()
131
+ assert len(self.model.references()) > 0
132
+ assert any("predict" in ref.lower() for ref in self.model.references())
133
+
134
+ def test_absolute_risk_basic(self) -> None:
135
+ """Test basic absolute risk calculation returns valid percentages."""
136
+ user = UserInput(
137
+ demographics=Demographics(
138
+ age_years=65,
139
+ sex=Sex.MALE,
140
+ ethnicity=Ethnicity.WHITE,
141
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
142
+ ),
143
+ lifestyle=Lifestyle(
144
+ smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
145
+ ),
146
+ personal_medical_history=PersonalMedicalHistory(
147
+ prostate_cancer_grade_group=2,
148
+ prostate_cancer_t_stage=1,
149
+ charlson_comorbidity_score=0,
150
+ ),
151
+ family_history=[],
152
+ clinical_tests=ClinicalTests(
153
+ psa=PSATest(value_ng_ml=5.0),
154
+ ),
155
+ )
156
+ risks = self.model.absolute_risk(user, years=15)
157
+ assert risks["pcsm"] > 0
158
+ assert risks["npcm"] > 0
159
+ assert risks["overall"] > 0
160
+ assert risks["pcsm"] < 100
161
+ assert risks["npcm"] < 100
162
+ assert risks["overall"] <= 100
163
+
164
+ @pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda case: case["name"])
165
+ def test_ground_truth_cases(self, case) -> None:
166
+ """Test model predictions against validated web calculator results.
167
+
168
+ Args:
169
+ case: Test case dictionary with patient parameters and expected results.
170
+ """
171
+ if case["expected_pcsm_15yr"] is None:
172
+ pytest.skip("TODO: Fill in expected values from web calculator")
173
+
174
+ user = create_test_user(
175
+ age=case["age"],
176
+ psa=case["psa"],
177
+ grade_group=case["grade_group"],
178
+ t_stage=case["t_stage"],
179
+ charlson=case["charlson"],
180
+ treatment=case["treatment"],
181
+ ethnicity=case.get("ethnicity", Ethnicity.WHITE),
182
+ )
183
+
184
+ risks = self.model.absolute_risk(user, years=15)
185
+ assert risks["pcsm"] == pytest.approx(case["expected_pcsm_15yr"], abs=4.0)
186
+ assert risks["npcm"] == pytest.approx(case["expected_npcm_15yr"], abs=4.0)
187
+ assert risks["overall"] == pytest.approx(case["expected_overall_15yr"], abs=4.0)
188
+
189
+ def test_compute_score_with_male_user_input(self) -> None:
190
+ """Test compute_score returns formatted string for valid male user."""
191
+ user = UserInput(
192
+ demographics=Demographics(
193
+ age_years=65,
194
+ sex=Sex.MALE,
195
+ ethnicity=Ethnicity.WHITE,
196
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
197
+ ),
198
+ lifestyle=Lifestyle(
199
+ smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
200
+ ),
201
+ personal_medical_history=PersonalMedicalHistory(
202
+ prostate_cancer_grade_group=2,
203
+ prostate_cancer_t_stage=1,
204
+ charlson_comorbidity_score=0,
205
+ prostate_cancer_treatment=ProstateCancerTreatment.CONSERVATIVE,
206
+ ),
207
+ family_history=[],
208
+ clinical_tests=ClinicalTests(
209
+ psa=PSATest(value_ng_ml=5.0),
210
+ ),
211
+ )
212
+
213
+ score = self.model.compute_score(user)
214
+ assert "PCSM" in score
215
+ assert "NPCM" in score
216
+ assert "Overall" in score
217
+ assert "%" in score
218
+
219
+ def test_compute_score_rejects_female_user(self) -> None:
220
+ """Test model correctly rejects female patients."""
221
+ user = UserInput(
222
+ demographics=Demographics(
223
+ age_years=65,
224
+ sex=Sex.FEMALE,
225
+ ethnicity=Ethnicity.WHITE,
226
+ anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=70.0),
227
+ ),
228
+ lifestyle=Lifestyle(
229
+ smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
230
+ ),
231
+ personal_medical_history=PersonalMedicalHistory(
232
+ prostate_cancer_grade_group=2,
233
+ prostate_cancer_t_stage=1,
234
+ ),
235
+ family_history=[],
236
+ clinical_tests=ClinicalTests(
237
+ psa=PSATest(value_ng_ml=5.0),
238
+ ),
239
+ )
240
+
241
+ score = self.model.compute_score(user)
242
+ assert "N/A" in score
243
+ assert "male" in score.lower()
244
+
245
+ def test_validation_errors(self) -> None:
246
+ """Test validation errors for missing required fields."""
247
+ user = UserInput(
248
+ demographics=Demographics(
249
+ age_years=65,
250
+ sex=Sex.MALE,
251
+ ethnicity=Ethnicity.WHITE,
252
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
253
+ ),
254
+ lifestyle=Lifestyle(
255
+ smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
256
+ ),
257
+ personal_medical_history=PersonalMedicalHistory(),
258
+ family_history=[],
259
+ clinical_tests=ClinicalTests(),
260
+ )
261
+
262
+ score = self.model.compute_score(user)
263
+ assert "N/A" in score
264
+ assert "Invalid" in score
265
+
266
+ def test_age_out_of_range(self) -> None:
267
+ """Test age outside validated range raises error."""
268
+ user = UserInput(
269
+ demographics=Demographics(
270
+ age_years=30,
271
+ sex=Sex.MALE,
272
+ ethnicity=Ethnicity.WHITE,
273
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
274
+ ),
275
+ lifestyle=Lifestyle(
276
+ smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
277
+ ),
278
+ personal_medical_history=PersonalMedicalHistory(
279
+ prostate_cancer_grade_group=2,
280
+ prostate_cancer_t_stage=1,
281
+ ),
282
+ family_history=[],
283
+ clinical_tests=ClinicalTests(
284
+ psa=PSATest(value_ng_ml=5.0),
285
+ ),
286
+ )
287
+
288
+ score = self.model.compute_score(user)
289
+ assert "N/A" in score
290
+
291
+ def test_psa_validation(self) -> None:
292
+ """Test PSA value validation at boundary values."""
293
+ user = UserInput(
294
+ demographics=Demographics(
295
+ age_years=65,
296
+ sex=Sex.MALE,
297
+ ethnicity=Ethnicity.WHITE,
298
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
299
+ ),
300
+ lifestyle=Lifestyle(
301
+ smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
302
+ ),
303
+ personal_medical_history=PersonalMedicalHistory(
304
+ prostate_cancer_grade_group=2,
305
+ prostate_cancer_t_stage=1,
306
+ ),
307
+ family_history=[],
308
+ clinical_tests=ClinicalTests(),
309
+ )
310
+
311
+ score = self.model.compute_score(user)
312
+ assert "N/A" in score
313
+
314
+ def test_competing_risks_consistency(self) -> None:
315
+ """Test competing risks sum correctly to overall mortality."""
316
+ user = UserInput(
317
+ demographics=Demographics(
318
+ age_years=65,
319
+ sex=Sex.MALE,
320
+ ethnicity=Ethnicity.WHITE,
321
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
322
+ ),
323
+ lifestyle=Lifestyle(
324
+ smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
325
+ ),
326
+ personal_medical_history=PersonalMedicalHistory(
327
+ prostate_cancer_grade_group=3,
328
+ prostate_cancer_t_stage=2,
329
+ charlson_comorbidity_score=1,
330
+ ),
331
+ family_history=[],
332
+ clinical_tests=ClinicalTests(
333
+ psa=PSATest(value_ng_ml=10.0),
334
+ ),
335
+ )
336
+
337
+ risks = self.model.absolute_risk(user, years=15)
338
+ total = risks["pcsm"] + risks["npcm"]
339
+ assert total == pytest.approx(risks["overall"], abs=0.5)
340
+
341
+ def test_different_time_horizons(self) -> None:
342
+ """Test model predictions for different time horizons."""
343
+ user = UserInput(
344
+ demographics=Demographics(
345
+ age_years=65,
346
+ sex=Sex.MALE,
347
+ ethnicity=Ethnicity.WHITE,
348
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
349
+ ),
350
+ lifestyle=Lifestyle(
351
+ smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
352
+ ),
353
+ personal_medical_history=PersonalMedicalHistory(
354
+ prostate_cancer_grade_group=2,
355
+ prostate_cancer_t_stage=1,
356
+ ),
357
+ family_history=[],
358
+ clinical_tests=ClinicalTests(
359
+ psa=PSATest(value_ng_ml=5.0),
360
+ ),
361
+ )
362
+
363
+ risk_5yr = self.model.absolute_risk(user, years=5)
364
+ risk_10yr = self.model.absolute_risk(user, years=10)
365
+ risk_15yr = self.model.absolute_risk(user, years=15)
366
+
367
+ assert risk_5yr["pcsm"] < risk_10yr["pcsm"] < risk_15yr["pcsm"]
368
+ assert risk_5yr["npcm"] < risk_10yr["npcm"] < risk_15yr["npcm"]
369
+ assert risk_5yr["overall"] < risk_10yr["overall"] < risk_15yr["overall"]
370
+
371
+ def test_treatment_effect_radical_vs_conservative(self) -> None:
372
+ """Test radical treatment reduces PCSM compared to conservative treatment."""
373
+ base_input = {
374
+ "demographics": Demographics(
375
+ age_years=65,
376
+ sex=Sex.MALE,
377
+ ethnicity=Ethnicity.WHITE,
378
+ anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=80.0),
379
+ ),
380
+ "lifestyle": Lifestyle(
381
+ smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0),
382
+ ),
383
+ "family_history": [],
384
+ "clinical_tests": ClinicalTests(
385
+ psa=PSATest(value_ng_ml=10.0),
386
+ ),
387
+ }
388
+
389
+ user_conservative = UserInput(
390
+ **base_input,
391
+ personal_medical_history=PersonalMedicalHistory(
392
+ prostate_cancer_grade_group=3,
393
+ prostate_cancer_t_stage=2,
394
+ charlson_comorbidity_score=0,
395
+ prostate_cancer_treatment=ProstateCancerTreatment.CONSERVATIVE,
396
+ ),
397
+ )
398
+
399
+ user_radical = UserInput(
400
+ **base_input,
401
+ personal_medical_history=PersonalMedicalHistory(
402
+ prostate_cancer_grade_group=3,
403
+ prostate_cancer_t_stage=2,
404
+ charlson_comorbidity_score=0,
405
+ prostate_cancer_treatment=ProstateCancerTreatment.RADICAL,
406
+ ),
407
+ )
408
+
409
+ risk_conservative = self.model.absolute_risk(user_conservative, years=15)
410
+ risk_radical = self.model.absolute_risk(user_radical, years=15)
411
+
412
+ assert risk_radical["pcsm"] < risk_conservative["pcsm"]