Add modules/tod_estimator.py
Browse files- 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
|