@kernel.chat/kbot 3.43.0 → 3.44.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/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/lab-health.d.ts +2 -0
- package/dist/tools/lab-health.d.ts.map +1 -0
- package/dist/tools/lab-health.js +2054 -0
- package/dist/tools/lab-health.js.map +1 -0
- package/dist/tools/lab-humanities.d.ts +2 -0
- package/dist/tools/lab-humanities.d.ts.map +1 -0
- package/dist/tools/lab-humanities.js +1993 -0
- package/dist/tools/lab-humanities.js.map +1 -0
- package/dist/tools/lab-neuro.d.ts +2 -0
- package/dist/tools/lab-neuro.d.ts.map +1 -0
- package/dist/tools/lab-neuro.js +2472 -0
- package/dist/tools/lab-neuro.js.map +1 -0
- package/dist/tools/lab-social.d.ts +2 -0
- package/dist/tools/lab-social.d.ts.map +1 -0
- package/dist/tools/lab-social.js +2557 -0
- package/dist/tools/lab-social.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,2054 @@
|
|
|
1
|
+
// kbot Lab Health Tools — Public Health, Epidemiology, Agriculture, Education
|
|
2
|
+
// Self-contained implementations: no external dependencies beyond Node.js built-ins.
|
|
3
|
+
// Covers SIR/SEIR compartmental models, epidemiological calculators, health equity,
|
|
4
|
+
// disease surveillance, crop modeling, nutrition analysis, learning analytics,
|
|
5
|
+
// vaccination modeling, environmental health risk assessment, and WHO data queries.
|
|
6
|
+
import { registerTool } from './index.js';
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
// SHARED HELPERS
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
const UA = 'KBot/3.0 (Lab Health Tools)';
|
|
11
|
+
function fmt(n, d = 4) {
|
|
12
|
+
if (!isFinite(n))
|
|
13
|
+
return String(n);
|
|
14
|
+
if (Number.isInteger(n) && Math.abs(n) < 1e15)
|
|
15
|
+
return String(n);
|
|
16
|
+
return n.toFixed(d);
|
|
17
|
+
}
|
|
18
|
+
function pct(n, d = 2) {
|
|
19
|
+
return (n * 100).toFixed(d) + '%';
|
|
20
|
+
}
|
|
21
|
+
function safeParse(s, label) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(s);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error(`Invalid JSON for ${label}: ${s}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function labFetch(url, timeout = 10000) {
|
|
30
|
+
return fetch(url, {
|
|
31
|
+
headers: { 'User-Agent': UA },
|
|
32
|
+
signal: AbortSignal.timeout(timeout),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const CROPS = {
|
|
36
|
+
corn: { name: 'Corn (Maize)', base_temp_c: 10, gdd_maturity: 1400, water_need_mm: 500, typical_yield_kg_ha: 10000, season_days: 125 },
|
|
37
|
+
wheat: { name: 'Wheat', base_temp_c: 0, gdd_maturity: 1800, water_need_mm: 450, typical_yield_kg_ha: 3500, season_days: 140 },
|
|
38
|
+
rice: { name: 'Rice', base_temp_c: 10, gdd_maturity: 2100, water_need_mm: 900, typical_yield_kg_ha: 4500, season_days: 150 },
|
|
39
|
+
soybean: { name: 'Soybean', base_temp_c: 10, gdd_maturity: 1300, water_need_mm: 450, typical_yield_kg_ha: 2800, season_days: 120 },
|
|
40
|
+
potato: { name: 'Potato', base_temp_c: 7, gdd_maturity: 1200, water_need_mm: 500, typical_yield_kg_ha: 20000, season_days: 110 },
|
|
41
|
+
tomato: { name: 'Tomato', base_temp_c: 10, gdd_maturity: 1200, water_need_mm: 600, typical_yield_kg_ha: 60000, season_days: 100 },
|
|
42
|
+
cotton: { name: 'Cotton', base_temp_c: 15, gdd_maturity: 1600, water_need_mm: 700, typical_yield_kg_ha: 1500, season_days: 160 },
|
|
43
|
+
barley: { name: 'Barley', base_temp_c: 0, gdd_maturity: 1500, water_need_mm: 400, typical_yield_kg_ha: 3000, season_days: 120 },
|
|
44
|
+
oat: { name: 'Oat', base_temp_c: 5, gdd_maturity: 1400, water_need_mm: 400, typical_yield_kg_ha: 2500, season_days: 110 },
|
|
45
|
+
sorghum: { name: 'Sorghum', base_temp_c: 15, gdd_maturity: 1500, water_need_mm: 450, typical_yield_kg_ha: 4000, season_days: 120 },
|
|
46
|
+
sugarcane: { name: 'Sugarcane', base_temp_c: 15, gdd_maturity: 4000, water_need_mm: 1500, typical_yield_kg_ha: 70000, season_days: 365 },
|
|
47
|
+
sunflower: { name: 'Sunflower', base_temp_c: 8, gdd_maturity: 1400, water_need_mm: 500, typical_yield_kg_ha: 1700, season_days: 120 },
|
|
48
|
+
canola: { name: 'Canola (Rapeseed)', base_temp_c: 5, gdd_maturity: 1100, water_need_mm: 400, typical_yield_kg_ha: 2000, season_days: 110 },
|
|
49
|
+
peanut: { name: 'Peanut', base_temp_c: 13, gdd_maturity: 1500, water_need_mm: 500, typical_yield_kg_ha: 3000, season_days: 140 },
|
|
50
|
+
cassava: { name: 'Cassava', base_temp_c: 15, gdd_maturity: 3500, water_need_mm: 1000, typical_yield_kg_ha: 12000, season_days: 300 },
|
|
51
|
+
millet: { name: 'Millet', base_temp_c: 10, gdd_maturity: 1200, water_need_mm: 350, typical_yield_kg_ha: 1000, season_days: 90 },
|
|
52
|
+
chickpea: { name: 'Chickpea', base_temp_c: 5, gdd_maturity: 1100, water_need_mm: 350, typical_yield_kg_ha: 1000, season_days: 100 },
|
|
53
|
+
lentil: { name: 'Lentil', base_temp_c: 5, gdd_maturity: 1000, water_need_mm: 300, typical_yield_kg_ha: 1100, season_days: 100 },
|
|
54
|
+
alfalfa: { name: 'Alfalfa', base_temp_c: 5, gdd_maturity: 2000, water_need_mm: 800, typical_yield_kg_ha: 8000, season_days: 180 },
|
|
55
|
+
tobacco: { name: 'Tobacco', base_temp_c: 13, gdd_maturity: 1300, water_need_mm: 500, typical_yield_kg_ha: 2500, season_days: 120 },
|
|
56
|
+
};
|
|
57
|
+
const FOOD_DB = {
|
|
58
|
+
'apple': { name: 'Apple, raw', kcal: 52, protein: 0.3, fat: 0.2, carbs: 13.8, fiber: 2.4, vit_c: 4.6, calcium: 6, iron: 0.1, potassium: 107, vit_a_mcg: 3 },
|
|
59
|
+
'banana': { name: 'Banana, raw', kcal: 89, protein: 1.1, fat: 0.3, carbs: 22.8, fiber: 2.6, vit_c: 8.7, calcium: 5, iron: 0.3, potassium: 358, vit_a_mcg: 3 },
|
|
60
|
+
'orange': { name: 'Orange, raw', kcal: 47, protein: 0.9, fat: 0.1, carbs: 11.8, fiber: 2.4, vit_c: 53.2, calcium: 40, iron: 0.1, potassium: 181, vit_a_mcg: 11 },
|
|
61
|
+
'strawberry': { name: 'Strawberry, raw', kcal: 32, protein: 0.7, fat: 0.3, carbs: 7.7, fiber: 2.0, vit_c: 58.8, calcium: 16, iron: 0.4, potassium: 153, vit_a_mcg: 1 },
|
|
62
|
+
'blueberry': { name: 'Blueberry, raw', kcal: 57, protein: 0.7, fat: 0.3, carbs: 14.5, fiber: 2.4, vit_c: 9.7, calcium: 6, iron: 0.3, potassium: 77, vit_a_mcg: 3 },
|
|
63
|
+
'grape': { name: 'Grape, raw', kcal: 69, protein: 0.7, fat: 0.2, carbs: 18.1, fiber: 0.9, vit_c: 3.2, calcium: 10, iron: 0.4, potassium: 191, vit_a_mcg: 3 },
|
|
64
|
+
'watermelon': { name: 'Watermelon, raw', kcal: 30, protein: 0.6, fat: 0.2, carbs: 7.6, fiber: 0.4, vit_c: 8.1, calcium: 7, iron: 0.2, potassium: 112, vit_a_mcg: 28 },
|
|
65
|
+
'avocado': { name: 'Avocado, raw', kcal: 160, protein: 2.0, fat: 14.7, carbs: 8.5, fiber: 6.7, vit_c: 10.0, calcium: 12, iron: 0.6, potassium: 485, vit_a_mcg: 7 },
|
|
66
|
+
'mango': { name: 'Mango, raw', kcal: 60, protein: 0.8, fat: 0.4, carbs: 15.0, fiber: 1.6, vit_c: 36.4, calcium: 11, iron: 0.2, potassium: 168, vit_a_mcg: 54 },
|
|
67
|
+
'pineapple': { name: 'Pineapple, raw', kcal: 50, protein: 0.5, fat: 0.1, carbs: 13.1, fiber: 1.4, vit_c: 47.8, calcium: 13, iron: 0.3, potassium: 109, vit_a_mcg: 3 },
|
|
68
|
+
'broccoli': { name: 'Broccoli, raw', kcal: 34, protein: 2.8, fat: 0.4, carbs: 6.6, fiber: 2.6, vit_c: 89.2, calcium: 47, iron: 0.7, potassium: 316, vit_a_mcg: 31 },
|
|
69
|
+
'carrot': { name: 'Carrot, raw', kcal: 41, protein: 0.9, fat: 0.2, carbs: 9.6, fiber: 2.8, vit_c: 5.9, calcium: 33, iron: 0.3, potassium: 320, vit_a_mcg: 835 },
|
|
70
|
+
'spinach': { name: 'Spinach, raw', kcal: 23, protein: 2.9, fat: 0.4, carbs: 3.6, fiber: 2.2, vit_c: 28.1, calcium: 99, iron: 2.7, potassium: 558, vit_a_mcg: 469 },
|
|
71
|
+
'kale': { name: 'Kale, raw', kcal: 49, protein: 4.3, fat: 0.9, carbs: 8.8, fiber: 3.6, vit_c: 120, calcium: 150, iron: 1.5, potassium: 491, vit_a_mcg: 500 },
|
|
72
|
+
'sweet potato': { name: 'Sweet Potato, raw', kcal: 86, protein: 1.6, fat: 0.1, carbs: 20.1, fiber: 3.0, vit_c: 2.4, calcium: 30, iron: 0.6, potassium: 337, vit_a_mcg: 709 },
|
|
73
|
+
'potato': { name: 'Potato, raw', kcal: 77, protein: 2.0, fat: 0.1, carbs: 17.5, fiber: 2.2, vit_c: 19.7, calcium: 12, iron: 0.8, potassium: 421, vit_a_mcg: 0 },
|
|
74
|
+
'tomato': { name: 'Tomato, raw', kcal: 18, protein: 0.9, fat: 0.2, carbs: 3.9, fiber: 1.2, vit_c: 13.7, calcium: 10, iron: 0.3, potassium: 237, vit_a_mcg: 42 },
|
|
75
|
+
'onion': { name: 'Onion, raw', kcal: 40, protein: 1.1, fat: 0.1, carbs: 9.3, fiber: 1.7, vit_c: 7.4, calcium: 23, iron: 0.2, potassium: 146, vit_a_mcg: 0 },
|
|
76
|
+
'garlic': { name: 'Garlic, raw', kcal: 149, protein: 6.4, fat: 0.5, carbs: 33.1, fiber: 2.1, vit_c: 31.2, calcium: 181, iron: 1.7, potassium: 401, vit_a_mcg: 0 },
|
|
77
|
+
'bell pepper': { name: 'Bell Pepper, raw', kcal: 26, protein: 1.0, fat: 0.3, carbs: 6.0, fiber: 2.1, vit_c: 127.7, calcium: 7, iron: 0.4, potassium: 211, vit_a_mcg: 18 },
|
|
78
|
+
'cucumber': { name: 'Cucumber, raw', kcal: 15, protein: 0.7, fat: 0.1, carbs: 3.6, fiber: 0.5, vit_c: 2.8, calcium: 16, iron: 0.3, potassium: 147, vit_a_mcg: 5 },
|
|
79
|
+
'lettuce': { name: 'Lettuce, raw', kcal: 15, protein: 1.4, fat: 0.2, carbs: 2.9, fiber: 1.3, vit_c: 9.2, calcium: 36, iron: 0.9, potassium: 194, vit_a_mcg: 370 },
|
|
80
|
+
'celery': { name: 'Celery, raw', kcal: 16, protein: 0.7, fat: 0.2, carbs: 3.0, fiber: 1.6, vit_c: 3.1, calcium: 40, iron: 0.2, potassium: 260, vit_a_mcg: 22 },
|
|
81
|
+
'cabbage': { name: 'Cabbage, raw', kcal: 25, protein: 1.3, fat: 0.1, carbs: 5.8, fiber: 2.5, vit_c: 36.6, calcium: 40, iron: 0.5, potassium: 170, vit_a_mcg: 5 },
|
|
82
|
+
'cauliflower': { name: 'Cauliflower, raw', kcal: 25, protein: 1.9, fat: 0.3, carbs: 5.0, fiber: 2.0, vit_c: 48.2, calcium: 22, iron: 0.4, potassium: 299, vit_a_mcg: 0 },
|
|
83
|
+
'mushroom': { name: 'Mushroom, white, raw', kcal: 22, protein: 3.1, fat: 0.3, carbs: 3.3, fiber: 1.0, vit_c: 2.1, calcium: 3, iron: 0.5, potassium: 318, vit_a_mcg: 0 },
|
|
84
|
+
'corn': { name: 'Sweet Corn, raw', kcal: 86, protein: 3.3, fat: 1.4, carbs: 18.7, fiber: 2.0, vit_c: 6.8, calcium: 2, iron: 0.5, potassium: 270, vit_a_mcg: 9 },
|
|
85
|
+
'peas': { name: 'Green Peas, raw', kcal: 81, protein: 5.4, fat: 0.4, carbs: 14.5, fiber: 5.7, vit_c: 40.0, calcium: 25, iron: 1.5, potassium: 244, vit_a_mcg: 38 },
|
|
86
|
+
'asparagus': { name: 'Asparagus, raw', kcal: 20, protein: 2.2, fat: 0.1, carbs: 3.9, fiber: 2.1, vit_c: 5.6, calcium: 24, iron: 2.1, potassium: 202, vit_a_mcg: 38 },
|
|
87
|
+
'zucchini': { name: 'Zucchini, raw', kcal: 17, protein: 1.2, fat: 0.3, carbs: 3.1, fiber: 1.0, vit_c: 17.9, calcium: 16, iron: 0.4, potassium: 261, vit_a_mcg: 10 },
|
|
88
|
+
'chicken breast': { name: 'Chicken Breast, cooked', kcal: 165, protein: 31.0, fat: 3.6, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 15, iron: 1.0, potassium: 256, vit_a_mcg: 6 },
|
|
89
|
+
'chicken thigh': { name: 'Chicken Thigh, cooked', kcal: 209, protein: 26.0, fat: 10.9, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 12, iron: 1.3, potassium: 222, vit_a_mcg: 17 },
|
|
90
|
+
'beef steak': { name: 'Beef Steak, cooked', kcal: 271, protein: 26.1, fat: 17.4, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 18, iron: 2.6, potassium: 315, vit_a_mcg: 0 },
|
|
91
|
+
'ground beef': { name: 'Ground Beef, 80/20', kcal: 254, protein: 17.2, fat: 20.0, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 18, iron: 2.2, potassium: 270, vit_a_mcg: 0 },
|
|
92
|
+
'pork chop': { name: 'Pork Chop, cooked', kcal: 231, protein: 25.7, fat: 13.2, carbs: 0.0, fiber: 0.0, vit_c: 0.6, calcium: 19, iron: 0.7, potassium: 362, vit_a_mcg: 2 },
|
|
93
|
+
'bacon': { name: 'Bacon, cooked', kcal: 541, protein: 37.0, fat: 42.0, carbs: 1.4, fiber: 0.0, vit_c: 0.0, calcium: 11, iron: 1.1, potassium: 565, vit_a_mcg: 0 },
|
|
94
|
+
'salmon': { name: 'Salmon, cooked', kcal: 208, protein: 20.4, fat: 13.4, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 12, iron: 0.3, potassium: 363, vit_a_mcg: 50 },
|
|
95
|
+
'tuna': { name: 'Tuna, canned', kcal: 132, protein: 28.2, fat: 1.3, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 11, iron: 1.0, potassium: 237, vit_a_mcg: 20 },
|
|
96
|
+
'shrimp': { name: 'Shrimp, cooked', kcal: 99, protein: 24.0, fat: 0.3, carbs: 0.2, fiber: 0.0, vit_c: 0.0, calcium: 70, iron: 0.5, potassium: 259, vit_a_mcg: 0 },
|
|
97
|
+
'egg': { name: 'Egg, whole, cooked', kcal: 155, protein: 13.0, fat: 11.0, carbs: 1.1, fiber: 0.0, vit_c: 0.0, calcium: 56, iron: 1.8, potassium: 126, vit_a_mcg: 160 },
|
|
98
|
+
'egg white': { name: 'Egg White', kcal: 52, protein: 10.9, fat: 0.2, carbs: 0.7, fiber: 0.0, vit_c: 0.0, calcium: 7, iron: 0.1, potassium: 163, vit_a_mcg: 0 },
|
|
99
|
+
'milk': { name: 'Milk, whole', kcal: 61, protein: 3.2, fat: 3.3, carbs: 4.8, fiber: 0.0, vit_c: 0.0, calcium: 113, iron: 0.0, potassium: 132, vit_a_mcg: 46 },
|
|
100
|
+
'skim milk': { name: 'Milk, skim', kcal: 34, protein: 3.4, fat: 0.1, carbs: 5.0, fiber: 0.0, vit_c: 0.0, calcium: 122, iron: 0.0, potassium: 156, vit_a_mcg: 0 },
|
|
101
|
+
'yogurt': { name: 'Yogurt, plain', kcal: 61, protein: 3.5, fat: 3.3, carbs: 4.7, fiber: 0.0, vit_c: 0.5, calcium: 121, iron: 0.1, potassium: 155, vit_a_mcg: 27 },
|
|
102
|
+
'greek yogurt': { name: 'Greek Yogurt, plain', kcal: 97, protein: 9.0, fat: 5.0, carbs: 3.6, fiber: 0.0, vit_c: 0.0, calcium: 100, iron: 0.1, potassium: 141, vit_a_mcg: 23 },
|
|
103
|
+
'cheddar': { name: 'Cheddar Cheese', kcal: 403, protein: 24.9, fat: 33.1, carbs: 1.3, fiber: 0.0, vit_c: 0.0, calcium: 721, iron: 0.7, potassium: 98, vit_a_mcg: 265 },
|
|
104
|
+
'mozzarella': { name: 'Mozzarella Cheese', kcal: 280, protein: 27.5, fat: 17.1, carbs: 3.1, fiber: 0.0, vit_c: 0.0, calcium: 505, iron: 0.4, potassium: 76, vit_a_mcg: 174 },
|
|
105
|
+
'butter': { name: 'Butter', kcal: 717, protein: 0.9, fat: 81.1, carbs: 0.1, fiber: 0.0, vit_c: 0.0, calcium: 24, iron: 0.0, potassium: 24, vit_a_mcg: 684 },
|
|
106
|
+
'olive oil': { name: 'Olive Oil', kcal: 884, protein: 0.0, fat: 100.0, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 1, iron: 0.6, potassium: 1, vit_a_mcg: 0 },
|
|
107
|
+
'white rice': { name: 'White Rice, cooked', kcal: 130, protein: 2.7, fat: 0.3, carbs: 28.2, fiber: 0.4, vit_c: 0.0, calcium: 10, iron: 0.2, potassium: 35, vit_a_mcg: 0 },
|
|
108
|
+
'brown rice': { name: 'Brown Rice, cooked', kcal: 112, protein: 2.6, fat: 0.9, carbs: 23.5, fiber: 1.8, vit_c: 0.0, calcium: 10, iron: 0.4, potassium: 43, vit_a_mcg: 0 },
|
|
109
|
+
'pasta': { name: 'Pasta, cooked', kcal: 131, protein: 5.0, fat: 1.1, carbs: 25.4, fiber: 1.8, vit_c: 0.0, calcium: 7, iron: 1.3, potassium: 44, vit_a_mcg: 0 },
|
|
110
|
+
'bread white': { name: 'Bread, white', kcal: 265, protein: 9.0, fat: 3.2, carbs: 49.0, fiber: 2.7, vit_c: 0.0, calcium: 151, iron: 3.6, potassium: 100, vit_a_mcg: 0 },
|
|
111
|
+
'bread whole wheat': { name: 'Bread, whole wheat', kcal: 247, protein: 13.0, fat: 3.4, carbs: 41.3, fiber: 6.8, vit_c: 0.0, calcium: 107, iron: 2.5, potassium: 254, vit_a_mcg: 0 },
|
|
112
|
+
'oatmeal': { name: 'Oatmeal, cooked', kcal: 68, protein: 2.4, fat: 1.4, carbs: 12.0, fiber: 1.7, vit_c: 0.0, calcium: 9, iron: 1.2, potassium: 61, vit_a_mcg: 0 },
|
|
113
|
+
'quinoa': { name: 'Quinoa, cooked', kcal: 120, protein: 4.4, fat: 1.9, carbs: 21.3, fiber: 2.8, vit_c: 0.0, calcium: 17, iron: 1.5, potassium: 172, vit_a_mcg: 0 },
|
|
114
|
+
'lentils': { name: 'Lentils, cooked', kcal: 116, protein: 9.0, fat: 0.4, carbs: 20.1, fiber: 7.9, vit_c: 1.5, calcium: 19, iron: 3.3, potassium: 369, vit_a_mcg: 0 },
|
|
115
|
+
'black beans': { name: 'Black Beans, cooked', kcal: 132, protein: 8.9, fat: 0.5, carbs: 23.7, fiber: 8.7, vit_c: 0.0, calcium: 27, iron: 2.1, potassium: 355, vit_a_mcg: 0 },
|
|
116
|
+
'chickpeas': { name: 'Chickpeas, cooked', kcal: 164, protein: 8.9, fat: 2.6, carbs: 27.4, fiber: 7.6, vit_c: 1.3, calcium: 49, iron: 2.9, potassium: 291, vit_a_mcg: 1 },
|
|
117
|
+
'tofu': { name: 'Tofu, firm', kcal: 76, protein: 8.2, fat: 4.8, carbs: 1.9, fiber: 0.3, vit_c: 0.1, calcium: 350, iron: 5.4, potassium: 121, vit_a_mcg: 0 },
|
|
118
|
+
'almonds': { name: 'Almonds', kcal: 579, protein: 21.2, fat: 49.9, carbs: 21.6, fiber: 12.5, vit_c: 0.0, calcium: 269, iron: 3.7, potassium: 733, vit_a_mcg: 0 },
|
|
119
|
+
'walnuts': { name: 'Walnuts', kcal: 654, protein: 15.2, fat: 65.2, carbs: 13.7, fiber: 6.7, vit_c: 1.3, calcium: 98, iron: 2.9, potassium: 441, vit_a_mcg: 1 },
|
|
120
|
+
'peanuts': { name: 'Peanuts', kcal: 567, protein: 25.8, fat: 49.2, carbs: 16.1, fiber: 8.5, vit_c: 0.0, calcium: 92, iron: 4.6, potassium: 705, vit_a_mcg: 0 },
|
|
121
|
+
'peanut butter': { name: 'Peanut Butter', kcal: 588, protein: 25.1, fat: 50.4, carbs: 20.0, fiber: 6.0, vit_c: 0.0, calcium: 43, iron: 1.7, potassium: 649, vit_a_mcg: 0 },
|
|
122
|
+
'chia seeds': { name: 'Chia Seeds', kcal: 486, protein: 16.5, fat: 30.7, carbs: 42.1, fiber: 34.4, vit_c: 1.6, calcium: 631, iron: 7.7, potassium: 407, vit_a_mcg: 0 },
|
|
123
|
+
'flax seeds': { name: 'Flax Seeds', kcal: 534, protein: 18.3, fat: 42.2, carbs: 28.9, fiber: 27.3, vit_c: 0.6, calcium: 255, iron: 5.7, potassium: 813, vit_a_mcg: 0 },
|
|
124
|
+
'dark chocolate': { name: 'Dark Chocolate (70%)', kcal: 598, protein: 7.8, fat: 42.6, carbs: 45.9, fiber: 10.9, vit_c: 0.0, calcium: 73, iron: 11.9, potassium: 715, vit_a_mcg: 2 },
|
|
125
|
+
'honey': { name: 'Honey', kcal: 304, protein: 0.3, fat: 0.0, carbs: 82.4, fiber: 0.2, vit_c: 0.5, calcium: 6, iron: 0.4, potassium: 52, vit_a_mcg: 0 },
|
|
126
|
+
'sugar': { name: 'Sugar, white', kcal: 387, protein: 0.0, fat: 0.0, carbs: 100, fiber: 0.0, vit_c: 0.0, calcium: 1, iron: 0.0, potassium: 2, vit_a_mcg: 0 },
|
|
127
|
+
'coconut oil': { name: 'Coconut Oil', kcal: 862, protein: 0.0, fat: 100.0, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 0, iron: 0.0, potassium: 0, vit_a_mcg: 0 },
|
|
128
|
+
'coconut': { name: 'Coconut Meat, raw', kcal: 354, protein: 3.3, fat: 33.5, carbs: 15.2, fiber: 9.0, vit_c: 3.3, calcium: 14, iron: 2.4, potassium: 356, vit_a_mcg: 0 },
|
|
129
|
+
'white flour': { name: 'White Flour', kcal: 364, protein: 10.3, fat: 1.0, carbs: 76.3, fiber: 2.7, vit_c: 0.0, calcium: 15, iron: 4.6, potassium: 107, vit_a_mcg: 0 },
|
|
130
|
+
'whole wheat flour': { name: 'Whole Wheat Flour', kcal: 340, protein: 13.2, fat: 2.5, carbs: 71.9, fiber: 10.7, vit_c: 0.0, calcium: 34, iron: 3.6, potassium: 363, vit_a_mcg: 0 },
|
|
131
|
+
'soy milk': { name: 'Soy Milk', kcal: 33, protein: 2.8, fat: 1.6, carbs: 1.8, fiber: 0.4, vit_c: 0.0, calcium: 25, iron: 0.6, potassium: 118, vit_a_mcg: 0 },
|
|
132
|
+
'almond milk': { name: 'Almond Milk, unsweetened', kcal: 15, protein: 0.6, fat: 1.1, carbs: 0.6, fiber: 0.2, vit_c: 0.0, calcium: 184, iron: 0.3, potassium: 67, vit_a_mcg: 0 },
|
|
133
|
+
'tempeh': { name: 'Tempeh', kcal: 192, protein: 20.3, fat: 10.8, carbs: 7.6, fiber: 0.0, vit_c: 0.0, calcium: 111, iron: 2.7, potassium: 412, vit_a_mcg: 0 },
|
|
134
|
+
'edamame': { name: 'Edamame', kcal: 121, protein: 11.9, fat: 5.2, carbs: 8.9, fiber: 5.2, vit_c: 6.1, calcium: 63, iron: 2.3, potassium: 436, vit_a_mcg: 0 },
|
|
135
|
+
'seaweed': { name: 'Seaweed, nori, dried', kcal: 35, protein: 5.8, fat: 0.3, carbs: 5.1, fiber: 0.3, vit_c: 0.0, calcium: 70, iron: 1.8, potassium: 356, vit_a_mcg: 260 },
|
|
136
|
+
'hummus': { name: 'Hummus', kcal: 166, protein: 7.9, fat: 9.6, carbs: 14.3, fiber: 6.0, vit_c: 0.0, calcium: 38, iron: 2.4, potassium: 228, vit_a_mcg: 1 },
|
|
137
|
+
'cottage cheese': { name: 'Cottage Cheese', kcal: 98, protein: 11.1, fat: 4.3, carbs: 3.4, fiber: 0.0, vit_c: 0.0, calcium: 83, iron: 0.1, potassium: 104, vit_a_mcg: 37 },
|
|
138
|
+
'cream cheese': { name: 'Cream Cheese', kcal: 342, protein: 5.9, fat: 34.2, carbs: 4.1, fiber: 0.0, vit_c: 0.0, calcium: 98, iron: 0.4, potassium: 138, vit_a_mcg: 362 },
|
|
139
|
+
'ham': { name: 'Ham, cooked', kcal: 145, protein: 21.0, fat: 5.5, carbs: 1.5, fiber: 0.0, vit_c: 0.0, calcium: 7, iron: 0.9, potassium: 287, vit_a_mcg: 0 },
|
|
140
|
+
'turkey': { name: 'Turkey Breast, cooked', kcal: 135, protein: 30.0, fat: 1.0, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 10, iron: 1.4, potassium: 249, vit_a_mcg: 0 },
|
|
141
|
+
'lamb': { name: 'Lamb, cooked', kcal: 294, protein: 25.5, fat: 20.9, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 17, iron: 1.9, potassium: 310, vit_a_mcg: 0 },
|
|
142
|
+
'sardines': { name: 'Sardines, canned', kcal: 208, protein: 24.6, fat: 11.5, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 382, iron: 2.9, potassium: 397, vit_a_mcg: 32 },
|
|
143
|
+
'cod': { name: 'Cod, cooked', kcal: 82, protein: 17.8, fat: 0.7, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 18, iron: 0.4, potassium: 244, vit_a_mcg: 12 },
|
|
144
|
+
'tilapia': { name: 'Tilapia, cooked', kcal: 128, protein: 26.2, fat: 2.7, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 14, iron: 0.7, potassium: 380, vit_a_mcg: 0 },
|
|
145
|
+
'lobster': { name: 'Lobster, cooked', kcal: 89, protein: 19.0, fat: 0.9, carbs: 0.0, fiber: 0.0, vit_c: 0.0, calcium: 96, iron: 0.3, potassium: 230, vit_a_mcg: 2 },
|
|
146
|
+
'rice cake': { name: 'Rice Cake', kcal: 387, protein: 8.0, fat: 2.8, carbs: 81.1, fiber: 3.4, vit_c: 0.0, calcium: 5, iron: 0.7, potassium: 101, vit_a_mcg: 0 },
|
|
147
|
+
'granola': { name: 'Granola', kcal: 489, protein: 14.5, fat: 24.4, carbs: 53.7, fiber: 5.0, vit_c: 0.0, calcium: 76, iron: 4.1, potassium: 539, vit_a_mcg: 0 },
|
|
148
|
+
'tortilla': { name: 'Tortilla, flour', kcal: 312, protein: 8.0, fat: 8.4, carbs: 50.6, fiber: 3.1, vit_c: 0.0, calcium: 128, iron: 3.1, potassium: 128, vit_a_mcg: 0 },
|
|
149
|
+
'sausage': { name: 'Sausage, pork, cooked', kcal: 339, protein: 19.4, fat: 28.4, carbs: 0.0, fiber: 0.0, vit_c: 0.6, calcium: 13, iron: 1.2, potassium: 246, vit_a_mcg: 0 },
|
|
150
|
+
'hot dog': { name: 'Hot Dog, beef', kcal: 290, protein: 10.3, fat: 26.1, carbs: 2.1, fiber: 0.0, vit_c: 0.0, calcium: 14, iron: 1.5, potassium: 152, vit_a_mcg: 0 },
|
|
151
|
+
'ice cream': { name: 'Ice Cream, vanilla', kcal: 207, protein: 3.5, fat: 11.0, carbs: 23.6, fiber: 0.7, vit_c: 0.6, calcium: 128, iron: 0.1, potassium: 199, vit_a_mcg: 118 },
|
|
152
|
+
'pizza': { name: 'Pizza, cheese', kcal: 266, protein: 11.4, fat: 10.4, carbs: 33.6, fiber: 2.3, vit_c: 1.0, calcium: 201, iron: 2.4, potassium: 172, vit_a_mcg: 74 },
|
|
153
|
+
'french fries': { name: 'French Fries', kcal: 312, protein: 3.4, fat: 14.7, carbs: 41.4, fiber: 3.8, vit_c: 4.7, calcium: 18, iron: 0.8, potassium: 579, vit_a_mcg: 0 },
|
|
154
|
+
'popcorn': { name: 'Popcorn, air-popped', kcal: 387, protein: 12.9, fat: 4.5, carbs: 77.9, fiber: 14.5, vit_c: 0.0, calcium: 7, iron: 3.2, potassium: 329, vit_a_mcg: 3 },
|
|
155
|
+
};
|
|
156
|
+
const DRI_TABLE = [
|
|
157
|
+
{ nutrient: 'Calories', unit: 'kcal', rda: 2000 },
|
|
158
|
+
{ nutrient: 'Protein', unit: 'g', rda: 50 },
|
|
159
|
+
{ nutrient: 'Fat', unit: 'g', rda: 65 },
|
|
160
|
+
{ nutrient: 'Carbohydrates', unit: 'g', rda: 300 },
|
|
161
|
+
{ nutrient: 'Fiber', unit: 'g', rda: 25 },
|
|
162
|
+
{ nutrient: 'Vitamin C', unit: 'mg', rda: 90 },
|
|
163
|
+
{ nutrient: 'Calcium', unit: 'mg', rda: 1000 },
|
|
164
|
+
{ nutrient: 'Iron', unit: 'mg', rda: 18 },
|
|
165
|
+
{ nutrient: 'Potassium', unit: 'mg', rda: 2600 },
|
|
166
|
+
{ nutrient: 'Vitamin A', unit: 'mcg', rda: 900 },
|
|
167
|
+
];
|
|
168
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
// EMBEDDED DATA: WHO GHO Indicator Codes
|
|
170
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
const WHO_INDICATORS = {
|
|
172
|
+
life_expectancy: { code: 'WHOSIS_000001', label: 'Life expectancy at birth (years)', unit: 'years' },
|
|
173
|
+
infant_mortality: { code: 'MDG_0000000001', label: 'Infant mortality rate (per 1000 live births)', unit: 'per 1000' },
|
|
174
|
+
health_expenditure: { code: 'GHED_CHE_pc_PPP_SHA2011', label: 'Current health expenditure per capita (PPP)', unit: 'PPP int. $' },
|
|
175
|
+
immunization: { code: 'WHS4_100', label: 'DTP3 immunization coverage (%)', unit: '%' },
|
|
176
|
+
physician_density: { code: 'HWF_0001', label: 'Physicians (per 10,000 population)', unit: 'per 10k' },
|
|
177
|
+
};
|
|
178
|
+
// Common country name -> ISO3 mapping for convenience
|
|
179
|
+
const COUNTRY_ISO = {
|
|
180
|
+
'united states': 'USA', 'us': 'USA', 'usa': 'USA',
|
|
181
|
+
'united kingdom': 'GBR', 'uk': 'GBR', 'gbr': 'GBR',
|
|
182
|
+
'canada': 'CAN', 'mexico': 'MEX', 'brazil': 'BRA',
|
|
183
|
+
'germany': 'DEU', 'france': 'FRA', 'italy': 'ITA', 'spain': 'ESP',
|
|
184
|
+
'japan': 'JPN', 'china': 'CHN', 'india': 'IND', 'australia': 'AUS',
|
|
185
|
+
'south korea': 'KOR', 'korea': 'KOR', 'russia': 'RUS',
|
|
186
|
+
'nigeria': 'NGA', 'south africa': 'ZAF', 'egypt': 'EGY', 'kenya': 'KEN',
|
|
187
|
+
'argentina': 'ARG', 'colombia': 'COL', 'chile': 'CHL', 'peru': 'PER',
|
|
188
|
+
'thailand': 'THA', 'indonesia': 'IDN', 'philippines': 'PHL', 'vietnam': 'VNM',
|
|
189
|
+
'pakistan': 'PAK', 'bangladesh': 'BGD', 'iran': 'IRN', 'iraq': 'IRQ',
|
|
190
|
+
'turkey': 'TUR', 'saudi arabia': 'SAU', 'israel': 'ISR',
|
|
191
|
+
'sweden': 'SWE', 'norway': 'NOR', 'denmark': 'DNK', 'finland': 'FIN',
|
|
192
|
+
'netherlands': 'NLD', 'belgium': 'BEL', 'switzerland': 'CHE', 'austria': 'AUT',
|
|
193
|
+
'portugal': 'PRT', 'poland': 'POL', 'czech republic': 'CZE', 'czechia': 'CZE',
|
|
194
|
+
'ireland': 'IRL', 'greece': 'GRC', 'romania': 'ROU', 'hungary': 'HUN',
|
|
195
|
+
'new zealand': 'NZL', 'singapore': 'SGP', 'malaysia': 'MYS',
|
|
196
|
+
'ghana': 'GHA', 'ethiopia': 'ETH', 'tanzania': 'TZA', 'uganda': 'UGA',
|
|
197
|
+
'dr congo': 'COD', 'morocco': 'MAR', 'algeria': 'DZA', 'tunisia': 'TUN',
|
|
198
|
+
'cuba': 'CUB', 'jamaica': 'JAM', 'haiti': 'HTI',
|
|
199
|
+
'nepal': 'NPL', 'sri lanka': 'LKA', 'myanmar': 'MMR', 'cambodia': 'KHM',
|
|
200
|
+
};
|
|
201
|
+
function resolveCountryCode(input) {
|
|
202
|
+
const lower = input.trim().toLowerCase();
|
|
203
|
+
if (COUNTRY_ISO[lower])
|
|
204
|
+
return COUNTRY_ISO[lower];
|
|
205
|
+
// If already an ISO3 code
|
|
206
|
+
if (input.length === 3 && input === input.toUpperCase())
|
|
207
|
+
return input;
|
|
208
|
+
// Try partial match
|
|
209
|
+
for (const [name, code] of Object.entries(COUNTRY_ISO)) {
|
|
210
|
+
if (name.includes(lower) || lower.includes(name))
|
|
211
|
+
return code;
|
|
212
|
+
}
|
|
213
|
+
return input.toUpperCase();
|
|
214
|
+
}
|
|
215
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
216
|
+
// REGISTRATION
|
|
217
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
218
|
+
export function registerLabHealthTools() {
|
|
219
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
220
|
+
// 1. SIR MODEL — Compartmental epidemic models
|
|
221
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
222
|
+
registerTool({
|
|
223
|
+
name: 'sir_model',
|
|
224
|
+
description: 'Simulate epidemiological compartmental models (SIR, SEIR, SEIRS, SIS) with Euler method integration. Supports time-varying interventions (lockdowns, vaccines). Computes R0, herd immunity threshold, peak infection timing, total infected. Returns day-by-day compartment values for plotting.',
|
|
225
|
+
parameters: {
|
|
226
|
+
model: { type: 'string', description: 'Model type: sir, seir, seirs, or sis', required: true },
|
|
227
|
+
population: { type: 'number', description: 'Total population N', required: true },
|
|
228
|
+
initial_infected: { type: 'number', description: 'Initial number of infected individuals', required: true },
|
|
229
|
+
beta: { type: 'number', description: 'Transmission rate (contacts per day * transmission probability)', required: true },
|
|
230
|
+
gamma: { type: 'number', description: 'Recovery rate (1/infectious period in days)', required: true },
|
|
231
|
+
sigma: { type: 'number', description: 'Incubation rate (1/latent period). Required for SEIR/SEIRS models.' },
|
|
232
|
+
xi: { type: 'number', description: 'Immunity loss rate (1/immunity duration). Required for SEIRS model.' },
|
|
233
|
+
days: { type: 'number', description: 'Simulation duration in days (default 180)' },
|
|
234
|
+
interventions: { type: 'string', description: 'JSON array of {day, beta_multiplier} to model lockdowns/vaccines' },
|
|
235
|
+
},
|
|
236
|
+
tier: 'free',
|
|
237
|
+
async execute(args) {
|
|
238
|
+
const modelType = String(args.model).toLowerCase();
|
|
239
|
+
const N = Number(args.population);
|
|
240
|
+
const I0 = Number(args.initial_infected);
|
|
241
|
+
const beta0 = Number(args.beta);
|
|
242
|
+
const gamma = Number(args.gamma);
|
|
243
|
+
const sigma = args.sigma != null ? Number(args.sigma) : 0;
|
|
244
|
+
const xi = args.xi != null ? Number(args.xi) : 0;
|
|
245
|
+
const days = typeof args.days === 'number' ? args.days : 180;
|
|
246
|
+
const dt = 0.1; // Euler step size
|
|
247
|
+
if (!['sir', 'seir', 'seirs', 'sis'].includes(modelType)) {
|
|
248
|
+
return `**Error:** Unknown model "${modelType}". Choose sir, seir, seirs, or sis.`;
|
|
249
|
+
}
|
|
250
|
+
if (N <= 0 || I0 <= 0 || beta0 <= 0 || gamma <= 0) {
|
|
251
|
+
return '**Error:** population, initial_infected, beta, and gamma must be positive.';
|
|
252
|
+
}
|
|
253
|
+
if ((modelType === 'seir' || modelType === 'seirs') && sigma <= 0) {
|
|
254
|
+
return `**Error:** sigma (incubation rate) is required for ${modelType.toUpperCase()} model.`;
|
|
255
|
+
}
|
|
256
|
+
if (modelType === 'seirs' && xi <= 0) {
|
|
257
|
+
return '**Error:** xi (immunity loss rate) is required for SEIRS model.';
|
|
258
|
+
}
|
|
259
|
+
let interventions = [];
|
|
260
|
+
if (args.interventions) {
|
|
261
|
+
interventions = safeParse(String(args.interventions), 'interventions');
|
|
262
|
+
interventions.sort((a, b) => a.day - b.day);
|
|
263
|
+
}
|
|
264
|
+
// Get effective beta at a given day
|
|
265
|
+
function getBeta(day) {
|
|
266
|
+
let b = beta0;
|
|
267
|
+
for (const iv of interventions) {
|
|
268
|
+
if (day >= iv.day) {
|
|
269
|
+
b = beta0 * iv.beta_multiplier;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return b;
|
|
273
|
+
}
|
|
274
|
+
// State: [S, E, I, R]
|
|
275
|
+
let S = N - I0;
|
|
276
|
+
let E = 0;
|
|
277
|
+
let I = I0;
|
|
278
|
+
let R = 0;
|
|
279
|
+
if (modelType === 'seir' || modelType === 'seirs') {
|
|
280
|
+
// Start with some in exposed
|
|
281
|
+
E = 0; // All initial cases start as infected
|
|
282
|
+
}
|
|
283
|
+
const records = [];
|
|
284
|
+
let peakI = I;
|
|
285
|
+
let peakDay = 0;
|
|
286
|
+
let totalInfected = I0;
|
|
287
|
+
records.push({ day: 0, S: Math.round(S), E: Math.round(E), I: Math.round(I), R: Math.round(R) });
|
|
288
|
+
const steps = Math.round(days / dt);
|
|
289
|
+
for (let step = 1; step <= steps; step++) {
|
|
290
|
+
const t = step * dt;
|
|
291
|
+
const beta = getBeta(t);
|
|
292
|
+
let dS, dE, dI, dR;
|
|
293
|
+
if (modelType === 'sir') {
|
|
294
|
+
const newInfections = beta * S * I / N;
|
|
295
|
+
dS = -newInfections;
|
|
296
|
+
dE = 0;
|
|
297
|
+
dI = newInfections - gamma * I;
|
|
298
|
+
dR = gamma * I;
|
|
299
|
+
}
|
|
300
|
+
else if (modelType === 'sis') {
|
|
301
|
+
const newInfections = beta * S * I / N;
|
|
302
|
+
dS = -newInfections + gamma * I;
|
|
303
|
+
dE = 0;
|
|
304
|
+
dI = newInfections - gamma * I;
|
|
305
|
+
dR = 0;
|
|
306
|
+
}
|
|
307
|
+
else if (modelType === 'seir') {
|
|
308
|
+
const newExposed = beta * S * I / N;
|
|
309
|
+
dS = -newExposed;
|
|
310
|
+
dE = newExposed - sigma * E;
|
|
311
|
+
dI = sigma * E - gamma * I;
|
|
312
|
+
dR = gamma * I;
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
// SEIRS
|
|
316
|
+
const newExposed = beta * S * I / N;
|
|
317
|
+
dS = -newExposed + xi * R;
|
|
318
|
+
dE = newExposed - sigma * E;
|
|
319
|
+
dI = sigma * E - gamma * I;
|
|
320
|
+
dR = gamma * I - xi * R;
|
|
321
|
+
}
|
|
322
|
+
S += dS * dt;
|
|
323
|
+
E += dE * dt;
|
|
324
|
+
I += dI * dt;
|
|
325
|
+
R += dR * dt;
|
|
326
|
+
// Clamp to valid ranges
|
|
327
|
+
S = Math.max(0, S);
|
|
328
|
+
E = Math.max(0, E);
|
|
329
|
+
I = Math.max(0, I);
|
|
330
|
+
R = Math.max(0, R);
|
|
331
|
+
// Track new infections for total count
|
|
332
|
+
if (modelType === 'sir' || modelType === 'sis') {
|
|
333
|
+
totalInfected += (beta * (S + dS * dt) * (I) / N) * dt; // approximate
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
totalInfected += (beta * (S + dS * dt) * (I) / N) * dt;
|
|
337
|
+
}
|
|
338
|
+
// Record integer days
|
|
339
|
+
if (Math.abs(t - Math.round(t)) < dt / 2) {
|
|
340
|
+
const day = Math.round(t);
|
|
341
|
+
records.push({ day, S: Math.round(S), E: Math.round(E), I: Math.round(I), R: Math.round(R) });
|
|
342
|
+
if (I > peakI) {
|
|
343
|
+
peakI = I;
|
|
344
|
+
peakDay = day;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Compute key metrics
|
|
349
|
+
const R0 = beta0 / gamma;
|
|
350
|
+
const herdImmunityThreshold = 1 - 1 / R0;
|
|
351
|
+
const finalS = records[records.length - 1].S;
|
|
352
|
+
const finalR = records[records.length - 1].R;
|
|
353
|
+
const totalInfectedFinal = N - finalS; // More accurate
|
|
354
|
+
// Build output
|
|
355
|
+
const lines = [];
|
|
356
|
+
lines.push(`# ${modelType.toUpperCase()} Epidemic Model Simulation`);
|
|
357
|
+
lines.push('');
|
|
358
|
+
lines.push('## Parameters');
|
|
359
|
+
lines.push(`| Parameter | Value |`);
|
|
360
|
+
lines.push(`|-----------|-------|`);
|
|
361
|
+
lines.push(`| Model | ${modelType.toUpperCase()} |`);
|
|
362
|
+
lines.push(`| Population (N) | ${N.toLocaleString()} |`);
|
|
363
|
+
lines.push(`| Initial Infected | ${I0.toLocaleString()} |`);
|
|
364
|
+
lines.push(`| Beta (transmission) | ${beta0} |`);
|
|
365
|
+
lines.push(`| Gamma (recovery) | ${gamma} |`);
|
|
366
|
+
if (sigma > 0)
|
|
367
|
+
lines.push(`| Sigma (incubation) | ${sigma} |`);
|
|
368
|
+
if (xi > 0)
|
|
369
|
+
lines.push(`| Xi (immunity loss) | ${xi} |`);
|
|
370
|
+
lines.push(`| Duration | ${days} days |`);
|
|
371
|
+
lines.push(`| Integration step | dt = ${dt} |`);
|
|
372
|
+
lines.push('');
|
|
373
|
+
if (interventions.length > 0) {
|
|
374
|
+
lines.push('## Interventions');
|
|
375
|
+
lines.push('| Day | Beta Multiplier | Effective Beta |');
|
|
376
|
+
lines.push('|-----|----------------|----------------|');
|
|
377
|
+
for (const iv of interventions) {
|
|
378
|
+
lines.push(`| ${iv.day} | ${iv.beta_multiplier} | ${fmt(beta0 * iv.beta_multiplier)} |`);
|
|
379
|
+
}
|
|
380
|
+
lines.push('');
|
|
381
|
+
}
|
|
382
|
+
lines.push('## Key Metrics');
|
|
383
|
+
lines.push(`| Metric | Value |`);
|
|
384
|
+
lines.push(`|--------|-------|`);
|
|
385
|
+
lines.push(`| R0 (basic reproduction number) | ${fmt(R0, 2)} |`);
|
|
386
|
+
lines.push(`| Herd immunity threshold | ${pct(herdImmunityThreshold)} |`);
|
|
387
|
+
lines.push(`| Peak infected | ${Math.round(peakI).toLocaleString()} (day ${peakDay}) |`);
|
|
388
|
+
lines.push(`| Peak infected (% of N) | ${pct(peakI / N)} |`);
|
|
389
|
+
lines.push(`| Total ever infected | ${Math.round(totalInfectedFinal).toLocaleString()} (${pct(totalInfectedFinal / N)}) |`);
|
|
390
|
+
lines.push(`| Final susceptible | ${finalS.toLocaleString()} |`);
|
|
391
|
+
lines.push(`| Final recovered/removed | ${finalR.toLocaleString()} |`);
|
|
392
|
+
if (R0 > 1) {
|
|
393
|
+
lines.push(`| Epidemic outcome | **Epidemic spreads** (R0 > 1) |`);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
lines.push(`| Epidemic outcome | **Disease dies out** (R0 <= 1) |`);
|
|
397
|
+
}
|
|
398
|
+
lines.push('');
|
|
399
|
+
// Output time series (sample every N days to keep output manageable)
|
|
400
|
+
const sampleInterval = days <= 30 ? 1 : days <= 90 ? 3 : days <= 180 ? 7 : 14;
|
|
401
|
+
const hasE = modelType === 'seir' || modelType === 'seirs';
|
|
402
|
+
lines.push('## Time Series');
|
|
403
|
+
if (hasE) {
|
|
404
|
+
lines.push('| Day | Susceptible | Exposed | Infected | Recovered |');
|
|
405
|
+
lines.push('|-----|-------------|---------|----------|-----------|');
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
lines.push('| Day | Susceptible | Infected | Recovered |');
|
|
409
|
+
lines.push('|-----|-------------|----------|-----------|');
|
|
410
|
+
}
|
|
411
|
+
for (const r of records) {
|
|
412
|
+
if (r.day % sampleInterval === 0 || r.day === days) {
|
|
413
|
+
if (hasE) {
|
|
414
|
+
lines.push(`| ${r.day} | ${r.S.toLocaleString()} | ${r.E.toLocaleString()} | ${r.I.toLocaleString()} | ${r.R.toLocaleString()} |`);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
lines.push(`| ${r.day} | ${r.S.toLocaleString()} | ${r.I.toLocaleString()} | ${r.R.toLocaleString()} |`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return lines.join('\n');
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
425
|
+
// 2. EPIDEMIOLOGY CALC — Standard epidemiological measures
|
|
426
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
427
|
+
registerTool({
|
|
428
|
+
name: 'epidemiology_calc',
|
|
429
|
+
description: 'Calculate standard epidemiological measures: incidence rate, prevalence, mortality rate, case fatality rate, attack rate, relative risk, odds ratio, attributable risk, NNT/NNH, sensitivity/specificity/PPV/NPV from 2x2 tables.',
|
|
430
|
+
parameters: {
|
|
431
|
+
measure: { type: 'string', description: 'Measure to compute: incidence, prevalence, risk_ratio, odds_ratio, diagnostic, or all (computes all applicable)', required: true },
|
|
432
|
+
data: { type: 'string', description: 'JSON data. For incidence: {cases, person_years}. For prevalence: {cases, population}. For risk_ratio/odds_ratio/diagnostic/all: {a, b, c, d} (2x2 table: a=exposed+disease, b=exposed+no_disease, c=unexposed+disease, d=unexposed+no_disease)', required: true },
|
|
433
|
+
},
|
|
434
|
+
tier: 'free',
|
|
435
|
+
async execute(args) {
|
|
436
|
+
const measure = String(args.measure).toLowerCase();
|
|
437
|
+
const data = safeParse(String(args.data), 'data');
|
|
438
|
+
const lines = [];
|
|
439
|
+
lines.push('# Epidemiological Calculator');
|
|
440
|
+
lines.push('');
|
|
441
|
+
if (measure === 'incidence' || measure === 'all') {
|
|
442
|
+
if (data.cases != null && data.person_years != null) {
|
|
443
|
+
const rate = data.cases / data.person_years;
|
|
444
|
+
lines.push('## Incidence Rate');
|
|
445
|
+
lines.push(`- **Cases:** ${data.cases}`);
|
|
446
|
+
lines.push(`- **Person-years:** ${data.person_years.toLocaleString()}`);
|
|
447
|
+
lines.push(`- **Incidence rate:** ${fmt(rate, 6)} per person-year`);
|
|
448
|
+
lines.push(`- **Per 1,000:** ${fmt(rate * 1000, 2)} per 1,000 person-years`);
|
|
449
|
+
lines.push(`- **Per 100,000:** ${fmt(rate * 100000, 2)} per 100,000 person-years`);
|
|
450
|
+
lines.push('');
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (measure === 'prevalence' || measure === 'all') {
|
|
454
|
+
if (data.cases != null && data.population != null) {
|
|
455
|
+
const prev = data.cases / data.population;
|
|
456
|
+
lines.push('## Prevalence');
|
|
457
|
+
lines.push(`- **Cases:** ${data.cases.toLocaleString()}`);
|
|
458
|
+
lines.push(`- **Population:** ${data.population.toLocaleString()}`);
|
|
459
|
+
lines.push(`- **Prevalence:** ${pct(prev)}`);
|
|
460
|
+
lines.push(`- **Per 100,000:** ${fmt(prev * 100000, 2)}`);
|
|
461
|
+
lines.push('');
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// 2x2 table measures
|
|
465
|
+
if (data.a != null && data.b != null && data.c != null && data.d != null) {
|
|
466
|
+
const a = data.a, b = data.b, c = data.c, d = data.d;
|
|
467
|
+
const n = a + b + c + d;
|
|
468
|
+
lines.push('## 2x2 Contingency Table');
|
|
469
|
+
lines.push('| | Disease+ | Disease- | Total |');
|
|
470
|
+
lines.push('|--|----------|----------|-------|');
|
|
471
|
+
lines.push(`| Exposed+ | ${a} | ${b} | ${a + b} |`);
|
|
472
|
+
lines.push(`| Exposed- | ${c} | ${d} | ${c + d} |`);
|
|
473
|
+
lines.push(`| Total | ${a + c} | ${b + d} | ${n} |`);
|
|
474
|
+
lines.push('');
|
|
475
|
+
if (measure === 'risk_ratio' || measure === 'all') {
|
|
476
|
+
const riskExposed = a / (a + b);
|
|
477
|
+
const riskUnexposed = c / (c + d);
|
|
478
|
+
const rr = riskExposed / riskUnexposed;
|
|
479
|
+
const arisk = riskExposed - riskUnexposed;
|
|
480
|
+
const arp = arisk / riskExposed;
|
|
481
|
+
const par = ((a + c) / n - riskUnexposed) / ((a + c) / n);
|
|
482
|
+
const nnt = 1 / Math.abs(arisk);
|
|
483
|
+
// 95% CI for RR using log method
|
|
484
|
+
const lnRR = Math.log(rr);
|
|
485
|
+
const seRR = Math.sqrt(1 / a - 1 / (a + b) + 1 / c - 1 / (c + d));
|
|
486
|
+
const rrLower = Math.exp(lnRR - 1.96 * seRR);
|
|
487
|
+
const rrUpper = Math.exp(lnRR + 1.96 * seRR);
|
|
488
|
+
lines.push('## Risk Ratio (Relative Risk)');
|
|
489
|
+
lines.push(`- **Risk in exposed:** ${fmt(riskExposed, 4)} (${pct(riskExposed)})`);
|
|
490
|
+
lines.push(`- **Risk in unexposed:** ${fmt(riskUnexposed, 4)} (${pct(riskUnexposed)})`);
|
|
491
|
+
lines.push(`- **Relative Risk (RR):** ${fmt(rr, 4)}`);
|
|
492
|
+
lines.push(`- **95% CI:** [${fmt(rrLower, 4)}, ${fmt(rrUpper, 4)}]`);
|
|
493
|
+
lines.push(`- **Attributable Risk (AR):** ${fmt(arisk, 4)}`);
|
|
494
|
+
lines.push(`- **AR% (in exposed):** ${pct(arp)}`);
|
|
495
|
+
lines.push(`- **Population Attributable Fraction:** ${pct(par)}`);
|
|
496
|
+
if (arisk > 0) {
|
|
497
|
+
lines.push(`- **NNH (Number Needed to Harm):** ${fmt(nnt, 1)}`);
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
lines.push(`- **NNT (Number Needed to Treat):** ${fmt(nnt, 1)}`);
|
|
501
|
+
}
|
|
502
|
+
lines.push('');
|
|
503
|
+
}
|
|
504
|
+
if (measure === 'odds_ratio' || measure === 'all') {
|
|
505
|
+
const or = (a * d) / (b * c);
|
|
506
|
+
const lnOR = Math.log(or);
|
|
507
|
+
const seOR = Math.sqrt(1 / a + 1 / b + 1 / c + 1 / d);
|
|
508
|
+
const orLower = Math.exp(lnOR - 1.96 * seOR);
|
|
509
|
+
const orUpper = Math.exp(lnOR + 1.96 * seOR);
|
|
510
|
+
lines.push('## Odds Ratio');
|
|
511
|
+
lines.push(`- **Odds in exposed:** ${fmt(a / b, 4)}`);
|
|
512
|
+
lines.push(`- **Odds in unexposed:** ${fmt(c / d, 4)}`);
|
|
513
|
+
lines.push(`- **Odds Ratio (OR):** ${fmt(or, 4)}`);
|
|
514
|
+
lines.push(`- **95% CI:** [${fmt(orLower, 4)}, ${fmt(orUpper, 4)}]`);
|
|
515
|
+
lines.push(`- **ln(OR):** ${fmt(lnOR, 4)} (SE: ${fmt(seOR, 4)})`);
|
|
516
|
+
if (or > 1) {
|
|
517
|
+
lines.push(`- **Interpretation:** Exposure is associated with **increased** odds of disease.`);
|
|
518
|
+
}
|
|
519
|
+
else if (or < 1) {
|
|
520
|
+
lines.push(`- **Interpretation:** Exposure is associated with **decreased** odds of disease.`);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
lines.push(`- **Interpretation:** No association between exposure and disease.`);
|
|
524
|
+
}
|
|
525
|
+
lines.push('');
|
|
526
|
+
}
|
|
527
|
+
if (measure === 'diagnostic' || measure === 'all') {
|
|
528
|
+
// For diagnostic: a=TP, b=FP, c=FN, d=TN
|
|
529
|
+
const tp = a, fp = b, fn = c, tn = d;
|
|
530
|
+
const sensitivity = tp / (tp + fn);
|
|
531
|
+
const specificity = tn / (tn + fp);
|
|
532
|
+
const ppv = tp / (tp + fp);
|
|
533
|
+
const npv = tn / (tn + fn);
|
|
534
|
+
const accuracy = (tp + tn) / n;
|
|
535
|
+
const prevalence = (tp + fn) / n;
|
|
536
|
+
const lrPos = sensitivity / (1 - specificity);
|
|
537
|
+
const lrNeg = (1 - sensitivity) / specificity;
|
|
538
|
+
const youden = sensitivity + specificity - 1;
|
|
539
|
+
lines.push('## Diagnostic Test Performance');
|
|
540
|
+
lines.push('| | Disease+ | Disease- |');
|
|
541
|
+
lines.push('|--|----------|----------|');
|
|
542
|
+
lines.push(`| Test+ | TP=${tp} | FP=${fp} |`);
|
|
543
|
+
lines.push(`| Test- | FN=${fn} | TN=${tn} |`);
|
|
544
|
+
lines.push('');
|
|
545
|
+
lines.push(`- **Sensitivity (recall):** ${pct(sensitivity)}`);
|
|
546
|
+
lines.push(`- **Specificity:** ${pct(specificity)}`);
|
|
547
|
+
lines.push(`- **PPV (precision):** ${pct(ppv)}`);
|
|
548
|
+
lines.push(`- **NPV:** ${pct(npv)}`);
|
|
549
|
+
lines.push(`- **Accuracy:** ${pct(accuracy)}`);
|
|
550
|
+
lines.push(`- **Prevalence:** ${pct(prevalence)}`);
|
|
551
|
+
lines.push(`- **LR+ (positive likelihood ratio):** ${fmt(lrPos, 2)}`);
|
|
552
|
+
lines.push(`- **LR- (negative likelihood ratio):** ${fmt(lrNeg, 4)}`);
|
|
553
|
+
lines.push(`- **Youden's J index:** ${fmt(youden, 4)}`);
|
|
554
|
+
lines.push('');
|
|
555
|
+
}
|
|
556
|
+
// Attack rate & case fatality (always when 2x2 available and measure=all)
|
|
557
|
+
if (measure === 'all') {
|
|
558
|
+
const attackRate = (a + c) / n;
|
|
559
|
+
const cfr = data.deaths != null ? data.deaths / (a + c) : null;
|
|
560
|
+
lines.push('## Additional Measures');
|
|
561
|
+
lines.push(`- **Attack rate:** ${pct(attackRate)}`);
|
|
562
|
+
if (cfr != null) {
|
|
563
|
+
lines.push(`- **Case fatality rate:** ${pct(cfr)}`);
|
|
564
|
+
}
|
|
565
|
+
lines.push('');
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (lines.length <= 2) {
|
|
569
|
+
return '**Error:** Insufficient data for the requested measure. Provide {cases, person_years} for incidence, {cases, population} for prevalence, or {a, b, c, d} for 2x2 table measures.';
|
|
570
|
+
}
|
|
571
|
+
return lines.join('\n');
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
575
|
+
// 3. HEALTH EQUITY — Disparity metrics
|
|
576
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
577
|
+
registerTool({
|
|
578
|
+
name: 'health_equity',
|
|
579
|
+
description: 'Calculate health equity and disparity metrics across population groups: rate ratio, rate difference, population attributable fraction, concentration index, slope index of inequality (SII), relative index of inequality (RII), and Theil index for health outcomes.',
|
|
580
|
+
parameters: {
|
|
581
|
+
groups: { type: 'string', description: 'JSON array of {name, rate, population} for each group, ordered from most to least disadvantaged', required: true },
|
|
582
|
+
outcome_name: { type: 'string', description: 'Name of the health outcome (e.g., "infant mortality")' },
|
|
583
|
+
},
|
|
584
|
+
tier: 'free',
|
|
585
|
+
async execute(args) {
|
|
586
|
+
const groups = safeParse(String(args.groups), 'groups');
|
|
587
|
+
const outcomeName = args.outcome_name ? String(args.outcome_name) : 'Health outcome';
|
|
588
|
+
if (groups.length < 2)
|
|
589
|
+
return '**Error:** At least 2 groups required.';
|
|
590
|
+
const totalPop = groups.reduce((s, g) => s + g.population, 0);
|
|
591
|
+
const overallRate = groups.reduce((s, g) => s + g.rate * g.population, 0) / totalPop;
|
|
592
|
+
// Sort by rate for some metrics (keep original order for SII)
|
|
593
|
+
const best = Math.min(...groups.map(g => g.rate));
|
|
594
|
+
const worst = Math.max(...groups.map(g => g.rate));
|
|
595
|
+
const bestGroup = groups.find(g => g.rate === best);
|
|
596
|
+
const worstGroup = groups.find(g => g.rate === worst);
|
|
597
|
+
// Rate ratio and rate difference
|
|
598
|
+
const rateRatio = worst / best;
|
|
599
|
+
const rateDifference = worst - best;
|
|
600
|
+
// Population attributable fraction
|
|
601
|
+
const paf = (overallRate - best) / overallRate;
|
|
602
|
+
// Concentration Index (based on cumulative population share)
|
|
603
|
+
// Groups should be ordered from most to least disadvantaged
|
|
604
|
+
let concentrationIndex = 0;
|
|
605
|
+
let cumulPop = 0;
|
|
606
|
+
for (const g of groups) {
|
|
607
|
+
const popFrac = g.population / totalPop;
|
|
608
|
+
const midpoint = (cumulPop + cumulPop + popFrac) / 2;
|
|
609
|
+
concentrationIndex += popFrac * g.rate * midpoint;
|
|
610
|
+
cumulPop += popFrac;
|
|
611
|
+
}
|
|
612
|
+
concentrationIndex = (2 / overallRate) * concentrationIndex - 1;
|
|
613
|
+
// Slope Index of Inequality (SII) — weighted linear regression of rate on cumulative pop rank
|
|
614
|
+
// RII = SII / overall rate
|
|
615
|
+
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumW = 0;
|
|
616
|
+
let cumulPop2 = 0;
|
|
617
|
+
for (const g of groups) {
|
|
618
|
+
const popFrac = g.population / totalPop;
|
|
619
|
+
const midpoint = cumulPop2 + popFrac / 2;
|
|
620
|
+
cumulPop2 += popFrac;
|
|
621
|
+
const w = g.population;
|
|
622
|
+
sumX += w * midpoint;
|
|
623
|
+
sumY += w * g.rate;
|
|
624
|
+
sumXY += w * midpoint * g.rate;
|
|
625
|
+
sumX2 += w * midpoint * midpoint;
|
|
626
|
+
sumW += w;
|
|
627
|
+
}
|
|
628
|
+
const siiSlope = (sumW * sumXY - sumX * sumY) / (sumW * sumX2 - sumX * sumX);
|
|
629
|
+
const sii = siiSlope; // SII = slope (rate change from rank 0 to rank 1)
|
|
630
|
+
const rii = sii / overallRate;
|
|
631
|
+
// Theil Index (entropy-based inequality)
|
|
632
|
+
let theilIndex = 0;
|
|
633
|
+
for (const g of groups) {
|
|
634
|
+
if (g.rate > 0 && overallRate > 0) {
|
|
635
|
+
const ratio = g.rate / overallRate;
|
|
636
|
+
const popFrac = g.population / totalPop;
|
|
637
|
+
theilIndex += popFrac * ratio * Math.log(ratio);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const lines = [];
|
|
641
|
+
lines.push(`# Health Equity Analysis: ${outcomeName}`);
|
|
642
|
+
lines.push('');
|
|
643
|
+
lines.push('## Group Data');
|
|
644
|
+
lines.push('| Group | Rate | Population | Pop Share |');
|
|
645
|
+
lines.push('|-------|------|------------|-----------|');
|
|
646
|
+
for (const g of groups) {
|
|
647
|
+
lines.push(`| ${g.name} | ${fmt(g.rate, 2)} | ${g.population.toLocaleString()} | ${pct(g.population / totalPop)} |`);
|
|
648
|
+
}
|
|
649
|
+
lines.push('');
|
|
650
|
+
lines.push('## Summary Measures');
|
|
651
|
+
lines.push(`| Metric | Value |`);
|
|
652
|
+
lines.push(`|--------|-------|`);
|
|
653
|
+
lines.push(`| Overall rate | ${fmt(overallRate, 2)} |`);
|
|
654
|
+
lines.push(`| Best rate | ${fmt(best, 2)} (${bestGroup.name}) |`);
|
|
655
|
+
lines.push(`| Worst rate | ${fmt(worst, 2)} (${worstGroup.name}) |`);
|
|
656
|
+
lines.push('');
|
|
657
|
+
lines.push('## Disparity Measures');
|
|
658
|
+
lines.push(`| Measure | Value | Interpretation |`);
|
|
659
|
+
lines.push(`|---------|-------|----------------|`);
|
|
660
|
+
lines.push(`| Rate Ratio | ${fmt(rateRatio, 2)} | Worst group has ${fmt(rateRatio, 1)}x the rate of best |`);
|
|
661
|
+
lines.push(`| Rate Difference | ${fmt(rateDifference, 2)} | Absolute gap between worst and best |`);
|
|
662
|
+
lines.push(`| Population Attributable Fraction | ${pct(paf)} | Proportion of total burden attributable to inequity |`);
|
|
663
|
+
lines.push(`| Concentration Index | ${fmt(concentrationIndex, 4)} | Range [-1,1]: 0 = perfect equality |`);
|
|
664
|
+
lines.push(`| Slope Index of Inequality (SII) | ${fmt(sii, 2)} | Rate change from most to least disadvantaged |`);
|
|
665
|
+
lines.push(`| Relative Index of Inequality (RII) | ${fmt(rii, 4)} | SII as proportion of overall rate |`);
|
|
666
|
+
lines.push(`| Theil Index | ${fmt(theilIndex, 6)} | Entropy-based; 0 = perfect equality |`);
|
|
667
|
+
lines.push('');
|
|
668
|
+
// Interpretation
|
|
669
|
+
lines.push('## Interpretation');
|
|
670
|
+
if (Math.abs(concentrationIndex) < 0.1) {
|
|
671
|
+
lines.push(`- Concentration Index near zero indicates **relatively equal** distribution of ${outcomeName}.`);
|
|
672
|
+
}
|
|
673
|
+
else if (concentrationIndex > 0) {
|
|
674
|
+
lines.push(`- Positive Concentration Index indicates ${outcomeName} is **concentrated among the advantaged** groups.`);
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
lines.push(`- Negative Concentration Index indicates ${outcomeName} is **concentrated among the disadvantaged** groups.`);
|
|
678
|
+
}
|
|
679
|
+
if (rateRatio > 2) {
|
|
680
|
+
lines.push(`- Rate ratio > 2 indicates **substantial disparity** between groups.`);
|
|
681
|
+
}
|
|
682
|
+
lines.push(`- If the best group's rate were achieved across all groups, ${pct(paf)} of the total burden would be eliminated.`);
|
|
683
|
+
return lines.join('\n');
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
687
|
+
// 4. DISEASE SURVEILLANCE — Outbreak detection & time series analysis
|
|
688
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
689
|
+
registerTool({
|
|
690
|
+
name: 'disease_surveillance',
|
|
691
|
+
description: 'Analyze disease case time series: detect outbreaks using CUSUM or moving average exceedance, calculate doubling time, and generate epidemic curves. Supports daily or weekly case counts.',
|
|
692
|
+
parameters: {
|
|
693
|
+
cases: { type: 'string', description: 'Comma-separated case counts (daily or weekly)', required: true },
|
|
694
|
+
method: { type: 'string', description: 'Analysis method: cusum, moving_average, doubling_time, or epidemic_curve', required: true },
|
|
695
|
+
baseline_period: { type: 'number', description: 'Number of initial periods to use as baseline (default: first 1/3 of data)' },
|
|
696
|
+
threshold: { type: 'number', description: 'Alert threshold — for CUSUM: k standard deviations (default 2), for moving_average: multiplier above mean (default 1.5)' },
|
|
697
|
+
},
|
|
698
|
+
tier: 'free',
|
|
699
|
+
async execute(args) {
|
|
700
|
+
const caseStr = String(args.cases).trim();
|
|
701
|
+
const caseCounts = caseStr.split(',').map(s => {
|
|
702
|
+
const v = parseFloat(s.trim());
|
|
703
|
+
return isNaN(v) ? 0 : v;
|
|
704
|
+
});
|
|
705
|
+
const method = String(args.method).toLowerCase();
|
|
706
|
+
const n = caseCounts.length;
|
|
707
|
+
if (n < 3)
|
|
708
|
+
return '**Error:** Need at least 3 data points.';
|
|
709
|
+
const baselinePeriod = typeof args.baseline_period === 'number'
|
|
710
|
+
? args.baseline_period
|
|
711
|
+
: Math.max(3, Math.floor(n / 3));
|
|
712
|
+
const lines = [];
|
|
713
|
+
if (method === 'cusum') {
|
|
714
|
+
const threshold = typeof args.threshold === 'number' ? args.threshold : 2;
|
|
715
|
+
// Baseline statistics
|
|
716
|
+
const baseline = caseCounts.slice(0, baselinePeriod);
|
|
717
|
+
const mean = baseline.reduce((a, b) => a + b, 0) / baseline.length;
|
|
718
|
+
const variance = baseline.reduce((a, b) => a + (b - mean) ** 2, 0) / baseline.length;
|
|
719
|
+
const sd = Math.sqrt(variance);
|
|
720
|
+
// CUSUM calculation
|
|
721
|
+
const k = 0.5 * sd; // Allowance (slack value)
|
|
722
|
+
const h = threshold * sd; // Decision interval
|
|
723
|
+
let cusumHigh = 0;
|
|
724
|
+
let cusumLow = 0;
|
|
725
|
+
const records = [];
|
|
726
|
+
const alerts = [];
|
|
727
|
+
for (let i = 0; i < n; i++) {
|
|
728
|
+
cusumHigh = Math.max(0, cusumHigh + caseCounts[i] - mean - k);
|
|
729
|
+
cusumLow = Math.min(0, cusumLow + caseCounts[i] - mean + k);
|
|
730
|
+
const alert = cusumHigh > h ? 'HIGH' : Math.abs(cusumLow) > h ? 'LOW' : '';
|
|
731
|
+
if (alert)
|
|
732
|
+
alerts.push(i + 1);
|
|
733
|
+
records.push({ period: i + 1, cases: caseCounts[i], cusumH: cusumHigh, cusumL: cusumLow, alert });
|
|
734
|
+
}
|
|
735
|
+
lines.push('# CUSUM Outbreak Detection');
|
|
736
|
+
lines.push('');
|
|
737
|
+
lines.push('## Baseline Statistics');
|
|
738
|
+
lines.push(`- **Baseline periods:** ${baselinePeriod}`);
|
|
739
|
+
lines.push(`- **Baseline mean:** ${fmt(mean, 2)}`);
|
|
740
|
+
lines.push(`- **Baseline SD:** ${fmt(sd, 2)}`);
|
|
741
|
+
lines.push(`- **Decision interval (h):** ${fmt(h, 2)} (${threshold} SDs)`);
|
|
742
|
+
lines.push(`- **Slack value (k):** ${fmt(k, 2)}`);
|
|
743
|
+
lines.push('');
|
|
744
|
+
if (alerts.length > 0) {
|
|
745
|
+
lines.push(`## ALERTS: ${alerts.length} periods exceeded threshold`);
|
|
746
|
+
lines.push(`- **Alert periods:** ${alerts.join(', ')}`);
|
|
747
|
+
lines.push(`- **First alert:** Period ${alerts[0]}`);
|
|
748
|
+
lines.push('');
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
lines.push('## No outbreak signals detected.');
|
|
752
|
+
lines.push('');
|
|
753
|
+
}
|
|
754
|
+
lines.push('## CUSUM Values');
|
|
755
|
+
lines.push('| Period | Cases | CUSUM+ | CUSUM- | Alert |');
|
|
756
|
+
lines.push('|--------|-------|--------|--------|-------|');
|
|
757
|
+
for (const r of records) {
|
|
758
|
+
lines.push(`| ${r.period} | ${r.cases} | ${fmt(r.cusumH, 2)} | ${fmt(r.cusumL, 2)} | ${r.alert} |`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
else if (method === 'moving_average') {
|
|
762
|
+
const multiplier = typeof args.threshold === 'number' ? args.threshold : 1.5;
|
|
763
|
+
const windowSize = Math.max(3, baselinePeriod);
|
|
764
|
+
const records = [];
|
|
765
|
+
const alerts = [];
|
|
766
|
+
for (let i = 0; i < n; i++) {
|
|
767
|
+
const start = Math.max(0, i - windowSize);
|
|
768
|
+
const window = caseCounts.slice(start, i);
|
|
769
|
+
if (window.length === 0) {
|
|
770
|
+
records.push({ period: i + 1, cases: caseCounts[i], ma: caseCounts[i], upper: caseCounts[i] * multiplier, alert: false });
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
const ma = window.reduce((a, b) => a + b, 0) / window.length;
|
|
774
|
+
const upper = ma * multiplier;
|
|
775
|
+
const alert = caseCounts[i] > upper;
|
|
776
|
+
if (alert)
|
|
777
|
+
alerts.push(i + 1);
|
|
778
|
+
records.push({ period: i + 1, cases: caseCounts[i], ma, upper, alert });
|
|
779
|
+
}
|
|
780
|
+
lines.push('# Moving Average Outbreak Detection');
|
|
781
|
+
lines.push('');
|
|
782
|
+
lines.push(`- **Window size:** ${windowSize} periods`);
|
|
783
|
+
lines.push(`- **Threshold multiplier:** ${multiplier}x`);
|
|
784
|
+
lines.push('');
|
|
785
|
+
if (alerts.length > 0) {
|
|
786
|
+
lines.push(`## ALERTS: ${alerts.length} periods exceeded threshold`);
|
|
787
|
+
lines.push(`- **Alert periods:** ${alerts.join(', ')}`);
|
|
788
|
+
lines.push('');
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
lines.push('## No outbreak signals detected.');
|
|
792
|
+
lines.push('');
|
|
793
|
+
}
|
|
794
|
+
lines.push('## Values');
|
|
795
|
+
lines.push('| Period | Cases | Moving Avg | Upper Limit | Alert |');
|
|
796
|
+
lines.push('|--------|-------|-----------|-------------|-------|');
|
|
797
|
+
for (const r of records) {
|
|
798
|
+
lines.push(`| ${r.period} | ${r.cases} | ${fmt(r.ma, 1)} | ${fmt(r.upper, 1)} | ${r.alert ? 'YES' : ''} |`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
else if (method === 'doubling_time') {
|
|
802
|
+
// Calculate cumulative cases and doubling time
|
|
803
|
+
const cumulative = [];
|
|
804
|
+
let cum = 0;
|
|
805
|
+
for (const c of caseCounts) {
|
|
806
|
+
cum += c;
|
|
807
|
+
cumulative.push(cum);
|
|
808
|
+
}
|
|
809
|
+
// Find doubling time using log-linear regression on recent growth phase
|
|
810
|
+
// Find the growth phase (where cases are increasing)
|
|
811
|
+
const growthStart = caseCounts.findIndex(c => c > 0);
|
|
812
|
+
if (growthStart === -1) {
|
|
813
|
+
return '**Error:** No positive case counts found.';
|
|
814
|
+
}
|
|
815
|
+
// Use last half of data for doubling time estimate (or full growth phase)
|
|
816
|
+
const recentStart = Math.max(growthStart, Math.floor(n / 2));
|
|
817
|
+
const xVals = [];
|
|
818
|
+
const yVals = [];
|
|
819
|
+
for (let i = recentStart; i < n; i++) {
|
|
820
|
+
if (cumulative[i] > 0) {
|
|
821
|
+
xVals.push(i);
|
|
822
|
+
yVals.push(Math.log(cumulative[i]));
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
let doublingTime = NaN;
|
|
826
|
+
let growthRate = NaN;
|
|
827
|
+
let r2 = NaN;
|
|
828
|
+
if (xVals.length >= 2) {
|
|
829
|
+
// Linear regression on log(cumulative) vs time
|
|
830
|
+
const xMean = xVals.reduce((a, b) => a + b, 0) / xVals.length;
|
|
831
|
+
const yMean = yVals.reduce((a, b) => a + b, 0) / yVals.length;
|
|
832
|
+
let ssXY = 0, ssXX = 0, ssTot = 0, ssRes = 0;
|
|
833
|
+
for (let i = 0; i < xVals.length; i++) {
|
|
834
|
+
ssXY += (xVals[i] - xMean) * (yVals[i] - yMean);
|
|
835
|
+
ssXX += (xVals[i] - xMean) ** 2;
|
|
836
|
+
}
|
|
837
|
+
growthRate = ssXY / ssXX;
|
|
838
|
+
const intercept = yMean - growthRate * xMean;
|
|
839
|
+
for (let i = 0; i < yVals.length; i++) {
|
|
840
|
+
const predicted = growthRate * xVals[i] + intercept;
|
|
841
|
+
ssRes += (yVals[i] - predicted) ** 2;
|
|
842
|
+
ssTot += (yVals[i] - yMean) ** 2;
|
|
843
|
+
}
|
|
844
|
+
r2 = 1 - ssRes / ssTot;
|
|
845
|
+
doublingTime = Math.LN2 / growthRate;
|
|
846
|
+
}
|
|
847
|
+
// Generation interval estimate (serial interval proxy)
|
|
848
|
+
// Using the time between peaks approach
|
|
849
|
+
let peakPeriod = 0;
|
|
850
|
+
let peakVal = 0;
|
|
851
|
+
for (let i = 0; i < n; i++) {
|
|
852
|
+
if (caseCounts[i] > peakVal) {
|
|
853
|
+
peakVal = caseCounts[i];
|
|
854
|
+
peakPeriod = i + 1;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const totalCases = cumulative[n - 1];
|
|
858
|
+
lines.push('# Doubling Time Analysis');
|
|
859
|
+
lines.push('');
|
|
860
|
+
lines.push('## Summary');
|
|
861
|
+
lines.push(`- **Total periods:** ${n}`);
|
|
862
|
+
lines.push(`- **Total cumulative cases:** ${totalCases.toLocaleString()}`);
|
|
863
|
+
lines.push(`- **Peak cases:** ${peakVal} (period ${peakPeriod})`);
|
|
864
|
+
lines.push('');
|
|
865
|
+
lines.push('## Growth Analysis');
|
|
866
|
+
if (isFinite(doublingTime) && doublingTime > 0) {
|
|
867
|
+
lines.push(`- **Doubling time:** ${fmt(doublingTime, 1)} periods`);
|
|
868
|
+
lines.push(`- **Growth rate:** ${fmt(growthRate, 4)} per period`);
|
|
869
|
+
lines.push(`- **R-squared (fit):** ${fmt(r2, 4)}`);
|
|
870
|
+
}
|
|
871
|
+
else if (isFinite(growthRate) && growthRate <= 0) {
|
|
872
|
+
lines.push(`- **Growth rate is negative or zero** — epidemic may be declining.`);
|
|
873
|
+
lines.push(`- **Growth rate:** ${fmt(growthRate, 4)} per period`);
|
|
874
|
+
lines.push(`- **Halving time:** ${fmt(Math.abs(Math.LN2 / growthRate), 1)} periods`);
|
|
875
|
+
}
|
|
876
|
+
else {
|
|
877
|
+
lines.push(`- Insufficient data to estimate doubling time.`);
|
|
878
|
+
}
|
|
879
|
+
lines.push('');
|
|
880
|
+
// Epidemic curve data
|
|
881
|
+
lines.push('## Epidemic Curve');
|
|
882
|
+
lines.push('| Period | New Cases | Cumulative |');
|
|
883
|
+
lines.push('|--------|-----------|-----------|');
|
|
884
|
+
for (let i = 0; i < n; i++) {
|
|
885
|
+
lines.push(`| ${i + 1} | ${caseCounts[i]} | ${cumulative[i]} |`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
else if (method === 'epidemic_curve') {
|
|
889
|
+
// Generate descriptive epidemic curve statistics
|
|
890
|
+
const total = caseCounts.reduce((a, b) => a + b, 0);
|
|
891
|
+
const mean = total / n;
|
|
892
|
+
const max = Math.max(...caseCounts);
|
|
893
|
+
const maxPeriod = caseCounts.indexOf(max) + 1;
|
|
894
|
+
const median = [...caseCounts].sort((a, b) => a - b)[Math.floor(n / 2)];
|
|
895
|
+
// Find epidemic phases
|
|
896
|
+
let onsetPeriod = caseCounts.findIndex(c => c > 0) + 1;
|
|
897
|
+
let peakStart = maxPeriod;
|
|
898
|
+
let peakEnd = maxPeriod;
|
|
899
|
+
const peakThreshold = max * 0.75;
|
|
900
|
+
for (let i = 0; i < n; i++) {
|
|
901
|
+
if (caseCounts[i] >= peakThreshold) {
|
|
902
|
+
if (i + 1 < peakStart)
|
|
903
|
+
peakStart = i + 1;
|
|
904
|
+
if (i + 1 > peakEnd)
|
|
905
|
+
peakEnd = i + 1;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// Build ASCII bar chart
|
|
909
|
+
const barWidth = 40;
|
|
910
|
+
const scale = max > 0 ? barWidth / max : 0;
|
|
911
|
+
lines.push('# Epidemic Curve Analysis');
|
|
912
|
+
lines.push('');
|
|
913
|
+
lines.push('## Summary Statistics');
|
|
914
|
+
lines.push(`| Metric | Value |`);
|
|
915
|
+
lines.push(`|--------|-------|`);
|
|
916
|
+
lines.push(`| Total cases | ${total.toLocaleString()} |`);
|
|
917
|
+
lines.push(`| Periods | ${n} |`);
|
|
918
|
+
lines.push(`| Mean cases/period | ${fmt(mean, 1)} |`);
|
|
919
|
+
lines.push(`| Median cases/period | ${median} |`);
|
|
920
|
+
lines.push(`| Peak cases | ${max} (period ${maxPeriod}) |`);
|
|
921
|
+
lines.push(`| Onset period | ${onsetPeriod} |`);
|
|
922
|
+
lines.push(`| Peak plateau | periods ${peakStart}--${peakEnd} |`);
|
|
923
|
+
lines.push('');
|
|
924
|
+
lines.push('## Epidemic Curve');
|
|
925
|
+
lines.push('```');
|
|
926
|
+
for (let i = 0; i < n; i++) {
|
|
927
|
+
const bar = '#'.repeat(Math.round(caseCounts[i] * scale));
|
|
928
|
+
const label = String(i + 1).padStart(3);
|
|
929
|
+
lines.push(`${label} | ${bar} ${caseCounts[i]}`);
|
|
930
|
+
}
|
|
931
|
+
lines.push('```');
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
return `**Error:** Unknown method "${method}". Choose cusum, moving_average, doubling_time, or epidemic_curve.`;
|
|
935
|
+
}
|
|
936
|
+
return lines.join('\n');
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
940
|
+
// 5. CROP MODEL — Degree-day accumulation and yield estimation
|
|
941
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
942
|
+
registerTool({
|
|
943
|
+
name: 'crop_model',
|
|
944
|
+
description: 'Simple crop growth modeling: degree-day (GDD) accumulation, water balance tracking (precipitation - evapotranspiration), and yield estimation. Supports ~20 major crops with embedded growing parameters.',
|
|
945
|
+
parameters: {
|
|
946
|
+
crop: { type: 'string', description: 'Crop name (corn, wheat, rice, soybean, potato, tomato, cotton, barley, oat, sorghum, sugarcane, sunflower, canola, peanut, cassava, millet, chickpea, lentil, alfalfa, tobacco)', required: true },
|
|
947
|
+
daily_temps: { type: 'string', description: 'JSON array of {min, max} daily temperatures in Celsius', required: true },
|
|
948
|
+
daily_precip: { type: 'string', description: 'Comma-separated daily precipitation in mm', required: true },
|
|
949
|
+
soil_capacity: { type: 'number', description: 'Soil water holding capacity in mm (default 200)' },
|
|
950
|
+
planting_date: { type: 'string', description: 'Planting date (YYYY-MM-DD) for reference' },
|
|
951
|
+
},
|
|
952
|
+
tier: 'free',
|
|
953
|
+
async execute(args) {
|
|
954
|
+
const cropKey = String(args.crop).toLowerCase().trim();
|
|
955
|
+
const crop = CROPS[cropKey];
|
|
956
|
+
if (!crop) {
|
|
957
|
+
const available = Object.keys(CROPS).join(', ');
|
|
958
|
+
return `**Error:** Unknown crop "${cropKey}". Available: ${available}`;
|
|
959
|
+
}
|
|
960
|
+
const temps = safeParse(String(args.daily_temps), 'daily_temps');
|
|
961
|
+
const precipStr = String(args.daily_precip).trim();
|
|
962
|
+
const precip = precipStr.split(',').map(s => parseFloat(s.trim()) || 0);
|
|
963
|
+
const soilCapacity = typeof args.soil_capacity === 'number' ? args.soil_capacity : 200;
|
|
964
|
+
const plantingDate = args.planting_date ? String(args.planting_date) : null;
|
|
965
|
+
const nDays = Math.min(temps.length, precip.length);
|
|
966
|
+
if (nDays < 1)
|
|
967
|
+
return '**Error:** Need at least 1 day of temperature and precipitation data.';
|
|
968
|
+
// Simulate day by day
|
|
969
|
+
let cumulativeGDD = 0;
|
|
970
|
+
let soilWater = soilCapacity * 0.5; // Start at 50% capacity
|
|
971
|
+
let totalPrecip = 0;
|
|
972
|
+
let totalET = 0;
|
|
973
|
+
let waterStressDays = 0;
|
|
974
|
+
let heatStressDays = 0;
|
|
975
|
+
const records = [];
|
|
976
|
+
for (let i = 0; i < nDays; i++) {
|
|
977
|
+
const tMin = temps[i].min;
|
|
978
|
+
const tMax = temps[i].max;
|
|
979
|
+
const tAvg = (tMin + tMax) / 2;
|
|
980
|
+
// Growing Degree Days (modified: clamp min at base temp)
|
|
981
|
+
const adjMin = Math.max(tMin, crop.base_temp_c);
|
|
982
|
+
const adjMax = Math.max(tMax, crop.base_temp_c);
|
|
983
|
+
const gdd = Math.max(0, (adjMin + adjMax) / 2 - crop.base_temp_c);
|
|
984
|
+
cumulativeGDD += gdd;
|
|
985
|
+
// Simple ET estimate using Hargreaves method (simplified)
|
|
986
|
+
// ET0 = 0.0023 * (tMean + 17.8) * sqrt(tMax - tMin) * Ra
|
|
987
|
+
// Using Ra ~ 15 MJ/m2/day (typical mid-latitude)
|
|
988
|
+
const Ra = 15;
|
|
989
|
+
const tRange = Math.max(0, tMax - tMin);
|
|
990
|
+
const et0 = 0.0023 * (tAvg + 17.8) * Math.sqrt(tRange) * Ra;
|
|
991
|
+
const cropKc = gdd > 0 ? (cumulativeGDD < crop.gdd_maturity * 0.3 ? 0.5 : cumulativeGDD < crop.gdd_maturity * 0.7 ? 1.0 : 0.7) : 0.3;
|
|
992
|
+
const et = et0 * cropKc;
|
|
993
|
+
// Water balance
|
|
994
|
+
totalPrecip += precip[i];
|
|
995
|
+
totalET += et;
|
|
996
|
+
soilWater += precip[i] - et;
|
|
997
|
+
soilWater = Math.max(0, Math.min(soilCapacity, soilWater));
|
|
998
|
+
// Stress detection
|
|
999
|
+
const stresses = [];
|
|
1000
|
+
if (soilWater < soilCapacity * 0.2) {
|
|
1001
|
+
waterStressDays++;
|
|
1002
|
+
stresses.push('drought');
|
|
1003
|
+
}
|
|
1004
|
+
if (tMax > 35) {
|
|
1005
|
+
heatStressDays++;
|
|
1006
|
+
stresses.push('heat');
|
|
1007
|
+
}
|
|
1008
|
+
records.push({
|
|
1009
|
+
day: i + 1,
|
|
1010
|
+
tMin, tMax, gdd,
|
|
1011
|
+
cumulGDD: cumulativeGDD,
|
|
1012
|
+
precip: precip[i],
|
|
1013
|
+
et,
|
|
1014
|
+
soilWater,
|
|
1015
|
+
stress: stresses.join('+') || '-',
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
// Growth stage determination
|
|
1019
|
+
const gddProgress = cumulativeGDD / crop.gdd_maturity;
|
|
1020
|
+
let growthStage;
|
|
1021
|
+
if (gddProgress < 0.15)
|
|
1022
|
+
growthStage = 'Emergence/Seedling';
|
|
1023
|
+
else if (gddProgress < 0.35)
|
|
1024
|
+
growthStage = 'Vegetative';
|
|
1025
|
+
else if (gddProgress < 0.55)
|
|
1026
|
+
growthStage = 'Reproductive/Flowering';
|
|
1027
|
+
else if (gddProgress < 0.80)
|
|
1028
|
+
growthStage = 'Grain Fill/Fruit Development';
|
|
1029
|
+
else if (gddProgress < 1.0)
|
|
1030
|
+
growthStage = 'Maturation';
|
|
1031
|
+
else
|
|
1032
|
+
growthStage = 'Harvest Ready';
|
|
1033
|
+
// Yield estimation
|
|
1034
|
+
// Factors: GDD completion, water stress, heat stress
|
|
1035
|
+
const gddFactor = Math.min(1, gddProgress);
|
|
1036
|
+
const waterFactor = Math.max(0.3, 1 - (waterStressDays / nDays) * 0.8);
|
|
1037
|
+
const heatFactor = Math.max(0.4, 1 - (heatStressDays / nDays) * 0.6);
|
|
1038
|
+
const precipFactor = Math.min(1, totalPrecip / (crop.water_need_mm * (nDays / crop.season_days)));
|
|
1039
|
+
const yieldFactor = gddFactor * waterFactor * heatFactor * Math.min(1, precipFactor);
|
|
1040
|
+
const estimatedYield = crop.typical_yield_kg_ha * yieldFactor;
|
|
1041
|
+
const lines = [];
|
|
1042
|
+
lines.push(`# Crop Growth Model: ${crop.name}`);
|
|
1043
|
+
lines.push('');
|
|
1044
|
+
if (plantingDate)
|
|
1045
|
+
lines.push(`**Planting date:** ${plantingDate}`);
|
|
1046
|
+
lines.push(`**Simulation days:** ${nDays}`);
|
|
1047
|
+
lines.push('');
|
|
1048
|
+
lines.push('## Crop Parameters');
|
|
1049
|
+
lines.push(`| Parameter | Value |`);
|
|
1050
|
+
lines.push(`|-----------|-------|`);
|
|
1051
|
+
lines.push(`| Base temperature | ${crop.base_temp_c} C |`);
|
|
1052
|
+
lines.push(`| GDD to maturity | ${crop.gdd_maturity} |`);
|
|
1053
|
+
lines.push(`| Water requirement | ${crop.water_need_mm} mm/season |`);
|
|
1054
|
+
lines.push(`| Typical yield | ${crop.typical_yield_kg_ha.toLocaleString()} kg/ha |`);
|
|
1055
|
+
lines.push(`| Typical season | ${crop.season_days} days |`);
|
|
1056
|
+
lines.push('');
|
|
1057
|
+
lines.push('## Growth Summary');
|
|
1058
|
+
lines.push(`| Metric | Value |`);
|
|
1059
|
+
lines.push(`|--------|-------|`);
|
|
1060
|
+
lines.push(`| Cumulative GDD | ${fmt(cumulativeGDD, 1)} / ${crop.gdd_maturity} (${pct(gddProgress)}) |`);
|
|
1061
|
+
lines.push(`| Current stage | ${growthStage} |`);
|
|
1062
|
+
lines.push(`| Total precipitation | ${fmt(totalPrecip, 1)} mm |`);
|
|
1063
|
+
lines.push(`| Total ET (estimated) | ${fmt(totalET, 1)} mm |`);
|
|
1064
|
+
lines.push(`| Water balance | ${fmt(totalPrecip - totalET, 1)} mm |`);
|
|
1065
|
+
lines.push(`| Final soil water | ${fmt(records[records.length - 1].soilWater, 1)} / ${soilCapacity} mm |`);
|
|
1066
|
+
lines.push(`| Water stress days | ${waterStressDays} / ${nDays} |`);
|
|
1067
|
+
lines.push(`| Heat stress days (>35C) | ${heatStressDays} / ${nDays} |`);
|
|
1068
|
+
lines.push('');
|
|
1069
|
+
lines.push('## Yield Estimation');
|
|
1070
|
+
lines.push(`| Factor | Value |`);
|
|
1071
|
+
lines.push(`|--------|-------|`);
|
|
1072
|
+
lines.push(`| GDD completion factor | ${pct(gddFactor)} |`);
|
|
1073
|
+
lines.push(`| Water stress factor | ${pct(waterFactor)} |`);
|
|
1074
|
+
lines.push(`| Heat stress factor | ${pct(heatFactor)} |`);
|
|
1075
|
+
lines.push(`| Precipitation factor | ${pct(Math.min(1, precipFactor))} |`);
|
|
1076
|
+
lines.push(`| **Combined yield factor** | **${pct(yieldFactor)}** |`);
|
|
1077
|
+
lines.push(`| **Estimated yield** | **${Math.round(estimatedYield).toLocaleString()} kg/ha** |`);
|
|
1078
|
+
lines.push(`| Estimated yield (tons/ha) | ${fmt(estimatedYield / 1000, 2)} |`);
|
|
1079
|
+
lines.push('');
|
|
1080
|
+
// Daily data (sample to keep output manageable)
|
|
1081
|
+
const interval = nDays <= 30 ? 1 : nDays <= 90 ? 7 : 14;
|
|
1082
|
+
lines.push('## Daily Data (sampled)');
|
|
1083
|
+
lines.push('| Day | Tmin | Tmax | GDD | Cumul GDD | Precip | ET | Soil H2O | Stress |');
|
|
1084
|
+
lines.push('|-----|------|------|-----|-----------|--------|-----|----------|--------|');
|
|
1085
|
+
for (const r of records) {
|
|
1086
|
+
if (r.day === 1 || r.day % interval === 0 || r.day === nDays) {
|
|
1087
|
+
lines.push(`| ${r.day} | ${fmt(r.tMin, 1)} | ${fmt(r.tMax, 1)} | ${fmt(r.gdd, 1)} | ${fmt(r.cumulGDD, 0)} | ${fmt(r.precip, 1)} | ${fmt(r.et, 1)} | ${fmt(r.soilWater, 0)} | ${r.stress} |`);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return lines.join('\n');
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1094
|
+
// 6. NUTRITION ANALYZE — Food composition lookup and DRI comparison
|
|
1095
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1096
|
+
registerTool({
|
|
1097
|
+
name: 'nutrition_analyze',
|
|
1098
|
+
description: 'Nutritional analysis: look up food composition data for ~100 common foods (USDA-based), calculate daily intake totals from a list of foods, and compare intake against Dietary Reference Intakes (DRI).',
|
|
1099
|
+
parameters: {
|
|
1100
|
+
foods: { type: 'string', description: 'JSON array of {name, grams} — food items and amounts', required: true },
|
|
1101
|
+
operation: { type: 'string', description: 'Operation: lookup (show per-100g data), daily_total (sum intake), or compare_dri (compare to daily recommended)', required: true },
|
|
1102
|
+
},
|
|
1103
|
+
tier: 'free',
|
|
1104
|
+
async execute(args) {
|
|
1105
|
+
const foods = safeParse(String(args.foods), 'foods');
|
|
1106
|
+
const operation = String(args.operation).toLowerCase();
|
|
1107
|
+
if (foods.length === 0)
|
|
1108
|
+
return '**Error:** No foods provided.';
|
|
1109
|
+
// Resolve food names to DB entries
|
|
1110
|
+
function findFood(name) {
|
|
1111
|
+
const lower = name.toLowerCase().trim();
|
|
1112
|
+
// Exact match
|
|
1113
|
+
if (FOOD_DB[lower])
|
|
1114
|
+
return FOOD_DB[lower];
|
|
1115
|
+
// Partial match
|
|
1116
|
+
for (const [key, entry] of Object.entries(FOOD_DB)) {
|
|
1117
|
+
if (key.includes(lower) || lower.includes(key) || entry.name.toLowerCase().includes(lower)) {
|
|
1118
|
+
return entry;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
const lines = [];
|
|
1124
|
+
if (operation === 'lookup') {
|
|
1125
|
+
lines.push('# Food Composition Lookup (per 100g)');
|
|
1126
|
+
lines.push('');
|
|
1127
|
+
lines.push('| Food | kcal | Protein | Fat | Carbs | Fiber | Vit C | Calcium | Iron | Potassium | Vit A |');
|
|
1128
|
+
lines.push('|------|------|---------|-----|-------|-------|-------|---------|------|-----------|-------|');
|
|
1129
|
+
for (const f of foods) {
|
|
1130
|
+
const entry = findFood(f.name);
|
|
1131
|
+
if (entry) {
|
|
1132
|
+
lines.push(`| ${entry.name} | ${entry.kcal} | ${entry.protein}g | ${entry.fat}g | ${entry.carbs}g | ${entry.fiber}g | ${entry.vit_c}mg | ${entry.calcium}mg | ${entry.iron}mg | ${entry.potassium}mg | ${entry.vit_a_mcg}mcg |`);
|
|
1133
|
+
}
|
|
1134
|
+
else {
|
|
1135
|
+
lines.push(`| ${f.name} | -- | *not found in database* | | | | | | | | |`);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
lines.push('');
|
|
1139
|
+
lines.push(`*Database contains ${Object.keys(FOOD_DB).length} foods. Values per 100g.*`);
|
|
1140
|
+
}
|
|
1141
|
+
else if (operation === 'daily_total' || operation === 'compare_dri') {
|
|
1142
|
+
// Calculate totals
|
|
1143
|
+
let totalKcal = 0, totalProtein = 0, totalFat = 0, totalCarbs = 0, totalFiber = 0;
|
|
1144
|
+
let totalVitC = 0, totalCalcium = 0, totalIron = 0, totalPotassium = 0, totalVitA = 0;
|
|
1145
|
+
const notFound = [];
|
|
1146
|
+
lines.push('# Nutritional Intake Analysis');
|
|
1147
|
+
lines.push('');
|
|
1148
|
+
lines.push('## Foods Consumed');
|
|
1149
|
+
lines.push('| Food | Amount | kcal | Protein | Fat | Carbs | Fiber |');
|
|
1150
|
+
lines.push('|------|--------|------|---------|-----|-------|-------|');
|
|
1151
|
+
for (const f of foods) {
|
|
1152
|
+
const entry = findFood(f.name);
|
|
1153
|
+
if (!entry) {
|
|
1154
|
+
notFound.push(f.name);
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
const scale = f.grams / 100;
|
|
1158
|
+
const kcal = entry.kcal * scale;
|
|
1159
|
+
const protein = entry.protein * scale;
|
|
1160
|
+
const fat = entry.fat * scale;
|
|
1161
|
+
const carbs = entry.carbs * scale;
|
|
1162
|
+
const fiber = entry.fiber * scale;
|
|
1163
|
+
totalKcal += kcal;
|
|
1164
|
+
totalProtein += protein;
|
|
1165
|
+
totalFat += fat;
|
|
1166
|
+
totalCarbs += carbs;
|
|
1167
|
+
totalFiber += fiber;
|
|
1168
|
+
totalVitC += entry.vit_c * scale;
|
|
1169
|
+
totalCalcium += entry.calcium * scale;
|
|
1170
|
+
totalIron += entry.iron * scale;
|
|
1171
|
+
totalPotassium += entry.potassium * scale;
|
|
1172
|
+
totalVitA += entry.vit_a_mcg * scale;
|
|
1173
|
+
lines.push(`| ${entry.name} | ${f.grams}g | ${fmt(kcal, 0)} | ${fmt(protein, 1)}g | ${fmt(fat, 1)}g | ${fmt(carbs, 1)}g | ${fmt(fiber, 1)}g |`);
|
|
1174
|
+
}
|
|
1175
|
+
lines.push('');
|
|
1176
|
+
if (notFound.length > 0) {
|
|
1177
|
+
lines.push(`> **Not found in database:** ${notFound.join(', ')}`);
|
|
1178
|
+
lines.push('');
|
|
1179
|
+
}
|
|
1180
|
+
lines.push('## Daily Totals');
|
|
1181
|
+
lines.push(`| Nutrient | Intake |`);
|
|
1182
|
+
lines.push(`|----------|--------|`);
|
|
1183
|
+
lines.push(`| Calories | ${fmt(totalKcal, 0)} kcal |`);
|
|
1184
|
+
lines.push(`| Protein | ${fmt(totalProtein, 1)} g |`);
|
|
1185
|
+
lines.push(`| Fat | ${fmt(totalFat, 1)} g |`);
|
|
1186
|
+
lines.push(`| Carbohydrates | ${fmt(totalCarbs, 1)} g |`);
|
|
1187
|
+
lines.push(`| Fiber | ${fmt(totalFiber, 1)} g |`);
|
|
1188
|
+
lines.push(`| Vitamin C | ${fmt(totalVitC, 1)} mg |`);
|
|
1189
|
+
lines.push(`| Calcium | ${fmt(totalCalcium, 0)} mg |`);
|
|
1190
|
+
lines.push(`| Iron | ${fmt(totalIron, 1)} mg |`);
|
|
1191
|
+
lines.push(`| Potassium | ${fmt(totalPotassium, 0)} mg |`);
|
|
1192
|
+
lines.push(`| Vitamin A | ${fmt(totalVitA, 0)} mcg |`);
|
|
1193
|
+
lines.push('');
|
|
1194
|
+
// Macronutrient breakdown
|
|
1195
|
+
const totalMacroKcal = totalProtein * 4 + totalFat * 9 + totalCarbs * 4;
|
|
1196
|
+
if (totalMacroKcal > 0) {
|
|
1197
|
+
lines.push('## Macronutrient Breakdown');
|
|
1198
|
+
lines.push(`- **Protein:** ${pct(totalProtein * 4 / totalMacroKcal)} of calories`);
|
|
1199
|
+
lines.push(`- **Fat:** ${pct(totalFat * 9 / totalMacroKcal)} of calories`);
|
|
1200
|
+
lines.push(`- **Carbs:** ${pct(totalCarbs * 4 / totalMacroKcal)} of calories`);
|
|
1201
|
+
lines.push('');
|
|
1202
|
+
}
|
|
1203
|
+
if (operation === 'compare_dri') {
|
|
1204
|
+
const intakes = {
|
|
1205
|
+
'Calories': totalKcal,
|
|
1206
|
+
'Protein': totalProtein,
|
|
1207
|
+
'Fat': totalFat,
|
|
1208
|
+
'Carbohydrates': totalCarbs,
|
|
1209
|
+
'Fiber': totalFiber,
|
|
1210
|
+
'Vitamin C': totalVitC,
|
|
1211
|
+
'Calcium': totalCalcium,
|
|
1212
|
+
'Iron': totalIron,
|
|
1213
|
+
'Potassium': totalPotassium,
|
|
1214
|
+
'Vitamin A': totalVitA,
|
|
1215
|
+
};
|
|
1216
|
+
lines.push('## Comparison to DRI (Dietary Reference Intakes)');
|
|
1217
|
+
lines.push('| Nutrient | Intake | RDA | % of RDA | Status |');
|
|
1218
|
+
lines.push('|----------|--------|-----|----------|--------|');
|
|
1219
|
+
for (const dri of DRI_TABLE) {
|
|
1220
|
+
const intake = intakes[dri.nutrient] || 0;
|
|
1221
|
+
const pctRDA = intake / dri.rda;
|
|
1222
|
+
let status;
|
|
1223
|
+
if (pctRDA < 0.5)
|
|
1224
|
+
status = 'LOW';
|
|
1225
|
+
else if (pctRDA < 0.8)
|
|
1226
|
+
status = 'Below target';
|
|
1227
|
+
else if (pctRDA <= 1.2)
|
|
1228
|
+
status = 'Adequate';
|
|
1229
|
+
else if (pctRDA <= 2.0)
|
|
1230
|
+
status = 'Above RDA';
|
|
1231
|
+
else
|
|
1232
|
+
status = 'Excessive';
|
|
1233
|
+
lines.push(`| ${dri.nutrient} | ${fmt(intake, 1)} ${dri.unit} | ${dri.rda} ${dri.unit} | ${pct(pctRDA)} | ${status} |`);
|
|
1234
|
+
}
|
|
1235
|
+
lines.push('');
|
|
1236
|
+
lines.push('*DRI values based on adult averages (19-50 years). Individual needs vary.*');
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
else {
|
|
1240
|
+
return `**Error:** Unknown operation "${operation}". Choose lookup, daily_total, or compare_dri.`;
|
|
1241
|
+
}
|
|
1242
|
+
return lines.join('\n');
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1246
|
+
// 7. LEARNING ANALYTICS — Education and assessment analysis
|
|
1247
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1248
|
+
registerTool({
|
|
1249
|
+
name: 'learning_analytics',
|
|
1250
|
+
description: 'Education and learning analytics: item analysis (difficulty/discrimination), test reliability (KR-20, split-half), learning curve modeling (power law of practice), and spaced repetition scheduling (SM-2 algorithm).',
|
|
1251
|
+
parameters: {
|
|
1252
|
+
operation: { type: 'string', description: 'Operation: item_analysis, reliability, learning_curve, or spaced_repetition', required: true },
|
|
1253
|
+
data: { type: 'string', description: 'JSON data. For item_analysis/reliability: {responses: [[0,1,...], ...]} (2D array, rows=students, cols=items, 0/1). For learning_curve: {trials: [time1, time2, ...]}. For spaced_repetition: {cards: [{quality, easiness?, interval?, repetitions?}, ...]}', required: true },
|
|
1254
|
+
},
|
|
1255
|
+
tier: 'free',
|
|
1256
|
+
async execute(args) {
|
|
1257
|
+
const operation = String(args.operation).toLowerCase();
|
|
1258
|
+
const lines = [];
|
|
1259
|
+
if (operation === 'item_analysis') {
|
|
1260
|
+
const data = safeParse(String(args.data), 'data');
|
|
1261
|
+
const responses = data.responses;
|
|
1262
|
+
const nStudents = responses.length;
|
|
1263
|
+
const nItems = responses[0]?.length || 0;
|
|
1264
|
+
if (nStudents < 2 || nItems < 2)
|
|
1265
|
+
return '**Error:** Need at least 2 students and 2 items.';
|
|
1266
|
+
// Calculate total scores for each student
|
|
1267
|
+
const totals = responses.map(r => r.reduce((a, b) => a + b, 0));
|
|
1268
|
+
const sortedByTotal = totals.map((t, i) => ({ score: t, index: i })).sort((a, b) => b.score - a.score);
|
|
1269
|
+
// Upper and lower 27% groups for discrimination
|
|
1270
|
+
const n27 = Math.max(1, Math.round(nStudents * 0.27));
|
|
1271
|
+
const upperGroup = sortedByTotal.slice(0, n27).map(s => s.index);
|
|
1272
|
+
const lowerGroup = sortedByTotal.slice(-n27).map(s => s.index);
|
|
1273
|
+
lines.push('# Item Analysis');
|
|
1274
|
+
lines.push('');
|
|
1275
|
+
lines.push(`- **Students:** ${nStudents}`);
|
|
1276
|
+
lines.push(`- **Items:** ${nItems}`);
|
|
1277
|
+
lines.push('');
|
|
1278
|
+
lines.push('| Item | Difficulty (p) | Discrimination (D) | Point-Biserial (rpb) | Quality |');
|
|
1279
|
+
lines.push('|------|---------------|-------------------|---------------------|---------|');
|
|
1280
|
+
for (let j = 0; j < nItems; j++) {
|
|
1281
|
+
// Difficulty = proportion correct
|
|
1282
|
+
const correct = responses.reduce((s, r) => s + r[j], 0);
|
|
1283
|
+
const p = correct / nStudents;
|
|
1284
|
+
// Discrimination index (D) = upper27% correct rate - lower27% correct rate
|
|
1285
|
+
const upperCorrect = upperGroup.reduce((s, i) => s + responses[i][j], 0) / n27;
|
|
1286
|
+
const lowerCorrect = lowerGroup.reduce((s, i) => s + responses[i][j], 0) / n27;
|
|
1287
|
+
const D = upperCorrect - lowerCorrect;
|
|
1288
|
+
// Point-biserial correlation
|
|
1289
|
+
const itemScores = responses.map(r => r[j]);
|
|
1290
|
+
const totalMean = totals.reduce((a, b) => a + b, 0) / nStudents;
|
|
1291
|
+
const totalSD = Math.sqrt(totals.reduce((s, t) => s + (t - totalMean) ** 2, 0) / nStudents);
|
|
1292
|
+
let rpb = 0;
|
|
1293
|
+
if (totalSD > 0 && p > 0 && p < 1) {
|
|
1294
|
+
const correctMean = totals.filter((_, i) => itemScores[i] === 1).reduce((a, b) => a + b, 0) / correct;
|
|
1295
|
+
const mp = correct / nStudents;
|
|
1296
|
+
const mq = 1 - mp;
|
|
1297
|
+
rpb = ((correctMean - totalMean) / totalSD) * Math.sqrt(mp / mq);
|
|
1298
|
+
}
|
|
1299
|
+
// Quality rating
|
|
1300
|
+
let quality;
|
|
1301
|
+
if (p < 0.2 || p > 0.9)
|
|
1302
|
+
quality = 'Revise (difficulty)';
|
|
1303
|
+
else if (D < 0.2 || rpb < 0.2)
|
|
1304
|
+
quality = 'Revise (discrimination)';
|
|
1305
|
+
else if (D >= 0.4 && rpb >= 0.3)
|
|
1306
|
+
quality = 'Excellent';
|
|
1307
|
+
else
|
|
1308
|
+
quality = 'Acceptable';
|
|
1309
|
+
lines.push(`| ${j + 1} | ${fmt(p, 3)} | ${fmt(D, 3)} | ${fmt(rpb, 3)} | ${quality} |`);
|
|
1310
|
+
}
|
|
1311
|
+
lines.push('');
|
|
1312
|
+
lines.push('## Interpretation');
|
|
1313
|
+
lines.push('- **Difficulty (p):** 0 = nobody correct, 1 = all correct. Ideal: 0.3-0.8');
|
|
1314
|
+
lines.push('- **Discrimination (D):** Higher = better item. D >= 0.4 = excellent, D < 0.2 = poor');
|
|
1315
|
+
lines.push('- **Point-Biserial (rpb):** Correlation between item score and total score. rpb >= 0.3 = good');
|
|
1316
|
+
}
|
|
1317
|
+
else if (operation === 'reliability') {
|
|
1318
|
+
const data = safeParse(String(args.data), 'data');
|
|
1319
|
+
const responses = data.responses;
|
|
1320
|
+
const nStudents = responses.length;
|
|
1321
|
+
const nItems = responses[0]?.length || 0;
|
|
1322
|
+
if (nStudents < 2 || nItems < 2)
|
|
1323
|
+
return '**Error:** Need at least 2 students and 2 items.';
|
|
1324
|
+
// KR-20 (Kuder-Richardson 20) — for dichotomous items
|
|
1325
|
+
const totals = responses.map(r => r.reduce((a, b) => a + b, 0));
|
|
1326
|
+
const totalMean = totals.reduce((a, b) => a + b, 0) / nStudents;
|
|
1327
|
+
const totalVariance = totals.reduce((s, t) => s + (t - totalMean) ** 2, 0) / nStudents;
|
|
1328
|
+
let sumPQ = 0;
|
|
1329
|
+
for (let j = 0; j < nItems; j++) {
|
|
1330
|
+
const p = responses.reduce((s, r) => s + r[j], 0) / nStudents;
|
|
1331
|
+
sumPQ += p * (1 - p);
|
|
1332
|
+
}
|
|
1333
|
+
const kr20 = totalVariance > 0
|
|
1334
|
+
? (nItems / (nItems - 1)) * (1 - sumPQ / totalVariance)
|
|
1335
|
+
: 0;
|
|
1336
|
+
// Split-half reliability (odd-even split + Spearman-Brown)
|
|
1337
|
+
const oddScores = responses.map(r => r.filter((_, i) => i % 2 === 0).reduce((a, b) => a + b, 0));
|
|
1338
|
+
const evenScores = responses.map(r => r.filter((_, i) => i % 2 === 1).reduce((a, b) => a + b, 0));
|
|
1339
|
+
const oddMean = oddScores.reduce((a, b) => a + b, 0) / nStudents;
|
|
1340
|
+
const evenMean = evenScores.reduce((a, b) => a + b, 0) / nStudents;
|
|
1341
|
+
let covOE = 0, varOdd = 0, varEven = 0;
|
|
1342
|
+
for (let i = 0; i < nStudents; i++) {
|
|
1343
|
+
covOE += (oddScores[i] - oddMean) * (evenScores[i] - evenMean);
|
|
1344
|
+
varOdd += (oddScores[i] - oddMean) ** 2;
|
|
1345
|
+
varEven += (evenScores[i] - evenMean) ** 2;
|
|
1346
|
+
}
|
|
1347
|
+
covOE /= nStudents;
|
|
1348
|
+
varOdd /= nStudents;
|
|
1349
|
+
varEven /= nStudents;
|
|
1350
|
+
const rHalf = (varOdd > 0 && varEven > 0) ? covOE / Math.sqrt(varOdd * varEven) : 0;
|
|
1351
|
+
const rSB = (2 * rHalf) / (1 + rHalf); // Spearman-Brown correction
|
|
1352
|
+
// Standard Error of Measurement
|
|
1353
|
+
const sem = Math.sqrt(totalVariance) * Math.sqrt(1 - kr20);
|
|
1354
|
+
// Interpretation
|
|
1355
|
+
let interpretation;
|
|
1356
|
+
if (kr20 >= 0.9)
|
|
1357
|
+
interpretation = 'Excellent reliability — suitable for high-stakes decisions';
|
|
1358
|
+
else if (kr20 >= 0.8)
|
|
1359
|
+
interpretation = 'Good reliability — suitable for most purposes';
|
|
1360
|
+
else if (kr20 >= 0.7)
|
|
1361
|
+
interpretation = 'Acceptable — suitable for group comparisons';
|
|
1362
|
+
else if (kr20 >= 0.6)
|
|
1363
|
+
interpretation = 'Questionable — use with caution';
|
|
1364
|
+
else
|
|
1365
|
+
interpretation = 'Poor — revise test items';
|
|
1366
|
+
lines.push('# Test Reliability Analysis');
|
|
1367
|
+
lines.push('');
|
|
1368
|
+
lines.push(`- **Students:** ${nStudents}`);
|
|
1369
|
+
lines.push(`- **Items:** ${nItems}`);
|
|
1370
|
+
lines.push(`- **Mean score:** ${fmt(totalMean, 2)} / ${nItems}`);
|
|
1371
|
+
lines.push(`- **Score variance:** ${fmt(totalVariance, 2)}`);
|
|
1372
|
+
lines.push(`- **Score SD:** ${fmt(Math.sqrt(totalVariance), 2)}`);
|
|
1373
|
+
lines.push('');
|
|
1374
|
+
lines.push('## Reliability Coefficients');
|
|
1375
|
+
lines.push(`| Measure | Value | Interpretation |`);
|
|
1376
|
+
lines.push(`|---------|-------|----------------|`);
|
|
1377
|
+
lines.push(`| KR-20 | ${fmt(kr20, 4)} | ${interpretation} |`);
|
|
1378
|
+
lines.push(`| Split-half (r) | ${fmt(rHalf, 4)} | Raw half-test correlation |`);
|
|
1379
|
+
lines.push(`| Spearman-Brown | ${fmt(rSB, 4)} | Corrected full-test reliability |`);
|
|
1380
|
+
lines.push(`| SEM | ${fmt(sem, 2)} | Standard Error of Measurement |`);
|
|
1381
|
+
lines.push('');
|
|
1382
|
+
lines.push('## Score Distribution');
|
|
1383
|
+
lines.push(`- **Min score:** ${Math.min(...totals)}`);
|
|
1384
|
+
lines.push(`- **Max score:** ${Math.max(...totals)}`);
|
|
1385
|
+
lines.push(`- **Median:** ${[...totals].sort((a, b) => a - b)[Math.floor(nStudents / 2)]}`);
|
|
1386
|
+
lines.push(`- **68% CI for a score at the mean:** ${fmt(totalMean - sem, 1)} to ${fmt(totalMean + sem, 1)}`);
|
|
1387
|
+
}
|
|
1388
|
+
else if (operation === 'learning_curve') {
|
|
1389
|
+
const data = safeParse(String(args.data), 'data');
|
|
1390
|
+
const trials = data.trials;
|
|
1391
|
+
const n = trials.length;
|
|
1392
|
+
if (n < 3)
|
|
1393
|
+
return '**Error:** Need at least 3 trial data points.';
|
|
1394
|
+
// Power Law of Practice: T = a * N^(-b)
|
|
1395
|
+
// Log-linear regression: ln(T) = ln(a) - b * ln(N)
|
|
1396
|
+
const xVals = trials.map((_, i) => Math.log(i + 1));
|
|
1397
|
+
const yVals = trials.map(t => Math.log(t));
|
|
1398
|
+
const xMean = xVals.reduce((a, b) => a + b, 0) / n;
|
|
1399
|
+
const yMean = yVals.reduce((a, b) => a + b, 0) / n;
|
|
1400
|
+
let ssXY = 0, ssXX = 0, ssTot = 0, ssRes = 0;
|
|
1401
|
+
for (let i = 0; i < n; i++) {
|
|
1402
|
+
ssXY += (xVals[i] - xMean) * (yVals[i] - yMean);
|
|
1403
|
+
ssXX += (xVals[i] - xMean) ** 2;
|
|
1404
|
+
}
|
|
1405
|
+
const negB = ssXY / ssXX;
|
|
1406
|
+
const lnA = yMean - negB * xMean;
|
|
1407
|
+
const a = Math.exp(lnA);
|
|
1408
|
+
const b = -negB;
|
|
1409
|
+
for (let i = 0; i < n; i++) {
|
|
1410
|
+
const predicted = lnA + negB * xVals[i];
|
|
1411
|
+
ssRes += (yVals[i] - predicted) ** 2;
|
|
1412
|
+
ssTot += (yVals[i] - yMean) ** 2;
|
|
1413
|
+
}
|
|
1414
|
+
const r2 = ssTot > 0 ? 1 - ssRes / ssTot : 0;
|
|
1415
|
+
// Predictions
|
|
1416
|
+
const predict = (trial) => a * Math.pow(trial, -b);
|
|
1417
|
+
// Learning rate (% improvement from trial N to trial N+1)
|
|
1418
|
+
const improvements = [];
|
|
1419
|
+
for (let i = 1; i < n; i++) {
|
|
1420
|
+
improvements.push((trials[i - 1] - trials[i]) / trials[i - 1]);
|
|
1421
|
+
}
|
|
1422
|
+
lines.push('# Learning Curve Analysis (Power Law of Practice)');
|
|
1423
|
+
lines.push('');
|
|
1424
|
+
lines.push(`**Model:** T = ${fmt(a, 2)} * N^(-${fmt(b, 4)})`);
|
|
1425
|
+
lines.push('');
|
|
1426
|
+
lines.push('## Parameters');
|
|
1427
|
+
lines.push(`| Parameter | Value |`);
|
|
1428
|
+
lines.push(`|-----------|-------|`);
|
|
1429
|
+
lines.push(`| a (initial performance) | ${fmt(a, 2)} |`);
|
|
1430
|
+
lines.push(`| b (learning rate exponent) | ${fmt(b, 4)} |`);
|
|
1431
|
+
lines.push(`| R-squared | ${fmt(r2, 4)} |`);
|
|
1432
|
+
lines.push(`| Total trials | ${n} |`);
|
|
1433
|
+
lines.push(`| First trial time | ${trials[0]} |`);
|
|
1434
|
+
lines.push(`| Last trial time | ${trials[n - 1]} |`);
|
|
1435
|
+
lines.push(`| Total improvement | ${pct((trials[0] - trials[n - 1]) / trials[0])} |`);
|
|
1436
|
+
lines.push('');
|
|
1437
|
+
lines.push('## Trial Data & Predictions');
|
|
1438
|
+
lines.push('| Trial | Actual | Predicted | Improvement |');
|
|
1439
|
+
lines.push('|-------|--------|-----------|-------------|');
|
|
1440
|
+
for (let i = 0; i < n; i++) {
|
|
1441
|
+
const pred = predict(i + 1);
|
|
1442
|
+
const imp = i > 0 ? pct(improvements[i - 1]) : '-';
|
|
1443
|
+
lines.push(`| ${i + 1} | ${trials[i]} | ${fmt(pred, 1)} | ${imp} |`);
|
|
1444
|
+
}
|
|
1445
|
+
lines.push('');
|
|
1446
|
+
// Future predictions
|
|
1447
|
+
lines.push('## Predictions');
|
|
1448
|
+
const futureTrial = [n * 2, n * 5, n * 10];
|
|
1449
|
+
for (const ft of futureTrial) {
|
|
1450
|
+
lines.push(`- **Trial ${ft}:** ${fmt(predict(ft), 1)} (${pct((trials[0] - predict(ft)) / trials[0])} improvement from start)`);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
else if (operation === 'spaced_repetition') {
|
|
1454
|
+
const data = safeParse(String(args.data), 'data');
|
|
1455
|
+
const cards = data.cards;
|
|
1456
|
+
if (cards.length === 0)
|
|
1457
|
+
return '**Error:** No cards provided.';
|
|
1458
|
+
const results = [];
|
|
1459
|
+
for (let i = 0; i < cards.length; i++) {
|
|
1460
|
+
const card = cards[i];
|
|
1461
|
+
const quality = Math.max(0, Math.min(5, card.quality));
|
|
1462
|
+
let ef = card.easiness ?? 2.5;
|
|
1463
|
+
let interval = card.interval ?? 0;
|
|
1464
|
+
let reps = card.repetitions ?? 0;
|
|
1465
|
+
const prevEF = ef;
|
|
1466
|
+
const prevInterval = interval;
|
|
1467
|
+
if (quality >= 3) {
|
|
1468
|
+
// Correct response
|
|
1469
|
+
if (reps === 0) {
|
|
1470
|
+
interval = 1;
|
|
1471
|
+
}
|
|
1472
|
+
else if (reps === 1) {
|
|
1473
|
+
interval = 6;
|
|
1474
|
+
}
|
|
1475
|
+
else {
|
|
1476
|
+
interval = Math.round(interval * ef);
|
|
1477
|
+
}
|
|
1478
|
+
reps++;
|
|
1479
|
+
}
|
|
1480
|
+
else {
|
|
1481
|
+
// Incorrect — reset
|
|
1482
|
+
reps = 0;
|
|
1483
|
+
interval = 1;
|
|
1484
|
+
}
|
|
1485
|
+
// Update easiness factor
|
|
1486
|
+
ef = ef + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
|
|
1487
|
+
ef = Math.max(1.3, ef);
|
|
1488
|
+
// Calculate next review date
|
|
1489
|
+
const nextDate = new Date();
|
|
1490
|
+
nextDate.setDate(nextDate.getDate() + interval);
|
|
1491
|
+
const nextReview = nextDate.toISOString().split('T')[0];
|
|
1492
|
+
results.push({
|
|
1493
|
+
index: i + 1,
|
|
1494
|
+
quality,
|
|
1495
|
+
prevEF,
|
|
1496
|
+
newEF: ef,
|
|
1497
|
+
prevInterval,
|
|
1498
|
+
newInterval: interval,
|
|
1499
|
+
repetitions: reps,
|
|
1500
|
+
nextReview,
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
lines.push('# Spaced Repetition Schedule (SM-2 Algorithm)');
|
|
1504
|
+
lines.push('');
|
|
1505
|
+
lines.push('## Quality Scale');
|
|
1506
|
+
lines.push('- **5:** Perfect response, no hesitation');
|
|
1507
|
+
lines.push('- **4:** Correct after slight hesitation');
|
|
1508
|
+
lines.push('- **3:** Correct with difficulty');
|
|
1509
|
+
lines.push('- **2:** Incorrect, but upon seeing answer it was easy to recall');
|
|
1510
|
+
lines.push('- **1:** Incorrect, answer remembered after seeing it');
|
|
1511
|
+
lines.push('- **0:** Complete blackout');
|
|
1512
|
+
lines.push('');
|
|
1513
|
+
lines.push('## Card Schedule');
|
|
1514
|
+
lines.push('| Card | Quality | Prev EF | New EF | Prev Interval | New Interval | Reps | Next Review |');
|
|
1515
|
+
lines.push('|------|---------|---------|--------|---------------|-------------|------|-------------|');
|
|
1516
|
+
for (const r of results) {
|
|
1517
|
+
lines.push(`| ${r.index} | ${r.quality} | ${fmt(r.prevEF, 2)} | ${fmt(r.newEF, 2)} | ${r.prevInterval}d | ${r.newInterval}d | ${r.repetitions} | ${r.nextReview} |`);
|
|
1518
|
+
}
|
|
1519
|
+
lines.push('');
|
|
1520
|
+
// Summary
|
|
1521
|
+
const avgQuality = results.reduce((s, r) => s + r.quality, 0) / results.length;
|
|
1522
|
+
const needsReview = results.filter(r => r.quality < 3).length;
|
|
1523
|
+
lines.push('## Summary');
|
|
1524
|
+
lines.push(`- **Total cards:** ${cards.length}`);
|
|
1525
|
+
lines.push(`- **Average quality:** ${fmt(avgQuality, 2)}`);
|
|
1526
|
+
lines.push(`- **Cards needing immediate review (q < 3):** ${needsReview}`);
|
|
1527
|
+
lines.push(`- **Average new EF:** ${fmt(results.reduce((s, r) => s + r.newEF, 0) / results.length, 2)}`);
|
|
1528
|
+
}
|
|
1529
|
+
else {
|
|
1530
|
+
return `**Error:** Unknown operation "${operation}". Choose item_analysis, reliability, learning_curve, or spaced_repetition.`;
|
|
1531
|
+
}
|
|
1532
|
+
return lines.join('\n');
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1536
|
+
// 8. VACCINATION MODEL — Herd immunity and vaccine impact
|
|
1537
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1538
|
+
registerTool({
|
|
1539
|
+
name: 'vaccination_model',
|
|
1540
|
+
description: 'Vaccine impact modeling: herd immunity calculator, vaccine effectiveness from case-control data, number needed to vaccinate, and age-structured vaccination strategy comparison.',
|
|
1541
|
+
parameters: {
|
|
1542
|
+
r0: { type: 'number', description: 'Basic reproduction number of the pathogen', required: true },
|
|
1543
|
+
vaccine_efficacy: { type: 'number', description: 'Vaccine efficacy (0-1)', required: true },
|
|
1544
|
+
coverage: { type: 'number', description: 'Vaccination coverage (0-1)', required: true },
|
|
1545
|
+
population: { type: 'number', description: 'Total population (for absolute numbers)' },
|
|
1546
|
+
age_groups: { type: 'string', description: 'JSON array of {age, fraction, contact_rate} for age-structured analysis. fraction should sum to 1, contact_rate is relative (1.0 = average)' },
|
|
1547
|
+
},
|
|
1548
|
+
tier: 'free',
|
|
1549
|
+
async execute(args) {
|
|
1550
|
+
const r0 = Number(args.r0);
|
|
1551
|
+
const ve = Number(args.vaccine_efficacy);
|
|
1552
|
+
const coverage = Number(args.coverage);
|
|
1553
|
+
const population = typeof args.population === 'number' ? args.population : 1000000;
|
|
1554
|
+
if (r0 <= 0 || ve < 0 || ve > 1 || coverage < 0 || coverage > 1) {
|
|
1555
|
+
return '**Error:** R0 must be positive, efficacy and coverage must be between 0 and 1.';
|
|
1556
|
+
}
|
|
1557
|
+
const lines = [];
|
|
1558
|
+
lines.push('# Vaccination Impact Model');
|
|
1559
|
+
lines.push('');
|
|
1560
|
+
// Basic herd immunity
|
|
1561
|
+
const herdThreshold = 1 - 1 / r0;
|
|
1562
|
+
const effectiveCoverage = coverage * ve;
|
|
1563
|
+
const effectiveR = r0 * (1 - effectiveCoverage);
|
|
1564
|
+
const herdAchieved = effectiveCoverage >= herdThreshold;
|
|
1565
|
+
const coverageNeeded = herdThreshold / ve;
|
|
1566
|
+
const nnv = 1 / ve; // Number needed to vaccinate to prevent 1 case (simplified)
|
|
1567
|
+
// Population-level estimates
|
|
1568
|
+
// Without vaccination: using SIR final size equation approximation
|
|
1569
|
+
// R_inf ~ 1 - exp(-R0 * R_inf) — solve iteratively
|
|
1570
|
+
let finalSizeNoVax = 0.9; // initial guess
|
|
1571
|
+
for (let iter = 0; iter < 100; iter++) {
|
|
1572
|
+
finalSizeNoVax = 1 - Math.exp(-r0 * finalSizeNoVax);
|
|
1573
|
+
}
|
|
1574
|
+
let finalSizeWithVax = 0;
|
|
1575
|
+
if (effectiveR > 1) {
|
|
1576
|
+
finalSizeWithVax = 0.9;
|
|
1577
|
+
for (let iter = 0; iter < 100; iter++) {
|
|
1578
|
+
finalSizeWithVax = 1 - Math.exp(-effectiveR * finalSizeWithVax);
|
|
1579
|
+
}
|
|
1580
|
+
// Scale to unvaccinated susceptible pool
|
|
1581
|
+
finalSizeWithVax = finalSizeWithVax * (1 - effectiveCoverage);
|
|
1582
|
+
}
|
|
1583
|
+
const casesWithout = Math.round(finalSizeNoVax * population);
|
|
1584
|
+
const casesWith = Math.round(finalSizeWithVax * population);
|
|
1585
|
+
const casesPrevented = casesWithout - casesWith;
|
|
1586
|
+
const vaccinated = Math.round(coverage * population);
|
|
1587
|
+
lines.push('## Parameters');
|
|
1588
|
+
lines.push(`| Parameter | Value |`);
|
|
1589
|
+
lines.push(`|-----------|-------|`);
|
|
1590
|
+
lines.push(`| R0 | ${r0} |`);
|
|
1591
|
+
lines.push(`| Vaccine efficacy | ${pct(ve)} |`);
|
|
1592
|
+
lines.push(`| Coverage | ${pct(coverage)} |`);
|
|
1593
|
+
lines.push(`| Population | ${population.toLocaleString()} |`);
|
|
1594
|
+
lines.push('');
|
|
1595
|
+
lines.push('## Herd Immunity Analysis');
|
|
1596
|
+
lines.push(`| Metric | Value |`);
|
|
1597
|
+
lines.push(`|--------|-------|`);
|
|
1598
|
+
lines.push(`| Herd immunity threshold | ${pct(herdThreshold)} |`);
|
|
1599
|
+
lines.push(`| Effective coverage (coverage x efficacy) | ${pct(effectiveCoverage)} |`);
|
|
1600
|
+
lines.push(`| Effective R (with vaccination) | ${fmt(effectiveR, 2)} |`);
|
|
1601
|
+
lines.push(`| Herd immunity achieved? | ${herdAchieved ? '**YES**' : '**NO**'} |`);
|
|
1602
|
+
lines.push(`| Coverage needed (for this efficacy) | ${pct(Math.min(1, coverageNeeded))}${coverageNeeded > 1 ? ' (impossible with this efficacy)' : ''} |`);
|
|
1603
|
+
lines.push('');
|
|
1604
|
+
lines.push('## Population Impact');
|
|
1605
|
+
lines.push(`| Metric | Value |`);
|
|
1606
|
+
lines.push(`|--------|-------|`);
|
|
1607
|
+
lines.push(`| People vaccinated | ${vaccinated.toLocaleString()} |`);
|
|
1608
|
+
lines.push(`| Expected cases (no vaccination) | ${casesWithout.toLocaleString()} (${pct(finalSizeNoVax)}) |`);
|
|
1609
|
+
lines.push(`| Expected cases (with vaccination) | ${casesWith.toLocaleString()} (${pct(finalSizeWithVax)}) |`);
|
|
1610
|
+
lines.push(`| Cases prevented | ${casesPrevented.toLocaleString()} |`);
|
|
1611
|
+
lines.push(`| % reduction in cases | ${pct(casesPrevented / Math.max(1, casesWithout))} |`);
|
|
1612
|
+
lines.push(`| NNV (per case prevented) | ${fmt(vaccinated / Math.max(1, casesPrevented), 0)} |`);
|
|
1613
|
+
lines.push('');
|
|
1614
|
+
// Sensitivity analysis: coverage vs effective R
|
|
1615
|
+
lines.push('## Sensitivity: Coverage vs Effective R');
|
|
1616
|
+
lines.push('| Coverage | Effective Coverage | Effective R | Herd Immunity? |');
|
|
1617
|
+
lines.push('|----------|--------------------|-------------|----------------|');
|
|
1618
|
+
for (let c = 0.1; c <= 1.0; c += 0.1) {
|
|
1619
|
+
const ec = c * ve;
|
|
1620
|
+
const er = r0 * (1 - ec);
|
|
1621
|
+
const hi = ec >= herdThreshold ? 'Yes' : 'No';
|
|
1622
|
+
lines.push(`| ${pct(c)} | ${pct(ec)} | ${fmt(er, 2)} | ${hi} |`);
|
|
1623
|
+
}
|
|
1624
|
+
lines.push('');
|
|
1625
|
+
// Age-structured analysis if provided
|
|
1626
|
+
if (args.age_groups) {
|
|
1627
|
+
const ageGroups = safeParse(String(args.age_groups), 'age_groups');
|
|
1628
|
+
lines.push('## Age-Structured Analysis');
|
|
1629
|
+
lines.push('');
|
|
1630
|
+
lines.push('### Strategy 1: Uniform coverage across all groups');
|
|
1631
|
+
lines.push('| Age Group | Pop Fraction | Contact Rate | Eff. Coverage | Group R |');
|
|
1632
|
+
lines.push('|-----------|-------------|-------------|---------------|---------|');
|
|
1633
|
+
for (const ag of ageGroups) {
|
|
1634
|
+
const groupR = r0 * ag.contact_rate * (1 - coverage * ve);
|
|
1635
|
+
lines.push(`| ${ag.age} | ${pct(ag.fraction)} | ${fmt(ag.contact_rate, 2)} | ${pct(coverage * ve)} | ${fmt(groupR, 2)} |`);
|
|
1636
|
+
}
|
|
1637
|
+
lines.push('');
|
|
1638
|
+
// Strategy 2: Prioritize high-contact groups
|
|
1639
|
+
const sorted = [...ageGroups].sort((a, b) => b.contact_rate - a.contact_rate);
|
|
1640
|
+
lines.push('### Strategy 2: Prioritize high-contact groups');
|
|
1641
|
+
lines.push('Allocating vaccines to groups with highest contact rates first:');
|
|
1642
|
+
lines.push('');
|
|
1643
|
+
let remainingDoses = coverage; // as fraction of total pop
|
|
1644
|
+
lines.push('| Age Group | Contact Rate | Allocated Coverage | Group Eff. Coverage |');
|
|
1645
|
+
lines.push('|-----------|-------------|-------------------|---------------------|');
|
|
1646
|
+
for (const ag of sorted) {
|
|
1647
|
+
const allocCoverage = Math.min(1, remainingDoses / ag.fraction);
|
|
1648
|
+
remainingDoses -= allocCoverage * ag.fraction;
|
|
1649
|
+
remainingDoses = Math.max(0, remainingDoses);
|
|
1650
|
+
lines.push(`| ${ag.age} | ${fmt(ag.contact_rate, 2)} | ${pct(allocCoverage)} | ${pct(allocCoverage * ve)} |`);
|
|
1651
|
+
}
|
|
1652
|
+
lines.push('');
|
|
1653
|
+
// Compute weighted effective R for both strategies
|
|
1654
|
+
let weightedR_uniform = 0;
|
|
1655
|
+
let weightedR_priority = 0;
|
|
1656
|
+
let remainingDoses2 = coverage;
|
|
1657
|
+
for (const ag of ageGroups) {
|
|
1658
|
+
weightedR_uniform += ag.fraction * r0 * ag.contact_rate * (1 - coverage * ve);
|
|
1659
|
+
}
|
|
1660
|
+
for (const ag of sorted) {
|
|
1661
|
+
const allocCoverage = Math.min(1, remainingDoses2 / ag.fraction);
|
|
1662
|
+
remainingDoses2 -= allocCoverage * ag.fraction;
|
|
1663
|
+
remainingDoses2 = Math.max(0, remainingDoses2);
|
|
1664
|
+
weightedR_priority += ag.fraction * r0 * ag.contact_rate * (1 - allocCoverage * ve);
|
|
1665
|
+
}
|
|
1666
|
+
lines.push('### Strategy Comparison');
|
|
1667
|
+
lines.push(`| Strategy | Weighted Effective R |`);
|
|
1668
|
+
lines.push(`|----------|---------------------|`);
|
|
1669
|
+
lines.push(`| Uniform | ${fmt(weightedR_uniform, 3)} |`);
|
|
1670
|
+
lines.push(`| Priority (high-contact first) | ${fmt(weightedR_priority, 3)} |`);
|
|
1671
|
+
if (weightedR_priority < weightedR_uniform) {
|
|
1672
|
+
lines.push(`\n**Priority strategy reduces effective R by ${fmt(weightedR_uniform - weightedR_priority, 3)}** compared to uniform distribution.`);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
return lines.join('\n');
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1679
|
+
// 9. ENVIRONMENTAL HEALTH — Risk assessment and dose-response
|
|
1680
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1681
|
+
registerTool({
|
|
1682
|
+
name: 'environmental_health',
|
|
1683
|
+
description: 'Environmental health risk assessment: dose-response modeling (linear, threshold, hormesis), exposure assessment (EPA intake equation), hazard quotient, reference dose comparison, and lifetime cancer risk calculation.',
|
|
1684
|
+
parameters: {
|
|
1685
|
+
calculation: { type: 'string', description: 'Calculation type: dose_response, exposure, hazard_quotient, or cancer_risk', required: true },
|
|
1686
|
+
params: { type: 'string', description: 'JSON parameters. For dose_response: {model, doses, responses}. For exposure: {concentration_mg_L, intake_rate_L_day, exposure_frequency_days, exposure_duration_years, body_weight_kg, averaging_time_days}. For hazard_quotient: {dose_mg_kg_day, reference_dose}. For cancer_risk: {dose_mg_kg_day, slope_factor}', required: true },
|
|
1687
|
+
},
|
|
1688
|
+
tier: 'free',
|
|
1689
|
+
async execute(args) {
|
|
1690
|
+
const calculation = String(args.calculation).toLowerCase();
|
|
1691
|
+
const params = safeParse(String(args.params), 'params');
|
|
1692
|
+
const lines = [];
|
|
1693
|
+
if (calculation === 'dose_response') {
|
|
1694
|
+
const model = String(params.model || 'linear').toLowerCase();
|
|
1695
|
+
const doses = params.doses;
|
|
1696
|
+
const responses = params.responses;
|
|
1697
|
+
if (!doses || !responses || doses.length !== responses.length || doses.length < 2) {
|
|
1698
|
+
return '**Error:** Need matching arrays of doses and responses with at least 2 points.';
|
|
1699
|
+
}
|
|
1700
|
+
const n = doses.length;
|
|
1701
|
+
lines.push('# Dose-Response Analysis');
|
|
1702
|
+
lines.push('');
|
|
1703
|
+
if (model === 'linear' || model === 'all') {
|
|
1704
|
+
// Linear: response = a + b * dose
|
|
1705
|
+
const xMean = doses.reduce((a, b) => a + b, 0) / n;
|
|
1706
|
+
const yMean = responses.reduce((a, b) => a + b, 0) / n;
|
|
1707
|
+
let ssXY = 0, ssXX = 0, ssTot = 0, ssRes = 0;
|
|
1708
|
+
for (let i = 0; i < n; i++) {
|
|
1709
|
+
ssXY += (doses[i] - xMean) * (responses[i] - yMean);
|
|
1710
|
+
ssXX += (doses[i] - xMean) ** 2;
|
|
1711
|
+
}
|
|
1712
|
+
const slope = ssXX > 0 ? ssXY / ssXX : 0;
|
|
1713
|
+
const intercept = yMean - slope * xMean;
|
|
1714
|
+
for (let i = 0; i < n; i++) {
|
|
1715
|
+
const pred = intercept + slope * doses[i];
|
|
1716
|
+
ssRes += (responses[i] - pred) ** 2;
|
|
1717
|
+
ssTot += (responses[i] - yMean) ** 2;
|
|
1718
|
+
}
|
|
1719
|
+
const r2 = ssTot > 0 ? 1 - ssRes / ssTot : 0;
|
|
1720
|
+
lines.push('## Linear Model');
|
|
1721
|
+
lines.push(`**Response = ${fmt(intercept, 4)} + ${fmt(slope, 4)} x Dose**`);
|
|
1722
|
+
lines.push('');
|
|
1723
|
+
lines.push(`- **Slope:** ${fmt(slope, 4)} (response per unit dose)`);
|
|
1724
|
+
lines.push(`- **Intercept:** ${fmt(intercept, 4)}`);
|
|
1725
|
+
lines.push(`- **R-squared:** ${fmt(r2, 4)}`);
|
|
1726
|
+
lines.push('');
|
|
1727
|
+
}
|
|
1728
|
+
if (model === 'threshold' || model === 'all') {
|
|
1729
|
+
// Threshold model: find the best-fit threshold (NOAEL/LOAEL approach)
|
|
1730
|
+
// Try each data point as a potential threshold
|
|
1731
|
+
let bestThreshold = 0;
|
|
1732
|
+
let bestR2 = -Infinity;
|
|
1733
|
+
for (let t = 0; t < n - 1; t++) {
|
|
1734
|
+
const threshold = (doses[t] + doses[t + 1]) / 2;
|
|
1735
|
+
let ssRes = 0, ssTot = 0;
|
|
1736
|
+
const yMean = responses.reduce((a, b) => a + b, 0) / n;
|
|
1737
|
+
for (let i = 0; i < n; i++) {
|
|
1738
|
+
const effectiveDose = Math.max(0, doses[i] - threshold);
|
|
1739
|
+
// Fit linear above threshold
|
|
1740
|
+
const pred = responses[0] + (doses[i] <= threshold ? 0 : (responses[n - 1] - responses[0]) * effectiveDose / (doses[n - 1] - threshold));
|
|
1741
|
+
ssRes += (responses[i] - pred) ** 2;
|
|
1742
|
+
ssTot += (responses[i] - yMean) ** 2;
|
|
1743
|
+
}
|
|
1744
|
+
const r2 = ssTot > 0 ? 1 - ssRes / ssTot : 0;
|
|
1745
|
+
if (r2 > bestR2) {
|
|
1746
|
+
bestR2 = r2;
|
|
1747
|
+
bestThreshold = threshold;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
// NOAEL/LOAEL
|
|
1751
|
+
let noael = 0;
|
|
1752
|
+
let loael = doses[0];
|
|
1753
|
+
const baseResponse = responses[0];
|
|
1754
|
+
const baseSD = Math.abs(baseResponse * 0.1) || 1; // approximate
|
|
1755
|
+
for (let i = 0; i < n; i++) {
|
|
1756
|
+
if (Math.abs(responses[i] - baseResponse) > 2 * baseSD) {
|
|
1757
|
+
loael = doses[i];
|
|
1758
|
+
noael = i > 0 ? doses[i - 1] : 0;
|
|
1759
|
+
break;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
lines.push('## Threshold Model');
|
|
1763
|
+
lines.push(`- **Estimated threshold:** ${fmt(bestThreshold, 4)}`);
|
|
1764
|
+
lines.push(`- **R-squared:** ${fmt(bestR2, 4)}`);
|
|
1765
|
+
lines.push(`- **NOAEL (approx):** ${fmt(noael, 4)}`);
|
|
1766
|
+
lines.push(`- **LOAEL (approx):** ${fmt(loael, 4)}`);
|
|
1767
|
+
lines.push('');
|
|
1768
|
+
}
|
|
1769
|
+
if (model === 'hormesis' || model === 'all') {
|
|
1770
|
+
// Hormesis: U-shaped or J-shaped — fit a quadratic
|
|
1771
|
+
// response = a + b*dose + c*dose^2
|
|
1772
|
+
// Using least squares: solve normal equations
|
|
1773
|
+
let sumX = 0, sumX2 = 0, sumX3 = 0, sumX4 = 0;
|
|
1774
|
+
let sumY = 0, sumXY = 0, sumX2Y = 0;
|
|
1775
|
+
for (let i = 0; i < n; i++) {
|
|
1776
|
+
const x = doses[i], y = responses[i];
|
|
1777
|
+
sumX += x;
|
|
1778
|
+
sumX2 += x ** 2;
|
|
1779
|
+
sumX3 += x ** 3;
|
|
1780
|
+
sumX4 += x ** 4;
|
|
1781
|
+
sumY += y;
|
|
1782
|
+
sumXY += x * y;
|
|
1783
|
+
sumX2Y += x ** 2 * y;
|
|
1784
|
+
}
|
|
1785
|
+
// Solve 3x3 system (Cramer's rule)
|
|
1786
|
+
const A = [
|
|
1787
|
+
[n, sumX, sumX2],
|
|
1788
|
+
[sumX, sumX2, sumX3],
|
|
1789
|
+
[sumX2, sumX3, sumX4],
|
|
1790
|
+
];
|
|
1791
|
+
const B = [sumY, sumXY, sumX2Y];
|
|
1792
|
+
function det3(m) {
|
|
1793
|
+
return m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
|
|
1794
|
+
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
|
1795
|
+
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
|
|
1796
|
+
}
|
|
1797
|
+
const detA = det3(A);
|
|
1798
|
+
if (Math.abs(detA) > 1e-12) {
|
|
1799
|
+
const a = det3([
|
|
1800
|
+
[B[0], A[0][1], A[0][2]],
|
|
1801
|
+
[B[1], A[1][1], A[1][2]],
|
|
1802
|
+
[B[2], A[2][1], A[2][2]],
|
|
1803
|
+
]) / detA;
|
|
1804
|
+
const b = det3([
|
|
1805
|
+
[A[0][0], B[0], A[0][2]],
|
|
1806
|
+
[A[1][0], B[1], A[1][2]],
|
|
1807
|
+
[A[2][0], B[2], A[2][2]],
|
|
1808
|
+
]) / detA;
|
|
1809
|
+
const c = det3([
|
|
1810
|
+
[A[0][0], A[0][1], B[0]],
|
|
1811
|
+
[A[1][0], A[1][1], B[1]],
|
|
1812
|
+
[A[2][0], A[2][1], B[2]],
|
|
1813
|
+
]) / detA;
|
|
1814
|
+
const yMean = responses.reduce((s, v) => s + v, 0) / n;
|
|
1815
|
+
let ssRes = 0, ssTot = 0;
|
|
1816
|
+
for (let i = 0; i < n; i++) {
|
|
1817
|
+
const pred = a + b * doses[i] + c * doses[i] ** 2;
|
|
1818
|
+
ssRes += (responses[i] - pred) ** 2;
|
|
1819
|
+
ssTot += (responses[i] - yMean) ** 2;
|
|
1820
|
+
}
|
|
1821
|
+
const r2 = ssTot > 0 ? 1 - ssRes / ssTot : 0;
|
|
1822
|
+
// Minimum/maximum of quadratic: dose = -b/(2c)
|
|
1823
|
+
const extremeDose = c !== 0 ? -b / (2 * c) : 0;
|
|
1824
|
+
const isHormetic = c > 0 && extremeDose > 0 && extremeDose < doses[n - 1];
|
|
1825
|
+
lines.push('## Hormesis (Quadratic) Model');
|
|
1826
|
+
lines.push(`**Response = ${fmt(a, 4)} + ${fmt(b, 4)} x Dose + ${fmt(c, 6)} x Dose^2**`);
|
|
1827
|
+
lines.push('');
|
|
1828
|
+
lines.push(`- **R-squared:** ${fmt(r2, 4)}`);
|
|
1829
|
+
if (isHormetic) {
|
|
1830
|
+
lines.push(`- **Hormetic zone detected:** minimum response at dose = ${fmt(extremeDose, 4)}`);
|
|
1831
|
+
lines.push(`- **Minimum response:** ${fmt(a + b * extremeDose + c * extremeDose ** 2, 4)}`);
|
|
1832
|
+
}
|
|
1833
|
+
else {
|
|
1834
|
+
lines.push(`- **No clear hormetic zone** within the dose range tested.`);
|
|
1835
|
+
}
|
|
1836
|
+
lines.push('');
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
// Data table
|
|
1840
|
+
lines.push('## Data Points');
|
|
1841
|
+
lines.push('| Dose | Response |');
|
|
1842
|
+
lines.push('|------|----------|');
|
|
1843
|
+
for (let i = 0; i < n; i++) {
|
|
1844
|
+
lines.push(`| ${doses[i]} | ${responses[i]} |`);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
else if (calculation === 'exposure') {
|
|
1848
|
+
// EPA Exposure Assessment: ADD = (C * IR * EF * ED) / (BW * AT)
|
|
1849
|
+
const C = Number(params.concentration_mg_L || params.concentration || 0);
|
|
1850
|
+
const IR = Number(params.intake_rate_L_day || params.intake_rate || 2);
|
|
1851
|
+
const EF = Number(params.exposure_frequency_days || params.exposure_frequency || 350);
|
|
1852
|
+
const ED = Number(params.exposure_duration_years || params.exposure_duration || 30);
|
|
1853
|
+
const BW = Number(params.body_weight_kg || params.body_weight || 70);
|
|
1854
|
+
const AT_days = Number(params.averaging_time_days || params.averaging_time || ED * 365);
|
|
1855
|
+
const ADD = (C * IR * EF * ED) / (BW * AT_days);
|
|
1856
|
+
// Lifetime average daily dose (for carcinogens, average over 70 years)
|
|
1857
|
+
const LADD = (C * IR * EF * ED) / (BW * 70 * 365);
|
|
1858
|
+
lines.push('# Exposure Assessment (EPA Methodology)');
|
|
1859
|
+
lines.push('');
|
|
1860
|
+
lines.push('**ADD = (C x IR x EF x ED) / (BW x AT)**');
|
|
1861
|
+
lines.push('');
|
|
1862
|
+
lines.push('## Input Parameters');
|
|
1863
|
+
lines.push(`| Parameter | Value | Description |`);
|
|
1864
|
+
lines.push(`|-----------|-------|-------------|`);
|
|
1865
|
+
lines.push(`| C | ${C} mg/L | Concentration in medium |`);
|
|
1866
|
+
lines.push(`| IR | ${IR} L/day | Intake rate |`);
|
|
1867
|
+
lines.push(`| EF | ${EF} days/year | Exposure frequency |`);
|
|
1868
|
+
lines.push(`| ED | ${ED} years | Exposure duration |`);
|
|
1869
|
+
lines.push(`| BW | ${BW} kg | Body weight |`);
|
|
1870
|
+
lines.push(`| AT | ${AT_days} days | Averaging time |`);
|
|
1871
|
+
lines.push('');
|
|
1872
|
+
lines.push('## Results');
|
|
1873
|
+
lines.push(`| Metric | Value |`);
|
|
1874
|
+
lines.push(`|--------|-------|`);
|
|
1875
|
+
lines.push(`| Average Daily Dose (ADD) | ${ADD.toExponential(4)} mg/kg/day |`);
|
|
1876
|
+
lines.push(`| Lifetime Average Daily Dose (LADD) | ${LADD.toExponential(4)} mg/kg/day |`);
|
|
1877
|
+
lines.push(`| Total lifetime intake | ${fmt(C * IR * EF * ED / 1000, 2)} g |`);
|
|
1878
|
+
}
|
|
1879
|
+
else if (calculation === 'hazard_quotient') {
|
|
1880
|
+
const dose = Number(params.dose_mg_kg_day || params.dose || 0);
|
|
1881
|
+
const rfd = Number(params.reference_dose || params.rfd || 0);
|
|
1882
|
+
if (rfd <= 0)
|
|
1883
|
+
return '**Error:** Reference dose must be positive.';
|
|
1884
|
+
const hq = dose / rfd;
|
|
1885
|
+
const mos = rfd / (dose || 1e-10);
|
|
1886
|
+
lines.push('# Hazard Quotient Assessment');
|
|
1887
|
+
lines.push('');
|
|
1888
|
+
lines.push('## Parameters');
|
|
1889
|
+
lines.push(`| Parameter | Value |`);
|
|
1890
|
+
lines.push(`|-----------|-------|`);
|
|
1891
|
+
lines.push(`| Exposure dose | ${dose.toExponential(4)} mg/kg/day |`);
|
|
1892
|
+
lines.push(`| Reference dose (RfD) | ${rfd.toExponential(4)} mg/kg/day |`);
|
|
1893
|
+
lines.push('');
|
|
1894
|
+
lines.push('## Results');
|
|
1895
|
+
lines.push(`| Metric | Value | Interpretation |`);
|
|
1896
|
+
lines.push(`|--------|-------|----------------|`);
|
|
1897
|
+
lines.push(`| Hazard Quotient (HQ) | ${fmt(hq, 4)} | ${hq > 1 ? '**EXCEEDS threshold** — potential health concern' : 'Below threshold — acceptable risk'} |`);
|
|
1898
|
+
lines.push(`| Margin of Safety (MOS) | ${fmt(mos, 1)} | ${mos > 100 ? 'Adequate' : mos > 10 ? 'Marginal' : '**Insufficient**'} |`);
|
|
1899
|
+
lines.push('');
|
|
1900
|
+
lines.push('## Risk Categories');
|
|
1901
|
+
lines.push('| HQ Range | Risk Level |');
|
|
1902
|
+
lines.push('|----------|-----------|');
|
|
1903
|
+
lines.push(`| < 0.1 | Negligible ${hq < 0.1 ? '<-- current' : ''} |`);
|
|
1904
|
+
lines.push(`| 0.1 - 1.0 | Acceptable ${hq >= 0.1 && hq <= 1 ? '<-- current' : ''} |`);
|
|
1905
|
+
lines.push(`| 1.0 - 10 | Concern ${hq > 1 && hq <= 10 ? '<-- current' : ''} |`);
|
|
1906
|
+
lines.push(`| > 10 | Significant concern ${hq > 10 ? '<-- current' : ''} |`);
|
|
1907
|
+
}
|
|
1908
|
+
else if (calculation === 'cancer_risk') {
|
|
1909
|
+
const dose = Number(params.dose_mg_kg_day || params.dose || 0);
|
|
1910
|
+
const sf = Number(params.slope_factor || params.cancer_slope_factor || 0);
|
|
1911
|
+
if (sf <= 0)
|
|
1912
|
+
return '**Error:** Cancer slope factor must be positive.';
|
|
1913
|
+
const risk = dose * sf;
|
|
1914
|
+
const oneInN = risk > 0 ? Math.round(1 / risk) : Infinity;
|
|
1915
|
+
lines.push('# Lifetime Cancer Risk Assessment');
|
|
1916
|
+
lines.push('');
|
|
1917
|
+
lines.push('**Risk = LADD x Cancer Slope Factor (CSF)**');
|
|
1918
|
+
lines.push('');
|
|
1919
|
+
lines.push('## Parameters');
|
|
1920
|
+
lines.push(`| Parameter | Value |`);
|
|
1921
|
+
lines.push(`|-----------|-------|`);
|
|
1922
|
+
lines.push(`| Lifetime Average Daily Dose | ${dose.toExponential(4)} mg/kg/day |`);
|
|
1923
|
+
lines.push(`| Cancer Slope Factor | ${sf.toExponential(4)} (mg/kg/day)^-1 |`);
|
|
1924
|
+
lines.push('');
|
|
1925
|
+
lines.push('## Results');
|
|
1926
|
+
lines.push(`| Metric | Value |`);
|
|
1927
|
+
lines.push(`|--------|-------|`);
|
|
1928
|
+
lines.push(`| Lifetime cancer risk | ${risk.toExponential(4)} |`);
|
|
1929
|
+
lines.push(`| Risk expressed as 1 in | 1 in ${oneInN.toLocaleString()} |`);
|
|
1930
|
+
lines.push('');
|
|
1931
|
+
let riskLevel;
|
|
1932
|
+
if (risk < 1e-6)
|
|
1933
|
+
riskLevel = 'De minimis (< 1 in 1,000,000) — generally considered acceptable';
|
|
1934
|
+
else if (risk < 1e-4)
|
|
1935
|
+
riskLevel = 'Acceptable range (EPA target: 10^-6 to 10^-4)';
|
|
1936
|
+
else if (risk < 1e-3)
|
|
1937
|
+
riskLevel = 'Elevated — may warrant risk management action';
|
|
1938
|
+
else
|
|
1939
|
+
riskLevel = 'HIGH — significant concern, action recommended';
|
|
1940
|
+
lines.push(`**Risk level:** ${riskLevel}`);
|
|
1941
|
+
lines.push('');
|
|
1942
|
+
lines.push('## EPA Risk Benchmarks');
|
|
1943
|
+
lines.push('| Risk Level | Value | Status |');
|
|
1944
|
+
lines.push('|-----------|-------|--------|');
|
|
1945
|
+
lines.push(`| 10^-6 (de minimis) | ${(1e-6).toExponential(1)} | ${risk < 1e-6 ? 'BELOW' : 'ABOVE'} |`);
|
|
1946
|
+
lines.push(`| 10^-4 (upper acceptable) | ${(1e-4).toExponential(1)} | ${risk < 1e-4 ? 'BELOW' : 'ABOVE'} |`);
|
|
1947
|
+
}
|
|
1948
|
+
else {
|
|
1949
|
+
return `**Error:** Unknown calculation "${calculation}". Choose dose_response, exposure, hazard_quotient, or cancer_risk.`;
|
|
1950
|
+
}
|
|
1951
|
+
return lines.join('\n');
|
|
1952
|
+
}
|
|
1953
|
+
});
|
|
1954
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1955
|
+
// 10. GLOBAL HEALTH DATA — WHO GHO API queries
|
|
1956
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1957
|
+
registerTool({
|
|
1958
|
+
name: 'global_health_data',
|
|
1959
|
+
description: 'Query WHO Global Health Observatory (GHO) data for country-level health indicators: life expectancy, infant mortality, health expenditure, immunization coverage, physician density. Uses the public GHO OData API.',
|
|
1960
|
+
parameters: {
|
|
1961
|
+
indicator: { type: 'string', description: 'Indicator: life_expectancy, infant_mortality, health_expenditure, immunization, or physician_density', required: true },
|
|
1962
|
+
country: { type: 'string', description: 'Country name or ISO 3166 code (e.g., "USA", "Japan", "Brazil")' },
|
|
1963
|
+
year: { type: 'number', description: 'Specific year to filter (optional — returns latest if omitted)' },
|
|
1964
|
+
},
|
|
1965
|
+
tier: 'free',
|
|
1966
|
+
async execute(args) {
|
|
1967
|
+
const indicatorKey = String(args.indicator).toLowerCase().trim();
|
|
1968
|
+
const indicatorInfo = WHO_INDICATORS[indicatorKey];
|
|
1969
|
+
if (!indicatorInfo) {
|
|
1970
|
+
const available = Object.keys(WHO_INDICATORS).join(', ');
|
|
1971
|
+
return `**Error:** Unknown indicator "${indicatorKey}". Available: ${available}`;
|
|
1972
|
+
}
|
|
1973
|
+
const country = args.country ? String(args.country) : null;
|
|
1974
|
+
const year = typeof args.year === 'number' ? args.year : null;
|
|
1975
|
+
// Build OData query URL
|
|
1976
|
+
let url = `https://ghoapi.azureedge.net/api/${indicatorInfo.code}`;
|
|
1977
|
+
const filters = [];
|
|
1978
|
+
if (country) {
|
|
1979
|
+
const code = resolveCountryCode(country);
|
|
1980
|
+
filters.push(`SpatialDim eq '${code}'`);
|
|
1981
|
+
}
|
|
1982
|
+
if (year) {
|
|
1983
|
+
filters.push(`TimeDim eq ${year}`);
|
|
1984
|
+
}
|
|
1985
|
+
if (filters.length > 0) {
|
|
1986
|
+
url += `?$filter=${filters.join(' and ')}`;
|
|
1987
|
+
}
|
|
1988
|
+
// Limit results
|
|
1989
|
+
url += (filters.length > 0 ? '&' : '?') + '$top=50&$orderby=TimeDim desc';
|
|
1990
|
+
try {
|
|
1991
|
+
const response = await labFetch(url, 15000);
|
|
1992
|
+
if (!response.ok) {
|
|
1993
|
+
return `**Error:** WHO GHO API returned ${response.status}. URL: \`${url}\``;
|
|
1994
|
+
}
|
|
1995
|
+
const json = await response.json();
|
|
1996
|
+
const records = json.value || [];
|
|
1997
|
+
if (records.length === 0) {
|
|
1998
|
+
return `**No data found** for indicator "${indicatorInfo.label}" with the given filters.\n\nTry a different country code or year, or omit filters to see all available data.`;
|
|
1999
|
+
}
|
|
2000
|
+
// Deduplicate and get most relevant records
|
|
2001
|
+
// Group by country + year, prefer "Both sexes" or "Total"
|
|
2002
|
+
const seen = new Map();
|
|
2003
|
+
for (const r of records) {
|
|
2004
|
+
const key = `${r.SpatialDim}-${r.TimeDim}`;
|
|
2005
|
+
if (!seen.has(key) || (r.Dim1 === 'BTSX' || r.Dim1 === 'TOTAL')) {
|
|
2006
|
+
seen.set(key, r);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
const unique = Array.from(seen.values())
|
|
2010
|
+
.filter(r => r.NumericValue != null)
|
|
2011
|
+
.sort((a, b) => b.TimeDim - a.TimeDim);
|
|
2012
|
+
const lines = [];
|
|
2013
|
+
lines.push(`# WHO Global Health Data: ${indicatorInfo.label}`);
|
|
2014
|
+
lines.push('');
|
|
2015
|
+
if (country)
|
|
2016
|
+
lines.push(`**Country filter:** ${country} (${resolveCountryCode(country)})`);
|
|
2017
|
+
if (year)
|
|
2018
|
+
lines.push(`**Year filter:** ${year}`);
|
|
2019
|
+
lines.push(`**Records found:** ${unique.length}`);
|
|
2020
|
+
lines.push('');
|
|
2021
|
+
lines.push('| Country | Year | Value | Unit |');
|
|
2022
|
+
lines.push('|---------|------|-------|------|');
|
|
2023
|
+
for (const r of unique.slice(0, 30)) {
|
|
2024
|
+
lines.push(`| ${r.SpatialDim} | ${r.TimeDim} | ${fmt(r.NumericValue, 2)} | ${indicatorInfo.unit} |`);
|
|
2025
|
+
}
|
|
2026
|
+
if (unique.length > 1 && country) {
|
|
2027
|
+
// Trend analysis
|
|
2028
|
+
const sorted = unique.sort((a, b) => a.TimeDim - b.TimeDim);
|
|
2029
|
+
const first = sorted[0];
|
|
2030
|
+
const last = sorted[sorted.length - 1];
|
|
2031
|
+
const change = last.NumericValue - first.NumericValue;
|
|
2032
|
+
const pctChange = change / first.NumericValue;
|
|
2033
|
+
const years = last.TimeDim - first.TimeDim;
|
|
2034
|
+
lines.push('');
|
|
2035
|
+
lines.push('## Trend');
|
|
2036
|
+
lines.push(`- **Period:** ${first.TimeDim} to ${last.TimeDim} (${years} years)`);
|
|
2037
|
+
lines.push(`- **Change:** ${fmt(change, 2)} ${indicatorInfo.unit} (${pct(pctChange)})`);
|
|
2038
|
+
lines.push(`- **Annual change:** ${fmt(change / Math.max(1, years), 3)} ${indicatorInfo.unit}/year`);
|
|
2039
|
+
}
|
|
2040
|
+
lines.push('');
|
|
2041
|
+
lines.push(`*Source: WHO Global Health Observatory (GHO). API endpoint: ${indicatorInfo.code}*`);
|
|
2042
|
+
return lines.join('\n');
|
|
2043
|
+
}
|
|
2044
|
+
catch (err) {
|
|
2045
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2046
|
+
if (msg.includes('timeout') || msg.includes('abort')) {
|
|
2047
|
+
return `**Error:** WHO GHO API timed out. The service may be temporarily unavailable. Try again or use a more specific query.`;
|
|
2048
|
+
}
|
|
2049
|
+
return `**Error querying WHO GHO API:** ${msg}`;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
});
|
|
2053
|
+
} // end registerLabHealthTools
|
|
2054
|
+
//# sourceMappingURL=lab-health.js.map
|