diff --git a/backend/dps_training_k/data/continuous_variables_data.py b/backend/dps_training_k/data/continuous_variables_data.py new file mode 100644 index 00000000..392440b6 --- /dev/null +++ b/backend/dps_training_k/data/continuous_variables_data.py @@ -0,0 +1,130 @@ +from template.constants import ActionIDs, MaterialIDs, ContinuousVariableIDs +from template.models.continuous_variable import ContinuousVariable + + +def update_or_create_continuous_variables(): + """ + "name": ContinuousVariable.Variable.SPO2, // name as defined in ContinuousVariable + "function": ContinuousVariable.Function.LINEAR, // function as defined in ContinuousVariable + "exceptions": [{ + "actions": ["A1uuid", ["A2uuid", "A3uuid"]], // this means action1 OR (action3 AND action4); can be None + "materials": ["M1uuid", ["M2uuid", "M3uuid"]], // material1 OR material2 OR (material3 AND material4); can be None + "function": ContinuousVariable.Function.LINEAR, // function as defined in ContinuousVariable + }] // exceptions listed according to their priority. If the first exceptions applies, the rest won't be looked at + // actions and materials both need to be true in order for the exception to apply + """ + + ContinuousVariable.objects.update_or_create( + uuid=ContinuousVariableIDs.SPO2, + defaults={ + "name": ContinuousVariable.Variable.SPO2, + "function": ContinuousVariable.Function.SIGMOID, + "exceptions": [ + { + "actions": [ + str(ActionIDs.BEATMUNGSGERAET_ANBRINGEN), + str(ActionIDs.SAUERSTOFF_ANBRINGEN), + [ + str(ActionIDs.ANALGETIKUM), + str(ActionIDs.ANTIKOAGULANZ), + str(ActionIDs.ERYTHROZYTENKONZENTRATE_ANWENDEN), + ], + ], + "materials": [ + str(MaterialIDs.BEATMUNGSGERAET_STATIONAER), + str(MaterialIDs.BEATMUNGSGERAET_TRAGBAR), + [ + str(MaterialIDs.FRESH_FROZEN_PLASMA), + str(MaterialIDs.LAB_GERAET_1), + str(MaterialIDs.LAB_GERAET_2), + ], + ], + "function": ContinuousVariable.Function.INCREMENT, + }, + { + "actions": [ + str(ActionIDs.BEATMUNGSGERAET_ANBRINGEN), + [ + str(ActionIDs.ANALGETIKUM), + str(ActionIDs.ANTIKOAGULANZ), + ], + ], + "materials": [ + str(MaterialIDs.BEATMUNGSGERAET_STATIONAER), + [ + str(MaterialIDs.FRESH_FROZEN_PLASMA), + str(MaterialIDs.LAB_GERAET_1), + ], + ], + "function": ContinuousVariable.Function.LINEAR, + }, + { + "actions": [str(ActionIDs.TURNIQUET)], + "materials": [], + "function": ContinuousVariable.Function.SIGMOID_DELAYED, + }, + { + "actions": [], + "materials": [str(MaterialIDs.BZ_MESSGERAET)], + "function": ContinuousVariable.Function.SIGMOID_DELAYED, + }, + ], + }, + ) + ContinuousVariable.objects.update_or_create( + uuid=ContinuousVariableIDs.HEART_RATE, + defaults={ + "name": ContinuousVariable.Variable.HEART_RATE, + "function": ContinuousVariable.Function.SIGMOID, + "exceptions": [ + { + "actions": [ + str(ActionIDs.BEATMUNGSGERAET_ANBRINGEN), + str(ActionIDs.SAUERSTOFF_ANBRINGEN), + [ + str(ActionIDs.ANALGETIKUM), + str(ActionIDs.ANTIKOAGULANZ), + str(ActionIDs.ERYTHROZYTENKONZENTRATE_ANWENDEN), + ], + ], + "materials": [ + str(MaterialIDs.BEATMUNGSGERAET_STATIONAER), + str(MaterialIDs.BEATMUNGSGERAET_TRAGBAR), + [ + str(MaterialIDs.FRESH_FROZEN_PLASMA), + str(MaterialIDs.LAB_GERAET_1), + str(MaterialIDs.LAB_GERAET_2), + ], + ], + "function": ContinuousVariable.Function.INCREMENT, + }, + { + "actions": [ + str(ActionIDs.BEATMUNGSGERAET_ANBRINGEN), + [ + str(ActionIDs.ANALGETIKUM), + str(ActionIDs.ANTIKOAGULANZ), + ], + ], + "materials": [ + str(MaterialIDs.BEATMUNGSGERAET_STATIONAER), + [ + str(MaterialIDs.FRESH_FROZEN_PLASMA), + str(MaterialIDs.LAB_GERAET_1), + ], + ], + "function": ContinuousVariable.Function.LINEAR, + }, + { + "actions": [str(ActionIDs.TURNIQUET)], + "materials": [], + "function": ContinuousVariable.Function.SIGMOID_DELAYED, + }, + { + "actions": [], + "materials": [str(MaterialIDs.BZ_MESSGERAET)], + "function": ContinuousVariable.Function.SIGMOID_DELAYED, + }, + ], + }, + ) diff --git a/backend/dps_training_k/game/channel_notifications.py b/backend/dps_training_k/game/channel_notifications.py index 066c57f7..f2724e03 100644 --- a/backend/dps_training_k/game/channel_notifications.py +++ b/backend/dps_training_k/game/channel_notifications.py @@ -16,6 +16,7 @@ class ChannelEventTypes: STATE_CHANGE_EVENT = "state.change.event" + CONTINUOUS_VARIABLE_UPDATE = "continuous.variable.event" EXERCISE_UPDATE = "send.exercise.event" EXERCISE_START_EVENT = "exercise.start.event" EXERCISE_END_EVENT = "exercise.end.event" @@ -158,6 +159,14 @@ def dispatch_event(cls, applied_action, changes, is_updated): # always send action list event cls._notify_action_event(ChannelEventTypes.ACTION_LIST_EVENT, channel) + if ( + "current_state" in changes + and applied_action.state_name == models.ActionInstanceStateNames.FINISHED + and applied_action.patient_instance + ): + event = {"type": ChannelEventTypes.CONTINUOUS_VARIABLE_UPDATE} + cls._notify_group(channel, event) + @classmethod def _notify_action_event(cls, event_type, channel, applied_action=None): # ACTION_LIST_EVENT is a special case, as it does not need an associated applied_Action @@ -333,6 +342,8 @@ def _notify_log_update_event(cls, log_entry): class MaterialInstanceDispatcher(ChannelNotifier): @classmethod def dispatch_event(cls, material, changes, is_updated): + from game.models import PatientInstance + changes_set = set(changes) if changes else set() assignment_changes = {"patient_instance", "area", "lab"} @@ -344,6 +355,18 @@ def dispatch_event(cls, material, changes, is_updated): event = {"type": ChannelEventTypes.RESOURCE_ASSIGNMENT_EVENT} cls._notify_group(channel, event) + # in case a necessary material was unassigned, leading to a different future state + if isinstance(material.moved_from, PatientInstance): + channel = cls.get_group_name(material.moved_from) + event = {"type": ChannelEventTypes.CONTINUOUS_VARIABLE_UPDATE} + cls._notify_group(channel, event) + + # in case a necessary material was assigned, leading to a different future state + if material.patient_instance: + channel = cls.get_group_name(material.patient_instance) + event = {"type": ChannelEventTypes.CONTINUOUS_VARIABLE_UPDATE} + cls._notify_group(channel, event) + @classmethod def create_trainer_log(cls, material, changes, is_updated): changes_set = set(changes) if changes else set() @@ -474,6 +497,9 @@ def _notify_patient_state_change(cls, patient_instance): } cls._notify_group(channel, event) + event = {"type": ChannelEventTypes.CONTINUOUS_VARIABLE_UPDATE} + cls._notify_group(channel, event) + @classmethod def _notify_patient_move(cls, patient_instance): channel = cls.get_group_name(patient_instance.exercise) diff --git a/backend/dps_training_k/game/consumers/patient_consumer.py b/backend/dps_training_k/game/consumers/patient_consumer.py index 48aeb2b0..01f8203f 100644 --- a/backend/dps_training_k/game/consumers/patient_consumer.py +++ b/backend/dps_training_k/game/consumers/patient_consumer.py @@ -16,6 +16,9 @@ LabActionCheckSerializer, ) from template.models import Action +from template.serializers.continuous_variable_serializer import ( + ContinuousVariableSerializer, +) from template.serializers.state_serialize import StateSerializer from .abstract_consumer import AbstractConsumer from ..channel_notifications import ( @@ -53,6 +56,7 @@ class PatientOutgoingMessageTypes: RESOURCE_ASSIGNMENTS = "resource-assignments" RESPONSE = "response" STATE = "state" + CONTINUOUS_VARIABLE = "continuous-variable" PATIENT_BACK = "patient-back" PATIENT_RELOCATING = "patient-relocating" @@ -63,6 +67,7 @@ def __init__(self, *args, **kwargs): ] self.patient_frontend_id = "" self.currently_inspected_action = None + self.continuous_variables_hashes = {} patient_request_map = { self.PatientIncomingMessageTypes.ACTION_ADD: ( @@ -282,6 +287,44 @@ def state_change_event(self, event=None): state=serialized_state, ) + def continuous_variable_event(self, event=None): + serialized_continuous_state = ContinuousVariableSerializer( + self.get_patient_instance() + ).data + + continuous_variables = serialized_continuous_state["continuousVariables"] + filtered_continuous_variables = [ + variable + for variable in continuous_variables + if self.has_continuous_variable_hash_changed(variable) + ] + self.update_continuous_variable_hashes(filtered_continuous_variables) + serialized_continuous_state["continuousVariables"] = ( + filtered_continuous_variables + ) + + self.send_event( + self.PatientOutgoingMessageTypes.CONTINUOUS_VARIABLE, + continuousState=serialized_continuous_state, + ) + + def has_continuous_variable_hash_changed(self, variable): + """Check if the hash of the variable has changed.""" + var_name = variable["name"] + var_hash = variable["hash"] + if var_name not in self.continuous_variables_hashes: + return True + return self.continuous_variables_hashes[var_name] != var_hash + + def update_continuous_variable_hashes(self, variables): + """Update the stored hashes with the new hashes.""" + for variable in variables: + self.continuous_variables_hashes[variable["name"]] = variable["hash"] + + def exercise_start_event(self, event=None): + super().exercise_start_event(event) + self.continuous_variable_event() + def action_check_changed_event(self, event=None): if self.currently_inspected_action: self.receive_json( diff --git a/backend/dps_training_k/game/migrations/0007_materialinstance_moved_from_content_type_and_more.py b/backend/dps_training_k/game/migrations/0007_materialinstance_moved_from_content_type_and_more.py new file mode 100644 index 00000000..fb7025bc --- /dev/null +++ b/backend/dps_training_k/game/migrations/0007_materialinstance_moved_from_content_type_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 5.0.1 on 2024-09-12 12:47 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("game", "0006_remove_area_unique_area_names_per_exercise"), + ] + + operations = [ + migrations.AddField( + model_name="materialinstance", + name="moved_from_content_type", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="contenttypes.contenttype", + ), + ), + migrations.AddField( + model_name="materialinstance", + name="moved_from_object_id", + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="personnel", + name="moved_from_content_type", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="contenttypes.contenttype", + ), + ), + migrations.AddField( + model_name="personnel", + name="moved_from_object_id", + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/backend/dps_training_k/game/models/patient_instance.py b/backend/dps_training_k/game/models/patient_instance.py index 4d82aebf..71d902d9 100644 --- a/backend/dps_training_k/game/models/patient_instance.py +++ b/backend/dps_training_k/game/models/patient_instance.py @@ -4,16 +4,16 @@ from django.core.exceptions import ValidationError from django.db import models -from configuration import settings -from game.models import Exercise from game.channel_notifications import PatientInstanceDispatcher +from game.models import Exercise +from helpers.completed_actions import CompletedActionsMixin from helpers.eventable import Eventable from helpers.moveable import Moveable from helpers.moveable_to import MoveableTo from helpers.triage import Triage -from helpers.completed_actions import CompletedActionsMixin from template.models import PatientState, Action, Subcondition, Material + # from game.models import ActionInstanceStateNames moved into function to avoid circular imports # from game.models import Area, Lab # moved into function to avoid circular imports @@ -145,18 +145,21 @@ def schedule_state_change(self, time_offset=0): patient=self, ) - def execute_state_change(self): + def next_state(self): if self.patient_state.is_dead or self.patient_state.is_final(): raise Exception( - f"Patient is dead or in final state, state change should have never been scheduled" + f"Patient is dead or in final state, next state cannot be calculated" ) fulfilled_subconditions = self.get_fulfilled_subconditions() - future_state = self.patient_state.transition.activate(fulfilled_subconditions) + return self.patient_state.transition.activate(fulfilled_subconditions) + + def execute_state_change(self): + future_state = self.next_state() if not future_state: return False self.patient_state = future_state - self.save(update_fields=["patient_state"]) self.schedule_state_change() + self.save(update_fields=["patient_state"]) return True def get_fulfilled_subconditions(self): diff --git a/backend/dps_training_k/game/tests/test_channel_notifications.py b/backend/dps_training_k/game/tests/test_channel_notifications.py index 9af47c1b..ca18c3ca 100644 --- a/backend/dps_training_k/game/tests/test_channel_notifications.py +++ b/backend/dps_training_k/game/tests/test_channel_notifications.py @@ -1,13 +1,15 @@ +from unittest.mock import patch + from django.test import TestCase + +import game.channel_notifications as cn +from game.models import ActionInstanceStateNames from .factories import ( ActionInstanceFactory, ActionInstanceStateFactory, PatientFactory, ) from .mixin import TestUtilsMixin -import game.channel_notifications as cn -from game.models import ActionInstanceStateNames -from unittest.mock import patch class ChannelNotifierTestCase(TestUtilsMixin, TestCase): @@ -28,11 +30,16 @@ def test_action_dispatcher(self, notify_group_mock): previous_function_calls = notify_group_mock.call_count action_instance.save(update_fields=["current_state"]) - self.assertEqual(notify_group_mock.call_count, previous_function_calls + 1) + self.assertEqual( + notify_group_mock.call_count, previous_function_calls + 2 + ) # action list & continuous variables @patch.object(cn.ChannelNotifier, "_notify_group") def test_patient_dispatcher(self, notify_group_mock): patient_instance = PatientFactory() previous_function_calls = notify_group_mock.call_count patient_instance.save(update_fields=["patient_state"]) - self.assertEqual(notify_group_mock.call_count, previous_function_calls + 1) + + self.assertEqual( + notify_group_mock.call_count, previous_function_calls + 2 + ) # state & continuous variables diff --git a/backend/dps_training_k/game/tests/test_resource_assignment.py b/backend/dps_training_k/game/tests/test_resource_assignment.py index 48a2df33..d093e44d 100644 --- a/backend/dps_training_k/game/tests/test_resource_assignment.py +++ b/backend/dps_training_k/game/tests/test_resource_assignment.py @@ -45,18 +45,23 @@ def test_channel_notifications_being_send( """ self.deactivate_live_updates() self.material_instance.try_moving_to(self.area) + # as the resource is already assigned to the area, no notification should be sent self.assertEqual(_notify_exercise_update.call_count, 0) - self.assertEqual( - _notify_group.call_count, 0 - ) # as the resource is already assigned to the area, no notification should be sent + self.assertEqual(_notify_group.call_count, 0) self.material_instance.try_moving_to(self.lab) - self.assertEqual(_notify_exercise_update.call_count, 0) - self.assertEqual(_notify_group.call_count, 1) + self.assertEqual( + _notify_exercise_update.call_count, 1 + ) # lab should be outside of area -> exercise update + self.assertEqual(_notify_group.call_count, 1) # + assignment self.material_instance.try_moving_to(self.patient) - self.assertEqual(_notify_exercise_update.call_count, 0) - self.assertEqual(_notify_group.call_count, 2) + self.assertEqual( + _notify_exercise_update.call_count, 2 + ) # patient should be inside of area again -> exercise update + self.assertEqual( + _notify_group.call_count, 3 + ) # + assignment & + continuous_variable self.activate_live_updates() def test_resource_block_and_release(self): diff --git a/backend/dps_training_k/helpers/assignable.py b/backend/dps_training_k/helpers/assignable.py index 4e4069a8..37834afd 100644 --- a/backend/dps_training_k/helpers/assignable.py +++ b/backend/dps_training_k/helpers/assignable.py @@ -1,11 +1,11 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.db import models from helpers import exactly_one_field_not_null -class Assignable( - models.Model, -): +class Assignable(models.Model): class Meta: abstract = True @@ -18,10 +18,16 @@ class Meta: area = models.ForeignKey("Area", on_delete=models.CASCADE, null=True, blank=True) lab = models.ForeignKey("Lab", on_delete=models.CASCADE, null=True, blank=True) + # GenericForeignKey is just a virtual field for unified access, only _content_type and _object_id are actually stored in DB + moved_from_content_type = models.ForeignKey( + ContentType, on_delete=models.SET_NULL, null=True, blank=True + ) + moved_from_object_id = models.PositiveIntegerField(null=True, blank=True) + moved_from = GenericForeignKey("moved_from_content_type", "moved_from_object_id") + def __init_subclass__(cls, **kwargs): - """Needed to create unique name for each constraint given to the subclasses of Assignable, but I can already define the constraint here.""" + """Create a unique name for each constraint for subclasses of Assignable.""" super().__init_subclass__(**kwargs) - # Add a unique constraint for each subclass of Assignable constraint_name = ( f"assignable_{cls.__name__.lower()}_exactly_one_field_not_null" ) @@ -34,6 +40,8 @@ def __init_subclass__(cls, **kwargs): def _perform_move(self, obj): from game.models import PatientInstance, Area, Lab + self.moved_from = self.attached_instance() + if isinstance(obj, PatientInstance): if self.patient_instance is obj: return False @@ -52,26 +60,30 @@ def _perform_move(self, obj): self.patient_instance = None self.area = None self.lab = obj - self.save(update_fields=["patient_instance", "area", "lab"]) + + self.save( + update_fields=[ + "patient_instance", + "area", + "lab", + "moved_from_content_type", + "moved_from_object_id", + ] + ) return True, "" @staticmethod def can_move_to_type(obj): from game.models import PatientInstance, Area, Lab - return ( - isinstance(obj, PatientInstance) - or isinstance(obj, Area) - or isinstance(obj, Lab) - ) + return isinstance(obj, (PatientInstance, Area, Lab)) def is_blocked(self): return self.action_instance is not None def attached_instance(self): - return ( - self.patient_instance or self.area or self.lab - ) # first not null value determined by short-circuiting + # First non-null value determined by short-circuiting + return self.patient_instance or self.area or self.lab def block(self, action_instance): self.action_instance = action_instance diff --git a/backend/dps_training_k/template/admin.py b/backend/dps_training_k/template/admin.py index efb33f89..f3c1a264 100644 --- a/backend/dps_training_k/template/admin.py +++ b/backend/dps_training_k/template/admin.py @@ -8,3 +8,4 @@ admin.site.register(StateTransition) admin.site.register(PatientInformation) admin.site.register(Material) +admin.site.register(ContinuousVariable) diff --git a/backend/dps_training_k/template/constants.py b/backend/dps_training_k/template/constants.py index cdddf46c..6bceb53e 100644 --- a/backend/dps_training_k/template/constants.py +++ b/backend/dps_training_k/template/constants.py @@ -147,3 +147,8 @@ class ActionResultIDs: HB430 = "4a76bb8d-ccd2-4f27-9e08-3131ed3c0178" BZ920 = "3ddade44-701a-43b9-9670-a8e346a45048" BZ930 = "c30d670e-c072-4263-9f6a-895d00ef7368" + + +class ContinuousVariableIDs: + SPO2 = "cb39ff83-6f6a-4dba-9ae1-b2028dbddb35" + HEART_RATE = "862cca82-555e-4f05-bd38-9a6dc9f01f60" diff --git a/backend/dps_training_k/template/management/commands/import_patient_information.py b/backend/dps_training_k/template/management/commands/import_patient_information.py index d53a829c..cb50a264 100644 --- a/backend/dps_training_k/template/management/commands/import_patient_information.py +++ b/backend/dps_training_k/template/management/commands/import_patient_information.py @@ -3,6 +3,7 @@ from django.core.management.base import BaseCommand +from data.continuous_variables_data import update_or_create_continuous_variables from helpers.triage import Triage from template.constants import ActionIDs from template.models import PatientInformation @@ -75,7 +76,7 @@ def import_patients(file_path): "start_status": row["Start-Status"].strip(), "start_location": row["Start-Ort"].strip(), "op": row["OP / Interventions-Verlauf"].strip(), - } + }, ) pretreatments_list = [ pt.strip() for pt in patient_information.pretreatment.split(",") @@ -102,8 +103,13 @@ def import_patients(file_path): class Command(BaseCommand): - help = "Imports patient data from a CSV file" + help = "Imports patient data from a CSV file and update continuous variables data" def handle(self, *args, **options): import_patients("./data/patient_information.csv") # path to the csv file - self.stdout.write(self.style.SUCCESS("Successfully imported patient data")) + update_or_create_continuous_variables() + self.stdout.write( + self.style.SUCCESS( + "Successfully imported patient data and updated continuous variables data" + ) + ) diff --git a/backend/dps_training_k/template/migrations/0011_continuousvariable.py b/backend/dps_training_k/template/migrations/0011_continuousvariable.py new file mode 100644 index 00000000..dfa6342d --- /dev/null +++ b/backend/dps_training_k/template/migrations/0011_continuousvariable.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.1 on 2024-09-12 16:54 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('template', '0010_merge_20240710_1548'), + ] + + operations = [ + migrations.CreateModel( + name='ContinuousVariable', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('name', models.CharField(choices=[('SpO2', 'Spo2'), ('BPM', 'Heart Rate')], unique=True)), + ('function', models.CharField(choices=[('linear', 'Linear'), ('increment', 'Increment'), ('decrement', 'Decrement')])), + ('exceptions', models.JSONField()), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/dps_training_k/template/migrations/0012_alter_continuousvariable_function.py b/backend/dps_training_k/template/migrations/0012_alter_continuousvariable_function.py new file mode 100644 index 00000000..a955987f --- /dev/null +++ b/backend/dps_training_k/template/migrations/0012_alter_continuousvariable_function.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.1 on 2024-09-16 12:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('template', '0011_continuousvariable'), + ] + + operations = [ + migrations.AlterField( + model_name='continuousvariable', + name='function', + field=models.CharField(choices=[('linear', 'Linear'), ('sigmoid', 'Sigmoid'), ('delayed sigmoid', 'Sigmoid Delayed'), ('increment', 'Increment'), ('decrement', 'Decrement')]), + ), + ] diff --git a/backend/dps_training_k/template/migrations/0013_merge_20241016_1616.py b/backend/dps_training_k/template/migrations/0013_merge_20241016_1616.py new file mode 100644 index 00000000..2f9b89a6 --- /dev/null +++ b/backend/dps_training_k/template/migrations/0013_merge_20241016_1616.py @@ -0,0 +1,13 @@ +# Generated by Django 5.0.1 on 2024-10-16 14:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("template", "0011_alter_patientstate_transition"), + ("template", "0012_alter_continuousvariable_function"), + ] + + operations = [] diff --git a/backend/dps_training_k/template/models/__init__.py b/backend/dps_training_k/template/models/__init__.py index 2675c5bc..b030ccef 100644 --- a/backend/dps_training_k/template/models/__init__.py +++ b/backend/dps_training_k/template/models/__init__.py @@ -1,7 +1,8 @@ from .action import Action +from .continuous_variable import ContinuousVariable +from .logic_node import LogicNode +from .material import Material from .patient_information import PatientInformation from .patient_state import PatientState from .state_transition import StateTransition from .subcondition import Subcondition -from .logic_node import LogicNode -from .material import Material diff --git a/backend/dps_training_k/template/models/continuous_variable.py b/backend/dps_training_k/template/models/continuous_variable.py new file mode 100644 index 00000000..ec94c9fe --- /dev/null +++ b/backend/dps_training_k/template/models/continuous_variable.py @@ -0,0 +1,23 @@ +from django.db import models + +from helpers.models import UUIDable + + +class ContinuousVariable(UUIDable, models.Model): + class Variable(models.TextChoices): + SPO2 = "SpO2" + HEART_RATE = "BPM" + + class Function(models.TextChoices): + LINEAR = "linear" + SIGMOID = ("sigmoid",) + SIGMOID_DELAYED = ("delayed sigmoid",) + INCREMENT = "increment" + DECREMENT = "decrement" + + name = models.CharField(choices=Variable.choices, unique=True) + function = models.CharField(choices=Function.choices) + exceptions = models.JSONField() + + def __str__(self): + return f"Continuous variable called {self.name}" diff --git a/backend/dps_training_k/template/serializers/continuous_variable_serializer.py b/backend/dps_training_k/template/serializers/continuous_variable_serializer.py new file mode 100644 index 00000000..5797bee1 --- /dev/null +++ b/backend/dps_training_k/template/serializers/continuous_variable_serializer.py @@ -0,0 +1,136 @@ +import re + +from django.db.models import Q +from rest_framework import serializers + +from game.models import PatientInstance, Owner +from template.models.continuous_variable import ContinuousVariable + + +def _extract_spo2(vital_signs): + """Extracts the SpO2 value from a vital signs text.""" + return int(re.search(r"SpO2:\s*(\d+)", vital_signs["Breathing"]).group(1)) + + +def _extract_bpm(vital_signs): + """Extracts the heart rate value from a vital signs text.""" + return int(re.search(r"Herzfreq:\s*(\d+)", vital_signs["Circulation"]).group(1)) + + +def _check_subset(condition_items, completed_items): + """Generic method to check if all given condition items are fulfilled by being within the completed_items set.""" + if not condition_items: + return True + + for item_group in condition_items: + if isinstance(item_group, str): + item_group = [item_group] + if set(item_group).issubset(completed_items): + return True + return False + + +class ContinuousVariableSerializer(serializers.ModelSerializer): + def __init__(self, patient_instance, **kwargs): + super().__init__(**kwargs) + if isinstance(patient_instance, PatientInstance): + self.patient_instance = patient_instance + else: + raise TypeError( + f"Expected 'patient_instance' to be of type PatientInstance. Got {type(patient_instance).__name__} instead." + ) + + @property + def data(self): + """Constructs the serialized data, including phase change time and continuous variables.""" + time_until_phase_change = self._get_time_until_phase_change() + continuous_variables = self.continuous_variables() + + return { + "timeUntilPhaseChange": time_until_phase_change, + "continuousVariables": continuous_variables if continuous_variables else [], + } + + def _get_time_until_phase_change(self): + """Returns the time until the next phase change event, or 0 if none is scheduled.""" + phase_change_event_owners = Owner.objects.filter( + Q(patient_owner=self.patient_instance) + & Q(event__method_name="execute_state_change") + ) + if phase_change_event_owners.exists(): + phase_change_event = ( + phase_change_event_owners.order_by("event__end_date").last().event + ) + return phase_change_event.get_time_until_completion(self.patient_instance) + return 0 + + def continuous_variables(self): + """Returns a list of continuous variable data for the patient.""" + future_state = self.patient_instance.next_state() + if not future_state: + return [] + + variables = ContinuousVariable.objects.all() + + result = [] + for variable in variables: + current, target = self._get_values(variable.name, future_state) + function, var_hash = self._get_applicable_function(variable) + var_hash = hash((var_hash, current, target)) + + result.append( + { + "name": variable.name, + "current": current, + "target": target, + "function": function, + "hash": var_hash, + } + ) + return result + + def _get_values(self, variable_name, future_state): + match variable_name: + case ContinuousVariable.Variable.SPO2: + fun = _extract_spo2 + case ContinuousVariable.Variable.HEART_RATE: + fun = _extract_bpm + case _: + raise TypeError( + f"Did not find values for given continuous variable {variable_name}." + ) + + return fun(self.patient_instance.patient_state.vital_signs), fun( + future_state.vital_signs + ) + + def _flatten(self, lst): + """Helper function to recursively flatten a list""" + if isinstance(lst, list): + return tuple(item for sublist in lst for item in self._flatten(sublist)) + return (lst,) + + def _get_applicable_function(self, variable): + completed_action_uuids = { + str(action.uuid) + for action in self.patient_instance.get_completed_action_types() + } + completed_material_uuids = { + str(material.template.uuid) + for material in self.patient_instance.materialinstance_set.all() + } + + for exception in variable.exceptions: + function = exception["function"] + actions = ( + self._flatten(exception["actions"]) if exception["actions"] else () + ) + materials = ( + self._flatten(exception["materials"]) if exception["materials"] else () + ) + + if _check_subset(actions, completed_action_uuids) and _check_subset( + materials, completed_material_uuids + ): + return function, hash((function, actions, materials)) + return variable.function, hash(variable.function) diff --git a/frontend/src/components/widgets/PatientStatus.vue b/frontend/src/components/widgets/PatientStatus.vue index 9d639059..9ff72263 100644 --- a/frontend/src/components/widgets/PatientStatus.vue +++ b/frontend/src/components/widgets/PatientStatus.vue @@ -1,8 +1,23 @@