Add names states for the Ventilation schemes, allowing their state to be persisted even if that scheme isn't currently active.
This commit is contained in:
parent
a95181612b
commit
9037e68c31
4 changed files with 239 additions and 33 deletions
39
cara/apps.py
39
cara/apps.py
|
|
@ -1,3 +1,4 @@
|
|||
import dataclasses
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
|
|
@ -239,8 +240,8 @@ class WidgetView:
|
|||
|
||||
def _build_ventilation(self, node):
|
||||
ventilation_widgets = {
|
||||
'Natural': self._build_window(node),
|
||||
# 'HEPA': self._build_hepa(node)
|
||||
'Natural': self._build_window(node._states['Natural']),
|
||||
'HEPA': self._build_hepa(node._states['HEPA']),
|
||||
}
|
||||
for name, widget in ventilation_widgets.items():
|
||||
widget.layout.visible = False
|
||||
|
|
@ -252,10 +253,9 @@ class WidgetView:
|
|||
def toggle_ventilation(value):
|
||||
for name, widget in ventilation_widgets.items():
|
||||
widget.layout.display = 'none'
|
||||
# if value == 'Natural':
|
||||
# node.dcs_update_from(models.PeriodicWindow())
|
||||
# elif value == 'HEPA':
|
||||
# node.dcs_update_from(models.PeriodicHEPA())
|
||||
|
||||
node.dcs_select(value)
|
||||
|
||||
widget = ventilation_widgets[value]
|
||||
widget.layout.visible = True
|
||||
widget.layout.display = 'block'
|
||||
|
|
@ -293,9 +293,34 @@ baseline_model = models.Model(
|
|||
)
|
||||
|
||||
|
||||
class CARAStateBuilder(state.StateBuilder):
|
||||
def build_type_Mask(self, _: dataclasses.Field):
|
||||
return state.DataclassStatePredefined(
|
||||
models.Mask,
|
||||
choices=models.Mask.types,
|
||||
)
|
||||
|
||||
def build_type_Ventilation(self, _: dataclasses.Field):
|
||||
s = state.DataclassStateNamed(
|
||||
states={
|
||||
'Natural': self.build_generic(models.PeriodicWindow),
|
||||
'HEPA': self.build_generic(models.PeriodicHEPA),
|
||||
},
|
||||
state_builder=self,
|
||||
)
|
||||
# Initialise the HEPA state
|
||||
s._states['HEPA'].dcs_update_from(
|
||||
models.PeriodicHEPA(120, 120, 500.)
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
class ExpertApplication:
|
||||
def __init__(self):
|
||||
self.model_state = state.DataclassState(models.Model)
|
||||
self.model_state = state.DataclassInstanceState(
|
||||
models.Model,
|
||||
state_builder=CARAStateBuilder(),
|
||||
)
|
||||
self.model_state.dcs_update_from(baseline_model)
|
||||
self.view = WidgetView(self.model_state)
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ class PeriodicHEPA(Ventilation):
|
|||
def air_exchange(self, room: Room, time: float) -> float:
|
||||
# Returns the rate at which air is being exchanged in the given room per cubic meter at a given time
|
||||
|
||||
period = self.period / 60.
|
||||
duration = self.duration / 60.
|
||||
|
||||
# If the HEPA is off, no air is being exchanged
|
||||
if time % self.period < (self.period - self.duration):
|
||||
return 0
|
||||
|
|
|
|||
158
cara/state.py
158
cara/state.py
|
|
@ -35,10 +35,75 @@ class StateBuilder:
|
|||
return self.build_generic
|
||||
|
||||
def build_generic(self, type_to_build: typing.Type):
|
||||
return DataclassState(type_to_build, self)
|
||||
return DataclassInstanceState(type_to_build, state_builder=self)
|
||||
|
||||
|
||||
class DataclassState:
|
||||
def __init__(self, state_builder=StateBuilder()):
|
||||
with self._object_setattr():
|
||||
self._state_builder = state_builder
|
||||
|
||||
@contextmanager
|
||||
def _object_setattr(self):
|
||||
"""
|
||||
For the lifetime of this contextmanager, don't do anything other than
|
||||
standard object.__setattr__ when setting attributes.
|
||||
|
||||
"""
|
||||
object.__setattr__(self, '_use_base_setattr', True)
|
||||
yield
|
||||
object.__setattr__(self, '_use_base_setattr', False)
|
||||
|
||||
def dcs_instance(self):
|
||||
"""
|
||||
Return the instance that this state represents. The instance returned
|
||||
is immutable, so it is advised to call this method each time that
|
||||
you want the instance so that it reflects the most up-to-date state.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
def dcs_observe(self, callback: typing.Callable):
|
||||
"""
|
||||
If any changes are made to the state, call the given callback.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@contextmanager
|
||||
def dcs_state_transaction(self):
|
||||
"""
|
||||
For the lifetime of this context manager, do not fire observer
|
||||
notifications. If any notifications would have been fired during the
|
||||
lifetime of this context manager, then an event will be fired once
|
||||
exiting the context.
|
||||
|
||||
"""
|
||||
yield
|
||||
|
||||
def dcs_update_from(self, data: dataclass_instance):
|
||||
"""
|
||||
Update the state based on the values of the given dataclass instance.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
def _dcs_set_value(self, attr_name, value):
|
||||
"""
|
||||
Set the state of the given attribute to the given value.
|
||||
|
||||
"""
|
||||
|
||||
def dcs_set_instance_type(self, instance_dataclass: Datamodel_T):
|
||||
"""
|
||||
Update the current instance of the state to this type.
|
||||
|
||||
Note: This currently wipes all downstream observers.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class DataclassInstanceState(DataclassState):
|
||||
"""
|
||||
Represents the state of a frozen dataclass.
|
||||
No type checking of the attributes is attempted.
|
||||
|
|
@ -58,6 +123,8 @@ class DataclassState:
|
|||
"""
|
||||
|
||||
def __init__(self, dataclass: Datamodel_T, state_builder=StateBuilder()):
|
||||
super().__init__(state_builder=state_builder)
|
||||
|
||||
# Note that the constructor does *not* insert any data by default. It
|
||||
# therefore doesn't build nested DataclassState instances when a dataclass contains another.
|
||||
# For that, use the build classmethod.
|
||||
|
|
@ -124,15 +191,9 @@ class DataclassState:
|
|||
for observer in self._observers:
|
||||
observer()
|
||||
|
||||
@contextmanager
|
||||
def _object_setattr(self):
|
||||
self._use_base_setattr = True
|
||||
yield
|
||||
self._use_base_setattr = False
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return super().__getattribute__(name)
|
||||
return object.__getattribute__(self, name)
|
||||
except AttributeError:
|
||||
pass
|
||||
if name in self._data:
|
||||
|
|
@ -171,6 +232,7 @@ class DataclassState:
|
|||
raise TypeError(f"The dataclass type provided ({instance_dataclass}) must be a subclass of the base ({self._base})")
|
||||
self._instance_type = instance_dataclass
|
||||
|
||||
# TODO: It is possible to cut observer connections by clearing like this.
|
||||
self._data.clear()
|
||||
for field in dataclasses.fields(instance_dataclass):
|
||||
if dataclasses.is_dataclass(field.type):
|
||||
|
|
@ -204,7 +266,7 @@ class DataclassState:
|
|||
return self._instance
|
||||
|
||||
|
||||
class DataclassStatePredefined(DataclassState):
|
||||
class DataclassStatePredefined(DataclassInstanceState):
|
||||
"""
|
||||
Only a pre-defined selection of states for the given type are allowed.
|
||||
Selected by name (the keys in the dictionary).
|
||||
|
|
@ -214,8 +276,12 @@ class DataclassStatePredefined(DataclassState):
|
|||
state.dcs_select(name)
|
||||
|
||||
"""
|
||||
def __init__(self, dataclass: Datamodel_T, choices: typing.Dict[typing.Hashable, dataclass_instance]):
|
||||
super().__init__(dataclass=dataclass)
|
||||
def __init__(self,
|
||||
dataclass: Datamodel_T,
|
||||
choices: typing.Dict[typing.Hashable, dataclass_instance],
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(dataclass=dataclass, **kwargs)
|
||||
|
||||
with self._object_setattr():
|
||||
self._choices = choices
|
||||
|
|
@ -235,11 +301,75 @@ class DataclassStatePredefined(DataclassState):
|
|||
def __repr__(self):
|
||||
return f"<state for {self._instance_type.__name__}. '{self._selected}' selected>"
|
||||
|
||||
def _instance_kwargs(self):
|
||||
raise NotImplementedError("Doesn't make much sense")
|
||||
|
||||
def _instance_state(self):
|
||||
return dataclasses.asdict(self.dcs_instance())
|
||||
|
||||
def _instance_kwargs(self):
|
||||
return dataclasses.asdict(self.dcs_instance())
|
||||
|
||||
|
||||
class DataclassStateNamed(DataclassState):
|
||||
"""
|
||||
A collection of instances of the given type, switchable by name, but each
|
||||
instance is still mutable.
|
||||
|
||||
"""
|
||||
def __init__(self,
|
||||
states: typing.Dict[typing.Hashable, DataclassState],
|
||||
**kwargs
|
||||
):
|
||||
# TODO: This is effectively a container type. We shouldn't use the standard constructor for this.
|
||||
enabled = list(states.keys())[0]
|
||||
t = states[enabled]
|
||||
super().__init__(**kwargs)
|
||||
|
||||
with self._object_setattr():
|
||||
self._states = states.copy()
|
||||
self._selected = None
|
||||
# Pick the first choice until we know otherwise.
|
||||
self.dcs_select(enabled)
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return object.__getattribute__(self, name)
|
||||
except AttributeError:
|
||||
pass
|
||||
return getattr(self._selected_state(), name)
|
||||
# if name in self._data:
|
||||
# return self._data[name]
|
||||
# elif name in self._instance_attrs():
|
||||
# raise ValueError(f"State not yet set for {name}")
|
||||
# else:
|
||||
# raise AttributeError(f"Attribute {name} does not exist on {self._instance_type.__name__}")
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name in self.__dict__ or self.__dict__.get('_use_base_setattr', True):
|
||||
return object.__setattr__(self, name, value)
|
||||
setattr(self._selected_state(), name, value)
|
||||
|
||||
def dcs_select(self, name: typing.Hashable):
|
||||
if name not in self._states:
|
||||
raise ValueError(f'The choice {name} is not valid. Possible options are {", ".join(self._states)}')
|
||||
self._selected = name
|
||||
self._selected_state()._fire_observers()
|
||||
|
||||
def _selected_state(self):
|
||||
return self._states[self._selected]
|
||||
|
||||
def dcs_instance(self):
|
||||
return self._selected_state().dcs_instance()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<state for {self._instance_type.__name__}. Holding {len(self._states)} state(s). '{self._selected}' selected>"
|
||||
|
||||
def dcs_observe(self, callback: typing.Callable):
|
||||
# Note there is no way to observe the selected state change currently.
|
||||
# You can only watch for the individual selected states being changed.
|
||||
for state in self._states.values():
|
||||
state.dcs_observe(callback)
|
||||
|
||||
def dcs_update_from(self, data: dataclass_instance):
|
||||
return self._selected_state().dcs_update_from(data)
|
||||
|
||||
def dcs_set_instance_type(self, instance_dataclass: Datamodel_T):
|
||||
return self._selected_state().dcs_set_instance_type(instance_dataclass)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ class DCSimple:
|
|||
attr2: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DCAnother:
|
||||
attr3: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class DCSimpleSubclass(DCSimple):
|
||||
attr3: float
|
||||
|
|
@ -51,24 +56,24 @@ def dc_simple():
|
|||
|
||||
|
||||
def test_DCS_construct():
|
||||
s = state.DataclassState(DCSimple)
|
||||
s = state.DataclassInstanceState(DCSimple)
|
||||
assert repr(s) == '<state for DCSimple(**{})>'
|
||||
|
||||
with pytest.raises(TypeError, match=r"A dataclass type must be provided, not an instance of one"):
|
||||
state.DataclassState(DCSimple('', 1))
|
||||
state.DataclassInstanceState(DCSimple('', 1))
|
||||
|
||||
with pytest.raises(TypeError, match="The given class is not a valid dataclass"):
|
||||
state.DataclassState(None)
|
||||
state.DataclassInstanceState(None)
|
||||
|
||||
|
||||
def test_DCS_construct_nested():
|
||||
s = state.DataclassState(DCNested)
|
||||
s = state.DataclassInstanceState(DCNested)
|
||||
assert repr(s) == "<state for DCNested(**{'simple': {}})>"
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
def test_DCS_subclass():
|
||||
s = state.DataclassState(DCSimple)
|
||||
s = state.DataclassInstanceState(DCSimple)
|
||||
s.dcs_set_instance_type(DCSimpleSubclass)
|
||||
s.set('attr3', 3.14)
|
||||
assert s._instance_kwargs() == {'attr3': 3.14}
|
||||
|
|
@ -78,35 +83,35 @@ def test_DCS_subclass():
|
|||
|
||||
|
||||
def test_DCS_setattr():
|
||||
s = state.DataclassState(DCSimple)
|
||||
s = state.DataclassInstanceState(DCSimple)
|
||||
s.attr1 = 'Hello world'
|
||||
assert s._instance_kwargs() == {'attr1': 'Hello world'}
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
def test_DCS_type_check():
|
||||
s = state.DataclassState(DCSimple)
|
||||
s = state.DataclassInstanceState(DCSimple)
|
||||
with pytest.raises(TypeError):
|
||||
# TODO: Should we make this fail? It involves type-checking / validation.
|
||||
s.attr1 = 1
|
||||
|
||||
|
||||
def test_DCS_update_from_instance():
|
||||
s = state.DataclassState(DCSimple)
|
||||
s = state.DataclassInstanceState(DCSimple)
|
||||
s.dcs_update_from(DCSimple('a1', 2))
|
||||
assert s._instance_type == DCSimple
|
||||
assert s._instance_kwargs() == {'attr1': 'a1', 'attr2': 2}
|
||||
|
||||
|
||||
def test_DCS_update_from_instance_subclass():
|
||||
s = state.DataclassState(DCSimple)
|
||||
s = state.DataclassInstanceState(DCSimple)
|
||||
s.dcs_update_from(DCSimpleSubclass('a1', 2, 3.14))
|
||||
assert s._instance_type == DCSimpleSubclass
|
||||
assert s._instance_kwargs() == {'attr1': 'a1', 'attr2': 2, 'attr3': 3.14}
|
||||
|
||||
|
||||
def test_DCS_update_from_instance_nested():
|
||||
s = state.DataclassState(DCNested)
|
||||
s = state.DataclassInstanceState(DCNested)
|
||||
nested = DCNested(DCSimpleSubclass('a1', 2, 3.14), [])
|
||||
s.dcs_update_from(nested)
|
||||
assert s.simple.dcs_instance() == nested.simple
|
||||
|
|
@ -117,7 +122,7 @@ def test_observe_instance_nested():
|
|||
top_level = Mock()
|
||||
nested = Mock()
|
||||
|
||||
s = state.DataclassState(DCNested)
|
||||
s = state.DataclassInstanceState(DCNested)
|
||||
|
||||
s.dcs_observe(top_level)
|
||||
s.simple.dcs_observe(nested)
|
||||
|
|
@ -154,8 +159,51 @@ def test_DCS_predefined():
|
|||
assert s.dcs_instance() == opt2
|
||||
|
||||
|
||||
def test_DCS_named():
|
||||
opt1 = DCSimpleSubclass('a', 1, 3.14)
|
||||
opt2 = DCAnother(4.2)
|
||||
s = state.DataclassStateNamed({
|
||||
# Entirely different types possible.
|
||||
'option 1': state.DataclassInstanceState(DCSimple),
|
||||
'option 2': state.DataclassInstanceState(DCAnother),
|
||||
})
|
||||
assert s._selected == 'option 1'
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
s.dcs_select('option 3')
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
# Not initialised all the values yet...
|
||||
s.dcs_instance()
|
||||
|
||||
opt1_observer = Mock()
|
||||
s.dcs_observe(opt1_observer)
|
||||
|
||||
s.dcs_update_from(opt1)
|
||||
assert s.dcs_instance() == opt1
|
||||
|
||||
opt1_observer.assert_called_once_with()
|
||||
opt1_observer.reset_mock()
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
s.dcs_update_from(opt2)
|
||||
|
||||
s.dcs_select('option 2')
|
||||
|
||||
s.dcs_update_from(opt2)
|
||||
assert s.dcs_instance() == opt2
|
||||
# We can't observe individual states directly.
|
||||
opt1_observer.assert_called_once_with()
|
||||
|
||||
s.dcs_select('option 1')
|
||||
assert s.dcs_instance() == opt1
|
||||
|
||||
# TODO: This should fail.
|
||||
s.foo = 10
|
||||
|
||||
|
||||
def test_DCS_non_dataclass_attrs():
|
||||
val = DCClassVar('a', 1)
|
||||
s = state.DataclassState(DCSimple)
|
||||
s = state.DataclassInstanceState(DCSimple)
|
||||
s.dcs_update_from(val)
|
||||
s.dcs_instance() == val
|
||||
|
|
|
|||
Loading…
Reference in a new issue