Muthukumarank commited on
Commit
97fe0fa
·
verified ·
1 Parent(s): c0e4f03

Add modules/tod_estimator.py

Browse files
Files changed (1) hide show
  1. modules/tod_estimator.py +255 -0
modules/tod_estimator.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module 2: Time-of-Death Estimation
3
+ ====================================
4
+ Implements Henssge nomogram for PMI estimation + postmortem signs.
5
+ """
6
+
7
+ import numpy as np
8
+ from scipy.optimize import brentq
9
+ from typing import Dict, Any
10
+ import plotly.graph_objects as go
11
+ from plotly.subplots import make_subplots
12
+
13
+
14
+ class TimeOfDeathEstimator:
15
+ """Estimates post-mortem interval using multiple forensic methods."""
16
+
17
+ T_BODY_INITIAL = 37.2
18
+
19
+ RIGOR_TIMING = {
20
+ "absent": (0, 3, "Rigor not yet developed — suggests <3 hours PMI"),
21
+ "developing": (2, 8, "Rigor developing — suggests 2-8 hours PMI"),
22
+ "full": (8, 24, "Rigor fully developed — suggests 8-24 hours PMI"),
23
+ "resolving": (24, 72, "Rigor resolving — suggests 24-72 hours PMI"),
24
+ }
25
+
26
+ LIVIDITY_TIMING = {
27
+ "absent": (0, 1, "No lividity — very early postmortem (<1 hour)"),
28
+ "developing": (0.5, 4, "Lividity developing — suggests 30min-4 hours"),
29
+ "present_movable": (2, 12, "Lividity present but movable — suggests 2-12 hours"),
30
+ "fixed": (8, 999, "Lividity fixed — suggests >8 hours PMI"),
31
+ }
32
+
33
+ DECOMP_TIMING = {
34
+ "absent": (0, 24, "No decomposition — suggests <24 hours"),
35
+ "early_discoloration": (24, 72, "Early discoloration — suggests 24-72 hours"),
36
+ "bloating": (48, 168, "Bloating stage — suggests 2-7 days"),
37
+ "advanced": (168, 999, "Advanced decomposition — suggests >1 week"),
38
+ }
39
+
40
+ def estimate(self, t_rectal, t_ambient, body_weight, corrective_factor=1.0,
41
+ rigor="absent", lividity="absent", decomp="absent",
42
+ humidity=50, wind_speed=0) -> Dict[str, Any]:
43
+ """Comprehensive PMI estimation."""
44
+ henssge_result = self._henssge_estimation(t_rectal, t_ambient, body_weight, corrective_factor)
45
+ signs_result = self._postmortem_signs_estimation(rigor, lividity, decomp)
46
+ env_correction = self._environmental_correction(humidity, wind_speed, corrective_factor)
47
+ combined = self._combine_estimates(henssge_result, signs_result, env_correction)
48
+
49
+ detail_md = self._generate_detail_markdown(
50
+ henssge_result, signs_result, env_correction, combined,
51
+ t_rectal, t_ambient, body_weight, corrective_factor
52
+ )
53
+
54
+ return {"summary": combined, "detail_markdown": detail_md,
55
+ "methods": {"henssge": henssge_result, "signs": signs_result, "env": env_correction}}
56
+
57
+ def _henssge_estimation(self, t_rectal, t_ambient, body_weight, corrective):
58
+ """Henssge double-exponential cooling model."""
59
+ if abs(self.T_BODY_INITIAL - t_ambient) < 0.1:
60
+ return {"error": "Ambient temp too close to body initial temp", "pmi_hours": None}
61
+
62
+ Q = (t_rectal - t_ambient) / (self.T_BODY_INITIAL - t_ambient)
63
+ if Q <= 0 or Q >= 1.0:
64
+ return {"error": f"Temperature ratio Q={Q:.3f} out of valid range", "pmi_hours": None}
65
+
66
+ effective_weight = corrective * body_weight
67
+ B = 1.2815 * (effective_weight ** -0.625) + 0.0284
68
+
69
+ def cooling_equation(t):
70
+ return 1.25 * np.exp(-B * t) - 0.25 * np.exp(-5 * B * t) - Q
71
+
72
+ try:
73
+ pmi = brentq(cooling_equation, 0.01, 200, xtol=0.01)
74
+ std_error = 2.8 if 50 <= body_weight <= 100 else 3.2
75
+ if corrective < 0.7:
76
+ std_error *= 1.5
77
+
78
+ return {
79
+ "pmi_hours": round(pmi, 2),
80
+ "lower_95ci": round(max(0, pmi - std_error), 1),
81
+ "upper_95ci": round(pmi + std_error, 1),
82
+ "std_error_hours": std_error,
83
+ "Q_ratio": round(Q, 4),
84
+ "B_constant": round(B, 5),
85
+ "method": "Henssge Nomogram (1988)",
86
+ "reliability": "HIGH" if 0.2 < Q < 0.8 else "MODERATE"
87
+ }
88
+ except ValueError as e:
89
+ return {"error": str(e), "pmi_hours": None, "Q_ratio": round(Q, 4)}
90
+
91
+ def _postmortem_signs_estimation(self, rigor, lividity, decomp):
92
+ """Estimate PMI from postmortem physical signs."""
93
+ estimates = []
94
+ if rigor in self.RIGOR_TIMING:
95
+ low, high, desc = self.RIGOR_TIMING[rigor]
96
+ estimates.append({"sign": "Rigor Mortis", "low": low, "high": high, "description": desc, "state": rigor})
97
+ if lividity in self.LIVIDITY_TIMING:
98
+ low, high, desc = self.LIVIDITY_TIMING[lividity]
99
+ estimates.append({"sign": "Lividity", "low": low, "high": high, "description": desc, "state": lividity})
100
+ if decomp in self.DECOMP_TIMING:
101
+ low, high, desc = self.DECOMP_TIMING[decomp]
102
+ estimates.append({"sign": "Decomposition", "low": low, "high": high, "description": desc, "state": decomp})
103
+
104
+ if not estimates:
105
+ return {"pmi_range_low": None, "pmi_range_high": None, "signs": []}
106
+
107
+ overall_low = max(e["low"] for e in estimates)
108
+ overall_high = min(e["high"] for e in estimates)
109
+
110
+ if overall_low > overall_high:
111
+ overall_low = min(e["low"] for e in estimates)
112
+ overall_high = max(e["high"] for e in estimates)
113
+ consistency = "INCONSISTENT"
114
+ else:
115
+ consistency = "CONSISTENT"
116
+
117
+ return {
118
+ "pmi_range_low": overall_low,
119
+ "pmi_range_high": min(overall_high, 168),
120
+ "consistency": consistency,
121
+ "signs": estimates,
122
+ }
123
+
124
+ def _environmental_correction(self, humidity, wind_speed, corrective):
125
+ humidity_factor = 1.0 + (humidity - 50) * 0.002
126
+ wind_factor = 1.0 - min(wind_speed * 0.01, 0.3)
127
+ combined_factor = humidity_factor * wind_factor
128
+ return {
129
+ "humidity_effect": round(humidity_factor, 3),
130
+ "wind_effect": round(wind_factor, 3),
131
+ "combined_correction": round(combined_factor, 3),
132
+ "note": f"Environmental factors {'accelerate' if combined_factor < 1 else 'decelerate'} cooling by {abs(1-combined_factor)*100:.1f}%"
133
+ }
134
+
135
+ def _combine_estimates(self, henssge, signs, env):
136
+ combined = {"method_agreement": "N/A", "confidence_level": "LOW"}
137
+ estimates = []
138
+
139
+ if henssge.get("pmi_hours") is not None:
140
+ pmi = henssge["pmi_hours"]
141
+ env_corrected = pmi * env.get("combined_correction", 1.0)
142
+ combined["estimated_pmi_hours"] = round(env_corrected, 1)
143
+ combined["henssge_pmi"] = round(pmi, 1)
144
+ combined["lower_bound"] = henssge.get("lower_95ci", round(pmi * 0.7, 1))
145
+ combined["upper_bound"] = henssge.get("upper_95ci", round(pmi * 1.3, 1))
146
+ estimates.append(env_corrected)
147
+
148
+ if signs.get("pmi_range_low") is not None:
149
+ signs_mid = (signs["pmi_range_low"] + signs["pmi_range_high"]) / 2
150
+ estimates.append(signs_mid)
151
+ combined["signs_pmi_range"] = f"{signs['pmi_range_low']}-{signs['pmi_range_high']}h"
152
+
153
+ if len(estimates) >= 2:
154
+ spread = max(estimates) - min(estimates)
155
+ mean_est = np.mean(estimates)
156
+ if mean_est > 0 and spread < mean_est * 0.3:
157
+ combined["method_agreement"] = "STRONG"
158
+ combined["confidence_level"] = "HIGH"
159
+ elif mean_est > 0 and spread < mean_est * 0.6:
160
+ combined["method_agreement"] = "MODERATE"
161
+ combined["confidence_level"] = "MODERATE"
162
+ else:
163
+ combined["method_agreement"] = "WEAK"
164
+ combined["confidence_level"] = "LOW"
165
+ elif len(estimates) == 1:
166
+ combined["confidence_level"] = "MODERATE"
167
+ combined["method_agreement"] = "SINGLE_METHOD"
168
+
169
+ if not combined.get("estimated_pmi_hours") and signs.get("pmi_range_low") is not None:
170
+ combined["estimated_pmi_hours"] = round((signs["pmi_range_low"] + signs["pmi_range_high"]) / 2, 1)
171
+
172
+ return combined
173
+
174
+ def _generate_detail_markdown(self, henssge, signs, env, combined, t_rectal, t_ambient, body_weight, corrective):
175
+ md = "## ⏱️ Time-of-Death Analysis Report\n\n"
176
+ if combined.get("estimated_pmi_hours"):
177
+ md += f"### 🎯 Estimated PMI: **{combined['estimated_pmi_hours']} hours**\n"
178
+ if combined.get("lower_bound"):
179
+ md += f"**95% CI:** {combined['lower_bound']} — {combined['upper_bound']} hours\n"
180
+ md += f"**Agreement:** {combined['method_agreement']} | **Confidence:** {combined['confidence_level']}\n\n"
181
+
182
+ md += "---\n### 🌡️ Henssge Nomogram\n\n"
183
+ md += f"| Parameter | Value |\n|-----------|-------|\n"
184
+ md += f"| Rectal Temp | {t_rectal}°C |\n| Ambient Temp | {t_ambient}°C |\n"
185
+ md += f"| Body Weight | {body_weight} kg |\n| Corrective | {corrective} |\n"
186
+ if henssge.get("pmi_hours"):
187
+ md += f"| **PMI** | **{henssge['pmi_hours']} hours** |\n"
188
+ md += f"| Reliability | {henssge.get('reliability', 'N/A')} |\n"
189
+ elif henssge.get("error"):
190
+ md += f"\n⚠️ {henssge['error']}\n"
191
+
192
+ md += "\n---\n### 🔬 Postmortem Signs\n\n"
193
+ if signs.get("signs"):
194
+ md += "| Sign | State | Range |\n|------|-------|-------|\n"
195
+ for s in signs["signs"]:
196
+ h = f"{s['high']}h" if s['high'] < 999 else ">8h"
197
+ md += f"| {s['sign']} | {s['state']} | {s['low']}-{h} |\n"
198
+ md += f"\n**Consistency:** {signs.get('consistency', 'N/A')}\n"
199
+
200
+ md += f"\n---\n### 🌤️ Environmental Correction: {env.get('combined_correction', 'N/A')}×\n"
201
+ md += f"{env.get('note', '')}\n"
202
+ md += "\n---\n*All estimates are approximations requiring expert corroboration.*\n"
203
+ return md
204
+
205
+ def plot_cooling_curve(self, t_rectal, t_ambient, body_weight, corrective):
206
+ """Generate cooling curve visualization."""
207
+ effective_weight = corrective * body_weight
208
+ B = 1.2815 * (effective_weight ** -0.625) + 0.0284
209
+
210
+ time_range = np.linspace(0, 48, 200)
211
+ temp_curve = t_ambient + (self.T_BODY_INITIAL - t_ambient) * (
212
+ 1.25 * np.exp(-B * time_range) - 0.25 * np.exp(-5 * B * time_range)
213
+ )
214
+
215
+ Q = (t_rectal - t_ambient) / (self.T_BODY_INITIAL - t_ambient)
216
+ estimated_pmi = None
217
+ try:
218
+ def eq(t):
219
+ return 1.25 * np.exp(-B * t) - 0.25 * np.exp(-5 * B * t) - Q
220
+ estimated_pmi = brentq(eq, 0.01, 200)
221
+ except:
222
+ pass
223
+
224
+ fig = go.Figure()
225
+ fig.add_trace(go.Scatter(
226
+ x=time_range, y=temp_curve, mode='lines',
227
+ name='Cooling Curve', line=dict(color='#79c0ff', width=3),
228
+ hovertemplate='PMI: %{x:.1f}h<br>Temp: %{y:.1f}°C<extra></extra>'
229
+ ))
230
+ fig.add_hline(y=t_ambient, line_dash="dash", line_color="#56d364",
231
+ annotation_text=f"Ambient: {t_ambient}°C")
232
+ fig.add_hline(y=self.T_BODY_INITIAL, line_dash="dot", line_color="#ffa657",
233
+ annotation_text=f"Initial: {self.T_BODY_INITIAL}°C")
234
+
235
+ if estimated_pmi is not None:
236
+ fig.add_trace(go.Scatter(
237
+ x=[estimated_pmi], y=[t_rectal], mode='markers+text',
238
+ name=f'Measured ({t_rectal}°C)',
239
+ marker=dict(color='#f85149', size=15, symbol='x'),
240
+ text=[f"PMI ≈ {estimated_pmi:.1f}h"], textposition="top center",
241
+ textfont=dict(color='#f85149', size=12)
242
+ ))
243
+ fig.add_vrect(x0=max(0, estimated_pmi - 2.8), x1=estimated_pmi + 2.8,
244
+ fillcolor="rgba(248, 81, 73, 0.1)", layer="below", line_width=0)
245
+
246
+ fig.update_layout(
247
+ title="Body Cooling Curve (Henssge Model)",
248
+ xaxis_title="Post-Mortem Interval (hours)",
249
+ yaxis_title="Body Temperature (°C)",
250
+ template="plotly_dark", paper_bgcolor="#0d1117", plot_bgcolor="#161b22",
251
+ font=dict(color="#e6edf3"), height=450, showlegend=True,
252
+ )
253
+ fig.update_xaxes(gridcolor="#30363d", range=[0, 48])
254
+ fig.update_yaxes(gridcolor="#30363d", range=[t_ambient - 2, 38])
255
+ return fig