diff --git a/simulator/README.md b/simulator/README.md index c9906f7..0432abe 100644 --- a/simulator/README.md +++ b/simulator/README.md @@ -42,6 +42,9 @@ INFLUXDB_URL=http://localhost:8086 INFLUXDB_TOKEN=tasks-token-secret INFLUXDB_ORG=tasks INFLUXDB_BUCKET=audit +SIMULATOR_INITIAL_MIN_PATIENTS=25 +SIMULATOR_LOOP_SLEEP_SECONDS_MIN=0.12 +SIMULATOR_LOOP_SLEEP_SECONDS_MAX=0.45 ``` ### Authentication Modes @@ -110,16 +113,16 @@ The simulator is split into multiple modules: - Non-Interactive: Direct Grant flow (if USE_DIRECT_GRANT=true and USERNAME/PASSWORD are set) - no browser required 2. Loads current state (locations, patients, tasks, users) 3. Displays the location structure hierarchy -4. Creates initial patients (some in waiting room, some admitted) -5. Continuously performs random actions: - - Create tasks (25%) - - Update tasks (20%) - - Create patients (15%) - - Admit patients (10%) - - Move patients (10%) - - Update positions (8%) - - Discharge patients (7%) - - Add teams (5%) +4. Seeds at least `SIMULATOR_INITIAL_MIN_PATIENTS` patients (default 25; some waiting, some admitted), each with diagnosis-based treatment tasks +5. Continuously performs random actions (approximate weights): + - Create tasks (32%) + - Update tasks (18%) + - Create patients (28%) + - Admit patients (6%) + - Move patients (6%) + - Update positions (4%) + - Discharge patients (3%) + - Add teams (3%) ## Diagnosis Types diff --git a/simulator/config.py b/simulator/config.py index 5fceb91..ce96a81 100644 --- a/simulator/config.py +++ b/simulator/config.py @@ -14,6 +14,29 @@ USERNAME = os.getenv("USERNAME", "test") PASSWORD = os.getenv("PASSWORD", "test") + +def _env_int(key: str, default: int) -> int: + raw = os.getenv(key) + if raw is None or raw.strip() == "": + return default + return int(raw) + + +def _env_float(key: str, default: float) -> float: + raw = os.getenv(key) + if raw is None or raw.strip() == "": + return default + return float(raw) + + +SIMULATOR_INITIAL_MIN_PATIENTS = _env_int("SIMULATOR_INITIAL_MIN_PATIENTS", 25) +_loop_sleep_min = _env_float("SIMULATOR_LOOP_SLEEP_SECONDS_MIN", 0.12) +_loop_sleep_max = _env_float("SIMULATOR_LOOP_SLEEP_SECONDS_MAX", 0.45) +if _loop_sleep_min > _loop_sleep_max: + _loop_sleep_min, _loop_sleep_max = _loop_sleep_max, _loop_sleep_min +SIMULATOR_LOOP_SLEEP_SECONDS_MIN = _loop_sleep_min +SIMULATOR_LOOP_SLEEP_SECONDS_MAX = _loop_sleep_max + CALLBACK_PORT = 8999 REDIRECT_URI = f"http://localhost:{CALLBACK_PORT}/callback" diff --git a/simulator/simulator.py b/simulator/simulator.py index 0fc8069..af3bb7f 100644 --- a/simulator/simulator.py +++ b/simulator/simulator.py @@ -1,6 +1,11 @@ from typing import Optional from graphql_client import GraphQLClient -from config import logger +from config import ( + SIMULATOR_INITIAL_MIN_PATIENTS, + SIMULATOR_LOOP_SLEEP_SECONDS_MAX, + SIMULATOR_LOOP_SLEEP_SECONDS_MIN, + logger, +) from location_manager import LocationManager from patient_manager import PatientManager from task_manager import TaskManager @@ -55,8 +60,10 @@ def run(self) -> None: self.location_manager.ensure_hospital_structure() self.location_manager.print_structure() - logger.info("Creating initial patients...") - while len(self.patient_manager.patient_ids) < 5: + logger.info( + f"Creating initial patients (target at least {SIMULATOR_INITIAL_MIN_PATIENTS})...", + ) + while len(self.patient_manager.patient_ids) < SIMULATOR_INITIAL_MIN_PATIENTS: admit_directly = random.random() < 0.4 patient_id, diagnosis = self.patient_manager.create_patient( admit_directly=admit_directly @@ -68,14 +75,14 @@ def run(self) -> None: logger.info("Starting continuous simulation loop...") actions = [ - (self._action_create_task, 0.25), - (self._action_update_task, 0.20), - (self._action_create_patient, 0.15), - (self._action_admit_patient, 0.10), - (self._action_move_patient, 0.10), - (self._action_update_position, 0.08), - (self._action_discharge_patient, 0.07), - (self._action_add_team, 0.05), + (self._action_create_task, 0.32), + (self._action_update_task, 0.18), + (self._action_create_patient, 0.28), + (self._action_admit_patient, 0.06), + (self._action_move_patient, 0.06), + (self._action_update_position, 0.04), + (self._action_discharge_patient, 0.03), + (self._action_add_team, 0.03), ] while True: @@ -86,7 +93,12 @@ def run(self) -> None: )[0] func() - time.sleep(random.uniform(2.0, 5.0)) + time.sleep( + random.uniform( + SIMULATOR_LOOP_SLEEP_SECONDS_MIN, + SIMULATOR_LOOP_SLEEP_SECONDS_MAX, + ), + ) except KeyboardInterrupt: logger.info("Simulation stopped by user.") diff --git a/web/components/patients/LoadTaskPresetDialog.tsx b/web/components/patients/LoadTaskPresetDialog.tsx index 68e33dc..4c78a51 100644 --- a/web/components/patients/LoadTaskPresetDialog.tsx +++ b/web/components/patients/LoadTaskPresetDialog.tsx @@ -14,6 +14,7 @@ import { useApplyTaskGraph, useTaskPresets } from '@/data' import { GetPatientDocument, type TaskPresetsQuery } from '@/api/gql/generated' import { useSystemSuggestionTasks } from '@/context/SystemSuggestionTasksContext' import { presetGraphToTaskGraphInput } from '@/utils/taskGraph' +import type { PatientDetailListSuccessHint } from '@/components/patients/patientDetailListHint' type PresetRow = TaskPresetsQuery['taskPresets'][number] @@ -21,7 +22,7 @@ type LoadTaskPresetDialogProps = { isOpen: boolean, onClose: () => void, patientId: string, - onSuccess?: () => void, + onSuccess?: (hint?: PatientDetailListSuccessHint) => void, } export function LoadTaskPresetDialog({ @@ -110,7 +111,7 @@ export function LoadTaskPresetDialog({ showToast(translation('tasksCreatedFromPreset')) setConfirmOpen(false) onClose() - onSuccess?.() + onSuccess?.({ needsPatientListRefetch: true }) }, [ applyTaskGraph, diff --git a/web/components/patients/PatientDataEditor.tsx b/web/components/patients/PatientDataEditor.tsx index ff1d39f..1743d17 100644 --- a/web/components/patients/PatientDataEditor.tsx +++ b/web/components/patients/PatientDataEditor.tsx @@ -9,6 +9,7 @@ import { useLocations, usePatient } from '@/data' import { Building2, CheckIcon, Locate, PlusIcon, Users, XIcon } from 'lucide-react' import { formatLocationPath, formatLocationPathFromId } from '@/utils/location' import { toISODate } from './PatientDetailView' +import type { PatientDetailListSuccessHint } from './patientDetailListHint' import { LocationSelectionDialog } from '@/components/locations/LocationSelectionDialog' import { useCreatePatient, @@ -34,7 +35,7 @@ type PatientFormValues = Omit, - onSuccess?: () => void, + onSuccess?: (hint?: PatientDetailListSuccessHint) => void, onClose?: () => void, onCreateDraftDirtyChange?: (dirty: boolean) => void, } @@ -136,7 +137,7 @@ export const PatientDataEditor = ({ variables: { data }, onCompleted: () => { onCreateDraftDirtyChange?.(false) - onSuccess?.() + onSuccess?.({ needsPatientListRefetch: true }) onClose?.() }, onError: (error) => { @@ -203,9 +204,10 @@ export const PatientDataEditor = ({ const samePosition = (data.positionId ?? current.position?.id) === current.position?.id const sameDescription = (data.description ?? current.description ?? '') === (current.description ?? '') if (sameFirstname && sameLastname && sameBirthdate && sameSex && sameAssignedIds && sameClinic && sameTeamIds && samePosition && sameDescription) return + const needsPatientListRefetch = !sameClinic || !sameTeamIds || !samePosition || !sameAssignedIds updatePatient({ variables: { id: patientId, data }, - onCompleted: () => onSuccess?.(), + onCompleted: () => onSuccess?.({ needsPatientListRefetch }), }) } }) @@ -412,7 +414,7 @@ export const PatientDataEditor = ({