diff --git a/Algorithm Explanation.md b/Algorithm Explanation.md index 4aa15dfa..4f8f4a30 100644 --- a/Algorithm Explanation.md +++ b/Algorithm Explanation.md @@ -605,14 +605,21 @@ $$ - 求解:每个假设用 Gauss-Newton(IRLS 权重)求 MAP,步长减半直到后验不变差;从原点和“残差中位数按先验比例分给 s 与 d”的起点出发(允许移动清除时与 `mipd` 一样从网格上每隔 5 个点起步),保留最好的解。漂移块是三对角的(每个化验只连到自己的锚点),所以用加边三对角矩阵的 Schur 补求解,每步 O(锚点数)。Laplace 近似(Tierney & Kadane 1986)给出各假设的边际似然与后验协方差;曲线取各假设对数校正量后验均值按后验概率的加权平均,方差取混合方差。 - 额外输出:`factorSdFn(t)` 是对数校正量的后验标准差,化验附近最小,化验变旧后增大;`latestInterval` 是最近一次化验时刻校准水平的 90% 区间。允许移动清除后,幅度与半衰期会互相抵换,只看幅度的区间反而变宽,所以报告的是化验真正约束住的水平。`discounted` 是后验平均 Student-t 权重低于 ½ 的化验数;`drift` 是无漂移、约两周、约三个月、约一年四类记忆的后验概率。 - 预测页:选用 `bayes` 时,每次蒙特卡洛模拟仍只抽一个标准正态数(与其他方法共用同一随机序列,其他方法的范围不变),但按 `factorSdFn` 逐小时缩放,取代来自拟合误差的常数。只有一个化验时拟合误差无定义,其他方法不加校准不确定性,`bayes` 仍按后验给出范围。 + - `mcmc`(个人药代,本项目自有):不再把一个人当成“原模型乘一个校正”,而是给每个人一套自己的药代参数,E2(t) = Σ_r exp(s + u_r) · C_r(t; k, a)(`src/domain/calibration/mcmc.ts`)。 + - C_r 是只算给药途径 r(注射、口服、舌下、凝胶、贴片)时的模型 E2。模型对剂量是线性的,所以按途径拆开再相加与原模拟完全一致(`routeCurves.ts`,测试核对到 1e-9),个人系数乘在各自那一份上是精确的,不需要额外模拟。s 是各途径共用的个人水平,u_r 是该途径相对这个水平的个人偏差(#22 的舌下吸收比例就是其中之一),k 是对数个人清除(同 `mipd` 的清除倍数),a 是对数个人吸收速度:所有一阶吸收速率(注射储库释放、口服肠道、舌下黏膜、凝胶皮肤)同乘 exp(a),零级释放的贴片不受影响。吸收速度改变的是峰谷的位置和高低,不是平均水平。 + - 先验:s 用 `bayes` 的 Student-t(ν = 4,尺度 0.50);u_r ~ N(0, 0.35²),分层,只有一种途径时不采样 u(此时模型即 `mipd` 加吸收速度),两种以上时各途径的化验通过共用的 s 互相借力;k ~ N(0, 0.35²),a ~ N(0, 0.40²),二者都限制在网格内(kMul ∈ [0.5, 2],吸收倍数 ∈ [0.4, 2.5])。0.35 与 0.40 是选定值。化验至少 3 簇才允许移动清除(与其他方法相同),至少 4 簇才允许移动吸收速度(`MCMC_MIN_LABS_FOR_ABSORPTION`),因为吸收只体现在化验落在给药间隔的哪个位置。每个化验的对数误差与 `bayes` 相同(`labLogSd`),似然少于 3 个化验用高斯,达到 3 个起用 Student-t(ν = 4)。 + - 网格:在 13 × 9 个 (k, a) 网格点上算出各途径在化验时刻的浓度,采样时在浓度上做双线性插值。 + - 采样:途径之和在对数尺度上不是线性的,后验没有闭式解,所以用 4 条链的自适应 Metropolis(Haario, Saksman & Tamminen 2001):预热 2500 步内用链自身的协方差(× 2.38²/维数)更新提议分布,并把步长朝 0.234 的接受率调(Roberts, Gelman & Gilks 1997);预热后提议分布固定,再走 2500 步,每 5 步保留一个,共 2000 个样本。用 split R-hat(Gelman et al. 2013)检查各链是否一致,结果放在 `rHat`。随机数种子由化验数据决定,同样的化验永远给出同样的曲线。 + - 曲线:取 400 个样本,它们落到的网格点(按质量最多 16 个)上用整段时间轴算各途径曲线,后验均值是这些曲线的线性组合,所以画出的正好是样本打分时用的那个模型的平均;`factorSdFn` 用其中 150 个样本的对数校准量算标准差。`latestInterval` 是最近一次化验时刻校准水平的 5% 与 95% 分位数,`discounted` 同 `bayes`。额外输出 `absMul`(吸收速度倍数,界面显示为吸收速度变化)与 `routeScales`(两种以上途径时各途径的个人水平倍数)。 + - 局限:不建模随时间的漂移(需要漂移请用 `bayes`);每日口服经过雌酮库平滑,吸收速度加倍只让化验变化约 10%,在化验误差之内,所以口服的吸收速度大多仍由先验决定,注射的储库释放速度则能从化验中较清楚地读出。尚未用真实的定时化验数据验证,因此不是默认方法。 - **个人清除**(仅 `mipd` 与 `ekf`):化验至少分成 3 簇才允许移动清除(`clearanceIdentifiable`,`MIN_LABS_FOR_CLEARANCE = 3`),否则只拟合幅度。分簇按时间顺序进行:比当前簇第一个化验晚 6 h 以上(`LAB_CLUSTER_H`)的化验开启新的一簇。同一时刻的几次抽血只算一簇(它们说明不了水平下降得多快);每 5 h 抽一次的一串化验按簇首计为多簇,例如 0、5、10、15、20 h 的五个化验是三簇(簇首为 0、10、20 h)。是否允许移动清除按化验的前缀判断:拟合前 i 个化验时只看这 i 个。清除网格覆盖 kMul ∈ [0.5, 2](`KMUL_MIN` / `KMUL_MAX`),在对数尺度上取 21 个网格点,每个点把 `e2_kClear` 与 `e2_kClearInj` 同乘该倍数重跑一次模拟(`runSimulationWithParams`;吸收速率与口服雌酮库 `kE1Pool` 不变),拟合时在网格之间线性插值。网格外的预测值取端点值、斜率为 0,拟合出的对数清除截在网格范围内(`clampLogK`),因此报告的 kMul 始终在 [0.5, 2] 内,半衰期变化在 −50% 到 +100% 之间。校准后的曲线在相邻两个网格点的对数浓度之间按与拟合相同的方式插值(`fieldLogConcAt`),所以画出的就是拟合所用的那条曲线(此前取最近的网格点,与拟合曲线最多差约 5%,报告的 kMul 也可能超出所画曲线的范围)。报告的半衰期变化为 (1/kMul − 1) × 100%,界面显示的半衰期变化取自此值。 - **历史模式**(`CalibrationHistoryMode`,默认 `retrospective`): - - `retrospective`:用全部化验值重估整段历史。`mipd` 与 `ekf` 对整段曲线使用最终拟合(幅度倍数乘以“该清除下的曲线 / 原曲线”,该清除下的曲线由网格插值得到);`ou_kalman` 使用平滑结果,两次化验之间按 OU 桥插值;`bayes` 使用对全部化验的模型平均。新化验可能改写过去的估计。 - - `forward`:因果滤波,每个时刻只用到当时为止的化验值。第一个化验之前倍数为 1;第 i 个化验之后使用前 i 个化验学到的参数(`ekf`、`mipd` 与 `bayes` 都用前 i 个化验重新拟合,是否允许移动清除也只看这 i 个化验,`bayes` 的漂移从第 i 个化验向后外推;`ou_kalman` 只用前向滤波)。在之后的日期再加化验时,四种方法的过去估计都不会改变。 + - `retrospective`:用全部化验值重估整段历史。`mipd` 与 `ekf` 对整段曲线使用最终拟合(幅度倍数乘以“该清除下的曲线 / 原曲线”,该清除下的曲线由网格插值得到);`ou_kalman` 使用平滑结果,两次化验之间按 OU 桥插值;`bayes` 使用对全部化验的模型平均,`mcmc` 使用对全部化验的后验样本。新化验可能改写过去的估计。 + - `forward`:因果滤波,每个时刻只用到当时为止的化验值。第一个化验之前倍数为 1;第 i 个化验之后使用前 i 个化验学到的参数(`ekf`、`mipd`、`bayes` 与 `mcmc` 都用前 i 个化验重新拟合,是否允许移动清除也只看这 i 个化验,`bayes` 的漂移从第 i 个化验向后外推;`ou_kalman` 只用前向滤波)。在之后的日期再加化验时,五种方法的过去估计都不会改变。 - **拟合误差**:至少 2 个化验值时,取实际绘制的曲线(模型 × `factorFn`)在各化验时刻的对数残差 RMSE,折算为 ±% 报告(`curveFitErrPct`),所有方法与历史模式都如此,因此与图上看到的一致。预测页的不确定范围也取自它(`bayes` 除外,见上)。 - **旧设置迁移**(`normalizeCalibrationMethod`):存储的 `average` 映射为 `mipd`,`adaptive` 映射为 `ekf`,无法识别的值也按 `mipd` 处理。 - **年龄**:填写了出生年份时,原始模型已按年龄调整(§17),清除网格的每个点也用同一出生年份重跑,所以拟合出的个人清除 kMul 与界面显示的半衰期变化都是相对年龄调整后的模型而言;年龄相当于把个人清除先验的中心移到该年龄的平均值。 -- `ekf`、`ou_kalman` 与 `mipd` 参照 hrt.transmtf.com 提供的校准模型,在本项目中针对可从化验识别的两个参数(个人幅度与个人清除)重新实现(`logic.ts` 注释);`bayes` 是本项目自有的方法。 +- `ekf`、`ou_kalman` 与 `mipd` 参照 hrt.transmtf.com 提供的校准模型,在本项目中针对可从化验识别的两个参数(个人幅度与个人清除)重新实现(`logic.ts` 注释);`bayes` 与 `mcmc` 是本项目自有的方法。 --- diff --git a/README.md b/README.md index 55c6c0a9..cb81e602 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ See [DONATE.md](./DONATE.md) for how donations are split (40% project, 30% @fant ## Lab results - Log blood test results and see them on the curve next to the estimate -- Calibrate the estradiol model to you with EKF, OU-Kalman, MIPD or Full Bayes, or edit its parameters by hand +- Calibrate the estradiol model to you with EKF, OU-Kalman, MIPD, Full Bayes or Personal PK (a level per route, clearance and absorption speed, sampled with MCMC), or edit its parameters by hand ## Supplies diff --git a/README.zh-CN.md b/README.zh-CN.md index f3cfc1c8..ba3a8f82 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -98,7 +98,7 @@ ## 检查结果 - 记录抽血检查结果,并在曲线上与估算值对照 -- 用 EKF、OU-Kalman、MIPD 或全贝叶斯按你的情况校准雌二醇模型,也可以手动编辑参数 +- 用 EKF、OU-Kalman、MIPD、全贝叶斯或个人药代(每种给药途径各自的水平、清除和吸收速度,用 MCMC 采样)按你的情况校准雌二醇模型,也可以手动编辑参数 ## 药量库存 diff --git a/docs/backup-schema-audit/BACKUP_TEST_VECTORS.md b/docs/backup-schema-audit/BACKUP_TEST_VECTORS.md index 8f65e9ee..e222010c 100644 --- a/docs/backup-schema-audit/BACKUP_TEST_VECTORS.md +++ b/docs/backup-schema-audit/BACKUP_TEST_VECTORS.md @@ -64,6 +64,7 @@ it has no keys or signature to check. | BTV-16 | Case-variant JSON keys | `"Mode"` or `"Format"` instead of `"mode"`/`"format"`; `"Events"` instead of `"events"`; both `"Mode":"transmasc"` and `"mode":"transfem"` in one object | Object keys are case-sensitive. `"Mode"` leaves `mode` missing → envelope error; `"Format"` leaves `format` missing; `"Events"` is not a known category → treated as missing and reported, never merged. | Go `encoding/json` matches envelope struct fields case-insensitively. If a case variant and the lowercase key both occur, both decode to the same field and the later JSON member wins (last-wins). Mode names inside the `modes` map and category keys (`events`, `labResults`, `doseTemplates`, `quickDoses`, `supplies`, `plan`) remain exact lowercase. | **TOOLS AUTHORITATIVE** | Neither side matches category keys case-insensitively; `"Events"` is ignored by both. Envelope fields can differ: Go may accept a spelling the website rejects, and case variants resolve last-wins in Go but lowercase-only in JS. | | BTV-17 | Tools encrypted / inspect rules | Literal, compressed-plaintext, or signed-only OpenPGP payload; forged PKESK recipient advertisement; tampered ciphertext; trailing packet after the encrypted data packet | The website never encrypts/decrypts complete backups and holds no OpenPGP keys or passphrases; it only reads the plaintext bundle. | `decrypt` and `inspect` validate the outer sequence: one or more PKESK packets, exactly one integrity-protected encrypted data packet, then EOF. Only legal marker packets may precede the encrypted packet; missing recipient/payload, a second encrypted packet, or any trailing packet is rejected. `decrypt` verifies the MDC and that the supplied private key actually decrypts. `inspect` only reads the outer container and unauthenticated PKESK advertisements, always reports `recipientVerified:false`, and does not decrypt or check the MDC, so it cannot detect ciphertext tampering. | **TOOLS AUTHORITATIVE** | A `.hrtb` that passes `inspect` is not recipient proof or integrity proof; only a successful `decrypt` demonstrates those. A decrypted bundle still carries website-defined record semantics. | | BTV-18 | Calibration method `bayes` | `"calibration":{"method":"bayes","historyMode":"retrospective"}` | Accepts and keeps `bayes` (Full Bayes calibration, added 2026-11-04). Older website versions report `calibration.method is invalid`. | Rejects anything outside `off`, `ekf`, `ou_kalman`, `mipd`. | **WEBSITE AUTHORITATIVE** | A bundle exported with Full Bayes selected cannot be encrypted by Tools until Tools accepts `bayes`; switching method first works. | +| BTV-19 | Calibration method `mcmc` | `"calibration":{"method":"mcmc","historyMode":"retrospective"}` | Accepts and keeps `mcmc` (Personal PK calibration, added 2026-11-11). Older website versions report `calibration.method is invalid`. | Rejects anything outside `off`, `ekf`, `ou_kalman`, `mipd`. | **WEBSITE AUTHORITATIVE** | A bundle exported with Personal PK selected cannot be encrypted by Tools until Tools accepts `mcmc`; switching method first works. | ## Source account diff --git a/src/App.tsx b/src/App.tsx index d9827c41..58d38053 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -73,6 +73,7 @@ import Disclaimer from './pages/Disclaimer'; import OpSec from './pages/OpSec'; import PKDemos from './pages/PKDemos'; import ModelGuide from './pages/ModelGuide'; +import CalibrationGuide from './pages/CalibrationGuide'; import MilkTeaEasterEgg from './pages/MilkTeaEasterEgg'; import CatStates from './pages/CatStates'; import Simulator from './pages/Simulator'; @@ -1057,10 +1058,15 @@ const AppContent = () => { historyMode={calibrationHistoryMode} setHistoryMode={setCalibrationHistoryMode} calibration={calibration} + onOpenGuide={() => handleViewChange('lab-calibration-guide')} onBack={() => handleViewChange('lab')} /> )} + {currentView === 'lab-calibration-guide' && ( + handleViewChange('lab-calibration')} /> + )} + {currentView === 'settings' && ( = ({ x, y, r = 3.6 }) => ( + +); + +const dot = (x: number, y: number, k: number) => ; + +/** + * One small drawing per calibration method, in the doodles' hand (Doodle.tsx): + * one line colour, a flat wash, paths a little off true. Each shows the one + * idea the method rests on, not its numbers. + */ +const DRAWINGS: Record = { + // The model alone: one curve, nothing moving it. + off: , + // One correction: the dashed model lifted to meet the lab. + mipd: ( + <> + + + + + + + ), + // One lab at a time: the estimate steps each time a result comes in. + ekf: ( + <> + + + + + + + + + + + ), + // A level that wanders and is pulled back to the model. + ou_kalman: ( + <> + + + + + ), + // Several stories at once; the bold line is their weighted average. + bayes: ( + <> + + + + + + + + + ), + // Random draws scattered round the curve they add up to. + mcmc: ( + <> + + + {[ + [12.8, 34.8], + [21.6, 43.6], + [24.2, 16.2], + [27.9, 19.7], + [31.6, 23.2], + ].map(([x, y], k) => dot(x, y, k))} + + {[ + [48, 46], + [52, 38.6], + [55.4, 31], + [60, 25.6], + [66, 18.8], + [72.4, 26.4], + [78, 23.6], + [84, 31.2], + [90.4, 34], + [96, 40.4], + [102, 41], + [108.4, 47.4], + [57, 36.4], + [75, 18.6], + [87, 37.6], + [99, 45.2], + ].map(([x, y], k) => dot(x, y, k + 10))} + + ), +}; + +const CalibrationDoodle: React.FC<{ method: CalibrationMethod; className?: string }> = ({ method, className = '' }) => ( + +); + +export default CalibrationDoodle; diff --git a/src/components/ModelChart.tsx b/src/components/ModelChart.tsx index 28d541aa..4c6eb564 100644 --- a/src/components/ModelChart.tsx +++ b/src/components/ModelChart.tsx @@ -6,6 +6,12 @@ export interface ModelSeries { values: number[]; /** The uncalibrated model, drawn faint and dashed under the real line. */ ghost?: boolean; + /** + * An edge of a likely range: not stroked. With `fillTo` (another series' + * index), the space between the two is washed faintly in the line colour. + */ + edge?: boolean; + fillTo?: number; } interface ModelChartProps { @@ -225,19 +231,44 @@ const ModelChart: React.FC = ({ - {series.map((s, i) => ( - - ))} + {series.map((s, i) => { + if (s.fillTo === undefined) return null; + const top = shown.values[i] ?? s.values; + const bottom = shown.values[s.fillTo] ?? series[s.fillTo]?.values ?? []; + if (!bottom.length) return null; + // The lower edge, walked back from right to left to close the shape. + const pts = [...bottom] + .map((v, j) => { + const x = X((j / Math.max(1, bottom.length - 1)) * xSpan); + return `${x.toFixed(1)} ${Y(Math.min(v, yTop)).toFixed(1)}`; + }) + .reverse(); + return ( + + ); + })} + + {series.map((s, i) => + s.edge ? null : ( + + ), + )} {doses .filter(d => d <= xSpan + 1e-9) diff --git a/src/components/icons/glyphs.ts b/src/components/icons/glyphs.ts index 7bf57449..aad9bcad 100644 --- a/src/components/icons/glyphs.ts +++ b/src/components/icons/glyphs.ts @@ -329,6 +329,17 @@ export const GLYPHS = { 'M3.8 16.9c2.4-.05 4.7 0 7-.05', ], + // A personal model read from many random draws: a pair of dice. + dice: [ + 'M3.6 10.6c2.8-.2 5.6-.2 8.5 0 .2 2.9.2 5.8 0 8.7-2.8.2-5.7.2-8.5 0-.2-2.9-.2-5.8 0-8.7z', + 'M12.6 3.8c2.6-.2 5.2-.2 7.8 0 .2 2.7.2 5.3 0 8-2.6.2-5.2.2-7.8 0-.2-2.7-.2-5.3 0-8z', + { d: 'M6.1 13.1h.05', dot: true }, + { d: 'M9.7 16.8h.05', dot: true }, + { d: 'M14.9 6.1h.05', dot: true }, + { d: 'M16.5 7.8h.05', dot: true }, + { d: 'M18.1 9.5h.05', dot: true }, + ], + // ── calibration history modes ─────────────────────────────────────── // Retrospective re-reads the whole past with every lab: two marks pointing // back. Forward only moves on: the same pair, pointing ahead. The pair diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index 47459f08..c22bae19 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -90,5 +90,6 @@ export const Flower = glyph('flower', 'Flower'); export const Gauge = glyph('gauge', 'Gauge'); export const Radar = glyph('radar', 'Radar'); export const Wind = glyph('wind', 'Wind'); +export const Dice = glyph('dice', 'Dice'); export const Rewind = glyph('rewind', 'Rewind'); export const FastForward = glyph('fast-forward', 'FastForward'); diff --git a/src/domain/advisories.ts b/src/domain/advisories.ts index f41e2a40..6f147099 100644 --- a/src/domain/advisories.ts +++ b/src/domain/advisories.ts @@ -440,4 +440,9 @@ export function getHormoneLevelAdvisory(results: LabResult[]): HormoneLevelAdvis * level and the curve's slope at the draw time, and a Laplace * posterior that gives the forecast a real uncertainty band. * See `bayesCalibrate`. + * - 'mcmc' : Personal PK. Also original to this app. A level per route + * (hierarchical around one personal level), a personal + * clearance and a personal absorption rate, sampled with + * adaptive Metropolis MCMC; the curve is the posterior mean. + * No drift. See `mcmcCalibrate`. */ diff --git a/src/domain/calibration/bayes.ts b/src/domain/calibration/bayes.ts index 4410d3a5..6bf6ccb5 100644 --- a/src/domain/calibration/bayes.ts +++ b/src/domain/calibration/bayes.ts @@ -1,5 +1,5 @@ import type { BayesDriftFamily, CalibrationHistoryMode, CalibrationPoint, CalibrationResult } from './types'; -import type { LabAssay } from '../types'; +import type { LabAssay, SimulationResult } from '../types'; import { interpolateConcentration_E2 } from '../simulation/interpolate'; import type { ClearanceField } from './estimators'; import { @@ -77,6 +77,23 @@ export const BAYES_SLOPE_STEP_H = 0.25; export const BAYES_DISCOUNTED_WEIGHT = 0.5; export const Z90 = 1.6448536269514722; +/** + * One lab's log-scale SD: the plateau floor, the assay's additive floor relative + * to the reading, and the draw-time error times the baseline curve's local + * log-slope, capped at BAYES_MAX_LAB_SD. + */ +export function labLogSd(baselineSim: SimulationResult, p: CalibrationPoint): number { + const floor = BAYES_ASSAY_FLOOR_PGML[p.assay ?? 'unknown']; + const before = interpolateConcentration_E2(baselineSim, p.timeH - BAYES_SLOPE_STEP_H); + const after = interpolateConcentration_E2(baselineSim, p.timeH + BAYES_SLOPE_STEP_H); + const slope = + before !== null && after !== null && before > 0 && after > 0 + ? (Math.log(after) - Math.log(before)) / (2 * BAYES_SLOPE_STEP_H) + : 0; + const variance = CAL_MEAS_SD ** 2 + (floor / p.obs) ** 2 + (BAYES_DRAW_TIME_SD_H * slope) ** 2; + return Math.min(BAYES_MAX_LAB_SD, Math.sqrt(variance)); +} + export interface BayesHypothesis { share: number; tauH: number; @@ -583,17 +600,7 @@ export function bayesCalibrate( ): Omit { const n = points.length; const labTimes = points.map(p => p.timeH); - const sigma = points.map(p => { - const floor = BAYES_ASSAY_FLOOR_PGML[p.assay ?? 'unknown']; - const before = interpolateConcentration_E2(field.baselineSim, p.timeH - BAYES_SLOPE_STEP_H); - const after = interpolateConcentration_E2(field.baselineSim, p.timeH + BAYES_SLOPE_STEP_H); - const slope = - before !== null && after !== null && before > 0 && after > 0 - ? (Math.log(after) - Math.log(before)) / (2 * BAYES_SLOPE_STEP_H) - : 0; - const variance = CAL_MEAS_SD ** 2 + (floor / p.obs) ** 2 + (BAYES_DRAW_TIME_SD_H * slope) ** 2; - return Math.min(BAYES_MAX_LAB_SD, Math.sqrt(variance)); - }); + const sigma = points.map(p => labLogSd(field.baselineSim, p)); const data: BayesData = { field, logObs: points.map(p => Math.log(p.obs)), sigma, labTimes }; const full = bayesMixture(data, n); diff --git a/src/domain/calibration/index.ts b/src/domain/calibration/index.ts index 9da9b95f..02a2f9c3 100644 --- a/src/domain/calibration/index.ts +++ b/src/domain/calibration/index.ts @@ -13,6 +13,7 @@ import { piecewiseFactor, } from './estimators'; import { bayesCalibrate } from './bayes'; +import { mcmcCalibrate } from './mcmc'; export type { BayesDriftFamily, @@ -56,6 +57,13 @@ export function computeCalibration( return { method, ...out, fitErrPct: curveFitErrPct(points, out.factorFn), n, points }; } + // Personal PK samples its own (route, clearance, absorption) model and + // builds its own grid; it does not use the clearance field. + if (method === 'mcmc') { + const out = mcmcCalibrate(baselineSim, events, bodyWeightKG, birthYear, points, historyMode); + return { method, ...out, n, fitErrPct: curveFitErrPct(points, out.factorFn), points }; + } + // EKF, MIPD and Full Bayes identify amplitude + clearance; all share the // clearance field. Whether clearance may move is decided per prefix of the // labs, so in forward mode a later lab never changes the segments before it. diff --git a/src/domain/calibration/mcmc.ts b/src/domain/calibration/mcmc.ts new file mode 100644 index 00000000..aa64864d --- /dev/null +++ b/src/domain/calibration/mcmc.ts @@ -0,0 +1,641 @@ +import type { CalibrationHistoryMode, CalibrationPoint, CalibrationResult } from './types'; +import type { DoseEvent, Route, SimulationResult } from '../types'; +import { + CAL_PRIOR_SD_AMP, + CAL_PRIOR_SD_K, + CAL_STUDENT_NU, + KMUL_MAX, + KMUL_MIN, + LAB_CLUSTER_H, + MIN_LABS_FOR_CLEARANCE, + clampFactor, + clearanceIdentifiable, +} from './estimators'; +import { BAYES_DISCOUNTED_WEIGHT, labLogSd, memoByTime } from './bayes'; +import { e2RouteCurves, e2Routes, type RouteCurves } from './routeCurves'; + +// --- Personal PK (MCMC) -------------------------------------------------------- +// +// The other estimators treat a person as the population model times one +// correction. This one gives each person their own pharmacokinetics: +// +// E2(t) = Σ_r exp(s + u_r) · C_r(t; k, a) +// +// C_r is the model's estradiol from route r alone (injections, oral, sublingual, +// gel, patch), which is exact because the model is linear in dose (see +// routeCurves.ts). s is a personal level shared by every route, u_r how far +// route r runs from that level for this person (the sublingual uptake of +// issue #22 is one of these), k the log personal clearance and a the log +// personal absorption rate, which moves every first-order uptake rate at once +// and so reshapes peaks and troughs rather than the level. +// +// The route terms are hierarchical, u_r ~ N(0, MCMC_ROUTE_SD²): with one route +// the model is MIPD's plus absorption, and with two the labs on each borrow +// strength from the shared s. s keeps Full Bayes's Student-t prior, k MIPD's +// normal one, and a gets its own. Clearance moves once the labs fall in +// MIN_LABS_FOR_CLEARANCE separate clusters, as for the other estimators; +// absorption needs MCMC_MIN_LABS_FOR_ABSORPTION, because it only shows in where +// labs sit within a dose interval. Each lab's SD is Full Bayes's (assay floor, +// draw-time slope), and the likelihood is Gaussian below +// MIN_LABS_FOR_CLEARANCE labs and Student-t from there on. +// +// The posterior has no closed form (the route sum is not log-linear), so it is +// sampled. MCMC_CHAINS chains of adaptive Metropolis (Haario, Saksman & +// Tamminen 2001, Bernoulli 7:223–242) adapt their proposal covariance during +// burn-in only, so the kept draws come from a fixed Markov kernel. Convergence +// is checked with split R-hat (Gelman et al. 2013, Bayesian Data Analysis, 3rd +// ed., §11.4). The random stream is seeded from the data, so the same labs +// always give the same curve. +// +// C_r(t; k, a) is precomputed on a (k, a) grid at the lab times and +// interpolated bilinearly in concentration. The drawn curve uses the same +// interpolation on the grid knots the posterior actually visits, so the curve +// is the posterior mean of exactly the model the draws were scored on. + +export const MCMC_K_STEPS = 13; +export const MCMC_ABS_MIN = 0.4; +export const MCMC_ABS_MAX = 2.5; +export const MCMC_ABS_STEPS = 9; +/** Prior SD of a route's log offset from the personal level. A chosen value, about ±40%. */ +export const MCMC_ROUTE_SD = 0.35; +/** Prior SD of the log absorption multiplier. A chosen value, about ±50%. */ +export const MCMC_ABS_PRIOR_SD = 0.4; +/** Lab clusters needed before the absorption rate may move. */ +export const MCMC_MIN_LABS_FOR_ABSORPTION = 4; +export const MCMC_CHAINS = 4; +export const MCMC_BURN_IN = 2500; +export const MCMC_KEEP = 2500; +export const MCMC_THIN = 5; +/** Draws used to build the curve and its band. */ +export const MCMC_CURVE_DRAWS = 400; +export const MCMC_SD_DRAWS = 150; +/** Most grid knots the drawn curve evaluates over the whole timeline. */ +export const MCMC_MAX_KNOTS = 16; + +const LOG_K_MIN = Math.log(KMUL_MIN); +const LOG_K_MAX = Math.log(KMUL_MAX); +const LOG_A_MIN = Math.log(MCMC_ABS_MIN); +const LOG_A_MAX = Math.log(MCMC_ABS_MAX); +const knots = (lo: number, hi: number, steps: number) => + Array.from({ length: steps }, (_, j) => (j === (steps - 1) / 2 ? 0 : lo + ((hi - lo) * j) / (steps - 1))); +export const MCMC_LOG_K = knots(LOG_K_MIN, LOG_K_MAX, MCMC_K_STEPS); +export const MCMC_LOG_A = knots(LOG_A_MIN, LOG_A_MAX, MCMC_ABS_STEPS); + +// --- Random numbers ------------------------------------------------------------ + +/** mulberry32: small, fast and good enough for a sampler; seeded so results repeat. */ +export function seededRandom(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function gaussian(rand: () => number): () => number { + let spare: number | null = null; + return () => { + if (spare !== null) { + const v = spare; + spare = null; + return v; + } + let u = 0; + while (u <= 1e-300) u = rand(); + const v = rand(); + const r = Math.sqrt(-2 * Math.log(u)); + spare = r * Math.sin(2 * Math.PI * v); + return r * Math.cos(2 * Math.PI * v); + }; +} + +function hashData(xs: number[]): number { + let h = 2166136261; + for (const x of xs) { + const s = x.toPrecision(12); + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); + } + return h >>> 0; +} + +// --- Grid ---------------------------------------------------------------------- + +/** Per-route E2 at the lab times on every (k, a) knot. */ +export interface PkGrid { + routes: Route[]; + n: number; + /** conc[(jk * MCMC_ABS_STEPS + ja) * R + r][i] */ + conc: Float64Array[]; +} + +export function buildPkGrid( + events: DoseEvent[], + bodyWeightKG: number, + birthYear: number | null, + labTimes: number[], +): PkGrid { + const routes = e2Routes(events); + const conc: Float64Array[] = []; + for (const lk of MCMC_LOG_K) { + for (const la of MCMC_LOG_A) { + const rc = e2RouteCurves(events, bodyWeightKG, birthYear, labTimes, Math.exp(lk), Math.exp(la), routes); + for (const c of rc.conc) conc.push(c); + } + } + return { routes, n: labTimes.length, conc }; +} + +/** Bracketing knots and weight of `x` on a knot list. */ +function bracket(list: number[], x: number): [number, number] { + const n = list.length; + if (x <= list[0]) return [0, 0]; + if (x >= list[n - 1]) return [n - 2, 1]; + let j = 1; + while (j < n - 1 && list[j] < x) j++; + return [j - 1, (x - list[j - 1]) / (list[j] - list[j - 1])]; +} + +/** The up to four (knot index, weight) pairs of a bilinear interpolation at (k, a). */ +export function corners(k: number, a: number): [number, number][] { + const [jk, wk] = bracket(MCMC_LOG_K, k); + const [ja, wa] = bracket(MCMC_LOG_A, a); + const out: [number, number][] = []; + const push = (dk: number, da: number, w: number) => { + if (w > 1e-12) out.push([(jk + dk) * MCMC_ABS_STEPS + ja + da, w]); + }; + push(0, 0, (1 - wk) * (1 - wa)); + push(1, 0, wk * (1 - wa)); + push(0, 1, (1 - wk) * wa); + push(1, 1, wk * wa); + return out; +} + +// --- Posterior ----------------------------------------------------------------- + +interface Layout { + R: number; + /** u_r are sampled only when there is more than one route. */ + hasRoutes: boolean; + kFree: boolean; + aFree: boolean; + dim: number; +} + +const iU = (r: number) => 1 + r; +const iK = (L: Layout) => 1 + (L.hasRoutes ? L.R : 0); +const iA = (L: Layout) => iK(L) + (L.kFree ? 1 : 0); + +function unpack(L: Layout, th: Float64Array | number[]) { + const s = th[0]; + const amps = Array.from({ length: L.R }, (_, r) => Math.exp(s + (L.hasRoutes ? th[iU(r)] : 0))); + const k = L.kFree ? th[iK(L)] : 0; + const a = L.aFree ? th[iA(L)] : 0; + return { s, amps, k, a }; +} + +/** Log of the model E2 at lab i for these amplitudes and (k, a). */ +function logPredAt(grid: PkGrid, L: Layout, amps: number[], cs: [number, number][], i: number): number { + let sum = 0; + for (const [c, w] of cs) { + const base = c * L.R; + for (let r = 0; r < L.R; r++) sum += w * amps[r] * grid.conc[base + r][i]; + } + return Math.log(Math.max(sum, 1e-9)); +} + +interface Data { + grid: PkGrid; + logObs: number[]; + sigma: number[]; + labTimes: number[]; +} + +function labClusters(labTimes: number[], count: number): number { + let clusters = 0; + let anchor = -Infinity; + for (let i = 0; i < count; i++) { + if (labTimes[i] - anchor > LAB_CLUSTER_H) { + clusters++; + anchor = labTimes[i]; + } + } + return clusters; +} + +function logPosterior(data: Data, L: Layout, count: number, robust: boolean, th: Float64Array): number { + const { s, amps, k, a } = unpack(L, th); + if (k < LOG_K_MIN || k > LOG_K_MAX || a < LOG_A_MIN || a > LOG_A_MAX) return -Infinity; + const nu = CAL_STUDENT_NU; + let lp = (-(nu + 1) / 2) * Math.log(1 + (s / CAL_PRIOR_SD_AMP) ** 2 / nu); + if (L.hasRoutes) for (let r = 0; r < L.R; r++) lp -= 0.5 * (th[iU(r)] / MCMC_ROUTE_SD) ** 2; + if (L.kFree) lp -= 0.5 * (k / CAL_PRIOR_SD_K) ** 2; + if (L.aFree) lp -= 0.5 * (a / MCMC_ABS_PRIOR_SD) ** 2; + const cs = corners(k, a); + for (let i = 0; i < count; i++) { + const z = (data.logObs[i] - logPredAt(data.grid, L, amps, cs, i)) / data.sigma[i]; + lp += robust ? (-(nu + 1) / 2) * Math.log(1 + (z * z) / nu) : -0.5 * z * z; + } + return lp; +} + +/** Lower Cholesky factor of a small symmetric positive definite matrix (row-major). */ +function cholesky(m: number[], d: number): number[] | null { + const l = new Array(d * d).fill(0); + for (let i = 0; i < d; i++) { + for (let j = 0; j <= i; j++) { + let sum = m[i * d + j]; + for (let p = 0; p < j; p++) sum -= l[i * d + p] * l[j * d + p]; + if (i === j) { + if (sum <= 0) return null; + l[i * d + i] = Math.sqrt(sum); + } else l[i * d + j] = sum / l[j * d + j]; + } + } + return l; +} + +/** Split R-hat for each parameter over all chains (each chain halved). */ +export function splitRHat(chains: Float64Array[][], dim: number): number { + let worst = 1; + for (let p = 0; p < dim; p++) { + const halves: number[][] = []; + for (const chain of chains) { + const half = Math.floor(chain.length / 2); + halves.push(chain.slice(0, half).map(x => x[p])); + halves.push(chain.slice(half, 2 * half).map(x => x[p])); + } + const m = halves.length; + const n = halves[0].length; + if (n < 2) continue; + const means = halves.map(h => h.reduce((a, b) => a + b, 0) / n); + const grand = means.reduce((a, b) => a + b, 0) / m; + const B = (n / (m - 1)) * means.reduce((acc, x) => acc + (x - grand) ** 2, 0); + const W = halves.reduce((acc, h, j) => acc + h.reduce((a2, x) => a2 + (x - means[j]) ** 2, 0) / (n - 1), 0) / m; + if (W < 1e-14) continue; + const varHat = ((n - 1) / n) * W + B / n; + worst = Math.max(worst, Math.sqrt(varHat / W)); + } + return worst; +} + +export interface McmcFit { + layout: Layout; + /** Kept draws, every chain, in order. */ + draws: Float64Array[]; + rHat: number; + acceptance: number; + /** Posterior-mean Student-t weight per lab (1 everywhere for a Gaussian likelihood). */ + tWeights: number[]; +} + +export function mcmcFit(data: Data, count: number): McmcFit { + const R = data.grid.routes.length; + const hasRoutes = R > 1; + const kFree = clearanceIdentifiable(data.labTimes, count); + const aFree = labClusters(data.labTimes, count) >= MCMC_MIN_LABS_FOR_ABSORPTION; + const dim = 1 + (hasRoutes ? R : 0) + (kFree ? 1 : 0) + (aFree ? 1 : 0); + const L: Layout = { R, hasRoutes, kFree, aFree, dim }; + const robust = count >= MIN_LABS_FOR_CLEARANCE; + const target = (th: Float64Array) => logPosterior(data, L, count, robust, th); + + const rand = seededRandom(hashData([count, ...data.logObs.slice(0, count), ...data.labTimes.slice(0, count)])); + const normal = gaussian(rand); + + // A crude centre for the chains: the geometric mean ratio of the labs to the + // unscaled model at (k, a) = (0, 0). + const ones = Array.from({ length: R }, () => 1); + const cs0 = corners(0, 0); + let centre = 0; + for (let i = 0; i < count; i++) centre += data.logObs[i] - logPredAt(data.grid, L, ones, cs0, i); + centre /= Math.max(1, count); + + const chains: Float64Array[][] = []; + let accepted = 0; + let proposed = 0; + for (let c = 0; c < MCMC_CHAINS; c++) { + let x = new Float64Array(dim); + x[0] = centre + 0.3 * normal(); + for (let p = 1; p < dim; p++) x[p] = 0.15 * normal(); + let lx = target(x); + if (!Number.isFinite(lx)) { + x = new Float64Array(dim); + x[0] = centre; + lx = target(x); + } + + // Proposal: N(x, λ·Σ). Σ starts diagonal and, during burn-in, is + // replaced by the chain's own covariance (Haario et al.); λ is tuned + // towards the 0.234 acceptance rate of Roberts, Gelman & Gilks 1997. + let chol: number[] = []; + for (let i = 0; i < dim; i++) for (let j = 0; j < dim; j++) chol.push(i === j ? 0.1 : 0); + let logLambda = 0; + const mean = new Float64Array(dim); + const cov = new Float64Array(dim * dim); + let seen = 0; + const kept: Float64Array[] = []; + let windowAcc = 0; + + for (let it = 0; it < MCMC_BURN_IN + MCMC_KEEP; it++) { + const z = Array.from({ length: dim }, () => normal()); + const y = new Float64Array(dim); + const lam = Math.exp(logLambda); + for (let i = 0; i < dim; i++) { + let step = 0; + for (let j = 0; j <= i; j++) step += chol[i * dim + j] * z[j]; + y[i] = x[i] + lam * step; + } + const ly = target(y); + const ok = Number.isFinite(ly) && Math.log(rand()) < ly - lx; + if (ok) { + x = y; + lx = ly; + } + if (it < MCMC_BURN_IN) { + windowAcc += ok ? 1 : 0; + // Running mean and covariance (Welford) of the burn-in path. + seen++; + const delta = Array.from(x, (v, i) => v - mean[i]); + for (let i = 0; i < dim; i++) mean[i] += delta[i] / seen; + for (let i = 0; i < dim; i++) + for (let j = 0; j < dim; j++) cov[i * dim + j] += delta[i] * (x[j] - mean[j]); + if ((it + 1) % 100 === 0) { + logLambda += windowAcc / 100 > 0.234 ? 0.2 : -0.2; + windowAcc = 0; + if (seen > 400) { + const scaled = Array.from(cov, (v, idx) => { + const i = Math.floor(idx / dim); + const j = idx % dim; + return ((2.38 * 2.38) / dim) * (v / (seen - 1)) + (i === j ? 1e-6 : 0); + }); + const l = cholesky(scaled, dim); + if (l) chol = l; + } + } + } else { + proposed++; + if (ok) accepted++; + if ((it - MCMC_BURN_IN) % MCMC_THIN === 0) kept.push(Float64Array.from(x)); + } + } + chains.push(kept); + } + + const draws = chains.flat(); + const tWeights = Array.from({ length: count }, () => 0); + if (robust) { + const nu = CAL_STUDENT_NU; + for (const th of draws) { + const { amps, k, a } = unpack(L, th); + const cs = corners(k, a); + for (let i = 0; i < count; i++) { + const z = (data.logObs[i] - logPredAt(data.grid, L, amps, cs, i)) / data.sigma[i]; + tWeights[i] += (nu + 1) / (nu + z * z) / draws.length; + } + } + } else tWeights.fill(1); + + return { + layout: L, + draws, + rHat: splitRHat(chains, dim), + acceptance: proposed ? accepted / proposed : 0, + tWeights, + }; +} + +// --- Curve --------------------------------------------------------------------- + +const quantile = (sorted: number[], q: number) => { + const pos = (sorted.length - 1) * q; + const lo = Math.floor(pos); + const hi = Math.ceil(pos); + return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo); +}; + +const every = (xs: T[], n: number): T[] => { + if (xs.length <= n) return xs; + const step = xs.length / n; + return Array.from({ length: n }, (_, i) => xs[Math.floor(i * step)]); +}; + +/** A draw reduced to what the curve needs: amplitudes and interpolation corners. */ +interface CurveDraw { + amps: number[]; + cs: [number, number][]; +} + +export function mcmcCalibrate( + baselineSim: SimulationResult, + events: DoseEvent[], + bodyWeightKG: number, + birthYear: number | null, + points: CalibrationPoint[], + historyMode: CalibrationHistoryMode, +): Omit { + const n = points.length; + const labTimes = points.map(p => p.timeH); + const grid = buildPkGrid(events, bodyWeightKG, birthYear, labTimes); + const R = grid.routes.length; + const data: Data = { + grid, + logObs: points.map(p => Math.log(p.obs)), + sigma: points.map(p => labLogSd(baselineSim, p)), + labTimes, + }; + + const full = mcmcFit(data, n); + const fits = + historyMode === 'forward' ? labTimes.map((_, i) => (i === n - 1 ? full : mcmcFit(data, i + 1))) : [full]; + + // Draws for the curve, and the knots they touch. + const curveDraws = fits.map(fit => + every(fit.draws, MCMC_CURVE_DRAWS).map(th => { + const { amps, k, a } = unpack(fit.layout, th); + return { amps, cs: corners(k, a) }; + }), + ); + const mass = new Map(); + for (const list of curveDraws) + for (const d of list) for (const [c, w] of d.cs) mass.set(c, (mass.get(c) ?? 0) + w / list.length); + const keptKnots = [...mass.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, MCMC_MAX_KNOTS) + .map(([c]) => c); + const keptSet = new Set(keptKnots); + const knotCoords = (c: number) => [Math.floor(c / MCMC_ABS_STEPS), c % MCMC_ABS_STEPS]; + // A draw whose corners fall outside the kept knots moves its weight onto the + // kept ones it does touch, or else onto the nearest kept knot. + const restrict = (d: CurveDraw): CurveDraw => { + const inside = d.cs.filter(([c]) => keptSet.has(c)); + const total = inside.reduce((acc, [, w]) => acc + w, 0); + if (total > 1e-9) return { amps: d.amps, cs: inside.map(([c, w]) => [c, w / total]) }; + const [main] = d.cs.reduce((best, x) => (x[1] > best[1] ? x : best)); + const [mk, ma] = knotCoords(main); + const nearest = keptKnots.reduce((best, c) => { + const [ck, ca] = knotCoords(c); + const [bk, ba] = knotCoords(best); + return (ck - mk) ** 2 + (ca - ma) ** 2 < (bk - mk) ** 2 + (ba - ma) ** 2 ? c : best; + }); + return { amps: d.amps, cs: [[nearest, 1]] }; + }; + const segDraws = curveDraws.map(list => list.map(restrict)); + + const times = baselineSim.timeH; + const knotCurves = new Map(); + for (const c of keptKnots) { + const [jk, ja] = knotCoords(c); + knotCurves.set( + c, + e2RouteCurves( + events, + bodyWeightKG, + birthYear, + times, + Math.exp(MCMC_LOG_K[jk]), + Math.exp(MCMC_LOG_A[ja]), + grid.routes, + ), + ); + } + + // Per segment, the posterior mean is linear in the knot curves: + // Σ_c Σ_r coef[c][r] · C_{c,r}(t). + const coefs = segDraws.map(list => { + const coef = new Map(); + for (const d of list) { + for (const [c, w] of d.cs) { + const row = coef.get(c) ?? new Array(R).fill(0); + for (let r = 0; r < R; r++) row[r] += (w * d.amps[r]) / list.length; + coef.set(c, row); + } + } + return coef; + }); + + // Index and weight of t on the baseline time grid. + const locate = (t: number): [number, number] | null => { + if (!times.length || t < times[0] || t > times[times.length - 1]) return null; + let lo = 0; + let hi = times.length - 1; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (times[mid] <= t) lo = mid; + else hi = mid; + } + const span = times[hi] - times[lo]; + return [lo, span > 0 ? (t - times[lo]) / span : 0]; + }; + const at = (arr: ArrayLike, pos: [number, number]) => + pos[1] === 0 ? arr[pos[0]] : arr[pos[0]] * (1 - pos[1]) + arr[pos[0] + 1] * pos[1]; + const baselineAt = (pos: [number, number]) => at(baselineSim.concPGmL_E2, pos); + const drawValue = (d: CurveDraw, pos: [number, number]) => { + let sum = 0; + for (const [c, w] of d.cs) { + const curves = knotCurves.get(c)!; + for (let r = 0; r < R; r++) sum += w * d.amps[r] * at(curves.conc[r], pos); + } + return sum; + }; + + const segmentAt = (t: number): number => { + if (historyMode !== 'forward') return 0; + if (t < labTimes[0]) return -1; + let idx = 0; + for (let i = 0; i < n; i++) { + if (labTimes[i] <= t) idx = i; + else break; + } + return idx; + }; + const levelOnly = segDraws.map(list => { + const mean = list.reduce((acc, d) => acc + Math.log(d.amps.reduce((x, y) => x + y, 0) / R), 0); + return Math.exp(mean / list.length); + }); + + const factorFn = memoByTime((t: number) => { + const seg = segmentAt(t); + if (seg < 0) return 1; + const pos = locate(t); + const b = pos ? baselineAt(pos) : 0; + if (!pos || b < 1) return clampFactor(levelOnly[seg]); + let mean = 0; + for (const [c, row] of coefs[seg]) { + const curves = knotCurves.get(c)!; + for (let r = 0; r < R; r++) mean += row[r] * at(curves.conc[r], pos); + } + return clampFactor(mean / b); + }); + const sdDraws = segDraws.map(list => every(list, MCMC_SD_DRAWS)); + const factorSdFn = memoByTime((t: number) => { + const seg = segmentAt(t); + if (seg < 0) return 0; + const pos = locate(t); + const b = pos ? baselineAt(pos) : 0; + const logs = sdDraws[seg].map(d => + !pos || b < 1 + ? Math.log(d.amps.reduce((x, y) => x + y, 0) / R) + : Math.log(Math.max(drawValue(d, pos), 1e-9) / b), + ); + const m = logs.reduce((a, x) => a + x, 0) / logs.length; + return Math.sqrt(logs.reduce((a, x) => a + (x - m) ** 2, 0) / Math.max(1, logs.length - 1)); + }); + + // Reported values describe the fit to every lab, at the latest one. + const L = full.layout; + const last = n - 1; + const levels: number[] = []; + const amplitudes: number[] = []; + let kSum = 0; + let aSum = 0; + const routeLog = new Array(R).fill(0); + const baseLast = Math.exp( + logPredAt( + grid, + L, + Array.from({ length: R }, () => 1), + corners(0, 0), + last, + ), + ); + for (const th of full.draws) { + const { amps, k, a } = unpack(L, th); + const cs = corners(k, a); + const pred = Math.exp(logPredAt(grid, L, amps, cs, last)); + const shape = Math.exp( + logPredAt( + grid, + L, + Array.from({ length: R }, () => 1), + cs, + last, + ), + ); + levels.push(pred / baseLast); + amplitudes.push(Math.log(pred / shape)); + kSum += k; + aSum += a; + for (let r = 0; r < R; r++) routeLog[r] += Math.log(amps[r]); + } + const D = full.draws.length; + levels.sort((x, y) => x - y); + const kMul = Math.exp(kSum / D); + const absMul = Math.exp(aSum / D); + const routeScales: Partial> = {}; + if (L.hasRoutes) grid.routes.forEach((r, i) => (routeScales[r] = clampFactor(Math.exp(routeLog[i] / D)))); + + return { + factorFn, + factorSdFn, + scale: clampFactor(Math.exp(amplitudes.reduce((a, x) => a + x, 0) / D)), + latestInterval: [clampFactor(quantile(levels, 0.05)), clampFactor(quantile(levels, 0.95))], + kMul, + halfLifeDeltaPct: (1 / kMul - 1) * 100, + absMul, + routeScales: L.hasRoutes ? routeScales : undefined, + discounted: full.tWeights.filter(w => w < BAYES_DISCOUNTED_WEIGHT).length, + rHat: full.rHat, + }; +} diff --git a/src/domain/calibration/routeCurves.ts b/src/domain/calibration/routeCurves.ts new file mode 100644 index 00000000..4f23ee42 --- /dev/null +++ b/src/domain/calibration/routeCurves.ts @@ -0,0 +1,99 @@ +import { Ester, Route } from '../types'; +import type { DoseEvent, PKParams } from '../types'; +import { CorePK, T_ESTERS, isPlausibleBirthYear, isUnmodelledDrug } from '../constants'; +import { resolveParams } from '../dispatch'; +import { PrecomputedEventModel, computeMaxLifetimeH } from '../shared/eventModel'; +import { getActivePKParams, setActivePKParams } from '../shared/activeParams'; + +// The estradiol model is linear in dose: the curve is a sum of one term per +// dose, and each dose's term is its route's absorption kernel times that +// route's bioavailability. Splitting the sum by route therefore lets a +// personal factor per route scale its own part exactly, with no new +// simulation. This file evaluates those per-route parts at chosen times, for a +// personal clearance multiplier and a personal absorption multiplier. +// +// It mirrors runSimulation's estradiol branch (same event filter, same +// per-event models, same volume) and computes nothing else, so evaluating it +// on the baseline's own time grid costs a fraction of a full simulation. + +/** E2 by route at each requested time, pg/mL. `conc[r][i]` belongs to `routes[r]` at `times[i]`. */ +export interface RouteCurves { + routes: Route[]; + conc: Float64Array[]; +} + +/** The routes that carry estradiol in these events, in a fixed order. */ +export function e2Routes(events: DoseEvent[]): Route[] { + const seen = new Set(); + for (const e of events) if (isE2Event(e)) seen.add(e.route); + return Object.values(Route).filter(r => seen.has(r)); +} + +function isE2Event(e: DoseEvent): boolean { + return ( + e.route !== Route.patchRemove && + !isUnmodelledDrug(e.ester) && + e.ester !== Ester.CPA && + e.ester !== Ester.BICA && + !T_ESTERS.has(e.ester) + ); +} + +/** + * Per-route E2 at `times` (ascending) with clearance × `kMul` and absorption + * (every first-order uptake rate: depot release, gut, mucosa, skin) × `absMul`. + * Zero-order patch delivery has no uptake rate and is left as it is. + */ +export function e2RouteCurves( + events: DoseEvent[], + bodyWeightKG: number, + birthYear: number | null, + times: ArrayLike, + kMul: number, + absMul: number, + routes: Route[] = e2Routes(events), +): RouteCurves { + const conc = routes.map(() => new Float64Array(times.length)); + if (!routes.length || bodyWeightKG <= 0) return { routes, conc }; + const born = isPlausibleBirthYear(birthYear) ? birthYear : null; + const saved = getActivePKParams(); + setActivePKParams({ ...saved, e2_kClear: saved.e2_kClear * kMul, e2_kClearInj: saved.e2_kClearInj * kMul }); + try { + // Patch removals stay in the list each model sees: they pair with applications. + const kept = events.filter(e => !isUnmodelledDrug(e.ester) && e.ester !== Ester.BICA); + const sorted = [...kept].sort((a, b) => a.timeH - b.timeH); + const routeIndex = new Map(routes.map((r, i) => [r, i])); + const models = sorted + .filter(isE2Event) + .map(e => { + const base = resolveParams(e, born); + const params: PKParams = + absMul === 1 ? base : { ...base, k1_fast: base.k1_fast * absMul, k1_slow: base.k1_slow * absMul }; + return { + model: new PrecomputedEventModel(e, sorted, params), + start: e.timeH, + life: computeMaxLifetimeH(params, e, sorted), + r: routeIndex.get(e.route) ?? -1, + }; + }) + .filter(m => m.r >= 0); + + const scale = 1e9 / (CorePK.vdPerKG * bodyWeightKG * 1000); + const active: typeof models = []; + let next = 0; + for (let i = 0; i < times.length; i++) { + const t = times[i]; + while (next < models.length && models[next].start <= t) active.push(models[next++]); + for (let j = 0; j < active.length;) { + if (t - active[j].start > active[j].life) { + active[j] = active[active.length - 1]; + active.pop(); + } else j++; + } + for (const m of active) conc[m.r][i] += m.model.amount(t) * scale; + } + } finally { + setActivePKParams(saved); + } + return { routes, conc }; +} diff --git a/src/domain/calibration/types.ts b/src/domain/calibration/types.ts index f9ccf5cd..304d5481 100644 --- a/src/domain/calibration/types.ts +++ b/src/domain/calibration/types.ts @@ -1,8 +1,8 @@ -import type { LabAssay, LabResult, SimulationResult } from '../types'; +import type { LabAssay, LabResult, Route, SimulationResult } from '../types'; import { convertToPgMl, isT_LabUnit } from '../labs'; import { interpolateConcentration_E2 } from '../simulation/interpolate'; -export type CalibrationMethod = 'off' | 'ekf' | 'ou_kalman' | 'mipd' | 'bayes'; +export type CalibrationMethod = 'off' | 'ekf' | 'ou_kalman' | 'mipd' | 'bayes' | 'mcmc'; /** * How newly-added labs are allowed to act on the historical curve. @@ -16,7 +16,8 @@ export type CalibrationHistoryMode = 'forward' | 'retrospective'; /** Map legacy stored method ids onto the current estimator set. */ export function normalizeCalibrationMethod(raw: string | null | undefined): CalibrationMethod { - if (raw === 'off' || raw === 'ekf' || raw === 'ou_kalman' || raw === 'mipd' || raw === 'bayes') return raw; + if (raw === 'off' || raw === 'ekf' || raw === 'ou_kalman' || raw === 'mipd' || raw === 'bayes' || raw === 'mcmc') + return raw; if (raw === 'average') return 'mipd'; // amplitude-only LS → MAP with prior if (raw === 'adaptive') return 'ekf'; // amplitude + clearance fit → EKF return 'mipd'; @@ -120,6 +121,12 @@ export interface CalibrationResult { discounted?: number; /** Full Bayes only: posterior probability of each drift memory: none, ~2 weeks, ~3 months, ~1 year. */ drift?: Record; + /** Personal PK only: personal absorption-rate multiplier (1 = the model's uptake speed). */ + absMul?: number; + /** Personal PK only, with more than one route: each route's personal level multiplier. */ + routeScales?: Partial>; + /** Personal PK only: worst split R-hat over the sampled parameters (near 1 = the chains agree). */ + rHat?: number; } export type BayesDriftFamily = 'none' | 'weeks' | 'months' | 'year'; diff --git a/src/hooks/useAppNavigation.ts b/src/hooks/useAppNavigation.ts index cd8da2dc..f143d85e 100644 --- a/src/hooks/useAppNavigation.ts +++ b/src/hooks/useAppNavigation.ts @@ -19,6 +19,7 @@ export type ViewKey = | 'history' | 'lab' | 'lab-calibration' + | 'lab-calibration-guide' | 'settings' | 'account' | 'admin' @@ -61,6 +62,7 @@ const VIEW_TAB: Partial> = { plan: 'home', share: 'home', 'lab-calibration': 'lab', + 'lab-calibration-guide': 'lab', 'settings-hrt-mode': 'settings', 'settings-language': 'settings', 'settings-appearance': 'settings', @@ -116,6 +118,7 @@ const VIEW_ORDER: ViewKey[] = [ 'history', 'lab', 'lab-calibration', + 'lab-calibration-guide', 'settings', 'account', 'sessions', diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index b130f031..21b255b9 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -785,6 +785,46 @@ export const RU = { 'cal.bayes.pro.1': 'Ваши анализы сами решают, постоянный у вас уровень или дрейфующий', 'cal.bayes.pro.2': 'Учитывает метод анализа и время забора, даёт честный вероятный диапазон', 'cal.bayes.con.1': 'Собственный метод приложения, не из моделей hrt.transmtf.com; считается дольше всех', + 'cal.mcmc': 'Личная ФК', + 'cal.desc.mcmc': + 'Подбирает вашу собственную фармакокинетику: уровень для каждого пути введения, скорость выведения и скорость всасывания. Полное апостериорное распределение получается выборкой MCMC, кривая усредняет все вероятные варианты.', + 'cal.mcmc.pro.1': 'Калибрует каждый путь введения отдельно, например сублингвально ниже, а инъекции выше', + 'cal.mcmc.pro.2': 'Узнаёт, как быстро вы всасываете препарат, поэтому сдвигаются пики и спады', + 'cal.mcmc.con.1': 'Собственный метод приложения, ещё проверяется; не учитывает дрейф со временем', + 'cal.absorption': 'скорость всасывания', + 'cal.route.injection': 'инъекции', + 'cal.route.oral': 'перорально', + 'cal.route.sublingual': 'сублингвально', + 'cal.route.gel': 'гель', + 'cal.route.patchApply': 'пластырь', + 'cal_guide.title': 'Как работает калибровка', + 'cal_guide.entry': 'Как считают эти методы', + 'cal_guide.lead': + 'Когда анализы расходятся с моделью, калибровка сдвигает кривую к ним. Шесть методов делают это по-разному; каждый рисунок и формула ниже это расчёт, который приложение выполняет на самом деле.', + 'cal_guide.try.title': 'Попробуйте', + 'cal_guide.try.text': + 'Еженедельно 5 мг эстрадиола валерата, во второй половине несколько анализов. Двигайте настройки и смотрите, как отвечает каждый метод.', + 'cal_guide.method': 'Метод', + 'cal_guide.caption': 'Пунктир это исходная модель, заливка вероятный диапазон 90%, ромбы анализы', + 'cal_guide.level': 'Анализы относительно модели', + 'cal_guide.change': 'С четвёртого анализа уровень становится', + 'cal_guide.count': 'Число анализов', + 'cal_guide.wrong': 'Один анализ измерен неверно', + 'cal_guide.try_this': 'Попробовать на рисунке выше', + 'cal_guide.off': 'Без калибровки: модель как есть. Удобно, чтобы увидеть саму модель или пока анализов нет.', + 'cal_guide.mipd': + 'Находит лучший личный множитель и скорость выведения, а среднее по популяции удерживает его. Мало анализов сдвигают его слабо; три и больше разнесённых по времени могут изменить период полувыведения, а одиночный выброс получает меньший вес.', + 'cal_guide.ekf': + 'Берёт анализы по одному по порядку, каждый раз немного поправляя прошлую оценку и помня, насколько он уверен. Старые анализы сохраняют влияние навсегда.', + 'cal_guide.ou_kalman': + 'Считает отличие от модели дрейфующим и возвращающимся к модели примерно за две недели. Исправляет только уровень, не форму кривой.', + 'cal_guide.bayes': + 'Взвешивает сразу несколько версий: отличие постоянно или дрейфует за две недели, три месяца или год. Усредняет их по тому, насколько анализы поддерживают каждую, и даёт вероятный диапазон.', + 'cal_guide.mcmc': + 'Подбирает целиком вашу фармакокинетику: уровень для каждого пути введения, скорость выведения и всасывания. Прямой формулы для этого нет, поэтому MCMC случайно вытягивает тысячи возможных наборов, а кривая их среднее.', + 'cal_guide.noise.title': 'Насколько учитывается каждый анализ', + 'cal_guide.noise.text': + 'Полный байес и личная ФК дают каждому анализу свою ошибку. Иммуноанализ менее точен при низких уровнях, а неточное время забора важнее там, где кривая крутая, поэтому такие анализы весят меньше.', 'cal.history': 'Применение к истории', 'cal.forward': 'Вперёд', 'cal.retrospective': 'Ретроспективно', @@ -793,7 +833,7 @@ export const RU = { 'cal.desc.retrospective': 'Сглаженно: все анализы заново оценивают всю историю. Точнее задним числом; новый анализ может пересмотреть старые оценки.', 'cal.source': - 'EKF, OU-Kalman и Hybrid-MIPD повторяют модели калибровки с hrt.transmtf.com; полный байес собственный метод приложения.', + 'EKF, OU-Kalman и Hybrid-MIPD повторяют модели калибровки с hrt.transmtf.com; полный байес и личная ФК собственные методы приложения.', 'cal.amplitude': 'амплитуда', 'cal.halflife': 'период полувыведения', 'cal.fit': 'подгонка', diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 72bdbfba..e10861b4 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -786,12 +786,51 @@ export const TRANSLATIONS_BASE = { 'cal.bayes.pro.1': '由化验结果判断水平是固定还是在漂移', 'cal.bayes.pro.2': '考虑检测方法与抽血时间;给出真实的可能范围', 'cal.bayes.con.1': '本应用自有的方法,不在 hrt.transmtf.com 的模型之列;计算最慢', + 'cal.mcmc': '个人药代', + 'cal.desc.mcmc': + '为你估一套自己的药代参数:每种给药途径各自的水平、清除速度和吸收速度,用 MCMC 采样得到完整的后验,曲线是所有可能情况的平均。', + 'cal.mcmc.pro.1': '不同给药途径分开校准,比如舌下偏低、注射偏高', + 'cal.mcmc.pro.2': '能看出吸收快慢,峰值和谷值的位置会跟着变', + 'cal.mcmc.con.1': '本应用自有的方法,仍在验证;不考虑随时间的漂移', + 'cal.absorption': '吸收速度', + 'cal.route.injection': '注射', + 'cal.route.oral': '口服', + 'cal.route.sublingual': '舌下', + 'cal.route.gel': '凝胶', + 'cal.route.patchApply': '贴片', + 'cal_guide.title': '校准方法讲解', + 'cal_guide.entry': '这些方法是怎么算的', + 'cal_guide.lead': + '化验结果和模型不一样时,校准会把曲线往化验那边挪。六种方法挪的方式不同,下面的图和公式都是应用里真实在用的计算。', + 'cal_guide.try.title': '试一试', + 'cal_guide.try.text': '每周注射 5 mg 戊酸雌二醇,后半段抽了几次血。拖动下面的控件,看每种方法怎么回应。', + 'cal_guide.method': '方法', + 'cal_guide.caption': '虚线是原始模型,阴影是 90% 的可能范围,菱形是化验', + 'cal_guide.level': '化验比模型高多少', + 'cal_guide.change': '第四次起水平变成', + 'cal_guide.count': '化验次数', + 'cal_guide.wrong': '有一次化验测错了', + 'cal_guide.try_this': '在上面的图里试试', + 'cal_guide.off': '不校准,直接显示模型。适合想看模型本身,或者还没有化验的时候。', + 'cal_guide.mipd': + '找一个最合适的个人倍数和清除速度,同时被群体平均拉住。化验少时变化小,三次以上分散的化验才会改半衰期,单个离群值会被降权。', + 'cal_guide.ekf': + '按时间顺序一次处理一个化验,每次都在上一次的估计上修正一点,并记住自己有多确定。旧化验会一直留着影响。', + 'cal_guide.ou_kalman': '认为你和模型的差别会随时间漂移,两周左右就回到模型。只改水平,不改曲线形状。', + 'cal_guide.bayes': + '同时考虑几种说法:差别固定,或者在两周、三个月、一年内漂移。按化验对每种说法的支持程度加权平均,并给出可能范围。', + 'cal_guide.mcmc': + '给你估一整套自己的药代参数:每种给药途径各自的水平、清除速度和吸收速度。没有公式能直接解出来,所以用 MCMC 随机抽几千组参数,曲线是它们的平均。', + 'cal_guide.noise.title': '每次化验有多可信', + 'cal_guide.noise.text': + '全贝叶斯和个人药代给每次化验算一个误差。免疫法在低浓度下不准,抽血时间记得不精确时曲线越陡影响越大,所以这些化验分量更轻。', 'cal.history': '应用于历史', 'cal.forward': '前向', 'cal.retrospective': '回顾', 'cal.desc.forward': '因果方式——每个时点只使用当时已有的血检,新增血检不会改写过去的估计。', 'cal.desc.retrospective': '平滑方式——用全部血检重新估计整段历史。事后更准确;新血检可能修正较早的估计。', - 'cal.source': 'EKF、OU-Kalman 与 Hybrid-MIPD 借鉴自 hrt.transmtf.com 的校准模型;全贝叶斯为本应用自有。', + 'cal.source': + 'EKF、OU-Kalman 与 Hybrid-MIPD 借鉴自 hrt.transmtf.com 的校准模型;全贝叶斯与个人药代为本应用自有。', 'cal.amplitude': '幅度', 'cal.halflife': '半衰期', 'cal.fit': '拟合', @@ -2113,6 +2152,47 @@ export const TRANSLATIONS_BASE = { 'cal.bayes.pro.1': 'Lets your labs decide whether your level is fixed or drifting', 'cal.bayes.pro.2': 'Accounts for assay and draw time; gives an honest likely range', 'cal.bayes.con.1': "This app's own method, not one of the hrt.transmtf.com models; the slowest to compute", + 'cal.mcmc': 'Personal PK', + 'cal.desc.mcmc': + 'Fits a set of pharmacokinetics that is yours: a level for each route, clearance and absorption speed, sampled with MCMC for a full posterior. The curve is the average over every likely version.', + 'cal.mcmc.pro.1': 'Calibrates each route on its own, e.g. sublingual low but injections high', + 'cal.mcmc.pro.2': 'Learns how fast you absorb, so peaks and troughs move too', + 'cal.mcmc.con.1': "This app's own method and still being checked; does not model drift over time", + 'cal.absorption': 'absorption speed', + 'cal.route.injection': 'injection', + 'cal.route.oral': 'oral', + 'cal.route.sublingual': 'sublingual', + 'cal.route.gel': 'gel', + 'cal.route.patchApply': 'patch', + 'cal_guide.title': 'How calibration works', + 'cal_guide.entry': 'How these methods work', + 'cal_guide.lead': + 'When your labs differ from the model, calibration moves the curve towards them. The six methods move it in different ways; every picture and formula below is the calculation the app really runs.', + 'cal_guide.try.title': 'Try it', + 'cal_guide.try.text': + 'Weekly 5 mg estradiol valerate, with a few labs in the second half. Move the controls and watch how each method responds.', + 'cal_guide.method': 'Method', + 'cal_guide.caption': 'Dashed is the raw model, the wash is the 90% likely range, diamonds are labs', + 'cal_guide.level': 'Labs compared with the model', + 'cal_guide.change': 'From the fourth lab the level becomes', + 'cal_guide.count': 'Number of labs', + 'cal_guide.wrong': 'One lab was measured wrong', + 'cal_guide.try_this': 'Try it in the picture above', + 'cal_guide.off': + 'No calibration: the model as it is. Useful to see the model itself, or before you have any labs.', + 'cal_guide.mipd': + 'Finds the best personal scale and clearance while the population average holds it back. Few labs move it little; three or more spread out labs can change the half-life, and a single outlier is discounted.', + 'cal_guide.ekf': + 'Takes your labs one at a time in order, nudging the last estimate each time and keeping track of how sure it is. Old labs keep their say for good.', + 'cal_guide.ou_kalman': + 'Treats the difference from the model as something that drifts and returns to the model within about two weeks. It corrects the level only, not the shape.', + 'cal_guide.bayes': + 'Weighs several stories at once: a fixed difference, or one that drifts over two weeks, three months or a year. It averages them by how well your labs support each, and gives a likely range.', + 'cal_guide.mcmc': + 'Fits a whole set of pharmacokinetics that is yours: a level for each route, clearance and absorption speed. No formula solves it directly, so MCMC draws thousands of possible sets at random and the curve is their average.', + 'cal_guide.noise.title': 'How much each lab counts', + 'cal_guide.noise.text': + 'Full Bayes and Personal PK give each lab its own error. Immunoassays are less accurate at low levels, and an inexact draw time matters more where the curve is steep, so those labs count for less.', 'cal.history': 'Applied to history', 'cal.forward': 'Forward', 'cal.retrospective': 'Retrospective', @@ -2121,7 +2201,7 @@ export const TRANSLATIONS_BASE = { 'cal.desc.retrospective': 'Smoothed — all labs re-estimate the whole history. More accurate in hindsight; a new lab can revise older estimates.', 'cal.source': - "EKF, OU-Kalman and Hybrid-MIPD mirror the calibration models on hrt.transmtf.com; Full Bayes is this app's own.", + "EKF, OU-Kalman and Hybrid-MIPD mirror the calibration models on hrt.transmtf.com; Full Bayes and Personal PK are this app's own.", 'cal.amplitude': 'amplitude', 'cal.halflife': 'half-life', 'cal.fit': 'fit', @@ -3408,12 +3488,50 @@ const ZH_TW = { 'cal.bayes.pro.1': '由檢驗結果判斷水準是固定還是在漂移', 'cal.bayes.pro.2': '考慮檢測方法與抽血時間;給出真實的可能範圍', 'cal.bayes.con.1': '本應用自有的方法,不在 hrt.transmtf.com 的模型之列;計算最慢', + 'cal.mcmc': '個人藥代', + 'cal.desc.mcmc': + '為你估一套自己的藥代參數:每種給藥途徑各自的水準、清除速度和吸收速度,用 MCMC 取樣得到完整的後驗,曲線是所有可能情況的平均。', + 'cal.mcmc.pro.1': '不同給藥途徑分開校準,例如舌下偏低、注射偏高', + 'cal.mcmc.pro.2': '能看出吸收快慢,峰值和谷值的位置會跟著變', + 'cal.mcmc.con.1': '本應用自有的方法,仍在驗證;不考慮隨時間的漂移', + 'cal.absorption': '吸收速度', + 'cal.route.injection': '注射', + 'cal.route.oral': '口服', + 'cal.route.sublingual': '舌下', + 'cal.route.gel': '凝膠', + 'cal.route.patchApply': '貼片', + 'cal_guide.title': '校準方法講解', + 'cal_guide.entry': '這些方法是怎麼算的', + 'cal_guide.lead': + '檢驗結果和模型不一樣時,校準會把曲線往檢驗那邊挪。六種方法挪的方式不同,下面的圖和公式都是應用裡真實在用的計算。', + 'cal_guide.try.title': '試一試', + 'cal_guide.try.text': '每週注射 5 mg 戊酸雌二醇,後半段抽了幾次血。拖動下面的控制項,看每種方法怎麼回應。', + 'cal_guide.method': '方法', + 'cal_guide.caption': '虛線是原始模型,陰影是 90% 的可能範圍,菱形是檢驗', + 'cal_guide.level': '檢驗比模型高多少', + 'cal_guide.change': '第四次起水準變成', + 'cal_guide.count': '檢驗次數', + 'cal_guide.wrong': '有一次檢驗測錯了', + 'cal_guide.try_this': '在上面的圖裡試試', + 'cal_guide.off': '不校準,直接顯示模型。適合想看模型本身,或者還沒有檢驗的時候。', + 'cal_guide.mipd': + '找一個最合適的個人倍數和清除速度,同時被群體平均拉住。檢驗少時變化小,三次以上分散的檢驗才會改半衰期,單個離群值會被降權。', + 'cal_guide.ekf': + '按時間順序一次處理一個檢驗,每次都在上一次的估計上修正一點,並記住自己有多確定。舊檢驗會一直留著影響。', + 'cal_guide.ou_kalman': '認為你和模型的差別會隨時間漂移,兩週左右就回到模型。只改水準,不改曲線形狀。', + 'cal_guide.bayes': + '同時考慮幾種說法:差別固定,或者在兩週、三個月、一年內漂移。按檢驗對每種說法的支持程度加權平均,並給出可能範圍。', + 'cal_guide.mcmc': + '為你估一整套自己的藥代參數:每種給藥途徑各自的水準、清除速度和吸收速度。沒有公式能直接解出來,所以用 MCMC 隨機抽幾千組參數,曲線是它們的平均。', + 'cal_guide.noise.title': '每次檢驗有多可信', + 'cal_guide.noise.text': + '全貝氏和個人藥代為每次檢驗算一個誤差。免疫法在低濃度下不準,抽血時間記得不精確時曲線越陡影響越大,所以這些檢驗分量更輕。', 'cal.history': '套用於歷史', 'cal.forward': '前向', 'cal.retrospective': '回顧', 'cal.desc.forward': '因果方式——每個時點只使用當時已有的檢驗,新增檢驗不會改寫過去的估計。', 'cal.desc.retrospective': '平滑方式——用全部檢驗重新估計整段歷史。事後更準確;新檢驗可能修正較早的估計。', - 'cal.source': 'EKF、OU-Kalman 與 Hybrid-MIPD 借鏡自 hrt.transmtf.com 的校準模型;全貝氏為本應用自有。', + 'cal.source': 'EKF、OU-Kalman 與 Hybrid-MIPD 借鏡自 hrt.transmtf.com 的校準模型;全貝氏與個人藥代為本應用自有。', 'cal.amplitude': '幅度', 'cal.halflife': '半衰期', 'cal.fit': '擬合', @@ -4379,12 +4497,51 @@ export const TRANSLATIONS = { 'cal.bayes.pro.1': '由你嘅檢驗決定水平係固定定係漂緊', 'cal.bayes.pro.2': '計埋檢測方法同抽血時間;俾出真實嘅可能範圍', 'cal.bayes.con.1': '呢個應用自己嘅方法,唔係 hrt.transmtf.com 嘅模型;計算最慢', + 'cal.mcmc': '個人藥代', + 'cal.desc.mcmc': + '幫你估一套自己嘅藥代參數:每種用藥途徑各自嘅水平、清除速度同吸收速度,用 MCMC 抽樣得到完整嘅後驗,條曲線係所有可能情況嘅平均。', + 'cal.mcmc.pro.1': '唔同用藥途徑分開校準,例如舌下偏低、注射偏高', + 'cal.mcmc.pro.2': '睇得出吸收快慢,峰值同谷值嘅位置都會跟住變', + 'cal.mcmc.con.1': '呢個應用自己嘅方法,仲喺驗證緊;唔考慮隨時間嘅漂移', + 'cal.absorption': '吸收速度', + 'cal.route.injection': '注射', + 'cal.route.oral': '口服', + 'cal.route.sublingual': '舌下', + 'cal.route.gel': '凝膠', + 'cal.route.patchApply': '貼片', + 'cal_guide.title': '校準方法講解', + 'cal_guide.entry': '呢啲方法點樣計', + 'cal_guide.lead': + '檢驗結果同模型唔同嗰陣,校準會將條曲線推向檢驗。六種方法推法唔同,下面啲圖同公式都係應用真係用緊嘅計算。', + 'cal_guide.try.title': '試吓', + 'cal_guide.try.text': '每個禮拜注射 5 mg 戊酸雌二醇,後半段抽咗幾次血。拉吓下面啲控制,睇吓每種方法點反應。', + 'cal_guide.method': '方法', + 'cal_guide.caption': '虛線係原始模型,陰影係 90% 嘅可能範圍,菱形係檢驗', + 'cal_guide.level': '檢驗比模型高幾多', + 'cal_guide.change': '第四次起水平變成', + 'cal_guide.count': '檢驗次數', + 'cal_guide.wrong': '有一次檢驗測錯咗', + 'cal_guide.try_this': '喺上面幅圖度試吓', + 'cal_guide.off': '唔校準,直接顯示模型。想睇模型本身,或者未有檢驗嗰陣啱用。', + 'cal_guide.mipd': + '搵一個最啱嘅個人倍數同清除速度,同時俾群體平均拉住。檢驗少嗰陣變化細,三次以上分散嘅檢驗先會改半衰期,單一個離群值會被降權。', + 'cal_guide.ekf': + '按時間順序一次處理一個檢驗,每次喺上一次嘅估計上面修正少少,仲記住自己有幾確定。舊檢驗會一直留低影響。', + 'cal_guide.ou_kalman': '當你同模型嘅差別會隨時間漂移,兩個禮拜左右就返去模型。淨係改水平,唔改曲線形狀。', + 'cal_guide.bayes': + '同時考慮幾種講法:差別固定,或者喺兩個禮拜、三個月、一年內漂移。按檢驗對每種講法嘅支持程度加權平均,仲會畀出可能範圍。', + 'cal_guide.mcmc': + '幫你估一整套自己嘅藥代參數:每種用藥途徑各自嘅水平、清除速度同吸收速度。冇公式可以直接解到,所以用 MCMC 隨機抽幾千組參數,條曲線係佢哋嘅平均。', + 'cal_guide.noise.title': '每次檢驗有幾可信', + 'cal_guide.noise.text': + '全貝葉斯同個人藥代會幫每次檢驗計一個誤差。免疫法喺低濃度唔準,抽血時間記得唔準嗰陣曲線越斜影響越大,所以呢啲檢驗分量輕啲。', 'cal.history': '套用喺歷史', 'cal.forward': '前向', 'cal.retrospective': '回顧', 'cal.desc.forward': '因果方式——每個時點淨係用當時已有嘅檢驗,加新檢驗唔會改寫過去嘅估計。', 'cal.desc.retrospective': '平滑方式——用晒所有檢驗重新估計成段歷史。事後準啲;新檢驗可能會改返早啲嘅估計。', - 'cal.source': 'EKF、OU-Kalman 同 Hybrid-MIPD 參考自 hrt.transmtf.com 嘅校準模型;全貝葉斯係呢個應用自己嘅。', + 'cal.source': + 'EKF、OU-Kalman 同 Hybrid-MIPD 參考自 hrt.transmtf.com 嘅校準模型;全貝葉斯同個人藥代係呢個應用自己嘅。', 'cal.amplitude': '幅度', 'cal.halflife': '半衰期', 'cal.fit': '擬合', @@ -5505,6 +5662,46 @@ export const TRANSLATIONS = { 'cal.bayes.pro.1': '水準が一定か変動しているかを検査結果が判断', 'cal.bayes.pro.2': '測定法と採血時刻を考慮し、正直な推定範囲を示す', 'cal.bayes.con.1': 'このアプリ独自の方法で hrt.transmtf.com のモデルではない。計算が最も重い', + 'cal.mcmc': '個人 PK', + 'cal.desc.mcmc': + 'あなた自身の薬物動態を推定します。投与経路ごとの水準、クリアランス、吸収速度を MCMC でサンプリングして事後分布全体を求め、曲線はありうるすべての場合の平均です。', + 'cal.mcmc.pro.1': '投与経路ごとに別々に補正。例えば舌下は低め、注射は高め', + 'cal.mcmc.pro.2': '吸収の速さがわかり、ピークと谷の位置も変わる', + 'cal.mcmc.con.1': 'このアプリ独自の方法で検証中。時間による変動は扱わない', + 'cal.absorption': '吸収速度', + 'cal.route.injection': '注射', + 'cal.route.oral': '経口', + 'cal.route.sublingual': '舌下', + 'cal.route.gel': 'ジェル', + 'cal.route.patchApply': 'パッチ', + 'cal_guide.title': '補正方法の解説', + 'cal_guide.entry': '各方法の仕組み', + 'cal_guide.lead': + '検査結果がモデルと違うとき、補正は曲線を検査値の方へ動かします。6つの方法は動かし方が違い、下の図と式はすべてアプリが実際に行っている計算です。', + 'cal_guide.try.title': '試してみる', + 'cal_guide.try.text': + '毎週 5 mg の吉草酸エストラジオール注射で、後半に何回か採血した例です。下の操作を動かして、各方法の反応を見てください。', + 'cal_guide.method': '方法', + 'cal_guide.caption': '破線は補正前のモデル、薄い帯は 90% の範囲、ひし形は検査値', + 'cal_guide.level': '検査値はモデルの何倍か', + 'cal_guide.change': '4回目以降の水準', + 'cal_guide.count': '検査の回数', + 'cal_guide.wrong': '1回の検査が誤っていた', + 'cal_guide.try_this': '上の図で試す', + 'cal_guide.off': '補正せずにモデルをそのまま表示します。モデル自体を見たいときや、まだ検査がないときに。', + 'cal_guide.mipd': + '集団平均に引き留められながら、最も合う個人の倍率とクリアランスを探します。検査が少ないうちは動きが小さく、時期の分かれた検査が3回以上で半減期が変わり、外れ値1つは重みを下げられます。', + 'cal_guide.ekf': + '検査を時間順に1つずつ処理し、そのたびに前の推定を少し直し、自分の確かさも覚えておきます。古い検査の影響はずっと残ります。', + 'cal_guide.ou_kalman': + 'モデルとの違いは時間とともに漂い、2週間ほどでモデルに戻るとみなします。水準だけを直し、曲線の形は変えません。', + 'cal_guide.bayes': + '違いが一定、または2週間・3か月・1年で漂う、といった複数の仮説を同時に考え、検査がそれぞれをどれだけ支持するかで重み付け平均し、ありうる範囲も示します。', + 'cal_guide.mcmc': + 'あなた自身の薬物動態を一式推定します。投与経路ごとの水準、クリアランス、吸収速度です。直接解ける式がないため、MCMC で数千組をランダムに抜き出し、曲線はその平均です。', + 'cal_guide.noise.title': '各検査の信頼度', + 'cal_guide.noise.text': + 'フルベイズと個人 PK は検査ごとに誤差を計算します。免疫測定法は低濃度で不正確で、採血時刻があいまいだと曲線が急な所ほど影響が大きいため、そうした検査は重みが軽くなります。', 'cal.history': '履歴への適用', 'cal.forward': '前向き', 'cal.retrospective': '遡及', @@ -5512,7 +5709,7 @@ export const TRANSLATIONS = { 'cal.desc.retrospective': '平滑化——すべての検査で履歴全体を再推定します。事後的には正確ですが、新しい検査が古い推定を修正することがあります。', 'cal.source': - 'EKF・OU-Kalman・Hybrid-MIPD は hrt.transmtf.com の補正モデルを参考にしています。フルベイズはこのアプリ独自です。', + 'EKF・OU-Kalman・Hybrid-MIPD は hrt.transmtf.com の補正モデルを参考にしています。フルベイズと個人 PK はこのアプリ独自です。', 'cal.amplitude': '振幅', 'cal.halflife': '半減期', 'cal.fit': '適合', @@ -6334,6 +6531,47 @@ export const TRANSLATIONS = { 'cal.bayes.pro.1': '수준이 고정인지 변동하는지 검사 결과가 판단', 'cal.bayes.pro.2': '측정법과 채혈 시각을 고려해 정직한 추정 범위를 제시', 'cal.bayes.con.1': '이 앱 고유의 방법으로 hrt.transmtf.com 모델이 아님. 계산이 가장 무거움', + 'cal.mcmc': '개인 PK', + 'cal.desc.mcmc': + '당신만의 약동학을 추정합니다. 투여 경로별 수준, 청소율, 흡수 속도를 MCMC로 샘플링해 전체 사후분포를 구하고, 곡선은 가능한 모든 경우의 평균입니다.', + 'cal.mcmc.pro.1': '투여 경로마다 따로 보정. 예: 설하는 낮고 주사는 높음', + 'cal.mcmc.pro.2': '흡수 속도를 알아내 최고점과 최저점 위치도 함께 바뀜', + 'cal.mcmc.con.1': '이 앱 고유의 방법으로 아직 검증 중. 시간에 따른 변동은 다루지 않음', + 'cal.absorption': '흡수 속도', + 'cal.route.injection': '주사', + 'cal.route.oral': '경구', + 'cal.route.sublingual': '설하', + 'cal.route.gel': '젤', + 'cal.route.patchApply': '패치', + 'cal_guide.title': '보정 방법 설명', + 'cal_guide.entry': '이 방법들은 어떻게 계산하나요', + 'cal_guide.lead': + '검사 결과가 모델과 다를 때 보정은 곡선을 검사값 쪽으로 옮깁니다. 여섯 가지 방법은 옮기는 방식이 다르며, 아래의 그림과 식은 모두 앱이 실제로 쓰는 계산입니다.', + 'cal_guide.try.title': '직접 해 보기', + 'cal_guide.try.text': + '매주 발레르산 에스트라디올 5 mg 주사, 후반에 몇 번 채혈한 예입니다. 아래 조절기를 움직여 각 방법이 어떻게 반응하는지 보세요.', + 'cal_guide.method': '방법', + 'cal_guide.caption': '점선은 보정 전 모델, 옅은 띠는 90% 범위, 마름모는 검사값', + 'cal_guide.level': '검사값은 모델의 몇 배', + 'cal_guide.change': '네 번째 검사부터 수준', + 'cal_guide.count': '검사 횟수', + 'cal_guide.wrong': '검사 한 번이 잘못 측정됨', + 'cal_guide.try_this': '위 그림에서 해 보기', + 'cal_guide.off': + '보정하지 않고 모델을 그대로 보여 줍니다. 모델 자체를 보고 싶을 때나 아직 검사가 없을 때 좋습니다.', + 'cal_guide.mipd': + '집단 평균에 붙잡힌 채 가장 잘 맞는 개인 배율과 청소율을 찾습니다. 검사가 적으면 변화가 작고, 시기가 나뉜 검사가 세 번 이상이어야 반감기가 바뀌며, 이상값 하나는 가중치가 낮아집니다.', + 'cal_guide.ekf': + '검사를 시간 순서대로 하나씩 처리하며 매번 이전 추정을 조금씩 고치고, 얼마나 확실한지도 기억합니다. 오래된 검사의 영향은 계속 남습니다.', + 'cal_guide.ou_kalman': + '모델과의 차이가 시간에 따라 표류하다가 2주쯤 지나면 모델로 돌아간다고 봅니다. 수준만 고치고 곡선 모양은 바꾸지 않습니다.', + 'cal_guide.bayes': + '차이가 고정되어 있거나 2주, 3개월, 1년에 걸쳐 표류한다는 여러 가설을 함께 고려하고, 검사가 각각을 얼마나 지지하는지로 가중 평균하며 가능한 범위도 보여 줍니다.', + 'cal_guide.mcmc': + '당신만의 약동학을 한 벌 추정합니다. 투여 경로별 수준, 청소율, 흡수 속도입니다. 바로 풀리는 식이 없어서 MCMC로 수천 벌을 무작위로 뽑고, 곡선은 그 평균입니다.', + 'cal_guide.noise.title': '각 검사의 신뢰도', + 'cal_guide.noise.text': + '완전 베이즈와 개인 PK는 검사마다 오차를 계산합니다. 면역측정법은 낮은 농도에서 부정확하고, 채혈 시각이 부정확하면 곡선이 가파른 곳일수록 영향이 커서 그런 검사는 가중치가 낮아집니다.', 'cal.history': '이력 적용 방식', 'cal.forward': '전향', 'cal.retrospective': '회고', @@ -6342,7 +6580,7 @@ export const TRANSLATIONS = { 'cal.desc.retrospective': '평활 —— 모든 검사로 전체 이력을 다시 추정합니다. 사후에는 더 정확하지만 새 검사가 이전 추정을 수정할 수 있습니다.', 'cal.source': - 'EKF, OU-Kalman, Hybrid-MIPD는 hrt.transmtf.com의 보정 모델을 참고했습니다. 완전 베이즈는 이 앱 고유의 방법입니다.', + 'EKF, OU-Kalman, Hybrid-MIPD는 hrt.transmtf.com의 보정 모델을 참고했습니다. 완전 베이즈와 개인 PK는 이 앱 고유의 방법입니다.', 'cal.amplitude': '진폭', 'cal.halflife': '반감기', 'cal.fit': '적합', @@ -7494,6 +7732,47 @@ export const TRANSLATIONS = { 'cal.bayes.pro.2': 'Ölçüm yöntemini ve kan alma saatini hesaba katar; dürüst bir olası aralık verir', 'cal.bayes.con.1': 'Bu uygulamaya özgü bir yöntem, hrt.transmtf.com modellerinden değil; hesaplaması en ağır olanı', + 'cal.mcmc': 'Kişisel PK', + 'cal.desc.mcmc': + 'Size ait bir farmakokinetik kurar: her yol için ayrı bir düzey, temizlenme ve emilim hızı. Tam sonsal dağılım MCMC ile örneklenir; eğri olası tüm durumların ortalamasıdır.', + 'cal.mcmc.pro.1': 'Her uygulama yolunu ayrı kalibre eder, örneğin dil altı düşük, enjeksiyon yüksek', + 'cal.mcmc.pro.2': 'Emilim hızınızı öğrenir; tepe ve dip noktaları da buna göre kayar', + 'cal.mcmc.con.1': 'Bu uygulamaya özgü, hâlâ doğrulanan bir yöntem; zamanla kaymayı modellemez', + 'cal.absorption': 'emilim hızı', + 'cal.route.injection': 'enjeksiyon', + 'cal.route.oral': 'oral', + 'cal.route.sublingual': 'dil altı', + 'cal.route.gel': 'jel', + 'cal.route.patchApply': 'bant', + 'cal_guide.title': 'Kalibrasyon nasıl çalışır', + 'cal_guide.entry': 'Bu yöntemler nasıl hesaplar', + 'cal_guide.lead': + 'Tahlilleriniz modelden farklı olduğunda kalibrasyon eğriyi onlara doğru kaydırır. Altı yöntem bunu farklı biçimlerde yapar; aşağıdaki her resim ve formül uygulamanın gerçekten yaptığı hesaptır.', + 'cal_guide.try.title': 'Deneyin', + 'cal_guide.try.text': + 'Haftalık 5 mg estradiol valerat, ikinci yarıda birkaç tahlil. Aşağıdaki denetimleri oynatın ve her yöntemin nasıl tepki verdiğini izleyin.', + 'cal_guide.method': 'Yöntem', + 'cal_guide.caption': 'Kesikli çizgi ham model, açık alan yüzde 90 olası aralık, baklavalar tahliller', + 'cal_guide.level': 'Tahliller modele göre', + 'cal_guide.change': 'Dördüncü tahlilden itibaren düzey', + 'cal_guide.count': 'Tahlil sayısı', + 'cal_guide.wrong': 'Bir tahlil yanlış ölçüldü', + 'cal_guide.try_this': 'Yukarıdaki resimde deneyin', + 'cal_guide.off': + 'Kalibrasyon yok: model olduğu gibi. Modelin kendisini görmek için ya da henüz tahliliniz yokken.', + 'cal_guide.mipd': + 'Toplum ortalaması onu tutarken en uygun kişisel ölçeği ve temizlenmeyi bulur. Az tahlil onu az oynatır; zamana yayılmış üç ya da daha fazla tahlil yarı ömrü değiştirebilir ve tek bir aykırı değerin ağırlığı düşürülür.', + 'cal_guide.ekf': + 'Tahlilleri sırayla tek tek alır, her seferinde son tahmini biraz düzeltir ve ne kadar emin olduğunu da tutar. Eski tahliller etkisini hep korur.', + 'cal_guide.ou_kalman': + 'Modelden farkı zamanla kayan ve yaklaşık iki haftada modele dönen bir şey olarak görür. Yalnızca düzeyi düzeltir, eğrinin biçimini değiştirmez.', + 'cal_guide.bayes': + 'Birkaç olasılığı birlikte tartar: sabit bir fark ya da iki hafta, üç ay veya bir yılda kayan bir fark. Tahlillerin her birini ne kadar desteklediğine göre ağırlıklı ortalama alır ve olası aralığı da verir.', + 'cal_guide.mcmc': + 'Size ait bütün bir farmakokinetik kurar: her yol için bir düzey, temizlenme ve emilim hızı. Bunu doğrudan çözen bir formül olmadığından MCMC binlerce olası seti rastgele çeker ve eğri onların ortalamasıdır.', + 'cal_guide.noise.title': 'Her tahlil ne kadar sayılır', + 'cal_guide.noise.text': + 'Tam Bayes ve Kişisel PK her tahlile kendi hatasını verir. İmmün testler düşük düzeylerde daha az doğrudur, kesin olmayan bir alım saati de eğrinin dik olduğu yerde daha çok önem taşır; bu yüzden bu tahliller daha az sayılır.', 'cal.history': 'Geçmişe uygulama', 'cal.forward': 'İleri', 'cal.retrospective': 'Geriye dönük', @@ -7502,7 +7781,7 @@ export const TRANSLATIONS = { 'cal.desc.retrospective': 'Düzleştirilmiş — tüm tahliller geçmişin tamamını yeniden tahmin eder. Geriye dönük olarak daha doğrudur; yeni bir tahlil eski tahminleri değiştirebilir.', 'cal.source': - "EKF, OU-Kalman ve Hybrid-MIPD hrt.transmtf.com'daki kalibrasyon modellerini örnek alır; Tam Bayes bu uygulamaya özgüdür.", + "EKF, OU-Kalman ve Hybrid-MIPD hrt.transmtf.com'daki kalibrasyon modellerini örnek alır; Tam Bayes ve Kişisel PK bu uygulamaya özgüdür.", 'cal.amplitude': 'genlik', 'cal.halflife': 'yarı ömür', 'cal.fit': 'uyum', diff --git a/src/i18n/uk.ts b/src/i18n/uk.ts index 6a8d93a7..ff537b49 100644 --- a/src/i18n/uk.ts +++ b/src/i18n/uk.ts @@ -782,6 +782,46 @@ export const UK = { 'cal.bayes.pro.1': 'Ваші аналізи самі вирішують, сталий у вас рівень чи дрейфує', 'cal.bayes.pro.2': 'Враховує метод аналізу і час забору, дає чесний імовірний діапазон', 'cal.bayes.con.1': 'Власний метод застосунку, не з моделей hrt.transmtf.com; рахується найдовше', + 'cal.mcmc': 'Особиста ФК', + 'cal.desc.mcmc': + 'Підбирає вашу власну фармакокінетику: рівень для кожного шляху введення, швидкість виведення і швидкість всмоктування. Повний апостеріорний розподіл отримується вибіркою MCMC, крива усереднює всі ймовірні варіанти.', + 'cal.mcmc.pro.1': 'Калібрує кожен шлях введення окремо, наприклад сублінгвально нижче, а ін’єкції вище', + 'cal.mcmc.pro.2': 'Дізнається, як швидко ви всмоктуєте препарат, тому зсуваються піки й спади', + 'cal.mcmc.con.1': 'Власний метод застосунку, ще перевіряється; не враховує дрейф із часом', + 'cal.absorption': 'швидкість всмоктування', + 'cal.route.injection': 'ін’єкції', + 'cal.route.oral': 'перорально', + 'cal.route.sublingual': 'сублінгвально', + 'cal.route.gel': 'гель', + 'cal.route.patchApply': 'пластир', + 'cal_guide.title': 'Як працює калібрування', + 'cal_guide.entry': 'Як рахують ці методи', + 'cal_guide.lead': + 'Коли аналізи розходяться з моделлю, калібрування зсуває криву до них. Шість методів роблять це по-різному; кожен малюнок і формула нижче це розрахунок, який застосунок виконує насправді.', + 'cal_guide.try.title': 'Спробуйте', + 'cal_guide.try.text': + 'Щотижня 5 мг естрадіолу валерату, у другій половині кілька аналізів. Рухайте налаштування й дивіться, як відповідає кожен метод.', + 'cal_guide.method': 'Метод', + 'cal_guide.caption': 'Пунктир це вихідна модель, заливка ймовірний діапазон 90%, ромби аналізи', + 'cal_guide.level': 'Аналізи відносно моделі', + 'cal_guide.change': 'З четвертого аналізу рівень стає', + 'cal_guide.count': 'Кількість аналізів', + 'cal_guide.wrong': 'Один аналіз виміряно неправильно', + 'cal_guide.try_this': 'Спробувати на малюнку вище', + 'cal_guide.off': 'Без калібрування: модель як є. Зручно, щоб побачити саму модель або поки аналізів немає.', + 'cal_guide.mipd': + 'Знаходить найкращий особистий множник і швидкість виведення, а середнє по популяції утримує його. Мало аналізів зсувають його слабко; три й більше рознесених у часі можуть змінити період напіввиведення, а поодинокий викид отримує меншу вагу.', + 'cal_guide.ekf': + 'Бере аналізи по одному за порядком, щоразу трохи виправляючи минулу оцінку й пам’ятаючи, наскільки впевнений. Старі аналізи зберігають вплив назавжди.', + 'cal_guide.ou_kalman': + 'Вважає відмінність від моделі дрейфом, що повертається до моделі приблизно за два тижні. Виправляє лише рівень, не форму кривої.', + 'cal_guide.bayes': + 'Зважує одразу кілька версій: відмінність стала або дрейфує за два тижні, три місяці чи рік. Усереднює їх за тим, наскільки аналізи підтримують кожну, і дає ймовірний діапазон.', + 'cal_guide.mcmc': + 'Підбирає цілком вашу фармакокінетику: рівень для кожного шляху введення, швидкість виведення і всмоктування. Прямої формули для цього немає, тому MCMC випадково витягує тисячі можливих наборів, а крива їхнє середнє.', + 'cal_guide.noise.title': 'Наскільки враховується кожен аналіз', + 'cal_guide.noise.text': + 'Повний баєс і особиста ФК дають кожному аналізу власну похибку. Імуноаналіз менш точний за низьких рівнів, а неточний час забору важить більше там, де крива крута, тому такі аналізи важать менше.', 'cal.history': 'Застосування до історії', 'cal.forward': 'Уперед', 'cal.retrospective': 'Ретроспективно', @@ -790,7 +830,7 @@ export const UK = { 'cal.desc.retrospective': 'Згладжено: усі аналізи заново оцінюють усю історію. Точніше заднім числом; новий аналіз може переглянути старі оцінки.', 'cal.source': - 'EKF, OU-Kalman і Hybrid-MIPD повторюють моделі калібрування з hrt.transmtf.com; повний баєс власний метод застосунку.', + 'EKF, OU-Kalman і Hybrid-MIPD повторюють моделі калібрування з hrt.transmtf.com; повний баєс і особиста ФК власні методи застосунку.', 'cal.amplitude': 'амплітуда', 'cal.halflife': 'період напіввиведення', 'cal.fit': 'припасування', diff --git a/src/pages/CalibrationGuide.tsx b/src/pages/CalibrationGuide.tsx new file mode 100644 index 00000000..f7022767 --- /dev/null +++ b/src/pages/CalibrationGuide.tsx @@ -0,0 +1,320 @@ +import React, { useDeferredValue, useMemo, useRef, useState } from 'react'; +import katex from 'katex'; +import 'katex/dist/katex.min.css'; +import PageHeader, { PAGE_COLUMN } from '../components/PageHeader'; +import ModelChart, { type ModelSeries } from '../components/ModelChart'; +import CustomSelect from '../components/CustomSelect'; +import Switch from '../components/Switch'; +import CalibrationDoodle from '../components/CalibrationDoodle'; +import { useTranslation } from '../contexts/LanguageContext'; +import { + runSimulation, + computeCalibration, + interpolateConcentration_E2, + Route, + Ester, + ExtraKey, + type CalibrationMethod, + type DoseEvent, + type LabResult, +} from '../../logic'; + +/** + * How each calibration method works, one level below the calibration + * settings: a picture you can move at the top, then each method with a small + * drawing and its formula. The picture is computed by computeCalibration on a + * runSimulation curve, the same functions the Lab page runs, so what it shows + * is what the method really does with labs like these. + */ + +const METHODS: CalibrationMethod[] = ['off', 'mipd', 'ekf', 'ou_kalman', 'bayes', 'mcmc']; +const CLEARANCE: CalibrationMethod[] = ['mipd', 'ekf', 'bayes', 'mcmc']; + +const D = 24; +const WEEK = 7 * D; +const DOSES = 12; +const SPAN_H = DOSES * WEEK + 3 * D; +const SAMPLES = 280; +/** Labs in the second half of the picture: which dose they follow, and how many hours after it. */ +const LAB_SLOTS: [number, number][] = [ + [5, 100], + [6, 160], + [7, 40], + [8, 150], + [9, 70], + [10, 165], +]; +const Z90 = 1.6448536269514722; + +/** Each formula as short lines, so none runs past a phone's width. */ +const FORMULAS: Record = { + off: [String.raw`\hat C(t) = C(t)`], + mipd: [ + String.raw`\hat C(t) = e^{\hat a}\, C(t; \hat k)`, + String.raw`(\hat a, \hat k) = \arg\max_{a,k}\ p(a, k \mid y)`, + ], + ekf: [ + String.raw`\theta_i = \theta_{i-1} + K_i\big(\log y_i - h_i(\theta_{i-1})\big)`, + String.raw`K_i = \frac{P_i H_i^{\top}}{H_i P_i H_i^{\top} + \sigma_i^2}`, + ], + ou_kalman: [ + String.raw`\mathrm{d}r = -\frac{r}{\tau}\,\mathrm{d}t + s\sqrt{\tfrac{2}{\tau}}\,\mathrm{d}W`, + String.raw`\tau = 14\ \text{d}`, + ], + bayes: [ + String.raw`p(\hat C \mid y) = \sum_h p(h \mid y)\, p(\hat C \mid y, h)`, + String.raw`p(h \mid y) \propto p(y \mid h)\, p(h)`, + ], + mcmc: [ + String.raw`\hat C(t) = \sum_r e^{\,s + u_r}\, C_r(t; k, a)`, + String.raw`\alpha = \min\left\{1,\ \frac{p(\theta' \mid y)}{p(\theta \mid y)}\right\}`, + ], +}; +const NOISE = [ + String.raw`\sigma_i^2 = 0.13^2 + \left(\frac{f}{y_i}\right)^2 + \left(1\,\text{h} \cdot \partial_t \log C\right)^2`, +]; + +const Formula: React.FC<{ lines: string[] }> = ({ lines }) => { + const latex = String.raw`\begin{gathered}` + lines.join(String.raw`\\[6pt]`) + String.raw`\end{gathered}`; + const html = useMemo( + () => katex.renderToString(latex, { displayMode: true, throwOnError: false, strict: 'ignore' }), + [latex], + ); + return ( +
+ ); +}; + +/** A labelled slider, its value at the end of the label line (as on the model page). */ +const Slider: React.FC<{ + id: string; + label: string; + shown: string; + min: number; + max: number; + step: number; + value: number; + onChange: (v: number) => void; +}> = ({ id, label, shown, min, max, step, value, onChange }) => ( +
+ + onChange(Number(e.target.value))} + className="mt-3 h-1 w-full accent-[var(--accent)]" + /> +
+); + +const times = (n: number) => `×${n.toFixed(2)}`; + +const CalibrationGuide: React.FC<{ onBack: () => void; weight: number }> = ({ onBack, weight }) => { + const { t } = useTranslation(); + const top = useRef(null); + + const [method, setMethod] = useState('mcmc'); + const [level, setLevel] = useState(1.4); + const [change, setChange] = useState(1); + const [count, setCount] = useState(4); + const [wrong, setWrong] = useState(false); + // Sampling methods take a moment; the sliders stay smooth and the picture follows. + const inputs = useDeferredValue({ method, level, change, count, wrong }); + + // Weekly 5 mg estradiol valerate for twelve weeks, ending a few days before now. + const base = useMemo(() => { + const t0 = Math.floor(Date.now() / 3_600_000) - SPAN_H; + const events: DoseEvent[] = Array.from({ length: DOSES }, (_, i) => ({ + id: `cal-guide-${i}`, + route: Route.injection, + ester: Ester.EV, + timeH: t0 + i * WEEK, + doseMG: 5, + extras: { [ExtraKey.concentrationMGmL]: 20 }, + })); + const w = Math.min(120, Math.max(40, Math.round(weight || 60))); + const sim = runSimulation(events, w); + return { t0, events, sim, w }; + }, [weight]); + + const picture = useMemo(() => { + const { t0, events, sim, w } = base; + if (!sim) return null; + const at = (h: number) => interpolateConcentration_E2(sim, h) ?? 0; + const n = inputs.count; + const wrongAt = Math.min(n - 1, 2); + const labs: LabResult[] = LAB_SLOTS.slice(0, n).map(([dose, after], i) => { + const h = t0 + dose * WEEK + after; + const ratio = + (i >= 3 ? inputs.level * inputs.change : inputs.level) * (inputs.wrong && i === wrongAt ? 3 : 1); + return { id: `cal-guide-lab-${i}`, timeH: h, concValue: Math.round(at(h) * ratio), unit: 'pg/ml' }; + }); + const cal = computeCalibration(sim, events, w, labs, inputs.method, 'retrospective'); + const hours = Array.from({ length: SAMPLES }, (_, i) => t0 + (i / (SAMPLES - 1)) * SPAN_H); + const raw = hours.map(at); + const fitted = hours.map((h, i) => raw[i] * cal.factorFn(h)); + const sd = cal.factorSdFn; + const series: ModelSeries[] = [{ values: raw, ghost: true }]; + if (sd && inputs.method !== 'off') { + const hi = hours.map((h, i) => fitted[i] * Math.exp(Z90 * sd(h))); + const lo = hours.map((h, i) => fitted[i] * Math.exp(-Z90 * sd(h))); + series.push({ values: hi, edge: true, fillTo: 2 }, { values: lo, edge: true }); + } else { + // Keep the same number of lines, so switching method glides instead of snapping. + series.push({ values: fitted, edge: true }, { values: fitted, edge: true }); + } + series.push({ values: fitted }); + const lastLab = labs[labs.length - 1]; + return { + series, + labs: labs.map(l => ({ x: (l.timeH - t0) / D, y: l.concValue })), + doses: events.map(e => (e.timeH - t0) / D), + atLab: cal.factorFn(lastLab.timeH), + halfLife: cal.halfLifeDeltaPct, + fit: cal.fitErrPct, + }; + }, [base, inputs]); + + const signedPct = (p: number) => `${p >= 0 ? '+' : ''}${p.toFixed(0)}%`; + const tryThis = (m: CalibrationMethod) => { + setMethod(m); + top.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + return ( +
+ +
+

{t('cal_guide.lead')}

+ +
+

{t('cal_guide.try.title')}

+

{t('cal_guide.try.text')}

+ +
+ setMethod(v as CalibrationMethod)} + options={METHODS.map(m => ({ value: m, label: t(`cal.${m}`) }))} + /> +
+ + {picture && ( +
+ +

{t('cal_guide.caption')}

+
+ )} + + + + +
+ + +
+ + {picture && ( +
+
+
{t('cal.interval')}
+
+ {times(picture.atLab)} +
+
+ {CLEARANCE.includes(inputs.method) && ( +
+
{t('cal.halflife')}
+
+ {signedPct(picture.halfLife)} +
+
+ )} + {picture.fit !== null && inputs.method !== 'off' && ( +
+
{t('cal.fit')}
+
+ ±{picture.fit.toFixed(0)}% +
+
+ )} +
+ )} +
+ + {METHODS.map(m => ( +
+
+

{t(`cal.${m}`)}

+ +
+

{t(`cal_guide.${m}`)}

+ + {m !== method && ( + + )} +
+ ))} + +
+

{t('cal_guide.noise.title')}

+

{t('cal_guide.noise.text')}

+ +
+
+
+ ); +}; + +export default CalibrationGuide; diff --git a/src/pages/CalibrationSettings.tsx b/src/pages/CalibrationSettings.tsx index 77d5660e..aa4cd026 100644 --- a/src/pages/CalibrationSettings.tsx +++ b/src/pages/CalibrationSettings.tsx @@ -9,6 +9,8 @@ import { Radar, Wind, Merge, + ChevronRight, + Dice, Rewind, FastForward, type IconComponent, @@ -22,6 +24,7 @@ interface CalibrationSettingsProps { historyMode: CalibrationHistoryMode; setHistoryMode: (m: CalibrationHistoryMode) => void; calibration: CalibrationResult; + onOpenGuide: () => void; onBack: () => void; } @@ -41,7 +44,9 @@ interface MethodDef { // hrt.transmtf.com (EKF parameter learning, OU-Kalman dynamic calibration, // Hybrid-MIPD Bayesian fit), reimplemented over this engine's two identifiable // parameters: a personal amplitude and a personal clearance. Full Bayes is this -// app's own: it averages over several of those stories at once. +// app's own: it averages over several of those stories at once. Personal PK is +// also this app's own: a level per route, clearance and absorption speed, +// sampled by MCMC. const METHODS: MethodDef[] = [ { value: 'off', icon: Off, pros: ['cal.off.pro.1'], cons: ['cal.off.con.1'] }, { @@ -64,10 +69,16 @@ const METHODS: MethodDef[] = [ pros: ['cal.bayes.pro.1', 'cal.bayes.pro.2'], cons: ['cal.bayes.con.1'], }, + { + value: 'mcmc', + icon: Dice, + pros: ['cal.mcmc.pro.1', 'cal.mcmc.pro.2'], + cons: ['cal.mcmc.con.1'], + }, ]; -// EKF, MIPD and Full Bayes fit a personal clearance; OU-Kalman corrects amplitude only. -const fitsClearance = (m: CalibrationMethod) => m === 'ekf' || m === 'mipd' || m === 'bayes'; +// EKF, MIPD, Full Bayes and Personal PK fit a personal clearance; OU-Kalman corrects amplitude only. +const fitsClearance = (m: CalibrationMethod) => m === 'ekf' || m === 'mipd' || m === 'bayes' || m === 'mcmc'; // The drift memory the labs favour most, for the Full Bayes summary. const likeliestDrift = (drift: Record): [BayesDriftFamily, number] => @@ -132,6 +143,7 @@ const CalibrationSettings: React.FC = ({ historyMode, setHistoryMode, calibration, + onOpenGuide, onBack, }) => { const { t } = useTranslation(); @@ -146,7 +158,10 @@ const CalibrationSettings: React.FC = ({ const latestLabH = calibration.points.length ? calibration.points[calibration.points.length - 1].timeH : null; const stats: { label: string; value: string }[] = showFit ? [ - { label: t('cal.amplitude'), value: `×${calibration.scale.toFixed(2)}` }, + // With a level per route, those rows replace the single amplitude. + ...(calibration.routeScales + ? [] + : [{ label: t('cal.amplitude'), value: `×${calibration.scale.toFixed(2)}` }]), ...(calibration.latestInterval && latestLabH !== null ? [ { @@ -158,6 +173,13 @@ const CalibrationSettings: React.FC = ({ ...(fitsClearance(method) ? [{ label: t('cal.halflife'), value: signedPct(calibration.halfLifeDeltaPct) }] : []), + ...(calibration.absMul !== undefined + ? [{ label: t('cal.absorption'), value: signedPct((calibration.absMul - 1) * 100) }] + : []), + ...Object.entries(calibration.routeScales ?? {}).map(([route, v]) => ({ + label: t(`cal.route.${route}`), + value: `×${(v as number).toFixed(2)}`, + })), ...(drift ? [ { @@ -181,6 +203,16 @@ const CalibrationSettings: React.FC = ({
+ {/* How the methods work: a page with pictures, formulas and a chart to move. */} + + {/* Method cards */}
{METHODS.map(({ value, icon, recommended, pros, cons }) => ( diff --git a/src/utils/backupBundle.ts b/src/utils/backupBundle.ts index 84af093c..bca8426e 100644 --- a/src/utils/backupBundle.ts +++ b/src/utils/backupBundle.ts @@ -370,10 +370,15 @@ const isLang = (value: unknown): value is Lang => value === 'ru' || value === 'uk'; -// `bayes` is website-only for now: hrt-cold Tools rejects it (see +// `bayes` and `mcmc` are website-only for now: hrt-cold Tools rejects them (see // docs/backup-schema-audit/BACKUP_TEST_VECTORS.md, BTV-1). const isCalibrationMethodValue = (value: unknown): value is CalibrationMethod => - value === 'off' || value === 'ekf' || value === 'ou_kalman' || value === 'mipd' || value === 'bayes'; + value === 'off' || + value === 'ekf' || + value === 'ou_kalman' || + value === 'mipd' || + value === 'bayes' || + value === 'mcmc'; const isCalibrationHistoryMode = (value: unknown): value is CalibrationHistoryMode => value === 'forward' || value === 'retrospective'; diff --git a/src/utils/backupState.ts b/src/utils/backupState.ts index ee87a704..5b7973c0 100644 --- a/src/utils/backupState.ts +++ b/src/utils/backupState.ts @@ -67,7 +67,14 @@ export const sanitizeModelSettings = (raw: unknown): ModelSettings | null => { if (!raw || typeof raw !== 'object') return null; const r = raw as Record; const method = r.calibrationMethod; - if (method !== 'off' && method !== 'ekf' && method !== 'ou_kalman' && method !== 'mipd' && method !== 'bayes') { + if ( + method !== 'off' && + method !== 'ekf' && + method !== 'ou_kalman' && + method !== 'mipd' && + method !== 'bayes' && + method !== 'mcmc' + ) { return null; } if (r.calibrationHistoryMode !== 'forward' && r.calibrationHistoryMode !== 'retrospective') return null; diff --git a/tests/mcmcCalibration.test.ts b/tests/mcmcCalibration.test.ts new file mode 100644 index 00000000..8bec5dc0 --- /dev/null +++ b/tests/mcmcCalibration.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + Ester, + ExtraKey, + LabResult, + Route, + applyPKOverrides, + computeCalibration, + interpolateConcentration_E2, + runSimulation, +} from '../logic'; +import { e2RouteCurves } from '../src/domain/calibration/routeCurves'; +import { splitRHat } from '../src/domain/calibration/mcmc'; +import { BW, NOW_H, lab, regimen } from './helpers'; + +// Personal PK gives each person their own route levels, clearance and +// absorption speed, sampled by MCMC. These tests make synthetic labs from a +// known personal model and check that the sampler finds it again, plus the +// guarantees every estimator owes (shrinkage, robustness, forward mode, +// determinism). + +const E2 = interpolateConcentration_E2; +afterEach(() => applyPKOverrides(null)); + +/** Labs from a "true" person: route multipliers, clearance and absorption multipliers. */ +function personLabs( + events: ReturnType, + times: number[], + truth: { route?: Partial>; kMul?: number; absMul?: number; noise?: number[] }, +): LabResult[] { + const rc = e2RouteCurves(events, BW, null, times, truth.kMul ?? 1, truth.absMul ?? 1); + return times.map((t, i) => { + let v = 0; + rc.routes.forEach((r, j) => (v += (truth.route?.[r] ?? 1) * rc.conc[j][i])); + return lab(t, v * Math.exp(truth.noise?.[i] ?? 0)); + }); +} + +const run = ( + events: ReturnType, + labs: LabResult[], + mode: 'forward' | 'retrospective' = 'retrospective', +) => computeCalibration(runSimulation(events, BW), events, BW, labs, 'mcmc', mode); + +// Weekly EV injections for a year plus twice-daily sublingual E2 for the last 20 weeks. +const injections = regimen(Route.injection, Ester.EV, 4, 168, 52, NOW_H - 60); +const sublingual = regimen(Route.sublingual, Ester.E2, 1, 12, 280, NOW_H - 6, { [ExtraKey.sublingualTier]: 1 }); +const mixed = [...injections, ...sublingual]; +// Labs before the sublingual started (injection only) and after (both), at varied phases. +const MIXED_TIMES = [ + NOW_H - 24 * 300 + 30, + NOW_H - 24 * 260 + 100, + NOW_H - 24 * 220 + 60, + NOW_H - 24 * 180 + 140, + NOW_H - 24 * 100 + 3, + NOW_H - 24 * 70 + 9, + NOW_H - 24 * 40 + 2, + NOW_H - 24 * 10 + 7, +]; + +describe('Personal PK (MCMC) calibration', () => { + it('evaluates per-route curves that add up to the simulation exactly', () => { + const sim = runSimulation(mixed, BW)!; + const rc = e2RouteCurves(mixed, BW, null, sim.timeH, 1, 1); + expect(rc.routes).toEqual([Route.sublingual, Route.injection]); + let worst = 0; + sim.timeH.forEach((_, i) => { + const sum = rc.conc[0][i] + rc.conc[1][i]; + worst = Math.max(worst, Math.abs(sum - sim.concPGmL_E2[i]) / Math.max(1, sim.concPGmL_E2[i])); + }); + expect(worst).toBeLessThan(1e-9); + }); + + it('tells the routes apart: sublingual low, injections high', () => { + const labs = personLabs(mixed, MIXED_TIMES, { + route: { [Route.injection]: 1.3, [Route.sublingual]: 0.5 }, + }); + const cal = run(mixed, labs); + const inj = cal.routeScales![Route.injection]!; + const sl = cal.routeScales![Route.sublingual]!; + expect(inj).toBeGreaterThan(1.1); + expect(sl).toBeLessThan(0.8); + expect(inj / sl).toBeGreaterThan(1.8); + expect(cal.fitErrPct!).toBeLessThan(12); + expect(cal.rHat!).toBeLessThan(1.1); + }, 60000); + + it('finds a personal depot release speed from where labs sit in the injection interval', () => { + const weekly = regimen(Route.injection, Ester.EV, 4, 168, 30, NOW_H - 10); + const times = [6, 20, 40, 70, 100, 130, 160, 166].map((h, i) => NOW_H - 168 * (20 - 2 * i) - 10 + h); + const fast = run(weekly, personLabs(weekly, times, { absMul: 2 })); + expect(fast.absMul!).toBeGreaterThan(1.5); + expect(fast.routeScales).toBeUndefined(); + const slow = run(weekly, personLabs(weekly, times, { absMul: 0.5 })); + expect(slow.absMul!).toBeLessThan(0.7); + expect(slow.fitErrPct!).toBeLessThan(8); + }, 60000); + + it('leaves absorption near the model when it barely shows (daily oral)', () => { + // The estrone pool smooths an oral dose: doubling uptake moves labs about 10%, + // inside the lab noise, so the prior keeps most of its say. + const oral = regimen(Route.oral, Ester.E2, 2, 24, 60, NOW_H - 10); + const times = [2, 4, 6, 9, 13, 18, 22, 23.5].map((h, i) => NOW_H - 24 * (40 - 4 * i) - 10 + h); + const cal = run(oral, personLabs(oral, times, { absMul: 2 })); + expect(cal.absMul!).toBeGreaterThan(1); + expect(cal.absMul!).toBeLessThan(2); + }, 60000); + + it('shrinks one lab towards the population and keeps clearance and absorption fixed', () => { + const one = run(injections, personLabs(injections, [NOW_H - 100], { route: { [Route.injection]: 2 } })); + expect(one.scale).toBeGreaterThan(1.4); + expect(one.scale).toBeLessThan(2); + expect(one.kMul).toBe(1); + expect(one.absMul).toBe(1); + const [lo, hi] = one.latestInterval!; + expect(lo).toBeLessThan(one.factorFn(NOW_H - 100)); + expect(hi).toBeGreaterThan(one.factorFn(NOW_H - 100)); + }, 60000); + + it('discounts one inconsistent lab among consistent ones', () => { + const times = MIXED_TIMES.slice(0, 6); + const noise = times.map((_, i) => (i === 3 ? Math.log(8) : 0)); + const cal = run(injections, personLabs(injections, times, { noise })); + expect(cal.discounted).toBe(1); + expect(Math.abs(Math.log(cal.scale))).toBeLessThan(Math.log(1.15)); + }, 60000); + + it('forward mode never rewrites the past and does nothing before the first lab', () => { + const labs = personLabs(mixed, MIXED_TIMES.slice(0, 5), { route: { [Route.injection]: 1.6 } }); + const early = run(mixed, labs.slice(0, 3), 'forward'); + const late = run(mixed, labs, 'forward'); + expect(late.factorFn(MIXED_TIMES[0] - 1)).toBe(1); + expect(late.factorSdFn!(MIXED_TIMES[0] - 1)).toBe(0); + const between = MIXED_TIMES[1] + 30; + expect(late.factorFn(between)).toBeCloseTo(early.factorFn(between), 2); + }, 120000); + + it('is deterministic and draws a band', () => { + const labs = personLabs(injections, MIXED_TIMES.slice(2, 6), { route: { [Route.injection]: 1.4 } }); + const a = run(injections, labs); + const b = run(injections, labs); + for (const t of [NOW_H - 24 * 300, NOW_H - 24 * 100, NOW_H, NOW_H + 300]) { + expect(a.factorFn(t)).toBe(b.factorFn(t)); + expect(a.factorSdFn!(t)).toBe(b.factorSdFn!(t)); + } + expect(a.factorSdFn!(MIXED_TIMES[4])).toBeGreaterThan(0); + }, 60000); + + it('agrees with the plain model when the labs sit on it', () => { + const sim = runSimulation(injections, BW)!; + const labs = MIXED_TIMES.slice(0, 6).map(t => lab(t, E2(sim, t)!)); + const cal = run(injections, labs); + for (const t of MIXED_TIMES) expect(Math.abs(Math.log(cal.factorFn(t)))).toBeLessThan(0.08); + }, 60000); + + it('split R-hat flags chains that disagree', () => { + const chain = (m: number) => Array.from({ length: 200 }, (_, i) => Float64Array.from([m + Math.sin(i)])); + expect(splitRHat([chain(0), chain(0)], 1)).toBeLessThan(1.05); + expect(splitRHat([chain(0), chain(5)], 1)).toBeGreaterThan(2); + }); +}); diff --git a/tests/modelSettingsSync.test.ts b/tests/modelSettingsSync.test.ts index 5404681e..e61d31ee 100644 --- a/tests/modelSettingsSync.test.ts +++ b/tests/modelSettingsSync.test.ts @@ -42,6 +42,16 @@ describe('model settings sync', () => { expect(storage.getItem(nsFor('u1', 'model-settings-at'))).toBe('500'); }); + it('syncs the Personal PK method like any other', () => { + const desktop = stateOf(device({})); + const phone = stateOf(device({ 'cal-method': 'mcmc', 'model-settings-at': '600' })); + const { merged } = mergeSyncStates(desktop, phone); + expect(merged.modelSettings?.calibrationMethod).toBe('mcmc'); + const storage = device({}); + applySyncedStateToStorage(storage, 'u1', 'transfem', merged); + expect(storage.getItem(nsFor('u1', 'cal-method'))).toBe('mcmc'); + }); + it('the newer edit wins', () => { const older = stateOf(device({ 'cal-method': 'off', 'model-settings-at': '100' })); const newer = stateOf( diff --git a/wiki/Usage.md b/wiki/Usage.md index c383ce52..3143d2e9 100644 --- a/wiki/Usage.md +++ b/wiki/Usage.md @@ -44,8 +44,8 @@ model and draw no curve. - Record a blood test with its date, value, unit, and assay. The app converts between the units it supports and shows the result next to the estimate. -- Estradiol can be calibrated against your labs with EKF, OU-Kalman, MIPD, or - Full Bayes, or you can edit the model parameters by hand under Settings. Calibration is +- Estradiol can be calibrated against your labs with EKF, OU-Kalman, MIPD, + Full Bayes, or Personal PK, or you can edit the model parameters by hand under Settings. Calibration is optional; the uncalibrated curve remains available. - Lab results are personal data. They are not included in share links, and backups are encrypted before they leave the browser.