alopecia/js/twin-engine.js

173 lines
7.5 KiB
JavaScript

/* ============================================================
twin-engine.js — 모낭 디지털 트윈 ODE 엔진 (브라우저 포팅)
digital_twin/follicle_model.py 의 정확한 포팅. RK4 적분.
라이브 'what-if' 모드용. 정밀 시나리오는 twin_scenarios.json 사용.
============================================================ */
(function (global) {
'use strict';
const STATE_NAMES = ["Wnt", "BMP", "SHH", "DP", "HFSC", "APO", "Hair"];
// Python Params 와 1:1 동일
const P = {
kWp: 0.45, kWd: 0.32, kBp: 0.30, kBd: 0.38, kSp: 0.50, kSd: 0.40,
kDp: 0.50, kDd: 0.30, kHp: 0.58, kHd: 0.24, kAd: 0.50,
kHairP: 0.80, kHairD: 0.16, Wbasal: 1.0, Bbasal: 0.4, Dbasal: 1.0,
kDKK: 0.65, Kd: 1.0, Kb: 1.2, kBMPand: 0.30, Kw: 0.7, Ksw: 0.5, Kb2: 0.9,
kIGFand: 0.55, kMiniAND: 0.40, hpow: 0.6, Hcap: 3.5,
kDinf: 0.45, kDapo: 0.5, Kh: 0.35, Ks: 0.35, Kb3: 1.5,
kHinf: 0.55, kHapo: 0.6, kApAND: 0.30, kApINF: 0.85, kSurv: 1.6,
Ksh: 0.4, kHairApo: 0.55,
};
const DISEASE_PRESETS = {
"Healthy": { AND: 0.10, INF: 0.05, chemo_amp: 0.0 },
"Androgenetic Alopecia": { AND: 0.62, INF: 0.08, chemo_amp: 0.0 },
"Alopecia Areata": { AND: 0.12, INF: 0.82, chemo_amp: 0.0 },
"Chemotherapy-induced Alopecia": { AND: 0.10, INF: 0.06, chemo_amp: 1.7 },
};
const CHEMO_TIMES = [10, 31, 52, 73];
// 개입 → drive 수정자
const INTERVENTIONS = {
none: d => d,
finasteride: d => (d.AND *= 0.40, d),
dutasteride: d => (d.AND *= 0.25, d),
AR_antagonist: d => (d.AND *= 0.45, d),
minoxidil: d => (d.uDP += 0.80, d.uWnt += 0.15, d),
anti_DKK1: d => (d.uWnt += 0.45, d),
wnt_agonist: d => (d.uWnt += 0.50, d),
JAK_inhibitor: d => (d.INF *= 0.18, d),
corticosteroid: d => (d.INF *= 0.55, d),
CDK46_inhibitor: d => (d.chemo_protect = 0.70, d),
scalp_cooling: d => (d.chemo_protect = Math.max(d.chemo_protect, 0.45), d),
PTH_CBD: d => (d.uDP += 0.35, d),
exosome_MSC: d => (d.uDP += 0.35, d.uWnt += 0.25, d),
};
function buildDrive(disease, interventions, overrides) {
const pre = DISEASE_PRESETS[disease] || DISEASE_PRESETS["Healthy"];
let d = { AND: pre.AND, INF: pre.INF, chemo_amp: pre.chemo_amp || 0,
uWnt: 0, uDP: 0, uNog: 0, chemo_protect: 0 };
(interventions || []).forEach(iv => { if (INTERVENTIONS[iv]) d = INTERVENTIONS[iv](d); });
if (overrides) Object.assign(d, overrides); // 라이브 슬라이더
d.AND = Math.max(0, d.AND); d.INF = Math.max(0, d.INF);
return d;
}
function chemo(d, t) {
if (!d.chemo_amp) return 0;
let s = 0;
for (const tc of CHEMO_TIMES) s += Math.exp(-((t - tc) ** 2) / (2 * 1.5 * 1.5));
return d.chemo_amp * s * (1 - (d.chemo_protect || 0));
}
function rhs(t, y, d) {
let [Wnt, BMP, SHH, DP, HFSC, APO, Hair] = y.map(v => Math.max(0, v));
const AND = d.AND, INF = d.INF, ch = chemo(d, t);
const DKK = P.kDKK * AND;
const dWnt = P.kWp * (P.Wbasal + d.uWnt) / (1 + DKK / P.Kd) / (1 + BMP / P.Kb) - P.kWd * Wnt;
const dBMP = P.kBp * (P.Bbasal + P.kBMPand * AND) / (1 + d.uNog) / (1 + Wnt / P.Kw) - P.kBd * BMP;
const dSHH = P.kSp * (Wnt / (P.Ksw + Wnt)) / (1 + BMP / P.Kb2) - P.kSd * SHH;
const IGF = 1 / (1 + P.kIGFand * AND);
const dDP = P.kDp * (P.Dbasal + d.uDP) * IGF - P.kDd * DP - DP * (P.kDinf * INF + P.kDapo * APO);
const dHFSC = P.kHp * (Wnt / (P.Kh + Wnt)) * (SHH / (P.Ks + SHH)) / (1 + BMP / P.Kb3)
- P.kHd * HFSC - HFSC * (P.kHinf * INF + P.kHapo * APO);
const dAPO = (P.kApAND * AND + P.kApINF * INF + ch) / (1 + P.kSurv * DP) - P.kAd * APO;
// 분수 지수(hpow=0.6)의 음수 밑 → Math.pow(neg,0.6)=NaN 방지: 밑을 명시적으로 0 하한.
// (위 y.map(Math.max(0,·)) 클램프와 중복이나, 적분기 단계 값이 음수를 흘려도 NaN 전파 차단)
const HFSCp = Math.pow(Math.max(0, HFSC), P.hpow);
const DPp = Math.pow(Math.max(0, DP), P.hpow);
const dHair = P.kHairP * HFSCp * DPp * (SHH / (P.Ksh + SHH))
/ (1 + P.kMiniAND * AND) * Math.max(0, 1 - Hair / P.Hcap)
- P.kHairD * Hair - P.kHairApo * APO * Hair;
return [dWnt, dBMP, dSHH, dDP, dHFSC, dAPO, dHair];
}
function rk4(y0, d, days, dt) {
dt = dt || 0.5;
const steps = Math.round(days / dt);
const out = { t: [], y: y0.map(() => []) };
let y = y0.slice();
for (let i = 0; i <= steps; i++) {
const t = i * dt;
if (Math.abs(t - Math.round(t)) < 1e-9) { // 일 단위 샘플만 저장
out.t.push(Math.round(t));
y.forEach((v, j) => out.y[j].push(v));
}
const k1 = rhs(t, y, d);
const k2 = rhs(t + dt / 2, y.map((v, j) => v + dt / 2 * k1[j]), d);
const k3 = rhs(t + dt / 2, y.map((v, j) => v + dt / 2 * k2[j]), d);
const k4 = rhs(t + dt, y.map((v, j) => v + dt * k3[j]), d);
y = y.map((v, j) => Math.max(0, v + dt / 6 * (k1[j] + 2 * k2[j] + 2 * k3[j] + k4[j])));
}
return out;
}
let _healthy = null;
function healthyState() {
if (!_healthy) {
const d = buildDrive("Healthy", []);
const sol = rk4([1.0, 0.45, 0.7, 1.0, 0.8, 0.1, 1.0], d, 400);
_healthy = sol.y.map(arr => arr[arr.length - 1]);
}
return _healthy;
}
function proteinReadouts(states, d) {
const n = states.Wnt.length, ones = new Array(n);
const AND = d.AND, INF = d.INF;
return {
"AR/DHT (AR)": ones.fill(AND).slice(),
"DKK1": new Array(n).fill(P.kDKK * AND),
"β-catenin (CTNNB1)": states.Wnt.slice(),
"BMP4": states.BMP.slice(),
"SHH": states.SHH.slice(),
"IGF1": new Array(n).fill(1 / (1 + P.kIGFand * AND)),
"VEGFA (DP)": states.DP.slice(),
"JAK-STAT (STAT1)": new Array(n).fill(INF),
"p53/apoptosis (TP53)": states.APO.slice(),
};
}
// 질환의 '무처치 평형상태' = 환자가 이미 탈모를 가진 출발점(치료 전).
// 건강 상태에서 질환 구동을 충분히 오래 적분해 정착시킨 종단 상태.
const _diseq = {};
function diseaseEquilibrium(disease) {
if (!_diseq[disease]) {
const d = buildDrive(disease, []);
const sol = rk4(healthyState(), d, 600);
_diseq[disease] = sol.y.map(arr => arr[arr.length - 1]);
}
return _diseq[disease].slice();
}
// 메인 API: 시뮬레이션 실행. opts.y0 = 커스텀 초기상태(미지정 시 건강 평형).
function run(disease, interventions, opts) {
opts = opts || {};
const d = buildDrive(disease, interventions, opts.overrides);
const y0 = opts.y0 || healthyState();
const sol = rk4(y0, d, opts.days || 240);
const states = {};
STATE_NAMES.forEach((nm, i) => states[nm] = sol.y[i]);
const hss = healthyState()[6];
const rel = states.Hair.map(h => Math.min(100, 100 * h / hss));
states.HairDensity = rel;
const proteins = proteinReadouts(states, d);
const metrics = {
final_hair_density_pct: +rel[rel.length - 1].toFixed(1),
min_hair_density_pct: +Math.min(...rel).toFixed(1),
anagen_fraction: +(rel.filter(v => v > 70).length / rel.length).toFixed(3),
AND_load: +d.AND.toFixed(3), INF_load: +d.INF.toFixed(3),
};
let tracked = [];
(interventions || []).forEach(iv => {
// genes 는 scenarios JSON 에서 보강; 엔진은 비움
});
return { disease, interventions, drive: d, t: sol.t, states, proteins, metrics };
}
global.TwinEngine = { run, diseaseEquilibrium, STATE_NAMES, DISEASE_PRESETS, INTERVENTIONS, buildDrive };
if (typeof module !== 'undefined' && module.exports) module.exports = global.TwinEngine;
})(typeof window !== 'undefined' ? window : globalThis);