diff --git a/.gitignore b/.gitignore
index 1c24555..440822a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,15 @@
# Origin project
KetaPK_FR_1_00/
KetaPK_FR_1_00*
+code_vba.txt
+
+# Python-generated files
+__pycache__/
+*.py[oc]
+*.egg-info
+
+# Virtual environments
+.venv
# Virtual environments
.venv
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e69de29
diff --git a/backend/package.json b/backend/package.json
new file mode 100644
index 0000000..e69de29
diff --git a/backend/server.js b/backend/server.js
new file mode 100644
index 0000000..0c92d0d
--- /dev/null
+++ b/backend/server.js
@@ -0,0 +1,45 @@
+const express = require('express');
+const cors = require('cors');
+const path = require('path');
+
+const app = express();
+const PORT = process.env.PORT || 3000;
+
+app.use(cors());
+app.use(express.json());
+
+// Service des fichiers statiques (Frontend)
+app.use(express.static(path.join(__dirname, '../frontend')));
+
+// Base de données temporaire en mémoire pour les sauvegardes
+const savedSimulations = [];
+
+// API : Enregistrer une simulation
+app.post('/api/simulations', (req, res) => {
+ const { patient, mode, inputs, results, timestamp } = req.body;
+
+ if (!inputs || !results) {
+ return res.status(400).json({ error: 'Données de simulation incomplètes' });
+ }
+
+ const newRecord = {
+ id: Date.now(),
+ timestamp: timestamp || new Date().toISOString(),
+ patient,
+ mode,
+ inputs,
+ results
+ };
+
+ savedSimulations.push(newRecord);
+ res.status(201).json({ message: 'Simulation sauvegardée avec succès', id: newRecord.id });
+});
+
+// API : Récupérer toutes les sauvegardes
+app.get('/api/simulations', (req, res) => {
+ res.json(savedSimulations);
+});
+
+app.listen(PORT, () => {
+ console.log(`Serveur KétaPK démarré sur http://localhost:${PORT}`);
+});
\ No newline at end of file
diff --git a/frontend/css/styles.css b/frontend/css/styles.css
new file mode 100644
index 0000000..7556b5f
--- /dev/null
+++ b/frontend/css/styles.css
@@ -0,0 +1,69 @@
+@charset "UTF-8";
+
+/* ==========================================================================
+ KétaPK Web - Custom Styles & Adjustments
+ ========================================================================== */
+
+/* 1. Suppression des flèches/spinners par défaut sur les inputs de type number */
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+input[type="number"] {
+ -moz-appearance: textfield;
+}
+
+/* 2. Personnalisation de la barre de défilement pour la grille temporelle */
+#timelineGrid::-webkit-scrollbar {
+ height: 8px;
+}
+
+#timelineGrid::-webkit-scrollbar-track {
+ background: rgba(15, 23, 42, 0.6);
+ border-radius: 9999px;
+}
+
+#timelineGrid::-webkit-scrollbar-thumb {
+ background: rgba(51, 65, 85, 0.8);
+ border-radius: 9999px;
+ border: 2px solid rgba(15, 23, 42, 0.6);
+}
+
+#timelineGrid::-webkit-scrollbar-thumb:hover {
+ background: rgba(100, 116, 139, 1);
+}
+
+/* 3. Styles d'animation douces pour les fenêtres modales */
+#modalCalc {
+ transition: opacity 0.2s ease-in-out;
+}
+
+#modalCalc.hidden {
+ opacity: 0;
+ pointer-events: none;
+}
+
+#modalCalc:not(.hidden) {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+/* 4. Effets au survol des cellules de la timeline */
+#timelineGrid > div {
+ min-width: 80px;
+ transition: border-color 0.15s ease, transform 0.15s ease;
+}
+
+#timelineGrid > div:focus-within {
+ border-color: rgba(245, 158, 11, 0.8);
+ box-shadow: 0 0 10px rgba(245, 158, 11, 0.15);
+}
+
+/* 5. Optimisations Responsive (écrans verticaux et smartphones) */
+@media (max-width: 640px) {
+ #pkChart {
+ min-height: 280px;
+ }
+}
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..987e9e3
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,229 @@
+
+
+
+
+
+ KétaPK - Simulation Pharmacocinétique
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⏱️ Saisie Chronologique Doses & Perfusion (0 - 300 min)
+
+ Défilement horizontal 👉
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🧮 Calculateur & Conversions de Doses
+
+
+
+
+
+
+
+
+
+
+
+
+
+ kg
+
+
+
+
+
+
+ mg/mL
+
+
+
+
+
+
+
+
+
+
Conversion Perfusion
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/js/app.js b/frontend/js/app.js
new file mode 100644
index 0000000..534adb3
--- /dev/null
+++ b/frontend/js/app.js
@@ -0,0 +1,166 @@
+import { MODES, PK_MODELS, CLINICAL_THRESHOLDS, generateSimulationData } from '../../pkCore.js';
+import { generatePDFReport } from './pdfExport.js';
+
+let currentMode = MODES.ESKETAMINE;
+let pkChart = null;
+
+// Initialisation de la Grille Saisie (61 intervalles de 5 min)
+const timelineInputs = Array.from({ length: 61 }, (_, i) => ({
+ time: i * 5,
+ bolusMg: 0,
+ perfRateMgKgH: 0
+}));
+
+document.addEventListener('DOMContentLoaded', () => {
+ initChart();
+ renderTimelineGrid();
+ setupEventListeners();
+ updateSimulation();
+});
+
+function initChart() {
+ const ctx = document.getElementById('pkChart').getContext('2d');
+
+ pkChart = new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels: timelineInputs.map(d => `${d.time}'`),
+ datasets: []
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ y: {
+ beginAtZero: true,
+ max: CLINICAL_THRESHOLDS[currentMode].maxScale,
+ title: { display: true, text: 'Cp (ng/mL)', color: '#94a3b8' },
+ grid: { color: 'rgba(51, 65, 85, 0.3)' }
+ },
+ x: {
+ title: { display: true, text: 'Temps (minutes)', color: '#94a3b8' },
+ grid: { color: 'rgba(51, 65, 85, 0.3)' }
+ }
+ },
+ plugins: {
+ legend: { labels: { color: '#f8fafc' } }
+ }
+ }
+ });
+}
+
+function updateSimulation() {
+ const weight = parseFloat(document.getElementById('inputWeight').value) || 70;
+
+ const datasets = [];
+
+ if (document.getElementById('chkDomino').checked) {
+ datasets.push({
+ label: PK_MODELS.DOMINO.name,
+ data: generateSimulationData({ model: PK_MODELS.DOMINO, weightKg: weight, timelineInputs }),
+ borderColor: PK_MODELS.DOMINO.color,
+ borderWidth: 3,
+ tension: 0.2
+ });
+ }
+
+ if (document.getElementById('chkClements').checked) {
+ datasets.push({
+ label: PK_MODELS.CLEMENTS.name,
+ data: generateSimulationData({ model: PK_MODELS.CLEMENTS, weightKg: weight, timelineInputs }),
+ borderColor: PK_MODELS.CLEMENTS.color,
+ borderWidth: 3,
+ tension: 0.2
+ });
+ }
+
+ if (document.getElementById('chkKamp').checked) {
+ datasets.push({
+ label: PK_MODELS.KAMP.name,
+ data: generateSimulationData({ model: PK_MODELS.KAMP, weightKg: weight, timelineInputs }),
+ borderColor: PK_MODELS.KAMP.color,
+ borderWidth: 3,
+ tension: 0.2
+ });
+ }
+
+ pkChart.data.datasets = datasets;
+ pkChart.options.scales.y.max = CLINICAL_THRESHOLDS[currentMode].maxScale;
+ pkChart.update();
+}
+
+function renderTimelineGrid() {
+ const container = document.getElementById('timelineGrid');
+ container.innerHTML = '';
+
+ timelineInputs.forEach((item, idx) => {
+ const col = document.createElement('div');
+ col.className = 'flex-1 bg-slate-900 border border-slate-700/60 rounded-xl p-2 text-center text-xs flex flex-col gap-1.5';
+ col.innerHTML = `
+ ${item.time} min
+
+ Bolus (mg)
+
+
+
+ Perf (mg/kg/h)
+
+
+ `;
+ container.appendChild(col);
+ });
+}
+
+function setupEventListeners() {
+ document.getElementById('timelineGrid').addEventListener('input', (e) => {
+ const idx = e.target.dataset.idx;
+ const field = e.target.dataset.field;
+ if (idx !== undefined && field) {
+ timelineInputs[idx][field] = parseFloat(e.target.value) || 0;
+ updateSimulation();
+ }
+ });
+
+ document.getElementById('btnEsk').addEventListener('click', () => setMode(MODES.ESKETAMINE));
+ document.getElementById('btnRac').addEventListener('click', () => setMode(MODES.RACEMIQUE));
+
+ ['inputWeight', 'chkDomino', 'chkClements', 'chkKamp'].forEach(id => {
+ document.getElementById(id).addEventListener('change', updateSimulation);
+ });
+
+ document.getElementById('btnRaz').addEventListener('click', () => {
+ timelineInputs.forEach(item => { item.bolusMg = 0; item.perfRateMgKgH = 0; });
+ renderTimelineGrid();
+ updateSimulation();
+ });
+
+ document.getElementById('btnExportPdf').addEventListener('click', () => {
+ // On rassemble les données actuelles du formulaire
+ const patientData = {
+ weight: document.getElementById('inputWeight').value || 70,
+ age: document.getElementById('inputAge').value || 50,
+ height: document.getElementById('inputHeight').value || 170
+ };
+
+ // On lance l'exportation (pkChart et currentMode sont des variables globales de app.js)
+ generatePDFReport(pkChart, patientData, currentMode);
+});
+}
+
+function setMode(mode) {
+ currentMode = mode;
+ const btnEsk = document.getElementById('btnEsk');
+ const btnRac = document.getElementById('btnRac');
+ const concInput = document.getElementById('inputConc');
+
+ if (mode === MODES.ESKETAMINE) {
+ btnEsk.className = 'px-4 py-1.5 rounded-lg text-sm font-semibold bg-amber-500 text-slate-950 shadow';
+ btnRac.className = 'px-4 py-1.5 rounded-lg text-sm font-semibold text-slate-300 hover:text-white';
+ concInput.value = CLINICAL_THRESHOLDS[MODES.ESKETAMINE].defaultConcentration;
+ } else {
+ btnRac.className = 'px-4 py-1.5 rounded-lg text-sm font-semibold bg-fuchsia-500 text-slate-950 shadow';
+ btnEsk.className = 'px-4 py-1.5 rounded-lg text-sm font-semibold text-slate-300 hover:text-white';
+ concInput.value = CLINICAL_THRESHOLDS[MODES.RACEMIQUE].defaultConcentration;
+ }
+ updateSimulation();
+}
\ No newline at end of file
diff --git a/frontend/js/calculator.js b/frontend/js/calculator.js
new file mode 100644
index 0000000..c170291
--- /dev/null
+++ b/frontend/js/calculator.js
@@ -0,0 +1,38 @@
+import { CalculatorEngine } from '../pkCore.js';
+
+export function initCalculatorUI() {
+ const weightInput = document.getElementById('calcWeight');
+ const concInput = document.getElementById('calcConc');
+
+ // Conversion Bolus
+ const mgInput = document.getElementById('calcMg');
+ const mgKgInput = document.getElementById('calcMgKg');
+
+ // Conversion Perfusion
+ const mgKgHInput = document.getElementById('calcMgKgH');
+ const ugKgMinInput = document.getElementById('calcUgKgMin');
+
+ // 1. Bolus mg <-> mg/kg
+ mgInput.addEventListener('input', () => {
+ const w = parseFloat(weightInput.value) || 70;
+ const mg = parseFloat(mgInput.value) || 0;
+ mgKgInput.value = CalculatorEngine.mgToMgKg(mg, w).toFixed(2);
+ });
+
+ mgKgInput.addEventListener('input', () => {
+ const w = parseFloat(weightInput.value) || 70;
+ const mgKg = parseFloat(mgKgInput.value) || 0;
+ mgInput.value = CalculatorEngine.mgKgToMg(mgKg, w).toFixed(1);
+ });
+
+ // 2. Vitesse mg/kg/h <-> µg/kg/min
+ mgKgHInput.addEventListener('input', () => {
+ const mgKgH = parseFloat(mgKgHInput.value) || 0;
+ ugKgMinInput.value = CalculatorEngine.mgKgHToMicrogKgMin(mgKgH).toFixed(1);
+ });
+
+ ugKgMinInput.addEventListener('input', () => {
+ const ugKgMin = parseFloat(ugKgMinInput.value) || 0;
+ mgKgHInput.value = CalculatorEngine.microgKgMinToMgKgH(ugKgMin).toFixed(2);
+ });
+}
\ No newline at end of file
diff --git a/frontend/js/chartManager.js b/frontend/js/chartManager.js
new file mode 100644
index 0000000..26f5c6f
--- /dev/null
+++ b/frontend/js/chartManager.js
@@ -0,0 +1,30 @@
+import { CLINICAL_THRESHOLDS } from '../pkCore.js';
+
+export function createChartAnnotations(mode) {
+ const thresholds = CLINICAL_THRESHOLDS[mode];
+ const annotations = {};
+
+ thresholds.zones.forEach((zone, index) => {
+ // Évite d'étendre la dernière zone à l'infini sur le graphe
+ const yMax = zone.max === Infinity ? thresholds.maxScale : zone.max;
+
+ if (zone.min < thresholds.maxScale) {
+ annotations[`zone_${index}`] = {
+ type: 'box',
+ yMin: zone.min,
+ yMax: Math.min(yMax, thresholds.maxScale),
+ backgroundColor: zone.color,
+ borderWidth: 0,
+ label: {
+ display: true,
+ content: zone.name,
+ color: zone.textHex,
+ font: { size: 14, weight: 'bold' },
+ position: 'center'
+ }
+ };
+ }
+ });
+
+ return annotations;
+}
\ No newline at end of file
diff --git a/frontend/js/pdfExport.js b/frontend/js/pdfExport.js
new file mode 100644
index 0000000..8f253bd
--- /dev/null
+++ b/frontend/js/pdfExport.js
@@ -0,0 +1,52 @@
+/**
+ * Génère et télécharge un rapport PDF de la simulation.
+ * @param {Object} pkChart - L'instance Chart.js actuelle
+ * @param {Object} patient - Les infos du patient { weight, age, height }
+ * @param {String} mode - ESKETAMINE ou RACEMIQUE
+ */
+export function generatePDFReport(pkChart, patient, mode) {
+ // jsPDF est chargé globalement via le CDN dans index.html
+ const { jsPDF } = window.jspdf;
+
+ // Format A4, orientation Paysage (landscape) idéal pour les graphiques
+ const doc = new jsPDF('landscape', 'mm', 'a4');
+
+ // --- 1. En-tête du document ---
+ doc.setFillColor(30, 41, 59); // Couleur bleue nuit (slate-800)
+ doc.rect(0, 0, 297, 25, 'F');
+
+ doc.setTextColor(255, 255, 255);
+ doc.setFontSize(18);
+ doc.setFont("helvetica", "bold");
+ doc.text("KétaPK - Rapport de Simulation Pharmacocinétique", 14, 16);
+
+ // --- 2. Informations de la simulation ---
+ doc.setTextColor(50, 50, 50);
+ doc.setFontSize(11);
+ doc.setFont("helvetica", "normal");
+
+ const dateStr = new Date().toLocaleString('fr-FR');
+
+ // Colonne de gauche (Patient)
+ doc.text(`Date de simulation : ${dateStr}`, 14, 35);
+ doc.setFont("helvetica", "bold");
+ doc.text(`Mode sélectionné : ${mode}`, 14, 42);
+ doc.text(`Patient : Poids ${patient.weight} kg | Âge ${patient.age} ans | Taille ${patient.height} cm`, 14, 49);
+
+ // --- 3. Capture et insertion du Graphique ---
+ // On récupère l'image du graphique sur fond blanc
+ const chartImage = pkChart.toBase64Image('image/jpeg', 1.0);
+
+ // Placement : x=14mm, y=60mm, largeur=260mm, hauteur=110mm
+ doc.addImage(chartImage, 'JPEG', 14, 60, 260, 110);
+
+ // --- 4. Pied de page ---
+ doc.setFont("helvetica", "italic");
+ doc.setFontSize(9);
+ doc.setTextColor(150, 150, 150);
+ doc.text("Ce document est généré par KétaPK Web. Il est destiné uniquement à l'enseignement et ne doit pas être utilisé pour la conduite thérapeutique.", 14, 190);
+
+ // --- 5. Téléchargement ---
+ const fileName = `KetaPK_Rapport_${patient.weight}kg_${mode}.pdf`;
+ doc.save(fileName);
+}
\ No newline at end of file
diff --git a/pkCore.js b/pkCore.js
new file mode 100644
index 0000000..95e48b9
--- /dev/null
+++ b/pkCore.js
@@ -0,0 +1,135 @@
+/**
+ * ============================================================================
+ * KetaPK - Moteur Pharmacocinétique (PK/PD)
+ * Modèles : Domino (1982), Clements (1981), Kamp (2020)
+ * ============================================================================
+ */
+
+export const MODES = {
+ ESKETAMINE: 'ESKETAMINE',
+ RACEMIQUE: 'RACEMIQUE'
+};
+
+// 1. Modèles Pharmacocinétiques (Multi-exponentiels)
+// Cp(t) = (Dose / Poids) * [ A * e^(-alpha * t) + B * e^(-beta * t) + C * e^(-gamma * t) ]
+export const PK_MODELS = {
+ DOMINO: {
+ name: "Domino (1982)",
+ refDose: 2.0, // mg/kg
+ A: 14300, alpha: 1.35167,
+ B: 2340, beta: 0.09517,
+ C: 197, gamma: 0.00378,
+ color: '#ff9933' // Orange
+ },
+ CLEMENTS: {
+ name: "Clements (1981)",
+ refDose: 0.25, // mg/kg
+ A: 108, alpha: 0.03940,
+ B: 39, beta: 0.00380,
+ C: 0, gamma: 0,
+ color: '#6ec46c' // Vert
+ },
+ KAMP: {
+ name: "Kamp (2020)",
+ refDose: 0.5, // mg/kg
+ A: 1332, alpha: 0.24658,
+ B: 155, beta: 0.03845,
+ C: 69, gamma: 0.00409,
+ color: '#4fafe3' // Bleu
+ }
+};
+
+// 2. Seuils d'effets cliniques (ng/mL)
+export const CLINICAL_THRESHOLDS = {
+ [MODES.ESKETAMINE]: {
+ maxScale: 500,
+ defaultConcentration: 5, // mg/mL
+ zones: [
+ { name: "Inactif", min: 0, max: 10, color: "rgba(249, 252, 126, 0.4)", textHex: "#8a8d00" },
+ { name: "Anti-Hyperalgésique", min: 10, max: 50, color: "rgba(218, 247, 166, 0.4)", textHex: "#417505" },
+ { name: "Analgésie", min: 50, max: 150, color: "rgba(255, 195, 0, 0.4)", textHex: "#b78100" },
+ { name: "Psychédélique", min: 150, max: 350, color: "rgba(242, 160, 223, 0.4)", textHex: "#a2137f" },
+ { name: "Émergence", min: 350, max: 500, color: "rgba(89, 196, 240, 0.4)", textHex: "#0c6796" },
+ { name: "Narcose", min: 500, max: 1000, color: "rgba(43, 122, 244, 0.4)", textHex: "#0a3c8a" },
+ { name: "Excessif", min: 1000, max: Infinity, color: "rgba(255, 87, 51, 0.4)", textHex: "#a81300" }
+ ]
+ },
+ [MODES.RACEMIQUE]: {
+ maxScale: 1000,
+ defaultConcentration: 10, // mg/mL
+ zones: [
+ { name: "Inactif", min: 0, max: 20, color: "rgba(249, 252, 126, 0.4)", textHex: "#8a8d00" },
+ { name: "Anti-Hyperalgésique", min: 20, max: 100, color: "rgba(218, 247, 166, 0.4)", textHex: "#417505" },
+ { name: "Analgésie", min: 100, max: 300, color: "rgba(255, 195, 0, 0.4)", textHex: "#b78100" },
+ { name: "Psychédélique", min: 300, max: 700, color: "rgba(242, 160, 223, 0.4)", textHex: "#a2137f" },
+ { name: "Émergence", min: 700, max: 1000, color: "rgba(89, 196, 240, 0.4)", textHex: "#0c6796" },
+ { name: "Narcose", min: 1000, max: 2000, color: "rgba(43, 122, 244, 0.4)", textHex: "#0a3c8a" },
+ { name: "Excessif", min: 2000, max: Infinity, color: "rgba(255, 87, 51, 0.4)", textHex: "#a81300" }
+ ]
+ }
+};
+
+// 3. Équation Fondamentale du Calcul de la Concentration Plasmatique Cp(t)
+/**
+ * Calcule la réponse impulsionnelle pour un bolus unique à un instant t.
+ * $C_p(t) = \frac{\text{dose}}{\text{poids}} \times \left( A \cdot e^{-\alpha t} + B \cdot e^{-\beta t} + C \cdot e^{-\gamma t} \right)$
+ */
+export function calculateBolusCp(model, doseMg, weightKg, timeElapsedMin) {
+ if (timeElapsedMin < 0 || weightKg <= 0) return 0;
+
+ const dosePerKg = doseMg / weightKg;
+ const { A, alpha, B, beta, C, gamma } = model;
+
+ const termA = A * Math.exp(-alpha * timeElapsedMin);
+ const termB = B * Math.exp(-beta * timeElapsedMin);
+ const termC = C * (gamma ? Math.exp(-gamma * timeElapsedMin) : 0);
+
+ return dosePerKg * (termA + termB + termC);
+}
+
+/**
+ * Superposition Linéaire des effets (Bolus multiples + Perfusions) sur 300 minutes (pas de 5 min)
+ */
+export function generateSimulationData({ model, weightKg, timelineInputs }) {
+ // timelineInputs : tableau de 61 pas de temps (0 à 300 min par pas de 5 min)
+ // { time: 0..300, bolusMg: number, perfRateMgKgH: number }
+
+ const timeGrid = Array.from({ length: 61 }, (_, i) => i * 5);
+ const cpResults = new Array(61).fill(0);
+
+ timeGrid.forEach((targetTime, targetIndex) => {
+ let totalCp = 0;
+
+ for (let i = 0; i <= targetIndex; i++) {
+ const inputTime = timeGrid[i];
+ const deltaTime = targetTime - inputTime;
+ const input = timelineInputs[i] || { bolusMg: 0, perfRateMgKgH: 0 };
+
+ // Contribution du Bolus
+ if (input.bolusMg > 0) {
+ totalCp += calculateBolusCp(model, input.bolusMg, weightKg, deltaTime);
+ }
+
+ // Contribution de la perfusion continue (discrétisée sur le pas de 5 min)
+ if (input.perfRateMgKgH > 0) {
+ // mg/kg/h converti en mg sur l'intervalle de 5 min (5/60 heure)
+ const perfDoseMg = (input.perfRateMgKgH * weightKg) * (5 / 60);
+ totalCp += calculateBolusCp(model, perfDoseMg, weightKg, deltaTime);
+ }
+ }
+
+ cpResults[targetIndex] = Math.round(totalCp * 100) / 100;
+ });
+
+ return cpResults;
+}
+
+// 4. Fonctions de Calculateur Médical (Conversions d'unités)
+export const CalculatorEngine = {
+ mgToMgKg: (mg, weight) => (weight > 0 ? mg / weight : 0),
+ mgKgToMg: (mgKg, weight) => mgKg * weight,
+ mgKgHToMicrogKgMin: (mgKgH) => (mgKgH * 1000) / 60,
+ microgKgMinToMgKgH: (microg) => (microg * 60) / 1000,
+ mgToMl: (mg, concMgMl) => (concMgMl > 0 ? mg / concMgMl : 0),
+ mlToMg: (ml, concMgMl) => ml * concMgMl
+};
\ No newline at end of file