Fix various issues with pharmacokinetics, improved parameters, distinction between adult/child

This commit is contained in:
2026-01-17 20:27:00 +00:00
parent b911fa1e16
commit 6983ce3853
13 changed files with 1505 additions and 115 deletions

View File

@@ -75,6 +75,7 @@ const MedPlanAssistant = () => {
addDoseToDay,
removeDoseFromDay,
updateDoseInDay,
updateDoseFieldInDay,
sortDosesInDay
} = useAppState();
@@ -209,6 +210,7 @@ const MedPlanAssistant = () => {
onAddDose={addDoseToDay}
onRemoveDose={removeDoseFromDay}
onUpdateDose={updateDoseInDay}
onUpdateDoseField={updateDoseFieldInDay}
onSortDoses={sortDosesInDay}
t={t}
/>

View File

@@ -17,7 +17,7 @@ import { FormNumericInput } from './ui/form-numeric-input';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { IconButtonWithTooltip } from './ui/icon-button-with-tooltip';
import CollapsibleCardHeader from './ui/collapsible-card-header';
import { Plus, Copy, Trash2, ArrowDownAZ, TrendingUp, TrendingDown } from 'lucide-react';
import { Plus, Copy, Trash2, ArrowDownAZ, TrendingUp, TrendingDown, Utensils } from 'lucide-react';
import type { DayGroup } from '../constants/defaults';
interface DayScheduleProps {
@@ -28,6 +28,7 @@ interface DayScheduleProps {
onAddDose: (dayId: string) => void;
onRemoveDose: (dayId: string, doseId: string) => void;
onUpdateDose: (dayId: string, doseId: string, field: 'time' | 'ldx' | 'damph', value: string) => void;
onUpdateDoseField: (dayId: string, doseId: string, field: string, value: any) => void; // For non-string fields like isFed
onSortDoses: (dayId: string) => void;
t: any;
}
@@ -40,6 +41,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
onAddDose,
onRemoveDose,
onUpdateDose,
onUpdateDoseField,
onSortDoses,
t
}) => {
@@ -199,8 +201,8 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
{!collapsedDays.has(day.id) && (
<CardContent className="space-y-3">
{/* Dose table header */}
<div className="grid grid-cols-[120px_1fr_auto] gap-3 text-sm font-medium text-muted-foreground">
<div className="flex items-center gap-2">
<div className="grid grid-cols-[100px_1fr_auto_auto] gap-2 text-sm font-medium text-muted-foreground">
<div className="flex items-center gap-1">
<span>{t('time')}</span>
<Tooltip>
<TooltipTrigger asChild>
@@ -227,7 +229,10 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
</Tooltip>
</div>
<div>{t('ldx')} (mg)</div>
<div></div>
<div className="text-center">
<Utensils className="h-4 w-4 inline" />
</div>
<div className="invisible">-</div>
</div>
{/* Dose rows */}
@@ -240,7 +245,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0';
return (
<div key={dose.id} className="grid grid-cols-[120px_1fr_auto] gap-3 items-center">
<div key={dose.id} className="grid grid-cols-[120px_1fr_auto_auto] gap-2 items-center">
<FormTimeInput
value={dose.time}
onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)}
@@ -260,14 +265,22 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
errorMessage={t('errorNumberRequired')}
warningMessage={t('warningZeroDose')}
/>
<IconButtonWithTooltip
onClick={() => onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed)}
icon={<Utensils className="h-4 w-4" />}
tooltip={dose.isFed ? t('doseWithFood') : t('doseFasted')}
size="sm"
variant={dose.isFed ? "default" : "outline"}
className={`h-9 w-9 p-0 ${dose.isFed ? 'bg-orange-500 hover:bg-orange-600' : ''}`}
/>
<IconButtonWithTooltip
onClick={() => onRemoveDose(day.id, dose.id)}
icon={<Trash2 className="h-4 w-4" />}
tooltip={t('removeDose')}
size="sm"
variant="ghost"
variant="outline"
disabled={day.isTemplate && day.doses.length === 1}
className="h-9 w-9 p-0"
className="h-9 w-9 p-0 border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:border-muted"
/>
</div>
);

View File

@@ -43,6 +43,8 @@ const getDefaultsForTranslation = (pkParams: any, therapeuticRange: any, uiSetti
ldxAbsorptionHalfLife: defaults.pkParams.ldx.absorptionHalfLife,
// Advanced Settings
standardVdValue: defaults.pkParams.advanced.standardVd?.preset === 'adult' ? '377' : defaults.pkParams.advanced.standardVd?.preset === 'child' ? '175' : defaults.pkParams.advanced.standardVd?.customValue || '377',
standardVdPreset: defaults.pkParams.advanced.standardVd?.preset || 'adult',
bodyWeight: defaults.pkParams.advanced.weightBasedVd.bodyWeight,
tmaxDelay: defaults.pkParams.advanced.foodEffect.tmaxDelay,
phTendency: defaults.pkParams.advanced.urinePh.phTendency,
@@ -886,8 +888,74 @@ const Settings = ({
<p className="text-yellow-800 dark:text-yellow-200">{t('advancedSettingsWarning')}</p>
</div>
{/* Standard Volume of Distribution */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">{t('standardVolumeOfDistribution')}</Label>
<Tooltip open={openTooltipId === 'standardVd'} onOpenChange={(open) => setOpenTooltipId(open ? 'standardVd' : null)}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleTooltipToggle('standardVd')}
onTouchStart={handleTooltipToggle('standardVd')}
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={t('standardVdTooltip')}
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'standardVdTooltip', {
...defaultsForT,
standardVdValue: pkParams.advanced.standardVd?.preset === 'adult' ? '377' : pkParams.advanced.standardVd?.preset === 'child' ? '175' : pkParams.advanced.standardVd?.customValue || '377',
standardVdPreset: t(`standardVdPreset${pkParams.advanced.standardVd?.preset?.charAt(0).toUpperCase()}${pkParams.advanced.standardVd?.preset?.slice(1)}` || 'standardVdPresetAdult')
}))}</p>
</TooltipContent>
</Tooltip>
</div>
<Select
value={pkParams.advanced.standardVd?.preset || 'adult'}
onValueChange={(value: 'adult' | 'child' | 'custom') => updateAdvanced('standardVd', 'preset', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="adult">{t('standardVdPresetAdult')}</SelectItem>
<SelectItem value="child">{t('standardVdPresetChild')}</SelectItem>
<SelectItem value="custom">{t('standardVdPresetCustom')}</SelectItem>
</SelectContent>
</Select>
{pkParams.advanced.weightBasedVd.enabled && (
<div className="ml-0 mt-2 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-xs text-blue-800 dark:text-blue-200">
Weight-based Vd is enabled below. This setting is currently overridden.
</div>
)}
{pkParams.advanced.standardVd?.preset === 'custom' && (
<div className="ml-8 mt-2">
<Label className="text-sm font-medium">{t('customVdValue')}</Label>
<FormNumericInput
value={pkParams.advanced.standardVd?.customValue || '377'}
onChange={val => updateAdvanced('standardVd', 'customValue', val)}
increment={10}
min={50}
max={800}
unit="L"
required={true}
/>
</div>
)}
</div>
<Separator className="my-4" />
{/* Weight-Based Vd */}
<div className="space-y-3">
{pkParams.advanced.weightBasedVd.enabled && (
<div className="p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-xs text-blue-800 dark:text-blue-200 mb-3">
When enabled, this overrides the Standard Vd setting above. Disable to use Standard Vd presets (Adult/Child/Custom).
</div>
)}
<div className="flex items-center gap-3">
<Switch
id="weightBasedVdEnabled"
@@ -950,66 +1018,38 @@ const Settings = ({
<Separator className="my-4" />
{/* Food Effect */}
<Separator className="my-4" />
{/* Food Effect Absorption Delay */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch
id="foodEffectEnabled"
checked={pkParams.advanced.foodEffect.enabled}
onCheckedChange={checked => updateAdvanced('foodEffect', 'enabled', checked)}
/>
<Label htmlFor="foodEffectEnabled" className="font-medium">
{t('foodEffectEnabled')}
</Label>
<Tooltip open={openTooltipId === 'foodEffect'} onOpenChange={(open) => setOpenTooltipId(open ? 'foodEffect' : null)}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleTooltipToggle('foodEffect')}
onTouchStart={handleTooltipToggle('foodEffect')}
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={t('foodEffectTooltip')}
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'foodEffectTooltip', defaultsForT)}</p>
</TooltipContent>
</Tooltip>
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">{t('foodEffectDelay')}</Label>
<Tooltip open={openTooltipId === 'tmaxDelay'} onOpenChange={(open) => setOpenTooltipId(open ? 'tmaxDelay' : null)}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleTooltipToggle('tmaxDelay')}
onTouchStart={handleTooltipToggle('tmaxDelay')}
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={t('tmaxDelayTooltip')}
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'tmaxDelayTooltip', defaultsForT))}</p>
</TooltipContent>
</Tooltip>
</div>
{pkParams.advanced.foodEffect.enabled && (
<div className="ml-8 space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">{t('tmaxDelay')}</Label>
<Tooltip open={openTooltipId === 'tmaxDelay'} onOpenChange={(open) => setOpenTooltipId(open ? 'tmaxDelay' : null)}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleTooltipToggle('tmaxDelay')}
onTouchStart={handleTooltipToggle('tmaxDelay')}
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={t('tmaxDelayTooltip')}
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'tmaxDelayTooltip', defaultsForT))}</p>
</TooltipContent>
</Tooltip>
</div>
<FormNumericInput
value={pkParams.advanced.foodEffect.tmaxDelay}
onChange={val => updateAdvanced('foodEffect', 'tmaxDelay', val)}
increment={0.1}
min={0}
max={2}
unit={t('tmaxDelayUnit')}
required={true}
/>
</div>
)}
<FormNumericInput
value={pkParams.advanced.foodEffect.tmaxDelay}
onChange={val => updateAdvanced('foodEffect', 'tmaxDelay', val)}
increment={0.1}
min={0}
max={2}
unit={t('tmaxDelayUnit')}
required={true}
/>
</div>
<Separator className="my-4" />
@@ -1078,6 +1118,108 @@ const Settings = ({
<Separator className="my-4" />
{/* Age Group Selection */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">{t('ageGroup')}</Label>
<Tooltip open={openTooltipId === 'ageGroup'} onOpenChange={(open) => setOpenTooltipId(open ? 'ageGroup' : null)}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleTooltipToggle('ageGroup')}
onTouchStart={handleTooltipToggle('ageGroup')}
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={t('ageGroupTooltip')}
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'ageGroupTooltip', defaultsForT))}</p>
</TooltipContent>
</Tooltip>
</div>
<Select
value={pkParams.advanced.ageGroup?.preset || 'adult'}
onValueChange={(value: 'child' | 'adult' | 'custom') => {
updateAdvancedDirect('ageGroup', { preset: value });
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="adult">{t('ageGroupAdult')}</SelectItem>
<SelectItem value="child">{t('ageGroupChild')}</SelectItem>
<SelectItem value="custom">{t('ageGroupCustom')}</SelectItem>
</SelectContent>
</Select>
</div>
<Separator className="my-4" />
{/* Renal Function */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch
id="renalFunctionEnabled"
checked={pkParams.advanced.renalFunction?.enabled || false}
onCheckedChange={checked => {
updateAdvancedDirect('renalFunction', {
enabled: checked,
severity: pkParams.advanced.renalFunction?.severity || 'normal'
});
}}
/>
<Label htmlFor="renalFunctionEnabled" className="font-medium">
{t('renalFunction')}
</Label>
<Tooltip open={openTooltipId === 'renalFunction'} onOpenChange={(open) => setOpenTooltipId(open ? 'renalFunction' : null)}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleTooltipToggle('renalFunction')}
onTouchStart={handleTooltipToggle('renalFunction')}
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={t('renalFunctionTooltip')}
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'renalFunctionTooltip', defaultsForT))}</p>
</TooltipContent>
</Tooltip>
</div>
{(pkParams.advanced.renalFunction?.enabled) && (
<div className="ml-8 space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">{t('renalFunctionSeverity')}</Label>
</div>
<Select
value={pkParams.advanced.renalFunction?.severity || 'normal'}
onValueChange={(value: 'normal' | 'mild' | 'severe') => {
updateAdvancedDirect('renalFunction', {
enabled: true,
severity: value
});
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">{t('renalFunctionNormal')}</SelectItem>
<SelectItem value="mild">{t('renalFunctionMild')}</SelectItem>
<SelectItem value="severe">{t('renalFunctionSevere')}</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<Separator className="my-4" />
{/* Oral Bioavailability */}
<div className="space-y-2">
<div className="flex items-center gap-2">

View File

@@ -209,12 +209,10 @@ const chartDomain = React.useMemo(() => {
// User set yAxisMax explicitly - use it as-is without padding
domainMax = numMax;
} else if (dataMax !== -Infinity) { // data exists
// No padding needed since it seems to be added automatically by Recharts
// // Auto mode: add 5% padding above
// const range = dataMax - dataMin;
// const padding = range * 0.05;
// domainMax = dataMax + padding;
domainMax = dataMax;
// Auto mode: add 5% padding above
const range = dataMax - dataMin;
const padding = range * 0.05;
domainMax = dataMax + padding;
} else { // no data
domainMax = 100;
}

View File

@@ -26,7 +26,7 @@ const versionInfo = versionJsonDefault && Object.keys(versionJsonDefault).length
gitDate: 'unknown',
};
export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v7';
export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v8'; // Incremented for ageGroup + renalFunction fields
export const PROJECT_REPOSITORY_URL = 'https://git.11001001.org/cbaoth/med-plan-assistant';
export const APP_VERSION = versionInfo.version;
export const BUILD_INFO = versionInfo;
@@ -39,11 +39,23 @@ export const DEFAULT_F_ORAL = 0.96;
// Type definitions
export interface AdvancedSettings {
standardVd: { preset: 'adult' | 'child' | 'custom'; customValue: string }; // Volume of distribution (L)
weightBasedVd: { enabled: boolean; bodyWeight: string }; // kg
foodEffect: { enabled: boolean; tmaxDelay: string }; // hours
urinePh: { enabled: boolean; phTendency: string }; // 5.5-8.0 range
fOral: string; // bioavailability fraction
steadyStateDays: string; // days of medication history to simulate
// Age-specific pharmacokinetics (Research Section 5.2)
// Children (6-12y) have faster elimination: t½ ~9h vs adult ~11h
ageGroup?: {
preset: 'child' | 'adult' | 'custom';
};
// Renal function effects (Research Section 8.2, FDA label 8.6)
// Severe impairment extends half-life by ~50% (11h → 16.5h)
renalFunction?: {
enabled: boolean;
severity: 'normal' | 'mild' | 'severe';
};
}
export interface PkParams {
@@ -57,6 +69,7 @@ export interface DayDose {
time: string;
ldx: string;
damph?: string; // Optional, kept for backwards compatibility but not used in UI
isFed?: boolean; // Optional: indicates if dose is taken with food (delays absorption ~1h)
}
export interface DayGroup {
@@ -121,9 +134,10 @@ export const getDefaultState = (): AppState => ({
damph: { halfLife: '11' },
ldx: {
halfLife: '0.8',
absorptionHalfLife: '0.9' // changed from 1.5, better reflects ~1h Tmax
absorptionHalfLife: '0.7' // Updated from 0.9 for better ~1h Tmax of prodrug
},
advanced: {
standardVd: { preset: 'adult', customValue: '377' }, // Adult: 377L (Roberts 2015), Child: ~150-200L
weightBasedVd: { enabled: false, bodyWeight: '70' }, // kg, adult average
foodEffect: { enabled: false, tmaxDelay: '1.0' }, // hours delay
urinePh: { enabled: false, phTendency: '6.0' }, // pH scale (5.5-8.0)
@@ -144,7 +158,7 @@ export const getDefaultState = (): AppState => ({
}
],
steadyStateConfig: { daysOnMedication: '7' }, // kept for backwards compatibility, now sourced from pkParams.advanced
therapeuticRange: { min: '5', max: '25' }, // widened from 10.5-11.5 to general adult range
therapeuticRange: { min: '', max: '' }, // Empty by default - users should personalize based on their response
doseIncrement: '2.5',
uiSettings: {
showDayTimeOnXAxis: '24h',
@@ -154,7 +168,7 @@ export const getDefaultState = (): AppState => ({
yAxisMax: '',
simulationDays: '5',
displayedDays: '2',
showTherapeuticRange: true,
showTherapeuticRange: false,
steadyStateDaysEnabled: true,
stickyChart: false,
}

View File

@@ -199,6 +199,25 @@ export const useAppState = () => {
}));
};
// More flexible update function for non-string fields (e.g., isFed boolean)
const updateDoseFieldInDay = (dayId: string, doseId: string, field: string, value: any) => {
setAppState(prev => ({
...prev,
days: prev.days.map(day => {
if (day.id !== dayId) return day;
const updatedDoses = day.doses.map(dose =>
dose.id === doseId ? { ...dose, [field]: value } : dose
);
return {
...day,
doses: updatedDoses
};
})
}));
};
const sortDosesInDay = (dayId: string) => {
setAppState(prev => ({
...prev,
@@ -238,6 +257,7 @@ export const useAppState = () => {
addDoseToDay,
removeDoseFromDay,
updateDoseInDay,
updateDoseFieldInDay,
sortDosesInDay,
handleReset
};

View File

@@ -24,6 +24,8 @@ export const de = {
afternoon: "Nachmittags",
evening: "Abends",
night: "Nachts",
doseWithFood: "Mit Nahrung eingenommen (verzögert Absorption ~1h)",
doseFasted: "Nüchtern eingenommen (normale Absorption)",
// Deviations
deviationsFromPlan: "Abweichungen vom Plan",
@@ -71,6 +73,12 @@ export const de = {
pharmacokineticsSettings: "Pharmakokinetik-Einstellungen",
advancedSettings: "Erweiterte Einstellungen",
advancedSettingsWarning: "⚠️ Diese Parameter beeinflussen die Simulationsgenauigkeit und können von Bevölkerungsdurchschnitten abweichen. Nur anpassen, wenn spezifische klinische Daten oder Forschungsreferenzen vorliegen.",
standardVolumeOfDistribution: "Verteilungsvolumen (Vd)",
standardVdTooltip: "Definiert wie sich der Wirkstoff im Körper verteilt. Erwachsene: 377L (Roberts 2015), Kinder: ~150-200L. Beeinflusst alle Konzentrationsberechnungen. Nur für pädiatrische oder spezialisierte Simulationen ändern. Standard: {{standardVdValue}}L ({{standardVdPreset}}).",
standardVdPresetAdult: "Erwachsene (377L)",
standardVdPresetChild: "Kinder (175L)",
standardVdPresetCustom: "Benutzerdefiniert",
customVdValue: "Benutzerdefiniertes Vd (L)",
xAxisTimeFormat: "Zeitformat",
xAxisFormatContinuous: "Fortlaufend",
xAxisFormatContinuousDesc: "Endlose Sequenz (0h, 6h, 12h...)",
@@ -96,7 +104,7 @@ export const de = {
yAxisRangeAutoButtonTitle: "Bereich automatisch anhand des Datenbereichs bestimmen",
auto: "Auto",
therapeuticRange: "Therapeutischer Bereich",
therapeuticRangeTooltip: "Referenzkonzentrationen für Medikamentenwirksamkeit. Typischer Bereich für Erwachsene: 5-25 ng/mL. Individuelle therapeutische Fenster variieren erheblich. Standard: {{therapeuticRangeMin}}-{{therapeuticRangeMax}} ng/mL. Konsultiere deinen Arzt.",
therapeuticRangeTooltip: "Personalisierte Konzentrationsziele basierend auf DEINER individuellen Reaktion. Setze diese nachdem du beobachtet hast, welche Werte Symptomkontrolle vs. Nebenwirkungen bieten. Referenzbereiche (stark variabel): Erwachsene ~10-80 ng/mL, Kinder ~20-120 ng/mL (aufgrund geringeren Körpergewichts/Vd). Leer lassen wenn unsicher. Konsultiere deinen Arzt.",
dAmphetamineParameters: "d-Amphetamin Parameter",
halfLife: "Eliminations-Halbwertszeit",
halfLifeTooltip: "Zeit bis der Körper die Hälfte des d-Amphetamins aus dem Blut ausscheidet. Beeinflusst durch Urin-pH: sauer (<6) → 7-9h, neutral (6-7,5) → 10-12h, alkalisch (>7,5) → 13-15h. Siehe [therapeutische Referenzbereiche](https://www.thieme-connect.com/products/ejournals/pdf/10.1055/a-2689-4911.pdf). Standard: {{damphHalfLife}}h.",
@@ -115,9 +123,10 @@ export const de = {
bodyWeightUnit: "kg",
foodEffectEnabled: "Mit Mahlzeit eingenommen",
foodEffectTooltip: "Fettreiche Mahlzeiten verzögern die Absorption ohne Gesamtaufnahme zu ändern. Verlangsamt Wirkungseintritt (~1h Verzögerung). Bei Deaktivierung: Nüchterner Zustand.",
tmaxDelay: "Absorptionsverzögerung",
tmaxDelayTooltip: "Wie viel die Mahlzeit die Absorption verzögert (Tmax-Verschiebung). Siehe [Nahrungseffekt-Studie](https://pmc.ncbi.nlm.nih.gov/articles/PMC4823324/) von Ermer et al. Typisch: 1,0h für fettreiche Mahlzeit. Standard: {{tmaxDelay}}h.",
foodEffectDelay: "Nahrungseffekt-Verzögerung",
foodEffectTooltip: "Fettreiche Mahlzeiten verzögern die Absorption ohne die Gesamtaufnahme zu ändern. Verlangsamt Wirkungseintritt (~1h Verzögerung). Deaktiviert nimmt nüchternen Zustand an.",
tmaxDelay: "Absorptions-Verzögerung",
tmaxDelayTooltip: "Zeitverzögerung bei Einnahme mit fettreicher Mahlzeit. Wird durch Einzel-Dosis Nahrungsschalter (🍴 Symbol) im Zeitplan angewendet. Forschung zeigt ~1h Verzögerung ohne Spitzenreduktion. Siehe [Studie](https://pmc.ncbi.nlm.nih.gov/articles/PMC4823324/). Standard: {{tmaxDelay}}h.",
tmaxDelayUnit: "h",
urinePHTendency: "Urin-pH-Effekte",
@@ -133,6 +142,21 @@ export const de = {
steadyStateDays: "Medikationshistorie",
steadyStateDaysTooltip: "Anzahl vorheriger Tage stabiler Medikamentendosis zur Simulation der Akkumulation/Steady-State. 0 setzen für \"erster Tag ohne Vorgeschichte.\" Standard: {{steadyStateDays}} Tage. Max: 7.",
// Age-specific pharmacokinetics
ageGroup: "Altersgruppe",
ageGroupTooltip: "Pädiatrische Personen (6-12 J.) zeigen schnellere d-Amphetamin-Elimination (t½ ~9h) verglichen mit Erwachsenen (~11h) aufgrund höherer gewichtsnormalisierter Stoffwechselrate. Siehe [Forschungsdokument](https://git.11001001.org/cbaoth/med-plan-assistant/src/branch/main/docs/2026-01-17_AI-Reseach_SimulatingLDXandD-AmphetaminePlasmaLevels.md#52-pediatric-vs-adult-modeling) Abschnitt 5.2. 'Benutzerdefiniert' wählen, um manuell konfigurierte Halbwertszeit zu verwenden. Standard: Erwachsener.",
ageGroupAdult: "Erwachsener (t½ 11h)",
ageGroupChild: "Kind 6-12 J. (t½ 9h)",
ageGroupCustom: "Benutzerdefiniert (manuelle t½)",
// Renal function effects
renalFunction: "Niereninsuffizienz",
renalFunctionTooltip: "Schwere Niereninsuffizienz verlängert d-Amphetamin-Halbwertszeit um ~50% (von 11h auf 16,5h). FDA-Label empfiehlt Dosierungsobergrenzen: 50mg bei schwerer Insuffizienz, 30mg bei Nierenversagen (ESRD). Siehe [FDA-Label Abschnitt 8.6](https://www.accessdata.fda.gov/drugsatfda_docs/label/2017/021977s049lbl.pdf) und [Forschungsdokument](https://git.11001001.org/cbaoth/med-plan-assistant/src/branch/main/docs/2026-01-17_AI-Reseach_SimulatingLDXandD-AmphetaminePlasmaLevels.md#82-renal-function) Abschnitt 8.2. Standard: deaktiviert.",
renalFunctionSeverity: "Schweregrad der Insuffizienz",
renalFunctionNormal: "Normal (keine Anpassung)",
renalFunctionMild: "Leicht (keine Anpassung)",
renalFunctionSevere: "Schwer (t½ +50%)",
resetAllSettings: "Alle Einstellungen zurücksetzen",
resetDiagramSettings: "Diagramm-Einstellungen zurücksetzen",
resetPharmacokineticSettings: "Pharmakokinetik-Einstellungen zurücksetzen",

View File

@@ -24,6 +24,8 @@ export const en = {
afternoon: "Afternoon",
evening: "Evening",
night: "Night",
doseWithFood: "Taken with food (delays absorption ~1h)",
doseFasted: "Taken fasted (normal absorption)",
// Deviations
deviationsFromPlan: "Deviations from Plan",
@@ -70,6 +72,12 @@ export const en = {
pharmacokineticsSettings: "Pharmacokinetics Settings",
advancedSettings: "Advanced Settings",
advancedSettingsWarning: "⚠️ These parameters affect simulation accuracy and may deviate from population averages. Adjust only if you have specific clinical data or research references.",
standardVolumeOfDistribution: "Volume of Distribution (Vd)",
standardVdTooltip: "Defines how drug disperses in body. Adult: 377L (Roberts 2015), Child: ~150-200L. Affects all concentration calculations. Change only for pediatric or specialized simulations. Default: {{standardVdValue}}L ({{standardVdPreset}}).",
standardVdPresetAdult: "Adult (377L)",
standardVdPresetChild: "Child (175L)",
standardVdPresetCustom: "Custom",
customVdValue: "Custom Vd (L)",
xAxisTimeFormat: "Time Format",
xAxisFormatContinuous: "Continuous",
xAxisFormatContinuousDesc: "Endless sequence (0h, 6h, 12h...)",
@@ -94,7 +102,7 @@ export const en = {
yAxisRangeAutoButtonTitle: "Determine range automatically based on data range",
auto: "Auto",
therapeuticRange: "Therapeutic Range",
therapeuticRangeTooltip: "Reference concentrations for medication efficacy. Typical adult range: 5-25 ng/mL. Individual therapeutic windows vary significantly. Default: {{therapeuticRangeMin}}-{{therapeuticRangeMax}} ng/mL. Consult your physician.",
therapeuticRangeTooltip: "Personalized concentration targets based on YOUR individual response. Set these after observing which levels provide symptom control vs. side effects. Reference ranges (highly variable): Adults ~10-80 ng/mL, Children ~20-120 ng/mL (due to lower body weight/Vd). Leave empty if unsure. Consult your physician.",
dAmphetamineParameters: "d-Amphetamine Parameters",
halfLife: "Elimination Half-life",
halfLifeTooltip: "Time for body to clear half the d-amphetamine from blood. Affected by urine pH: acidic (<6) → 7-9h, neutral (6-7.5) → 10-12h, alkaline (>7.5) → 13-15h. See [therapeutic reference ranges](https://www.thieme-connect.com/products/ejournals/pdf/10.1055/a-2689-4911.pdf). Default: {{damphHalfLife}}h.",
@@ -113,9 +121,10 @@ export const en = {
bodyWeightUnit: "kg",
foodEffectEnabled: "Taken With Meal",
foodEffectDelay: "Food Effect Delay",
foodEffectTooltip: "High-fat meals delay absorption without changing total exposure. Slows onset of effects (~1h delay). When disabled, assumes fasted state.",
tmaxDelay: "Absorption Delay",
tmaxDelayTooltip: "How much the meal delays absorption (Tmax shift). See [food effect study](https://pmc.ncbi.nlm.nih.gov/articles/PMC4823324/) by Ermer et al. Typical: 1.0h for high-fat meal. Default: {{tmaxDelay}}h.",
tmaxDelayTooltip: "Time delay when dose is taken with high-fat meal. Applied using per-dose food toggles (🍴 icon) in schedule. Research shows ~1h delay without peak reduction. See [study](https://pmc.ncbi.nlm.nih.gov/articles/PMC4823324/). Default: {{tmaxDelay}}h.",
tmaxDelayUnit: "h",
urinePHTendency: "Urine pH Effects",
@@ -131,6 +140,21 @@ export const en = {
steadyStateDays: "Medication History",
steadyStateDaysTooltip: "Number of prior days on stable medication dose to simulate accumulation/steady-state. Set 0 for \"first day from scratch.\" Default: {{steadyStateDays}} days. Max: 7.",
// Age-specific pharmacokinetics
ageGroup: "Age Group",
ageGroupTooltip: "Pediatric subjects (6-12y) exhibit faster d-amphetamine elimination (t½ ~9h) compared to adults (~11h) due to higher weight-normalized metabolic rate. See [research document](https://git.11001001.org/cbaoth/med-plan-assistant/src/branch/main/docs/2026-01-17_AI-Reseach_SimulatingLDXandD-AmphetaminePlasmaLevels.md#52-pediatric-vs-adult-modeling) Section 5.2. Select 'custom' to use your manually configured half-life. Default: adult.",
ageGroupAdult: "Adult (t½ 11h)",
ageGroupChild: "Child 6-12y (t½ 9h)",
ageGroupCustom: "Custom (use manual t½)",
// Renal function effects
renalFunction: "Renal Impairment",
renalFunctionTooltip: "Severe renal impairment extends d-amphetamine half-life by ~50% (from 11h to 16.5h). FDA label recommends dose caps: 50mg for severe impairment, 30mg for ESRD. See [FDA Label Section 8.6](https://www.accessdata.fda.gov/drugsatfda_docs/label/2017/021977s049lbl.pdf) and [research document](https://git.11001001.org/cbaoth/med-plan-assistant/src/branch/main/docs/2026-01-17_AI-Reseach_SimulatingLDXandD-AmphetaminePlasmaLevels.md#82-renal-function) Section 8.2. Default: disabled.",
renalFunctionSeverity: "Impairment Severity",
renalFunctionNormal: "Normal (no adjustment)",
renalFunctionMild: "Mild (no adjustment)",
renalFunctionSevere: "Severe (t½ +50%)",
resetAllSettings: "Reset All Settings",
resetDiagramSettings: "Reset Diagram Settings",
resetPharmacokineticSettings: "Reset Pharmacokinetic Settings",

View File

@@ -17,6 +17,7 @@ interface ProcessedDose {
timeMinutes: number;
ldx: number;
damph: number;
isFed?: boolean; // Optional: indicates if dose was taken with food
}
export const calculateCombinedProfile = (
@@ -50,7 +51,8 @@ export const calculateCombinedProfile = (
allDoses.push({
timeMinutes: timeToMinutes(dose.time) + dayOffsetMinutes,
ldx: ldxNum,
damph: 0 // d-amph is calculated from LDX conversion, not administered directly
damph: 0, // d-amph is calculated from LDX conversion, not administered directly
isFed: dose.isFed // Pass through per-dose food effect flag
});
}
});
@@ -66,7 +68,8 @@ export const calculateCombinedProfile = (
allDoses.push({
timeMinutes: timeToMinutes(dose.time) + dayOffsetMinutes,
ldx: ldxNum,
damph: 0 // d-amph is calculated from LDX conversion, not administered directly
damph: 0, // d-amph is calculated from LDX conversion, not administered directly
isFed: dose.isFed // Pass through per-dose food effect flag
});
}
});
@@ -81,11 +84,12 @@ export const calculateCombinedProfile = (
const timeSinceDoseHours = t - dose.timeMinutes / 60;
if (timeSinceDoseHours >= 0) {
// Calculate LDX contribution
// Calculate LDX contribution with per-dose food effect
const ldxConcentrations = calculateSingleDoseConcentration(
String(dose.ldx),
timeSinceDoseHours,
pkParams
pkParams,
dose.isFed // Pass per-dose food flag
);
totalLdx += ldxConcentrations.ldx;
totalDamph += ldxConcentrations.damph;

View File

@@ -5,6 +5,12 @@
* and its active metabolite dextroamphetamine (d-amph). Uses first-order
* absorption and elimination kinetics with optional advanced modifiers.
*
* RESEARCH REFERENCES:
* - Roberts et al. (2015): Population PK parameters for d-amphetamine
* - PMC4823324 (Ermer et al.): Meta-analysis of LDX pharmacokinetics
* - FDA NDA 021-977: Clinical pharmacology of lisdexamfetamine
* - AI Research Document (2026-01-17): Sections 3.2, 5.2, 8.2
*
* @author Andreas Weyer
* @license MIT
*/
@@ -16,26 +22,91 @@ interface ConcentrationResult {
damph: number;
}
// Standard adult volume of distribution (Roberts et al. 2015): 377 L
const STANDARD_VD_ADULT = 377.0;
/**
* Volume of Distribution Constants
*
* LDX Apparent Vd (~710L): Due to rapid RBC hydrolysis, intact LDX exhibits a large
* apparent Vd. The prodrug is cleared so quickly from plasma that it creates a
* "metabolic sink" effect, requiring a mathematically larger Vd to match observed
* low peak concentrations (~58 ng/mL for 70mg dose).
*
* d-Amphetamine Vd (377L adult, 175L child): Standard central Vd from population PK.
* Scales with body weight (~5.4 L/kg).
*
* Ratio: LDX Vd / d-Amph Vd ≈ 1.9 ensures proper concentration crossover
* (LDX peaks early but lower than d-amph, as observed clinically).
*
* Reference: AI Research Document Section 3.2 "Quantitative Derivation of Apparent Vd"
*/
const STANDARD_VD_DAMPH_ADULT = 377.0; // d-amphetamine Vd (adult)
const STANDARD_VD_DAMPH_CHILD = 175.0; // d-amphetamine Vd (pediatric, 6-12y)
const LDX_VD_SCALING_FACTOR = 1.9; // LDX apparent Vd is ~1.9x d-amphetamine Vd
/**
* Age-Specific Elimination Half-Life Constants
*
* Pediatric subjects (6-12y) exhibit faster d-amphetamine clearance due to
* higher weight-normalized metabolic rate. Adult values represent population mean.
*
* Reference: AI Research Document Section 5.2 "Pediatric vs. Adult Modeling"
*/
const DAMPH_T_HALF_ADULT = 11.0; // hours
const DAMPH_T_HALF_CHILD = 9.0; // hours
/**
* Renal Function Modifiers
*
* Severe impairment can extend half-life by ~50% (from 11h to ~16.5h).
* ESRD (end-stage renal disease) can extend to 20h+.
*
* Reference: AI Research Document Section 8.2, FDA label Section 8.6
*/
const RENAL_SEVERE_FACTOR = 1.5; // 50% slower elimination
// Pharmacokinetic calculations
export const calculateSingleDoseConcentration = (
dose: string,
timeSinceDoseHours: number,
pkParams: PkParams
pkParams: PkParams,
isFed?: boolean // Optional: per-dose food effect override (true = with food, false/undefined = fasted or use global setting)
): ConcentrationResult => {
const numDose = parseFloat(dose) || 0;
if (timeSinceDoseHours < 0 || numDose <= 0) return { ldx: 0, damph: 0 };
// Extract base parameters
// ===== EXTRACT BASE PARAMETERS =====
const absorptionHalfLife = parseFloat(pkParams.ldx.absorptionHalfLife);
const conversionHalfLife = parseFloat(pkParams.ldx.halfLife);
const damphHalfLife = parseFloat(pkParams.damph.halfLife);
// Use base d-amph half-life from config (default: 11h adult)
let damphHalfLife = parseFloat(pkParams.damph.halfLife);
// ===== APPLY AGE-SPECIFIC ELIMINATION (Research Section 5.2) =====
// Children metabolize d-amphetamine faster due to higher weight-normalized metabolic rate
// This modifier takes precedence over base half-life if age group is explicitly set
if (pkParams.advanced.ageGroup) {
if (pkParams.advanced.ageGroup.preset === 'child') {
damphHalfLife = DAMPH_T_HALF_CHILD; // 9h
} else if (pkParams.advanced.ageGroup.preset === 'adult') {
damphHalfLife = DAMPH_T_HALF_ADULT; // 11h
}
// 'custom' preset uses the base pkParams.damph.halfLife value
}
// ===== APPLY RENAL FUNCTION MODIFIER (Research Section 8.2, FDA label Section 8.6) =====
// Renal impairment significantly extends d-amphetamine elimination half-life
// Severe: ~50% slower (11h → 16.5h), ESRD: up to 20h+
if (pkParams.advanced.renalFunction && pkParams.advanced.renalFunction.enabled) {
const impairment = pkParams.advanced.renalFunction.severity;
if (impairment === 'severe') {
damphHalfLife *= RENAL_SEVERE_FACTOR; // ~16.5h for adult
}
// 'normal' and 'mild' severity: no adjustment (adequate renal clearance)
}
// Extract advanced parameters
const fOral = parseFloat(pkParams.advanced.fOral) || DEFAULT_F_ORAL;
const foodEnabled = pkParams.advanced.foodEffect.enabled;
// Per-dose food effect takes precedence over global setting
const foodEnabled = isFed !== undefined ? isFed : pkParams.advanced.foodEffect.enabled;
const tmaxDelay = foodEnabled ? parseFloat(pkParams.advanced.foodEffect.tmaxDelay) : 0;
const urinePHEnabled = pkParams.advanced.urinePh.enabled;
const phTendency = urinePHEnabled ? parseFloat(pkParams.advanced.urinePh.phTendency) : 6.0;
@@ -47,9 +118,11 @@ export const calculateSingleDoseConcentration = (
return { ldx: 0, damph: 0 };
}
// Apply food effect: high-fat meal delays absorption by slowing rate (~+1h to Tmax)
// Approximate by increasing absorption half-life proportionally
const adjustedAbsorptionHL = absorptionHalfLife * (1 + (tmaxDelay / 1.5));
// Apply food effect: high-fat meal delays absorption by ~1h without changing Cmax
// Research shows Tmax delay but no significant AUC/Cmax reduction (Krishnan & Zhang)
// Shift absorption start time rightward instead of modifying rate constants
const adjustedTime = Math.max(0, timeSinceDoseHours - tmaxDelay);
const calculationTime = adjustedTime; // Use delayed time for all kinetic calculations
// Apply urine pH effect on elimination half-life
// pH < 6: acidic (faster elimination, HL ~7-9h)
@@ -68,43 +141,77 @@ export const calculateSingleDoseConcentration = (
}
// Calculate rate constants
const ka_ldx = Math.log(2) / adjustedAbsorptionHL;
const ka_ldx = Math.log(2) / absorptionHalfLife;
const k_conv = Math.log(2) / conversionHalfLife;
const ke_damph = Math.log(2) / adjustedDamphHL;
// Apply stoichiometric conversion and bioavailability
const effectiveDose = numDose * LDX_TO_DAMPH_SALT_FACTOR * fOral;
// Calculate LDX concentration (prodrug)
let ldxConcentration = 0;
// ===== COMPARTMENTAL MODELING (Research Section 6.2) =====
// LDX CONCENTRATION (Prodrug compartment)
// Uses LDX-SPECIFIC APPARENT Vd = 710L (Research Section 3.2, 3.3)
// This larger Vd ensures LDX peak (~58 ng/mL for 70mg dose) is LOWER than
// d-amph peak (~80 ng/mL), reproducing the clinical "crossover" phenomenon
let ldxAmount = 0;
if (Math.abs(ka_ldx - k_conv) > 0.0001) {
ldxConcentration = (numDose * ka_ldx / (ka_ldx - k_conv)) *
(Math.exp(-k_conv * timeSinceDoseHours) - Math.exp(-ka_ldx * timeSinceDoseHours));
ldxAmount = (numDose * ka_ldx / (ka_ldx - k_conv)) *
(Math.exp(-k_conv * calculationTime) - Math.exp(-ka_ldx * calculationTime));
}
// Calculate d-amphetamine concentration (active metabolite)
let damphConcentration = 0;
// Calculate d-amphetamine concentration (active metabolite) - amount in compartment (mg)
let damphAmount = 0;
if (Math.abs(ka_ldx - ke_damph) > 0.0001 &&
Math.abs(k_conv - ke_damph) > 0.0001 &&
Math.abs(ka_ldx - k_conv) > 0.0001) {
const term1 = Math.exp(-ke_damph * timeSinceDoseHours) / ((ka_ldx - ke_damph) * (k_conv - ke_damph));
const term2 = Math.exp(-k_conv * timeSinceDoseHours) / ((ka_ldx - k_conv) * (ke_damph - k_conv));
const term3 = Math.exp(-ka_ldx * timeSinceDoseHours) / ((k_conv - ka_ldx) * (ke_damph - ka_ldx));
damphConcentration = effectiveDose * ka_ldx * k_conv * (term1 + term2 + term3);
const term1 = Math.exp(-ke_damph * calculationTime) / ((ka_ldx - ke_damph) * (k_conv - ke_damph));
const term2 = Math.exp(-k_conv * calculationTime) / ((ka_ldx - k_conv) * (ke_damph - k_conv));
const term3 = Math.exp(-ka_ldx * calculationTime) / ((k_conv - ka_ldx) * (ke_damph - ka_ldx));
damphAmount = effectiveDose * ka_ldx * k_conv * (term1 + term2 + term3);
}
// Apply weight-based Vd scaling if enabled
// Standard adult Vd = 377 L; weight-normalized ~5.4 L/kg
// Concentration inversely proportional to Vd: C = Amount / Vd
if (pkParams.advanced.weightBasedVd.enabled) {
const bodyWeight = parseFloat(pkParams.advanced.weightBasedVd.bodyWeight);
if (!isNaN(bodyWeight) && bodyWeight > 0) {
const weightBasedVd = bodyWeight * 5.4; // L/kg factor from literature
const scalingFactor = STANDARD_VD_ADULT / weightBasedVd;
damphConcentration *= scalingFactor;
ldxConcentration *= scalingFactor;
// ===== DETERMINE VOLUME OF DISTRIBUTION (Research Section 8.1) =====
// Priority: Weight-based Vd > Age/preset Vd > Standard adult Vd (377L)
let baseVd_damph = STANDARD_VD_DAMPH_ADULT; // Default fallback for d-amphetamine
// Age-based or custom Vd preset
if (pkParams.advanced.standardVd) {
if (pkParams.advanced.standardVd.preset === 'adult') {
baseVd_damph = STANDARD_VD_DAMPH_ADULT; // 377L
} else if (pkParams.advanced.standardVd.preset === 'child') {
baseVd_damph = STANDARD_VD_DAMPH_CHILD; // 175L (~5.4 L/kg for 32kg pediatric average)
} else if (pkParams.advanced.standardVd.preset === 'custom') {
const customVd = parseFloat(pkParams.advanced.standardVd.customValue);
if (!isNaN(customVd) && customVd > 0) {
baseVd_damph = customVd;
}
}
}
// Weight-based Vd scaling (OVERRIDES preset if enabled)
// Research Section 8.1: Vd_damph ≈ 5.4 L/kg body weight
// Lighter person → smaller Vd → higher concentration
// Heavier person → larger Vd → lower concentration
let effectiveVd_damph = baseVd_damph;
if (pkParams.advanced.weightBasedVd.enabled) {
const bodyWeight = parseFloat(pkParams.advanced.weightBasedVd.bodyWeight);
if (!isNaN(bodyWeight) && bodyWeight > 0) {
effectiveVd_damph = bodyWeight * 5.4; // L/kg factor from literature
}
}
// LDX apparent Vd (Research Section 3.2, 3.3)
// Uses fixed 1.9x scaling factor relative to d-amph Vd
// This ratio is derived from clinical AUC data and ensures proper peak height relationship
// Clinical validation: 70mg dose → LDX peak ~58 ng/mL, d-amph peak ~80 ng/mL
const effectiveVd_ldx = effectiveVd_damph * LDX_VD_SCALING_FACTOR; // ~710L for 70kg adult
// ===== CONVERT AMOUNTS TO PLASMA CONCENTRATIONS =====
// Formula: C(ng/mL) = (Amount_mg / Vd_L) × 1000
// This is the critical step - without 1000x scaling factor, concentrations are too low
let ldxConcentration = (ldxAmount / effectiveVd_ldx) * 1000;
let damphConcentration = (damphAmount / effectiveVd_damph) * 1000;
return { ldx: Math.max(0, ldxConcentration), damph: Math.max(0, damphConcentration) };
};