Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

330 add experimental gradual patient progression option #332

Draft
wants to merge 21 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8b65607
#330 frontend: use enum for the exercise status
Wolkenfarmer Sep 1, 2024
860bebf
Revert "#330 frontend: use enum for the exercise status"
Wolkenfarmer Sep 4, 2024
66dbdf5
#330 backend: add moved_from field to assignable mixin
Wolkenfarmer Sep 14, 2024
0564c32
#330 backend: add continuousVariable model and data
Wolkenfarmer Sep 14, 2024
99d008e
#330 backend: add continuous variable serializer
Wolkenfarmer Sep 14, 2024
de69740
#330: add continuous variable event logic
Wolkenfarmer Sep 14, 2024
8c45c0a
#330 frontend: add continuous variables store with function update logic
Wolkenfarmer Sep 14, 2024
2acedd0
#330 frontend: add continuous SpO2 display
Wolkenfarmer Sep 14, 2024
c855937
#330 frontend: fix interface exports to enable enum exports
Wolkenfarmer Sep 16, 2024
3680da1
#330 frontend: add xStart and tDelta as internal continuous variable …
Wolkenfarmer Sep 16, 2024
4a741f1
#330: add sigmoid and delayed sigmoid function options to continuous …
Wolkenfarmer Sep 16, 2024
1ec1e72
#330 backend: only send continuous variable update if continuous vari…
Wolkenfarmer Sep 16, 2024
267a9a2
#330: add full heart rate support as continuous variable
Wolkenfarmer Sep 16, 2024
67910d5
#330 frontend: improved sigmoid and delayed sigmoid functions to be m…
Wolkenfarmer Sep 16, 2024
612d300
Merge branch 'main' into 330-add-experimental-gradual-patient-progres…
Wolkenfarmer Oct 4, 2024
5f0fcb0
Merge branch 'main' into 330-add-experimental-gradual-patient-progres…
Wolkenfarmer Oct 16, 2024
14deec8
#330 backend: fix tests
Wolkenfarmer Oct 16, 2024
6656c81
#330 backend: update continuous_variables_data for more realistic run…
Wolkenfarmer Oct 16, 2024
f90fc06
#330 backend: always send continuous variable update last
Wolkenfarmer Oct 17, 2024
d16e7f8
#330 backend: fix crash when activating continuous variable exception…
Wolkenfarmer Oct 17, 2024
448894a
#330 frontend: simplify sigmoid function
Wolkenfarmer Oct 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions backend/dps_training_k/data/continuous_variables_data.py
Original file line number Diff line number Diff line change
@@ -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,
},
],
},
)
26 changes: 26 additions & 0 deletions backend/dps_training_k/game/channel_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"}

Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions backend/dps_training_k/game/consumers/patient_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"

Expand All @@ -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: (
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
17 changes: 10 additions & 7 deletions backend/dps_training_k/game/models/patient_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
17 changes: 12 additions & 5 deletions backend/dps_training_k/game/tests/test_channel_notifications.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Loading