use serde::{Deserialize, Serialize};
use crate::error::{MastishkError, validate_dt};
use crate::neurotransmitter::NeurotransmitterProfile;
use crate::receptor::{ReceptorMap, ReceptorOccupancies, ReceptorSubtype, TransporterType};
#[inline]
#[must_use]
pub fn hill_equation(concentration: f32, ec50: f32, hill_coeff: f32, emax: f32) -> f32 {
if concentration <= 0.0 || ec50 <= 0.0 {
return 0.0;
}
let conc_n = concentration.powf(hill_coeff);
let ec50_n = ec50.powf(hill_coeff);
emax * conc_n / (ec50_n + conc_n)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum DrugMechanism {
Agonist,
Antagonist,
PositiveAllostericModulator,
ReuptakeInhibitor,
PartialAgonist,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceptorBinding {
pub receptor: ReceptorSubtype,
pub ec50: f32,
pub hill_coeff: f32,
pub emax: f32,
pub mechanism: DrugMechanism,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransporterBinding {
pub transporter: TransporterType,
pub ec50: f32,
pub hill_coeff: f32,
pub emax: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrugProfile {
pub name: String,
pub bindings: Vec<ReceptorBinding>,
#[serde(default)]
pub transporter_bindings: Vec<TransporterBinding>,
pub half_life: f32,
pub onset_delay: f32,
}
impl DrugProfile {
#[must_use]
pub fn ssri_fluoxetine() -> Self {
Self {
name: "fluoxetine".into(),
bindings: Vec::new(),
transporter_bindings: vec![TransporterBinding {
transporter: TransporterType::Sert,
ec50: 0.10,
hill_coeff: 1.5,
emax: 0.85,
}],
half_life: 432_000.0, onset_delay: 3600.0, }
}
#[must_use]
pub fn ssri_sertraline() -> Self {
Self {
name: "sertraline".into(),
bindings: Vec::new(),
transporter_bindings: vec![TransporterBinding {
transporter: TransporterType::Sert,
ec50: 0.08,
hill_coeff: 1.8,
emax: 0.90,
}],
half_life: 93_600.0, onset_delay: 3600.0,
}
}
#[must_use]
pub fn benzodiazepine_diazepam() -> Self {
Self {
name: "diazepam".into(),
bindings: vec![ReceptorBinding {
receptor: ReceptorSubtype::GabaA,
ec50: 0.15,
hill_coeff: 1.2,
emax: 0.80,
mechanism: DrugMechanism::PositiveAllostericModulator,
}],
transporter_bindings: Vec::new(),
half_life: 172_800.0, onset_delay: 1800.0, }
}
#[must_use]
pub fn benzodiazepine_alprazolam() -> Self {
Self {
name: "alprazolam".into(),
bindings: vec![ReceptorBinding {
receptor: ReceptorSubtype::GabaA,
ec50: 0.10,
hill_coeff: 2.0,
emax: 0.90,
mechanism: DrugMechanism::PositiveAllostericModulator,
}],
transporter_bindings: Vec::new(),
half_life: 21_600.0, onset_delay: 900.0, }
}
#[must_use]
pub fn stimulant_amphetamine() -> Self {
Self {
name: "amphetamine".into(),
bindings: vec![
ReceptorBinding {
receptor: ReceptorSubtype::D1,
ec50: 0.15,
hill_coeff: 1.5,
emax: 0.60,
mechanism: DrugMechanism::Agonist,
},
ReceptorBinding {
receptor: ReceptorSubtype::D2,
ec50: 0.18,
hill_coeff: 1.5,
emax: 0.45,
mechanism: DrugMechanism::Agonist,
},
],
transporter_bindings: vec![
TransporterBinding {
transporter: TransporterType::Dat,
ec50: 0.12,
hill_coeff: 1.5,
emax: 0.85,
},
TransporterBinding {
transporter: TransporterType::Net,
ec50: 0.15,
hill_coeff: 1.3,
emax: 0.70,
},
],
half_life: 36_000.0, onset_delay: 1800.0, }
}
#[must_use]
pub fn stimulant_methylphenidate() -> Self {
Self {
name: "methylphenidate".into(),
bindings: Vec::new(),
transporter_bindings: vec![
TransporterBinding {
transporter: TransporterType::Dat,
ec50: 0.15,
hill_coeff: 1.3,
emax: 0.75,
},
TransporterBinding {
transporter: TransporterType::Net,
ec50: 0.20,
hill_coeff: 1.2,
emax: 0.50,
},
],
half_life: 10_800.0, onset_delay: 1200.0, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveDrug {
pub profile: DrugProfile,
pub plasma_concentration: f32,
pub time_since_admin: f32,
pub dose: f32,
}
impl ActiveDrug {
#[must_use]
pub fn new(profile: DrugProfile, dose: f32) -> Self {
Self {
profile,
plasma_concentration: 0.0,
time_since_admin: 0.0,
dose: dose.clamp(0.0, 1.0),
}
}
#[inline]
pub(crate) fn tick_pk(&mut self, dt: f32) {
self.time_since_admin += dt;
if self.time_since_admin < self.profile.onset_delay {
self.plasma_concentration =
self.dose * (self.time_since_admin / self.profile.onset_delay);
} else {
let elapsed = self.time_since_admin - self.profile.onset_delay;
let k_elim = core::f32::consts::LN_2 / self.profile.half_life;
self.plasma_concentration = self.dose * (-k_elim * elapsed).exp();
}
}
#[inline]
#[must_use]
pub fn is_negligible(&self) -> bool {
self.time_since_admin > self.profile.onset_delay && self.plasma_concentration < 0.001
}
#[inline]
#[must_use]
pub fn occupancy_at(&self, binding: &ReceptorBinding) -> f32 {
hill_equation(
self.plasma_concentration,
binding.ec50,
binding.hill_coeff,
binding.emax,
)
}
#[inline]
#[must_use]
pub fn occupancy_at_transporter(&self, binding: &TransporterBinding) -> f32 {
hill_equation(
self.plasma_concentration,
binding.ec50,
binding.hill_coeff,
binding.emax,
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ClearanceRateSnapshot {
pub serotonin: f32,
pub dopamine: f32,
pub norepinephrine: f32,
pub gaba: f32,
pub glutamate: f32,
pub oxytocin: f32,
pub endorphins: f32,
pub acetylcholine: f32,
pub bdnf: f32,
pub histamine: f32,
pub endocannabinoid: f32,
pub orexin: f32,
}
impl ClearanceRateSnapshot {
pub fn capture(profile: &NeurotransmitterProfile) -> Self {
Self {
serotonin: profile.serotonin.clearance_rate,
dopamine: profile.dopamine.clearance_rate,
norepinephrine: profile.norepinephrine.clearance_rate,
gaba: profile.gaba.clearance_rate,
glutamate: profile.glutamate.clearance_rate,
oxytocin: profile.oxytocin.clearance_rate,
endorphins: profile.endorphins.clearance_rate,
acetylcholine: profile.acetylcholine.clearance_rate,
bdnf: profile.bdnf.clearance_rate,
histamine: profile.histamine.clearance_rate,
endocannabinoid: profile.endocannabinoid.clearance_rate,
orexin: profile.orexin.clearance_rate,
}
}
pub fn restore(&self, profile: &mut NeurotransmitterProfile) {
profile.serotonin.clearance_rate = self.serotonin;
profile.dopamine.clearance_rate = self.dopamine;
profile.norepinephrine.clearance_rate = self.norepinephrine;
profile.gaba.clearance_rate = self.gaba;
profile.glutamate.clearance_rate = self.glutamate;
profile.oxytocin.clearance_rate = self.oxytocin;
profile.endorphins.clearance_rate = self.endorphins;
profile.acetylcholine.clearance_rate = self.acetylcholine;
profile.bdnf.clearance_rate = self.bdnf;
profile.histamine.clearance_rate = self.histamine;
profile.endocannabinoid.clearance_rate = self.endocannabinoid;
profile.orexin.clearance_rate = self.orexin;
}
}
impl Default for ClearanceRateSnapshot {
fn default() -> Self {
Self::capture(&NeurotransmitterProfile::default())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PharmacologyState {
pub receptors: ReceptorMap,
pub active_drugs: Vec<ActiveDrug>,
clearance_snapshot: ClearanceRateSnapshot,
gaba_pam_cache: f32,
}
impl Default for PharmacologyState {
fn default() -> Self {
Self {
receptors: ReceptorMap::default(),
active_drugs: Vec::new(),
clearance_snapshot: ClearanceRateSnapshot::default(),
gaba_pam_cache: 1.0,
}
}
}
impl PharmacologyState {
pub fn administer(&mut self, profile: DrugProfile, dose: f32) {
tracing::debug!(drug = %profile.name, dose, "drug administered");
self.active_drugs.push(ActiveDrug::new(profile, dose));
}
#[inline]
#[must_use]
pub fn gaba_pam_multiplier(&self) -> f32 {
self.gaba_pam_cache
}
#[inline]
pub fn tick(&mut self, dt: f32, nt: &mut NeurotransmitterProfile) -> Result<(), MastishkError> {
validate_dt(dt)?;
if self.active_drugs.is_empty() {
self.gaba_pam_cache = 1.0;
apply_withdrawal_rebound(&self.receptors, nt, dt);
let zero_occ = ReceptorOccupancies::default();
self.receptors.tick_all(&zero_occ, dt)?;
return Ok(());
}
tracing::trace!(active_drugs = self.active_drugs.len(), "pharmacology tick");
for drug in &mut self.active_drugs {
drug.tick_pk(dt);
}
self.active_drugs.retain(|d| !d.is_negligible());
if self.active_drugs.is_empty() {
self.clearance_snapshot.restore(nt);
self.gaba_pam_cache = 1.0;
return Ok(());
}
let mut occupancies = ReceptorOccupancies::default();
for drug in &self.active_drugs {
for binding in &drug.profile.bindings {
let occ = drug.occupancy_at(binding);
occupancies.add(binding.receptor, occ);
}
}
self.receptors.tick_all(&occupancies, dt)?;
self.clearance_snapshot.restore(nt);
let mut gaba_pam = 1.0_f32;
for drug in &self.active_drugs {
for binding in &drug.profile.bindings {
let occ = drug.occupancy_at(binding);
let receptor_avail = self.receptors.get(binding.receptor).availability;
let effective = (occ * receptor_avail).clamp(0.0, 1.0);
match binding.mechanism {
DrugMechanism::Agonist => {
let transmitter = receptor_to_transmitter(binding.receptor);
let baseline = get_baseline_mut(nt, transmitter);
*baseline = (*baseline + effective * 0.3).min(1.0);
}
DrugMechanism::Antagonist => {
let transmitter = receptor_to_transmitter(binding.receptor);
let baseline = get_baseline_mut(nt, transmitter);
*baseline = (*baseline - effective * 0.3).max(0.0);
}
DrugMechanism::PositiveAllostericModulator => {
gaba_pam += effective * 1.5;
}
DrugMechanism::PartialAgonist => {
let transmitter = receptor_to_transmitter(binding.receptor);
let baseline = get_baseline_mut(nt, transmitter);
let ceiling = binding.emax * 0.5; if *baseline < ceiling {
*baseline = (*baseline + effective * 0.2).min(ceiling);
} else {
*baseline = (*baseline - effective * 0.15).max(ceiling);
}
}
DrugMechanism::ReuptakeInhibitor => {
let transmitter = receptor_to_transmitter(binding.receptor);
let rate = get_clearance_rate_mut(nt, transmitter);
*rate *= (1.0 - effective * 0.7).max(0.3);
}
}
}
for binding in &drug.profile.transporter_bindings {
let occ = drug.occupancy_at_transporter(binding);
let transmitter = transporter_to_transmitter(binding.transporter);
let rate = get_clearance_rate_mut(nt, transmitter);
*rate *= (1.0 - occ * 0.7).max(0.3);
}
}
self.gaba_pam_cache = gaba_pam;
Ok(())
}
}
fn apply_withdrawal_rebound(receptors: &ReceptorMap, nt: &mut NeurotransmitterProfile, dt: f32) {
let alpha = 1.0 - (-0.05 * dt).exp();
let pairs: [(f32, f32, TransmitterTarget); 4] = [
(
receptors.ht1a.availability,
receptors.ht1a.baseline,
TransmitterTarget::Serotonin,
),
(
receptors.d1.availability,
receptors.d1.baseline,
TransmitterTarget::Dopamine,
),
(
receptors.gaba_a.availability,
receptors.gaba_a.baseline,
TransmitterTarget::Gaba,
),
(
receptors.mu_opioid.availability,
receptors.mu_opioid.baseline,
TransmitterTarget::Endorphins,
),
];
for (avail, baseline_avail, target) in pairs {
let deviation = avail - baseline_avail;
if deviation.abs() > 0.01 {
let nt_baseline = get_baseline_mut(nt, target);
*nt_baseline = (*nt_baseline + deviation * 0.1 * alpha).clamp(0.0, 1.0);
}
}
}
#[derive(Debug, Clone, Copy)]
enum TransmitterTarget {
Serotonin,
Dopamine,
Norepinephrine,
Gaba,
Glutamate,
Endocannabinoid,
Endorphins,
Orexin,
Acetylcholine,
Histamine,
}
fn receptor_to_transmitter(subtype: ReceptorSubtype) -> TransmitterTarget {
match subtype {
ReceptorSubtype::Ht1a | ReceptorSubtype::Ht2a => TransmitterTarget::Serotonin,
ReceptorSubtype::D1 | ReceptorSubtype::D2 => TransmitterTarget::Dopamine,
ReceptorSubtype::Alpha1 | ReceptorSubtype::Alpha2 | ReceptorSubtype::Beta => {
TransmitterTarget::Norepinephrine
}
ReceptorSubtype::GabaA | ReceptorSubtype::GabaB => TransmitterTarget::Gaba,
ReceptorSubtype::Cb1 => TransmitterTarget::Endocannabinoid,
ReceptorSubtype::MuOpioid => TransmitterTarget::Endorphins,
ReceptorSubtype::Nmda | ReceptorSubtype::Ampa => TransmitterTarget::Glutamate,
ReceptorSubtype::Ht2c => TransmitterTarget::Serotonin,
ReceptorSubtype::NicotinicA4b2 | ReceptorSubtype::NicotinicA7 => {
TransmitterTarget::Acetylcholine
}
ReceptorSubtype::H1 => TransmitterTarget::Histamine,
ReceptorSubtype::Ox1 | ReceptorSubtype::Ox2 => TransmitterTarget::Orexin,
}
}
fn transporter_to_transmitter(transporter: TransporterType) -> TransmitterTarget {
match transporter {
TransporterType::Sert => TransmitterTarget::Serotonin,
TransporterType::Dat => TransmitterTarget::Dopamine,
TransporterType::Net => TransmitterTarget::Norepinephrine,
}
}
fn get_clearance_rate_mut(nt: &mut NeurotransmitterProfile, target: TransmitterTarget) -> &mut f32 {
match target {
TransmitterTarget::Serotonin => &mut nt.serotonin.clearance_rate,
TransmitterTarget::Dopamine => &mut nt.dopamine.clearance_rate,
TransmitterTarget::Norepinephrine => &mut nt.norepinephrine.clearance_rate,
TransmitterTarget::Gaba => &mut nt.gaba.clearance_rate,
TransmitterTarget::Glutamate => &mut nt.glutamate.clearance_rate,
TransmitterTarget::Endocannabinoid => &mut nt.endocannabinoid.clearance_rate,
TransmitterTarget::Endorphins => &mut nt.endorphins.clearance_rate,
TransmitterTarget::Orexin => &mut nt.orexin.clearance_rate,
TransmitterTarget::Acetylcholine => &mut nt.acetylcholine.clearance_rate,
TransmitterTarget::Histamine => &mut nt.histamine.clearance_rate,
}
}
fn get_baseline_mut(nt: &mut NeurotransmitterProfile, target: TransmitterTarget) -> &mut f32 {
match target {
TransmitterTarget::Serotonin => &mut nt.serotonin.baseline,
TransmitterTarget::Dopamine => &mut nt.dopamine.baseline,
TransmitterTarget::Norepinephrine => &mut nt.norepinephrine.baseline,
TransmitterTarget::Gaba => &mut nt.gaba.baseline,
TransmitterTarget::Glutamate => &mut nt.glutamate.baseline,
TransmitterTarget::Endocannabinoid => &mut nt.endocannabinoid.baseline,
TransmitterTarget::Endorphins => &mut nt.endorphins.baseline,
TransmitterTarget::Orexin => &mut nt.orexin.baseline,
TransmitterTarget::Acetylcholine => &mut nt.acetylcholine.baseline,
TransmitterTarget::Histamine => &mut nt.histamine.baseline,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hill_equation_zero_concentration() {
assert!((hill_equation(0.0, 0.5, 1.0, 1.0) - 0.0).abs() < f32::EPSILON);
}
#[test]
fn test_hill_equation_at_ec50() {
let effect = hill_equation(0.5, 0.5, 1.0, 1.0);
assert!((effect - 0.5).abs() < 0.01);
}
#[test]
fn test_hill_equation_high_concentration() {
let effect = hill_equation(10.0, 0.5, 1.0, 1.0);
assert!(effect > 0.9);
}
#[test]
fn test_hill_equation_steep_curve() {
let shallow = hill_equation(0.3, 0.5, 1.0, 1.0);
let steep = hill_equation(0.3, 0.5, 3.0, 1.0);
assert!(steep < shallow); }
#[test]
fn test_pk_absorption_phase() {
let mut drug = ActiveDrug::new(DrugProfile::ssri_fluoxetine(), 0.8);
assert!(drug.plasma_concentration < f32::EPSILON);
drug.tick_pk(1800.0); assert!((drug.plasma_concentration - 0.4).abs() < 0.01);
}
#[test]
fn test_pk_peak_and_elimination() {
let mut drug = ActiveDrug::new(DrugProfile::benzodiazepine_alprazolam(), 1.0);
drug.tick_pk(900.0); assert!((drug.plasma_concentration - 1.0).abs() < 0.01);
drug.tick_pk(21_600.0);
assert!((drug.plasma_concentration - 0.5).abs() < 0.05);
}
#[test]
fn test_pk_elimination_to_negligible() {
let mut drug = ActiveDrug::new(DrugProfile::stimulant_methylphenidate(), 0.5);
drug.tick_pk(1200.0); drug.tick_pk(108_000.0); assert!(drug.is_negligible());
}
#[test]
fn test_preset_constructors() {
let drugs = [
DrugProfile::ssri_fluoxetine(),
DrugProfile::ssri_sertraline(),
DrugProfile::benzodiazepine_diazepam(),
DrugProfile::benzodiazepine_alprazolam(),
DrugProfile::stimulant_amphetamine(),
DrugProfile::stimulant_methylphenidate(),
];
for drug in &drugs {
assert!(!drug.name.is_empty());
assert!(
!drug.bindings.is_empty() || !drug.transporter_bindings.is_empty(),
"{} has no bindings",
drug.name
);
assert!(drug.half_life > 0.0);
assert!(drug.onset_delay > 0.0);
}
}
#[test]
fn test_empty_pharmacology_is_noop() {
let mut pharm = PharmacologyState::default();
let mut nt = NeurotransmitterProfile::default();
let original_clearance = nt.serotonin.clearance_rate;
pharm.tick(1.0, &mut nt).unwrap();
assert!((nt.serotonin.clearance_rate - original_clearance).abs() < f32::EPSILON);
assert!((pharm.gaba_pam_multiplier() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_ssri_reduces_serotonin_clearance() {
let mut pharm = PharmacologyState::default();
let mut nt = NeurotransmitterProfile::default();
let original_clearance = nt.serotonin.clearance_rate;
pharm.administer(DrugProfile::ssri_fluoxetine(), 0.8);
for _ in 0..7200 {
pharm.tick(1.0, &mut nt).unwrap();
}
assert!(
nt.serotonin.clearance_rate < original_clearance,
"clearance={}, original={}",
nt.serotonin.clearance_rate,
original_clearance
);
}
#[test]
fn test_stimulant_raises_dopamine_baseline() {
let mut pharm = PharmacologyState::default();
let mut nt = NeurotransmitterProfile::default();
let original_baseline = nt.dopamine.baseline;
pharm.administer(DrugProfile::stimulant_amphetamine(), 0.7);
for _ in 0..3600 {
pharm.tick(1.0, &mut nt).unwrap();
}
assert!(
nt.dopamine.baseline > original_baseline,
"baseline={}, original={}",
nt.dopamine.baseline,
original_baseline
);
}
#[test]
fn test_benzodiazepine_sets_gaba_pam() {
let mut pharm = PharmacologyState::default();
let mut nt = NeurotransmitterProfile::default();
pharm.administer(DrugProfile::benzodiazepine_diazepam(), 0.8);
for _ in 0..3600 {
pharm.tick(1.0, &mut nt).unwrap();
}
assert!(
pharm.gaba_pam_multiplier() > 1.0,
"pam={}",
pharm.gaba_pam_multiplier()
);
}
#[test]
fn test_drug_elimination_restores_clearance() {
let mut pharm = PharmacologyState::default();
let mut nt = NeurotransmitterProfile::default();
let original_clearance = nt.serotonin.clearance_rate;
pharm.administer(
DrugProfile {
name: "test_ssri".into(),
bindings: Vec::new(),
transporter_bindings: vec![TransporterBinding {
transporter: TransporterType::Sert,
ec50: 0.1,
hill_coeff: 1.0,
emax: 0.9,
}],
half_life: 100.0, onset_delay: 10.0,
},
0.5,
);
for _ in 0..100 {
pharm.tick(1.0, &mut nt).unwrap();
}
assert!(nt.serotonin.clearance_rate < original_clearance);
for _ in 0..2000 {
pharm.tick(1.0, &mut nt).unwrap();
}
assert!(
(nt.serotonin.clearance_rate - original_clearance).abs() < 0.001,
"clearance={}, expected={}",
nt.serotonin.clearance_rate,
original_clearance
);
}
#[test]
fn test_negative_dt_rejected() {
let mut pharm = PharmacologyState::default();
let mut nt = NeurotransmitterProfile::default();
assert!(pharm.tick(-1.0, &mut nt).is_err());
}
#[test]
fn test_serde_roundtrip() {
let mut pharm = PharmacologyState::default();
pharm.administer(DrugProfile::ssri_fluoxetine(), 0.5);
let json = serde_json::to_string(&pharm).unwrap();
let pharm2: PharmacologyState = serde_json::from_str(&json).unwrap();
assert_eq!(pharm2.active_drugs.len(), 1);
assert!(
(pharm2.receptors.ht1a.availability - pharm.receptors.ht1a.availability).abs()
< f32::EPSILON
);
}
}