@jjlmoya/utils-forensic-science 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/category/index.ts +21 -16
- package/src/entries.ts +8 -1
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/time-of-death-algor-mortis-calculator/bibliography.astro +14 -0
- package/src/tool/time-of-death-algor-mortis-calculator/bibliography.ts +7 -0
- package/src/tool/time-of-death-algor-mortis-calculator/component.astro +133 -0
- package/src/tool/time-of-death-algor-mortis-calculator/controller.ts +254 -0
- package/src/tool/time-of-death-algor-mortis-calculator/dom-views.ts +144 -0
- package/src/tool/time-of-death-algor-mortis-calculator/entry.ts +29 -0
- package/src/tool/time-of-death-algor-mortis-calculator/evaluator.ts +34 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/de.ts +139 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/en.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/es.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/fr.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/id.ts +139 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/it.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/ja.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/ko.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/nl.ts +139 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/pl.ts +139 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/pt.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/ru.ts +139 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/sv.ts +139 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/tr.ts +139 -0
- package/src/tool/time-of-death-algor-mortis-calculator/i18n/zh.ts +136 -0
- package/src/tool/time-of-death-algor-mortis-calculator/index.ts +11 -0
- package/src/tool/time-of-death-algor-mortis-calculator/logic.test.ts +77 -0
- package/src/tool/time-of-death-algor-mortis-calculator/logic.ts +177 -0
- package/src/tool/time-of-death-algor-mortis-calculator/seo.astro +15 -0
- package/src/tool/time-of-death-algor-mortis-calculator/storage.ts +21 -0
- package/src/tool/time-of-death-algor-mortis-calculator/time-of-death-algor-mortis-calculator.css +596 -0
- package/src/tool/time-of-death-algor-mortis-calculator/ui.ts +60 -0
- package/src/tools.ts +3 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
computeHenssgeConstant,
|
|
4
|
+
computeHenssgeQuotient,
|
|
5
|
+
solveHenssgeTime,
|
|
6
|
+
computeConfidenceMargin,
|
|
7
|
+
computeCoolingRate,
|
|
8
|
+
computeGlaisterPmi,
|
|
9
|
+
determineCoolingPhase,
|
|
10
|
+
calculateAlgorMortis
|
|
11
|
+
} from './logic';
|
|
12
|
+
|
|
13
|
+
describe('Algor Mortis Logic Suite', () => {
|
|
14
|
+
it('calculates henssge cooling constant correctly', () => {
|
|
15
|
+
const k = computeHenssgeConstant(70, 1.0);
|
|
16
|
+
expect(k).toBeGreaterThan(0.05);
|
|
17
|
+
expect(k).toBeLessThan(0.15);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('computes henssge quotient within valid range', () => {
|
|
21
|
+
const q1 = computeHenssgeQuotient(37.2, 20);
|
|
22
|
+
expect(q1).toBe(1);
|
|
23
|
+
const q2 = computeHenssgeQuotient(20, 20);
|
|
24
|
+
expect(q2).toBe(0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('solves henssge time for typical corpse cooling', () => {
|
|
28
|
+
const k = computeHenssgeConstant(75, 1.1);
|
|
29
|
+
const q = computeHenssgeQuotient(30.0, 18.0);
|
|
30
|
+
const time = solveHenssgeTime(q, k);
|
|
31
|
+
expect(time).toBeGreaterThan(5);
|
|
32
|
+
expect(time).toBeLessThan(18);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('computes confidence margin according to time ranges', () => {
|
|
36
|
+
const marginShort = computeConfidenceMargin(5, 1.0);
|
|
37
|
+
expect(marginShort).toBe(2.8);
|
|
38
|
+
const marginMed = computeConfidenceMargin(15, 1.0);
|
|
39
|
+
expect(marginMed).toBe(3.5);
|
|
40
|
+
const marginLong = computeConfidenceMargin(25, 1.0);
|
|
41
|
+
expect(marginLong).toBe(4.5);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('calculates cooling rate accurately', () => {
|
|
45
|
+
const rate = computeCoolingRate(6, 0.08, 18);
|
|
46
|
+
expect(rate).toBeGreaterThan(0);
|
|
47
|
+
expect(rate).toBeLessThan(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('calculates glaister rule PMI properly', () => {
|
|
51
|
+
const pmi = computeGlaisterPmi(33.05);
|
|
52
|
+
expect(pmi).toBeCloseTo(5.0, 1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('identifies cooling phases properly', () => {
|
|
56
|
+
expect(determineCoolingPhase(1.5, 36.8, 20)).toBe('plateau');
|
|
57
|
+
expect(determineCoolingPhase(8.0, 31.0, 20)).toBe('descent');
|
|
58
|
+
expect(determineCoolingPhase(30.0, 20.3, 20)).toBe('equilibrium');
|
|
59
|
+
expect(determineCoolingPhase(0.5, 38.5, 20)).toBe('hyperthermia');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('runs complete algor mortis calculation bundle', () => {
|
|
63
|
+
const result = calculateAlgorMortis({
|
|
64
|
+
rectalTemp: 29.5,
|
|
65
|
+
ambientTemp: 18.0,
|
|
66
|
+
bodyWeight: 70,
|
|
67
|
+
correctionFactor: 1.1,
|
|
68
|
+
measurementTime: '14:30',
|
|
69
|
+
unitSystem: 'metric'
|
|
70
|
+
});
|
|
71
|
+
expect(result.pmiHours).toBeGreaterThan(0);
|
|
72
|
+
expect(result.confidenceMarginHours).toBeGreaterThan(0);
|
|
73
|
+
expect(result.minPmiHours).toBeLessThan(result.maxPmiHours);
|
|
74
|
+
expect(result.curvePoints.length).toBeGreaterThan(10);
|
|
75
|
+
expect(result.timeOfDeathEstimated).toBeDefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
export interface AlgorMortisInputs {
|
|
2
|
+
rectalTemp: number;
|
|
3
|
+
ambientTemp: number;
|
|
4
|
+
bodyWeight: number;
|
|
5
|
+
correctionFactor: number;
|
|
6
|
+
measurementTime: string;
|
|
7
|
+
unitSystem: 'metric' | 'imperial';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CurvePoint {
|
|
11
|
+
time: number;
|
|
12
|
+
temp: number;
|
|
13
|
+
upperTemp: number;
|
|
14
|
+
lowerTemp: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AlgorMortisResult {
|
|
18
|
+
pmiHours: number;
|
|
19
|
+
confidenceMarginHours: number;
|
|
20
|
+
minPmiHours: number;
|
|
21
|
+
maxPmiHours: number;
|
|
22
|
+
timeOfDeathMin: string;
|
|
23
|
+
timeOfDeathMax: string;
|
|
24
|
+
timeOfDeathEstimated: string;
|
|
25
|
+
coolingPhase: 'plateau' | 'descent' | 'equilibrium' | 'hyperthermia';
|
|
26
|
+
coolingRate: number;
|
|
27
|
+
glaisterPmiHours: number;
|
|
28
|
+
curvePoints: CurvePoint[];
|
|
29
|
+
rectalTempDisplay: number;
|
|
30
|
+
ambientTempDisplay: number;
|
|
31
|
+
refTempDisplay: number;
|
|
32
|
+
coolingRateDisplay: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const STANDARD_BODY_TEMP = 37.2;
|
|
36
|
+
|
|
37
|
+
export function celsiusToFahrenheit(c: number): number {
|
|
38
|
+
return Number(((c * 9) / 5 + 32).toFixed(1));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function fahrenheitToCelsius(f: number): number {
|
|
42
|
+
return Number((((f - 32) * 5) / 9).toFixed(1));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function kgToLb(kg: number): number {
|
|
46
|
+
return Number((kg * 2.20462).toFixed(1));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function lbToKg(lb: number): number {
|
|
50
|
+
return Number((lb / 2.20462).toFixed(1));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function computeHenssgeConstant(weightKg: number, factor: number): number {
|
|
54
|
+
const safeWeight = Math.max(5, weightKg);
|
|
55
|
+
const safeFactor = Math.max(0.2, factor);
|
|
56
|
+
const rawK = 1.2815 / (Math.pow(safeWeight, 0.625) * safeFactor) - 0.0284;
|
|
57
|
+
return Math.max(0.005, rawK);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function computeHenssgeQuotient(rectalTemp: number, ambientTemp: number): number {
|
|
61
|
+
const denominator = STANDARD_BODY_TEMP - ambientTemp;
|
|
62
|
+
if (denominator <= 0.1) return 0;
|
|
63
|
+
const quotient = (rectalTemp - ambientTemp) / denominator;
|
|
64
|
+
return Math.min(1.0, Math.max(0.0, quotient));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function henssgeEquation(t: number, k: number): number {
|
|
68
|
+
return 1.25 * Math.exp(-k * t) - 0.25 * Math.exp(-5 * k * t);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function solveHenssgeTime(q: number, k: number): number {
|
|
72
|
+
if (q >= 0.999) return 0;
|
|
73
|
+
if (q <= 0.001) return 40;
|
|
74
|
+
let low = 0;
|
|
75
|
+
let high = 60;
|
|
76
|
+
for (let i = 0; i < 40; i++) {
|
|
77
|
+
const mid = (low + high) / 2;
|
|
78
|
+
if (henssgeEquation(mid, k) > q) {
|
|
79
|
+
low = mid;
|
|
80
|
+
} else {
|
|
81
|
+
high = mid;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return Number(((low + high) / 2).toFixed(2));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function computeConfidenceMargin(pmiHours: number, factor: number): number {
|
|
88
|
+
let baseMargin = 2.8;
|
|
89
|
+
if (pmiHours > 20) {
|
|
90
|
+
baseMargin = 4.5;
|
|
91
|
+
} else if (pmiHours > 10) {
|
|
92
|
+
baseMargin = 3.5;
|
|
93
|
+
}
|
|
94
|
+
const scaled = baseMargin * Math.sqrt(Math.max(0.5, factor));
|
|
95
|
+
return Number(scaled.toFixed(1));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function computeCoolingRate(t: number, k: number, ambientTemp: number): number {
|
|
99
|
+
const delta = STANDARD_BODY_TEMP - ambientTemp;
|
|
100
|
+
const derivative = 1.25 * k * Math.exp(-k * t) - 1.25 * k * Math.exp(-5 * k * t);
|
|
101
|
+
return Number(Math.max(0, delta * derivative).toFixed(2));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function computeGlaisterPmi(rectalTemp: number): number {
|
|
105
|
+
if (rectalTemp >= STANDARD_BODY_TEMP) return 0;
|
|
106
|
+
const diff = STANDARD_BODY_TEMP - rectalTemp;
|
|
107
|
+
return Number(Math.max(0, diff / 0.83).toFixed(2));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function calculateTimeWindow(measurementTime: string, pmiHours: number): string {
|
|
111
|
+
const [hoursStr, minsStr] = measurementTime.split(':');
|
|
112
|
+
const measDate = new Date();
|
|
113
|
+
measDate.setHours(Number(hoursStr) || 12, Number(minsStr) || 0, 0, 0);
|
|
114
|
+
const deathTimeMs = measDate.getTime() - pmiHours * 3600 * 1000;
|
|
115
|
+
const deathDate = new Date(deathTimeMs);
|
|
116
|
+
const hh = String(deathDate.getHours()).padStart(2, '0');
|
|
117
|
+
const mm = String(deathDate.getMinutes()).padStart(2, '0');
|
|
118
|
+
return `${hh}:${mm}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function determineCoolingPhase(t: number, rectalTemp: number, ambientTemp: number): 'plateau' | 'descent' | 'equilibrium' | 'hyperthermia' {
|
|
122
|
+
if (rectalTemp > STANDARD_BODY_TEMP) return 'hyperthermia';
|
|
123
|
+
if (rectalTemp <= ambientTemp + 0.5) return 'equilibrium';
|
|
124
|
+
if (t <= 2.5) return 'plateau';
|
|
125
|
+
return 'descent';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function generateCoolingCurve(k: number, ambientTemp: number, maxHours: number, isImperial: boolean): CurvePoint[] {
|
|
129
|
+
const points: CurvePoint[] = [];
|
|
130
|
+
const delta = STANDARD_BODY_TEMP - ambientTemp;
|
|
131
|
+
const step = Math.max(0.5, maxHours / 50);
|
|
132
|
+
for (let t = 0; t <= maxHours; t += step) {
|
|
133
|
+
const q = henssgeEquation(t, k);
|
|
134
|
+
let temp = Number((ambientTemp + delta * q).toFixed(2));
|
|
135
|
+
let upperTemp = Number((ambientTemp + delta * henssgeEquation(Math.max(0, t - 1.5), k)).toFixed(2));
|
|
136
|
+
let lowerTemp = Number((ambientTemp + delta * henssgeEquation(t + 1.5, k)).toFixed(2));
|
|
137
|
+
if (isImperial) {
|
|
138
|
+
temp = celsiusToFahrenheit(temp);
|
|
139
|
+
upperTemp = celsiusToFahrenheit(upperTemp);
|
|
140
|
+
lowerTemp = celsiusToFahrenheit(lowerTemp);
|
|
141
|
+
}
|
|
142
|
+
points.push({ time: Number(t.toFixed(1)), temp, upperTemp, lowerTemp });
|
|
143
|
+
}
|
|
144
|
+
return points;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function calculateAlgorMortis(inputs: AlgorMortisInputs): AlgorMortisResult {
|
|
148
|
+
const isImp = inputs.unitSystem === 'imperial';
|
|
149
|
+
const cRectal = isImp ? fahrenheitToCelsius(inputs.rectalTemp) : inputs.rectalTemp;
|
|
150
|
+
const cAmbient = isImp ? fahrenheitToCelsius(inputs.ambientTemp) : inputs.ambientTemp;
|
|
151
|
+
const kgWeight = isImp ? lbToKg(inputs.bodyWeight) : inputs.bodyWeight;
|
|
152
|
+
|
|
153
|
+
const k = computeHenssgeConstant(kgWeight, inputs.correctionFactor);
|
|
154
|
+
const q = computeHenssgeQuotient(cRectal, cAmbient);
|
|
155
|
+
const pmiHours = solveHenssgeTime(q, k);
|
|
156
|
+
const confidenceMarginHours = computeConfidenceMargin(pmiHours, inputs.correctionFactor);
|
|
157
|
+
const maxCurveTime = Math.max(24, Math.ceil(pmiHours + 8));
|
|
158
|
+
const rateMetric = computeCoolingRate(pmiHours, k, cAmbient);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
pmiHours,
|
|
162
|
+
confidenceMarginHours,
|
|
163
|
+
minPmiHours: Math.max(0, Number((pmiHours - confidenceMarginHours).toFixed(2))),
|
|
164
|
+
maxPmiHours: Number((pmiHours + confidenceMarginHours).toFixed(2)),
|
|
165
|
+
timeOfDeathEstimated: calculateTimeWindow(inputs.measurementTime, pmiHours),
|
|
166
|
+
timeOfDeathMin: calculateTimeWindow(inputs.measurementTime, pmiHours + confidenceMarginHours),
|
|
167
|
+
timeOfDeathMax: calculateTimeWindow(inputs.measurementTime, Math.max(0, pmiHours - confidenceMarginHours)),
|
|
168
|
+
coolingPhase: determineCoolingPhase(pmiHours, cRectal, cAmbient),
|
|
169
|
+
coolingRate: rateMetric,
|
|
170
|
+
glaisterPmiHours: computeGlaisterPmi(cRectal),
|
|
171
|
+
curvePoints: generateCoolingCurve(k, cAmbient, maxCurveTime, isImp),
|
|
172
|
+
rectalTempDisplay: inputs.rectalTemp,
|
|
173
|
+
ambientTempDisplay: inputs.ambientTemp,
|
|
174
|
+
refTempDisplay: isImp ? 98.9 : 37.2,
|
|
175
|
+
coolingRateDisplay: isImp ? Number((rateMetric * 1.8).toFixed(2)) : rateMetric
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { timeOfDeathAlgorMortisCalculator } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const content = await timeOfDeathAlgorMortisCalculator.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AlgorMortisInputs } from './logic';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'jjlmoya_forensic_algor_mortis_state';
|
|
4
|
+
|
|
5
|
+
export function loadSavedState(): Partial<AlgorMortisInputs> | null {
|
|
6
|
+
try {
|
|
7
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
8
|
+
if (!raw) return null;
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function saveState(inputs: AlgorMortisInputs): void {
|
|
16
|
+
try {
|
|
17
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(inputs));
|
|
18
|
+
} catch {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
}
|