Merge branch 'feature/CO2_expert' into 'master'

CO2 Expert App

See merge request caimira/caimira!424
This commit is contained in:
Andre Henriques 2023-03-29 16:51:11 +02:00
commit f80dff1d74
25 changed files with 1104 additions and 197 deletions

View file

@ -124,12 +124,12 @@ auth-service-image_builder:
DOCKER_CONTEXT_DIRECTORY: app-config/auth-service
caimira-webservice-image_builder:
calculator-app-image_builder:
extends:
- .image_builder
variables:
IMAGE_NAME: caimira-webservice
DOCKERFILE_DIRECTORY: app-config/caimira-webservice
IMAGE_NAME: calculator-app
DOCKERFILE_DIRECTORY: app-config/calculator-app
DOCKER_CONTEXT_DIRECTORY: ""
@ -159,11 +159,11 @@ link_auth-service_with_gitlab_registry:
variables:
IMAGE_NAME: auth-service
link_caimira-webservice_with_gitlab_registry:
link_calculator-app_with_gitlab_registry:
extends:
- .link_docker_images_with_gitlab_registry
variables:
IMAGE_NAME: caimira-webservice
IMAGE_NAME: calculator-app
link_calculator_with_gitlab_registry:
extends:

View file

@ -153,7 +153,7 @@ pytest ./caimira
```
s2i build file://$(pwd) --copy --keep-symlinks --context-dir ./app-config/nginx/ centos/nginx-112-centos7 caimira-nginx-app
docker build . -f ./app-config/caimira-webservice/Dockerfile -t caimira-webservice
docker build . -f ./app-config/calculator-app/Dockerfile -t calculator-app
docker build ./app-config/auth-service -t auth-service
```

View file

@ -15,6 +15,7 @@ COPY ./app-config/caimira-public-docker-image/run_caimira.sh /opt/caimira/start.
# In the best case this will be a no-op.
RUN cd /opt/caimira/src/ && /opt/caimira/app/bin/pip install -r /opt/caimira/src/requirements.txt
RUN /opt/caimira/app/bin/jupyter trust /opt/caimira/src/caimira/apps/expert/*.ipynb
RUN /opt/caimira/app/bin/jupyter trust /opt/caimira/src/caimira/apps/expert_co2/*.ipynb
COPY ./app-config/caimira-public-docker-image/nginx.conf /opt/caimira/nginx.conf
EXPOSE 8080

View file

@ -50,6 +50,12 @@ http {
rewrite ^/expert-app$ /voila-server/ last;
rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last;
location /co2-voila-server/ {
proxy_pass http://localhost:8083/co2-voila-server/;
}
rewrite ^/co2-app$ /voila-server/ last;
rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last;
location / {
proxy_pass http://localhost:8081;
}

View file

@ -12,5 +12,10 @@ cd /opt/caimira/src/caimira
--Voila.tornado_settings 'allow_origin=*' \
>> /var/log/expert-app.log 2>&1 &
/opt/caimira/app/bin/python -m voila /opt/caimira/src/caimira/apps/expert_co2/caimira.ipynb \
--port=8083 --no-browser --base_url=/co2-voila-server/ \
--Voila.tornado_settings 'allow_origin=*' \
>> /var/log/co2-app.log 2>&1 &
# Run the calculator in the foreground.
/opt/caimira/app/bin/python -m caimira.apps.calculator --port 8081 --no-debug

View file

@ -3,7 +3,7 @@ FROM registry.cern.ch/docker.io/condaforge/mambaforge as conda
RUN mamba create --yes -p /opt/app python=3.9
COPY . /opt/app-source
RUN cd /opt/app-source && conda run -p /opt/app python -m pip install -r ./requirements.txt .[app]
COPY app-config/caimira-webservice/app.sh /opt/app/bin/caimira-app.sh
COPY app-config/calculator-app/app.sh /opt/app/bin/calculator-app.sh
RUN cd /opt/app \
&& find -name '*.a' -delete \
&& rm -rf /opt/app/conda-meta \
@ -32,5 +32,5 @@ WORKDIR /scratch
RUN CAIMIRA_INIT_FILE=$(/opt/app/bin/python -c "import caimira; print(caimira.__file__)") \
&& ln -s $(dirname ${CAIMIRA_INIT_FILE}) /scratch/caimira
CMD [ \
"caimira-app.sh" \
"calculator-app.sh" \
]

View file

@ -1,6 +1,6 @@
#!/bin/bash
if [[ "$APP_NAME" == "caimira-webservice" ]]; then
if [[ "$APP_NAME" == "calculator-app" ]]; then
args=("$@")
if [ "$DEBUG" != "true" ] && [[ ! "${args[@]}" =~ "--no-debug" ]]; then
args+=("--no-debug")
@ -26,6 +26,9 @@ if [[ "$APP_NAME" == "caimira-webservice" ]]; then
elif [[ "$APP_NAME" == "caimira-voila" ]]; then
echo "Starting the voila service"
voila caimira/apps/expert/ --port=8080 --no-browser --base_url=/voila-server/ --tornado_settings 'allow_origin=*'
elif [[ "$APP_NAME" == "caimira-co2-voila" ]]; then
echo "Starting the CO2 voila service"
voila caimira/apps/expert_co2/ --port=8080 --no-browser --base_url=/co2-voila-server/ --tornado_settings 'allow_origin=*'
else
echo "No APP_NAME specified"
exit 1

View file

@ -1,26 +1,32 @@
version: "3.8"
services:
caimira-app:
image: caimira-webservice
expert-app:
image: calculator-app
environment:
- APP_NAME=caimira-voila
user: ${CURRENT_UID:?"Please run as follows 'CURRENT_UID=$(id -u):$(id -g) docker-compose up'"}
caimira-webservice:
image: caimira-webservice
expert-co2-app:
image: calculator-app
environment:
- APP_NAME=caimira-co2-voila
user: ${CURRENT_UID:?"Please run as follows 'CURRENT_UID=$(id -u):$(id -g) docker-compose up'"}
calculator-app:
image: calculator-app
environment:
- COOKIE_SECRET
- APP_NAME=caimira-webservice
- APP_NAME=calculator-app
- APPLICATION_ROOT=/
- CAIMIRA_CALCULATOR_PREFIX=/calculator-cern
- CAIMIRA_THEME=caimira/apps/templates/cern
user: ${CURRENT_UID}
caimira-calculator-open:
image: caimira-webservice
calculator-open-app:
image: calculator-app
environment:
- COOKIE_SECRET
- APP_NAME=caimira-webservice
- APP_NAME=calculator-app
- APPLICATION_ROOT=/
- CAIMIRA_CALCULATOR_PREFIX=/calculator-open
user: ${CURRENT_UID}
@ -40,11 +46,13 @@ services:
ports:
- "8080:8080"
depends_on:
caimira-webservice:
calculator-app:
condition: service_started
caimira-calculator-open:
calculator-open-app:
condition: service_started
caimira-app:
expert-app:
condition: service_started
expert-co2-app:
condition: service_started
auth-service:
condition: service_started

View file

@ -70,7 +70,7 @@ http {
# Pass the request on to the webservice. Most likely the URI won't
# exist so we get a 404 from that service instead (good as the 404
# pages are consistent).
proxy_pass http://caimira-webservice:8080/$request_uri;
proxy_pass http://calculator-app:8080/$request_uri;
}
location /voila-server/ {
@ -81,9 +81,9 @@ http {
error_page 401 = @error401;
error_page 404 = @proxy_404_error_handler;
# caimira-app is the name of the voila server in each of docker-compose,
# expert-app is the name of the voila server in each of docker-compose,
# caimira-test.web.cern.ch and caimira.web.cern.ch.
proxy_pass http://caimira-app:8080/voila-server/;
proxy_pass http://expert-app:8080/voila-server/;
}
rewrite ^/expert-app$ /voila-server/voila/render/caimira.ipynb last;
rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last;
@ -93,9 +93,28 @@ http {
absolute_redirect off;
rewrite ^/voila/(.*)$ /voila-server/voila/$1 redirect;
location /co2-voila-server/ {
proxy_intercept_errors on;
# Anything under voila-server or co2-app is authenticated.
auth_request /auth/probe;
error_page 401 = @error401;
error_page 404 = @proxy_404_error_handler;
# expert-co2-app is the name of the voila server in each of docker-compose,
# caimira-test.web.cern.ch and caimira.web.cern.ch.
proxy_pass http://expert-co2-app:8080/co2-voila-server/;
}
rewrite ^/co2-app$ /co2-voila-server/voila/render/caimira.ipynb last;
rewrite ^/(files/static)/(.*)$ /co2-voila-server/voila/$1/$2 last;
# Before implementing the nginx router we could access /voila/render/caimira.ipynb.
# Redirect this (and all other) URLs to the new scheme.
rewrite ^/voila/(.*)$ /co2-voila-server/voila/$1 redirect;
location / {
# By default we have no authentication.
proxy_pass http://caimira-webservice:8080;
proxy_pass http://calculator-app:8080;
}
location /calculator {
@ -107,14 +126,14 @@ http {
auth_request /auth/probe;
error_page 401 = @error401;
# caimira-webservice is the name of the tornado server (for the calculator)
# calculator-app is the name of the tornado server (for the calculator)
# in each of docker-compose, caimira-test.web.cern.ch and caimira.web.cern.ch.
proxy_pass http://caimira-webservice:8080/calculator-cern;
proxy_pass http://calculator-app:8080/calculator-cern;
}
location /calculator-open {
# Public open calculator
proxy_pass http://caimira-calculator-open:8080/calculator-open;
proxy_pass http://calculator-open-app:8080/calculator-open;
}
}
}

View file

@ -72,21 +72,21 @@
apiVersion: apps.openshift.io/v1
kind: DeploymentConfig
metadata:
name: caimira-app
labels: {app: caimira-app}
name: expert-app
labels: {app: expert-app}
spec:
replicas: 1
template:
metadata:
labels:
app: caimira-app
app: expert-app
spec:
containers:
- name: caimira-webservice
- name: calculator-app
env:
- name: APP_NAME
value: caimira-voila
image: '${PROJECT_NAME}/caimira-webservice'
image: '${PROJECT_NAME}/calculator-app'
ports:
- containerPort: 8080
protocol: TCP
@ -113,17 +113,74 @@
type: Rolling
test: false
selector:
app: caimira-app
app: expert-app
triggers:
- type: ConfigChange
- type: ImageChange
imageChangeParams:
automatic: true
containerNames:
- caimira-webservice
- calculator-app
from:
kind: ImageStreamTag
name: 'caimira-webservice:latest'
name: 'calculator-app:latest'
namespace: ${PROJECT_NAME}
-
apiVersion: apps.openshift.io/v1
kind: DeploymentConfig
metadata:
name: expert-co2-app
labels: {app: expert-co2-app}
spec:
replicas: 1
template:
metadata:
labels:
app: expert-co2-app
spec:
containers:
- name: calculator-app
env:
- name: APP_NAME
value: caimira-co2-voila
image: '${PROJECT_NAME}/calculator-app'
ports:
- containerPort: 8080
protocol: TCP
imagePullPolicy: Always
resources:
limits: { cpu: '1', memory: 1Gi }
requests: { cpu: 1m, memory: 512Mi }
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: { }
terminationGracePeriodSeconds: 30
strategy:
activeDeadlineSeconds: 21600
resources: { }
rollingParams:
intervalSeconds: 1
maxSurge: 25%
maxUnavailable: 25%
timeoutSeconds: 600
updatePeriodSeconds: 1
type: Rolling
test: false
selector:
app: expert-co2-app
triggers:
- type: ConfigChange
- type: ImageChange
imageChangeParams:
automatic: true
containerNames:
- calculator-app
from:
kind: ImageStreamTag
name: 'calculator-app:latest'
namespace: ${PROJECT_NAME}
-
apiVersion: apps.openshift.io/v1
@ -182,19 +239,19 @@
apiVersion: apps.openshift.io/v1
kind: DeploymentConfig
metadata:
name: caimira-webservice
name: calculator-app
labels:
image: caimira-webservice
app: caimira-webservice
image: calculator-app
app: calculator-app
spec:
replicas: 1
template:
metadata:
labels:
app: caimira-webservice
app: calculator-app
spec:
containers:
- name: caimira-webservice
- name: calculator-app
env:
- name: COOKIE_SECRET
valueFrom:
@ -204,7 +261,7 @@
- name: REPORT_PARALLELISM
value: '3'
- name: APP_NAME
value: caimira-webservice
value: calculator-app
- name: APPLICATION_ROOT
value: /
- name: CAIMIRA_CALCULATOR_PREFIX
@ -226,7 +283,7 @@
secretKeyRef:
key: ARVE_API_KEY
name: arve-api
image: '${PROJECT_NAME}/caimira-webservice'
image: '${PROJECT_NAME}/calculator-app'
ports:
- containerPort: 8080
protocol: TCP
@ -267,43 +324,43 @@
type: Rolling
test: false
selector:
app: caimira-webservice
app: calculator-app
triggers:
- type: ImageChange
imageChangeParams:
automatic: true
containerNames:
- caimira-webservice
- calculator-app
from:
kind: ImageStreamTag
name: 'caimira-webservice:latest'
name: 'calculator-app:latest'
namespace: ${PROJECT_NAME}
- type: ConfigChange
-
apiVersion: apps.openshift.io/v1
kind: DeploymentConfig
metadata:
name: caimira-calculator-open
name: calculator-open-app
labels:
image: caimira-webservice
app: caimira-calculator-open
image: calculator-app
app: calculator-open-app
spec:
replicas: 1
template:
metadata:
labels:
app: caimira-calculator-open
app: calculator-open-app
spec:
containers:
- name: caimira-calculator-open
- name: calculator-open-app
env:
- name: APP_NAME
value: caimira-webservice
value: calculator-app
- name: APPLICATION_ROOT
value: /
- name: CAIMIRA_CALCULATOR_PREFIX
value: /calculator-open
image: '${PROJECT_NAME}/caimira-webservice'
image: '${PROJECT_NAME}/calculator-app'
ports:
- containerPort: 8080
protocol: TCP
@ -334,17 +391,17 @@
type: Rolling
test: false
selector:
app: caimira-calculator-open
app: calculator-open-app
triggers:
- type: ConfigChange
- type: ImageChange
imageChangeParams:
automatic: true
containerNames:
- caimira-calculator-open
- calculator-open-app
from:
kind: ImageStreamTag
name: 'caimira-webservice:latest'
name: 'calculator-app:latest'
namespace: ${PROJECT_NAME}
- type: ConfigChange

View file

@ -30,7 +30,7 @@
kind: ImageStream
apiVersion: image.openshift.io/v1
metadata:
name: caimira-webservice
name: calculator-app
spec:
lookupPolicy:
local: False

View file

@ -32,8 +32,8 @@
kind: Service
metadata:
labels:
app: caimira-app
name: caimira-app
app: expert-app
name: expert-app
spec:
ports:
- name: 8080-tcp
@ -41,7 +41,24 @@
protocol: TCP
targetPort: 8080
selector:
deploymentconfig: caimira-app
deploymentconfig: expert-app
sessionAffinity: 'None'
type: 'ClusterIP'
-
apiVersion: v1
kind: Service
metadata:
labels:
app: expert-co2-app
name: expert-co2-app
spec:
ports:
- name: 8080-tcp
port: 8080
protocol: TCP
targetPort: 8080
selector:
deploymentconfig: expert-co2-app
sessionAffinity: 'None'
type: 'ClusterIP'
-
@ -66,8 +83,8 @@
kind: Service
metadata:
labels:
app: caimira-webservice
name: caimira-webservice
app: calculator-app
name: calculator-app
spec:
ports:
- name: 8080-tcp
@ -75,7 +92,7 @@
protocol: TCP
targetPort: 8080
selector:
deploymentconfig: caimira-webservice
deploymentconfig: calculator-app
sessionAffinity: 'None'
type: 'ClusterIP'
-
@ -83,8 +100,8 @@
kind: Service
metadata:
labels:
app: caimira-calculator-open
name: caimira-calculator-open
app: calculator-open-app
name: calculator-open-app
spec:
ports:
- name: 8080-tcp
@ -92,6 +109,6 @@
protocol: TCP
targetPort: 8080
selector:
deploymentconfig: caimira-calculator-open
deploymentconfig: calculator-open-app
sessionAffinity: 'None'
type: 'ClusterIP'

View file

@ -1,4 +1,4 @@
from .expert import ExpertApplication
from .expert_co2 import CO2Application
__all__ = ['ExpertApplication']
__all__ = ['ExpertApplication', 'CO2Application']

View file

@ -35,7 +35,7 @@ from .user import AuthenticatedUser, AnonymousUser
# calculator version. If the calculator needs to make breaking changes (e.g. change
# form attributes) then it can also increase its MAJOR version without needing to
# increase the overall CAiMIRA version (found at ``caimira.__version__``).
__version__ = "4.6"
__version__ = "4.7"
class BaseRequestHandler(RequestHandler):

View file

@ -214,7 +214,7 @@ class ExposureModelResult(View):
self.html_output.value = '<br>\n'.join(lines)
class ExposureComparissonResult(View):
class ExposureComparisonResult(View):
def __init__(self):
self.figure = matplotlib.figure.Figure(figsize=(9, 6))
ipympl_canvas(self.figure)
@ -255,6 +255,7 @@ class ExposureComparissonResult(View):
def update_plot(self, exp_models: typing.Tuple[models.ExposureModel, ...], labels: typing.Tuple[str, ...]):
[line.remove() for line in self.ax.lines]
[line.remove() for line in self.ax2.lines]
start, finish = models_start_end(exp_models)
colors=['blue', 'red', 'orange', 'yellow', 'pink', 'purple', 'green', 'brown', 'black' ]
ts = np.linspace(start, finish, num=250)
@ -275,7 +276,10 @@ class ExposureComparissonResult(View):
cumulative_top = max([max(cumulative_dose) for cumulative_dose in cumulative_doses])
self.ax2.set_ylim(bottom=0., top=cumulative_top)
self.ax.legend()
handles, labels = self.figure.gca().get_legend_handles_labels()
by_label = dict(zip(labels, handles))
self.ax.legend(by_label.values(), by_label.keys())
self.figure.canvas.draw()
@ -879,6 +883,9 @@ class CAIMIRAStateBuilder(state.StateBuilder):
# Note: The methods in this class must correspond to the *type* of the data classes.
# For example, build_type__VentilationBase is called when dealing with ConcentrationModel
# types as it has a ventilation: _VentilationBase field.
def __init__(self, selected_ventilation: str):
self.selected_ventilation = selected_ventilation
def build_type_Mask(self, _: dataclasses.Field):
return state.DataclassStatePredefined(
@ -897,6 +904,7 @@ class CAIMIRAStateBuilder(state.StateBuilder):
'HEPAFilter': self.build_generic(models.HEPAFilter),
},
base_type=self.selected_ventilation,
state_builder=self,
)
#Initialise the "Hinged window" state
@ -933,14 +941,16 @@ class ExpertApplication(Controller):
self._model_scenarios: typing.List[ScenarioType] = []
self._active_scenario = 0
self.multi_model_view = MultiModelView(self)
self.comparison_view = ExposureComparissonResult()
self.comparison_view = ExposureComparisonResult()
self.current_scenario_figure = ExposureModelResult()
self._results_tab = widgets.Tab(children=(
self.current_scenario_figure.widget,
self.comparison_view.widget,
# self._debug_output,
))
self._results_tab.titles = ['Current scenario', 'Scenario comparison', "Debug"]
for i, title in enumerate(['Current scenario', 'Scenario comparison', "Debug"]):
self._results_tab.set_title(i, title)
self.widget = widgets.HBox(
children=(
self.multi_model_view.widget,
@ -949,10 +959,10 @@ class ExpertApplication(Controller):
)
self.add_scenario('Scenario 1')
def build_new_model(self) -> state.DataclassInstanceState[models.ExposureModel]:
def build_new_model(self, vent: str) -> state.DataclassInstanceState[models.ExposureModel]:
default_model = state.DataclassInstanceState(
models.ExposureModel,
state_builder=CAIMIRAStateBuilder(),
state_builder=CAIMIRAStateBuilder(selected_ventilation=vent),
)
default_model.dcs_update_from(baseline_model)
# For the time-being, we have to initialise the select states. Careful
@ -961,9 +971,13 @@ class ExpertApplication(Controller):
return default_model
def add_scenario(self, name, copy_from_model: typing.Optional[state.DataclassInstanceState] = None):
model = self.build_new_model()
if copy_from_model is not None:
model = self.build_new_model(vent=copy_from_model.concentration_model.ventilation._selected)
model.dcs_update_from(copy_from_model.dcs_instance())
else:
model = self.build_new_model(vent='Natural') # Default
model.dcs_update_from(baseline_model)
self._model_scenarios.append((name, model))
self._active_scenario = len(self._model_scenarios) - 1
model.dcs_observe(self.notify_model_values_changed)
@ -1036,7 +1050,7 @@ class MultiModelView(View):
self.add_tab(scenario_name, model)
model_scenario_ids.append(id(model))
tab_index = self._tab_model_ids.index(id(model))
self.widget.titles = [scenario_name for (scenario_name, _) in model_scenarios]
self.widget.set_title(tab_index, scenario_name)
# Any remaining model_scenario_ids are no longer needed, so remove
# their tabs.
@ -1067,8 +1081,7 @@ class MultiModelView(View):
self._tab_model_ids.pop(tab_index)
self._tab_widgets.pop(tab_index)
self._tab_model_views.pop(tab_index)
if self._active_tab_index >= tab_index:
self._active_tab_index = max(0, self._active_tab_index - 1)
self.update_tab_widget()
def update_tab_widget(self):

View file

@ -12,14 +12,30 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 1,
"metadata": {
"pycharm": {
"name": "#%%\n"
},
"scrolled": false
},
"outputs": [],
"outputs": [
{
"data": {
"application/vnd.jupyter.widget-view+json": {
"model_id": "e8c4e2146d4847d5a1443781f2018483",
"version_major": 2,
"version_minor": 0
},
"text/plain": [
"HBox(children=(Tab(children=(VBox(children=(VBox(children=(Button(button_style='success', description='Duplica…"
]
},
"execution_count": 1,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import caimira.apps\n",
"\n",
@ -30,7 +46,7 @@
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"display_name": "caimira",
"language": "python",
"name": "python3"
},
@ -44,7 +60,12 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.6.12"
"version": "3.9.6"
},
"vscode": {
"interpreter": {
"hash": "c77495895472738765eb97c8f848f37a4e60c741d594ab92dd40b6b8f4cac818"
}
}
},
"nbformat": 4,

787
caimira/apps/expert_co2.py Normal file
View file

@ -0,0 +1,787 @@
import dataclasses
import ipywidgets as widgets
import typing
import numpy as np
from caimira import data, models, state
import matplotlib
import matplotlib.figure
import matplotlib.lines as mlines
import matplotlib.patches as patches
from .expert import collapsible, ipympl_canvas, WidgetGroup, CAIMIRAStateBuilder
baseline_model = models.CO2ConcentrationModel(
room=models.Room(volume=120, humidity=0.5, inside_temp=models.PiecewiseConstant((0., 24.), (293.15,))),
ventilation=models.HVACMechanical(active=models.PeriodicInterval(period=120, duration=120), q_air_mech=500),
CO2_emitters=models.Population(
number=10,
presence=models.SpecificInterval(((8., 12.), (13., 17.))),
mask=models.Mask.types['No mask'],
activity=models.Activity.types['Seated'],
host_immunity=0.,
),
CO2_atmosphere_concentration=440.44,
CO2_fraction_exhaled=0.042,
)
class Controller:
"""
The singleton thing which is the top-level Application.
It is responsible for owning the Model data and the Views, and
orchestrating event messages to each if the Model/View change.
"""
pass
ScenarioType = typing.Tuple[str, state.DataclassState]
class View:
"""
A thing which exposes a ``.widget`` attribute which is a view on some
data. This view is essentially a complex combination of widgets, along with
some event handling capabilities, which may or may not be sent back up to
the underlying controller.
We strive hard to keep "Model" data out of the View (and try to avoid
storing it at all on the View itself), instead relying on being able
to notify, and receive notifications, of important events from the Controller.
"""
pass
class ExposureModelResult(View):
def __init__(self):
self.figure = matplotlib.figure.Figure(figsize=(9, 6))
ipympl_canvas(self.figure)
self.html_output = widgets.HTML()
self.ax = self.initialize_axes()
self.concentration_line = None
self.concentration_area = None
@property
def widget(self):
return widgets.VBox([
self.html_output,
self.figure.canvas,
])
def initialize_axes(self) -> matplotlib.figure.Axes:
ax = self.figure.add_subplot(1, 1, 1)
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.set_xlabel('Time (hours)')
ax.set_ylabel('CO₂ concentration (ppm)')
ax.set_title('CO₂ Concentration')
return ax
def update(self, model: models.CO2ConcentrationModel):
self.update_plot(model)
def update_plot(self, model: models.CO2ConcentrationModel):
resolution = 600
ts = np.linspace(sorted(model.CO2_emitters.presence.transition_times())[0],
sorted(model.CO2_emitters.presence.transition_times())[-1], resolution)
concentration = [model.concentration(t) for t in ts]
if self.concentration_line is None:
[self.concentration_line] = self.ax.plot(ts, concentration, color='#3530fe')
else:
self.ax.ignore_existing_data_limits = False
self.concentration_line.set_data(ts, concentration)
if self.concentration_area is None:
self.concentration_area = self.ax.fill_between(x = ts, y1=0, y2=concentration, color="#96cbff",
where = ((model.CO2_emitters.presence.boundaries()[0][0] < ts) & (ts < model.CO2_emitters.presence.boundaries()[0][1]) |
(model.CO2_emitters.presence.boundaries()[1][0] < ts) & (ts < model.CO2_emitters.presence.boundaries()[1][1])))
else:
self.concentration_area.remove()
self.concentration_area = self.ax.fill_between(x = ts, y1=0, y2=concentration, color="#96cbff",
where = ((model.CO2_emitters.presence.boundaries()[0][0] < ts) & (ts < model.CO2_emitters.presence.boundaries()[0][1]) |
(model.CO2_emitters.presence.boundaries()[1][0] < ts) & (ts < model.CO2_emitters.presence.boundaries()[1][1])))
concentration_top = max(np.array(concentration))
self.ax.set_ylim(bottom=model.CO2_atmosphere_concentration * 0.9, top=concentration_top*1.1)
self.ax.set_xlim(left = min(model.CO2_emitters.presence.boundaries()[0])*0.95,
right = max(model.CO2_emitters.presence.boundaries()[1])*1.05)
figure_legends = [mlines.Line2D([], [], color='#3530fe', markersize=15, label='CO₂ concentration'),
mlines.Line2D([], [], color='salmon', markersize=15, label='Insufficient level', linestyle='--'),
mlines.Line2D([], [], color='limegreen', markersize=15, label='Acceptable level', linestyle='--'),
patches.Patch(edgecolor="#96cbff", facecolor='#96cbff', label='Presence of person(s)')]
self.ax.legend(handles=figure_legends)
if 1500 < concentration_top:
self.ax.set_ylim(top=concentration_top*1.1)
else:
self.ax.set_ylim(top=1550)
self.ax.hlines([800, 1500], xmin=min(model.CO2_emitters.presence.boundaries()[0])*0.95, xmax=max(model.CO2_emitters.presence.boundaries()[1])*1.05, colors=['limegreen', 'salmon'], linestyles='dashed')
self.figure.canvas.draw()
class ExposureComparisonResult(View):
def __init__(self):
self.figure = matplotlib.figure.Figure(figsize=(9, 6))
ipympl_canvas(self.figure)
self.html_output = widgets.HTML()
self.ax = self.initialize_axes()
@property
def widget(self):
# Workaround to a bug with ipymlp, which doesn't work well with tabs
# unless the widget is wrapped in a container (it is seen on all tabs otherwise!).
return widgets.HBox([self.figure.canvas])
def initialize_axes(self) -> matplotlib.figure.Axes:
ax = self.figure.add_subplot(1, 1, 1)
ax.spines[['right', 'top']].set_visible(False)
ax.set_xlabel('Time (hours)')
ax.set_ylabel('CO₂ concentration (ppm)')
ax.set_title('CO₂ Concentration')
return ax
def scenarios_updated(self, scenarios: typing.Sequence[ScenarioType], _):
updated_labels, updated_models = zip(*scenarios)
CO2_models = tuple(
model.dcs_instance() for model in updated_models
)
self.update_plot(CO2_models, updated_labels)
def update_plot(self, CO2_models: typing.Tuple[models.CO2ConcentrationModel, ...], labels: typing.Tuple[str, ...]):
self.ax.cla()
start, finish = models_start_end(CO2_models)
colors=['blue', 'red', 'orange', 'yellow', 'pink', 'purple', 'green', 'brown', 'black' ]
ts = np.linspace(start, finish, num=250)
concentrations = [[conc_model.concentration(t) for t in ts] for conc_model in CO2_models]
for label, concentration, color in zip(labels, concentrations, colors):
self.ax.plot(ts, concentration, label=label, color=color)
concentration_top = max([max(np.array(concentration)) for concentration in concentrations])
concentration_min = min([model.CO2_atmosphere_concentration for model in CO2_models])
self.ax.set_ylim(bottom=concentration_min * 0.9, top=concentration_top*1.1)
self.ax.set_xlim(left = start*0.95,
right = finish*1.05)
if 1500 < concentration_top:
self.ax.set_ylim(top=concentration_top*1.1)
else:
self.ax.set_ylim(top=1550)
self.ax.hlines([800, 1500], xmin=start*0.95, xmax=finish*1.05, colors=['limegreen', 'salmon'], linestyles='dashed')
self.ax.legend()
self.figure.canvas.draw_idle()
self.figure.canvas.flush_events()
class CO2Application(Controller):
def __init__(self) -> None:
# self._debug_output = widgets.Output()
#: A list of scenario name and ModelState instances. This is intended to be
#: mutated. Any mutation should notify the appropriate Views for handling.
self._model_scenarios: typing.List[ScenarioType] = []
self._active_scenario = 0
self.multi_model_view = MultiModelView(self)
self.comparison_view = ExposureComparisonResult()
self.current_scenario_figure = ExposureModelResult()
self._results_tab = widgets.Tab(children=(
self.current_scenario_figure.widget,
self.comparison_view.widget,
# self._debug_output,
))
for i, title in enumerate(['Current scenario', 'Scenario comparison', "Debug"]):
self._results_tab.set_title(i, title)
self.widget = widgets.HBox(
children=(
self.multi_model_view.widget,
self._results_tab,
),
)
self.add_scenario('Scenario 1')
def build_new_model(self, vent: str) -> state.DataclassInstanceState[models.CO2ConcentrationModel]:
new_model = state.DataclassInstanceState(
models.CO2ConcentrationModel,
state_builder=CAIMIRACO2StateBuilder(selected_ventilation=vent)
)
return new_model
def add_scenario(self, name, copy_from_model: typing.Optional[state.DataclassInstanceState] = None):
if copy_from_model is not None:
model = self.build_new_model(vent=copy_from_model.ventilation._selected)
model.dcs_update_from(copy_from_model.dcs_instance())
else:
model = self.build_new_model(vent='HVACMechanical') # Default
model.dcs_update_from(baseline_model)
self._model_scenarios.append((name, model))
self._active_scenario = len(self._model_scenarios) - 1
model.dcs_observe(self.notify_model_values_changed)
self.notify_scenarios_changed()
def _find_model_id(self, model_id):
for index, (name, model) in enumerate(list(self._model_scenarios)):
if id(model) == model_id:
return index, name, model
else:
raise ValueError("Model not found")
def rename_scenario(self, model_id, new_name):
index, _, model = self._find_model_id(model_id)
self._model_scenarios[index] = (new_name, model)
self.notify_scenarios_changed()
def remove_scenario(self, model_id):
index, _, model = self._find_model_id(model_id)
self._model_scenarios.pop(index)
self.multi_model_view.remove_tab(index)
self._active_scenario = index - 1
model.dcs_observe(self.notify_model_values_changed)
self.notify_scenarios_changed()
def set_active_scenario(self, model_id):
index, _, model = self._find_model_id(model_id)
self._active_scenario = index
self.notify_scenarios_changed()
self.notify_model_values_changed()
def notify_scenarios_changed(self):
"""
Occurs when the set of scenarios has been modified, but not if the values of the scenario has changed.
"""
self.multi_model_view.scenarios_updated(self._model_scenarios, self._active_scenario)
self.comparison_view.scenarios_updated(self._model_scenarios, self._active_scenario)
def notify_model_values_changed(self):
"""
Occurs when *any* value in *any* of the scenarios has been modified.
"""
self.comparison_view.scenarios_updated(self._model_scenarios, self._active_scenario)
self.current_scenario_figure.update(self._model_scenarios[self._active_scenario][1].dcs_instance())
class ModelWidgets(View):
def __init__(self, model_state: state.DataclassState):
#: The widgets that this view produces (inputs and outputs together)
self.widget = widgets.VBox([])
self.construct_widgets(model_state)
def construct_widgets(self, model_state: state.DataclassState):
# Build the input widgets.
self._build_widget(model_state)
def _build_widget(self, node):
self.widget.children += (self._build_room(node.room),)
self.widget.children += (self._build_population(node.CO2_emitters, node.ventilation),)
self.widget.children += (self._build_atmospheric_concentration(node),)
self.widget.children += (self._build_ventilation(node.ventilation, node.CO2_emitters),)
def _build_atmospheric_concentration(self, node):
return collapsible([widgets.VBox([
self._build_co2_concentration(node),
])], title="Carbon Dioxide")
def _build_population(self, node, ventilation_node):
return collapsible([widgets.VBox([
self._build_population_number(node),
self._build_activity(node.activity),
self._build_population_presence(node.presence, ventilation_node)
])], title="Population")
def _build_co2_concentration(self, node):
concentration = widgets.IntSlider(value=node.CO2_atmosphere_concentration, min=300, max=1000, step=10)
def on_atmospheric_concentration_change(change):
node.CO2_atmosphere_concentration = change['new']
concentration.observe(on_atmospheric_concentration_change, names=['value'])
return widgets.HBox([widgets.Label('Atmospheric Concentration (ppm) '), concentration], layout=widgets.Layout(justify_content='space-between'))
def _build_room(self,node):
room_volume = widgets.IntSlider(value=node.volume, min=5, max=200, step=5)
humidity = widgets.FloatSlider(value = node.humidity*100, min=20, max=80, step=5)
inside_temp = widgets.IntSlider(value=node.inside_temp.values[0]-273.15, min=15., max=25.)
def on_volume_change(change):
node.volume = change['new']
def on_humidity_change(change):
node.humidity = change['new']/100
def on_insidetemp_change(change):
node.inside_temp.values = (change['new']+273.15,)
room_volume.observe(on_volume_change, names=['value'])
humidity.observe(on_humidity_change, names=['value'])
inside_temp.observe(on_insidetemp_change, names=['value'])
widget = collapsible(
[ widgets.VBox([
widgets.HBox([widgets.Label('Room volume (m³)'), room_volume],
layout=widgets.Layout(width='100%', justify_content='space-between')),
widgets.HBox([widgets.Label('Inside temperature (℃)'), inside_temp],
layout=widgets.Layout(width='100%', justify_content='space-between')),
widgets.HBox([widgets.Label('Indoor relative humidity (%)'), humidity],
layout=widgets.Layout(width='100%', justify_content='space-between')),])
], title="Specification of workspace"
)
return widget
def _build_activity(self, node):
activity = node.dcs_instance()
for name, activity_ in models.Activity.types.items():
if activity == activity_:
break
activity = widgets.Dropdown(options=list(models.Activity.types.keys()), value=name)
def on_activity_change(change):
act = models.Activity.types[change['new']]
node.dcs_update_from(act)
activity.observe(on_activity_change, names=['value'])
return widgets.HBox([widgets.Label("Activity"), activity], layout=widgets.Layout(justify_content='space-between'))
def _build_population_number(self, node):
number = widgets.IntSlider(value=node.number, min=1, max=200, step=1)
def on_population_number_change(change):
node.number = change['new']
number.observe(on_population_number_change, names=['value'])
return widgets.HBox([widgets.Label('Number of people in the room '), number], layout=widgets.Layout(justify_content='space-between'))
def _build_population_presence(self, node, ventilation_node):
presence_start = widgets.FloatRangeSlider(value = node.present_times[0], min = 8., max=13., step=0.1)
presence_finish = widgets.FloatRangeSlider(value = node.present_times[1], min = 13., max=18., step=0.1)
def on_presence_start_change(change):
ventilation_node.active.start = change['new'][0] - ventilation_node.active.duration / 60
node.present_times = (change['new'], presence_finish.value)
def on_presence_finish_change(change):
node.present_times = (presence_start.value, change['new'])
presence_start.observe(on_presence_start_change, names=['value'])
presence_finish.observe(on_presence_finish_change, names=['value'])
return widgets.HBox([widgets.Label('Population presence'), presence_start, presence_finish], layout = widgets.Layout(justify_content='space-between'))
def present(self):
return self.widget
def _build_ventilation(
self,
node: typing.Union[
state.DataclassStateNamed[models.Ventilation],
state.DataclassStateNamed[models.MultipleVentilation],
],
emitters_node: models.Population,
) -> widgets.Widget:
ventilation_widgets = {
'HVACMechanical': self._build_mechanical(node),
'Sliding window': self._build_window(node, emitters_node),
'No ventilation': self._build_no_ventilation(node._states['No ventilation']),
}
keys=[("Mechanical", "HVACMechanical"), ("Natural", "Sliding window"), ("No ventilation", "No ventilation")]
for name, widget in ventilation_widgets.items():
widget.layout.visible = False
ventilation_w = widgets.Dropdown(
options=keys,
)
def toggle_ventilation(value):
for name, widget in ventilation_widgets.items():
widget.layout.visible = False
widget.layout.display = 'none'
node.dcs_select(value)
widget = ventilation_widgets[value]
widget.layout.visible = True
widget.layout.display = 'flex'
ventilation_w.observe(lambda event: toggle_ventilation(event['new']), 'value')
toggle_ventilation(ventilation_w.value)
w = collapsible(
([widgets.HBox([widgets.Label('Ventilation type'), ventilation_w], layout=widgets.Layout(justify_content='space-between'))])
+ list(ventilation_widgets.values()),
title='Ventilation scheme',
)
return w
def _build_month(self, node) -> WidgetGroup:
month_choice = widgets.Select(options=list(data.GenevaTemperatures.keys()), value='Jan')
def on_month_change(change):
node.outside_temp = data.GenevaTemperatures[change['new']]
month_choice.observe(on_month_change, names=['value'])
return WidgetGroup(
(
(widgets.Label("Month"), month_choice),
),
)
def _build_outsidetemp(self, node) -> WidgetGroup:
outside_temp = widgets.IntSlider(value=10, min=-10, max=30)
def on_outsidetemp_change(change):
node.values = (change['new'] + 273.15, )
outside_temp.observe(on_outsidetemp_change, names=['value'])
auto_width = widgets.Layout(width='auto')
return WidgetGroup(
(
(
widgets.Label('Outside temperature (℃)', layout=auto_width,),
outside_temp,
),
),
)
def _build_hinged_window(self, node):
hinged_window = widgets.FloatSlider(value=node.window_width, min=0.1, max=2, step=0.1)
def on_hinged_window_change(change):
node.window_width = change['new']
hinged_window.observe(on_hinged_window_change, names=['value'])
return widgets.HBox([widgets.Label('Window width (meters) '), hinged_window], layout=widgets.Layout(justify_content='space-between', width='100%'))
def _build_sliding_window(self, node):
return widgets.HBox([])
def _build_window(self, node, emitters_node) -> WidgetGroup:
window_widgets = {
'Sliding window': self._build_sliding_window(node._states['Sliding window']),
'Hinged window': self._build_hinged_window(node._states['Hinged window']),
}
for name, widget in window_widgets.items():
widget.layout.visible = False
widget.layout.display = 'none'
window_w = widgets.RadioButtons(
options= list(zip(['Sliding window', 'Hinged window'], window_widgets.keys())),
button_style='info',
layout=widgets.Layout(height='auto', width='auto'),
)
def toggle_window(value):
for name, widget in window_widgets.items():
widget.layout.visible = False
widget.layout.display = 'none'
node.dcs_select(value)
widget = window_widgets[value]
widget.layout.visible = True
widget.layout.display = 'flex'
window_w.observe(lambda event: toggle_window(event['new']), 'value')
toggle_window(window_w.value)
number_of_windows= widgets.IntText(value= 1, min= 0, max= 5, step=1)
frequency = widgets.IntSlider(value=node.active.period, min=0, max=120)
duration = widgets.IntSlider(value=node.active.duration, min=0, max=frequency.value)
opening_length = widgets.FloatSlider(value=node.opening_length, min=0, max=3, step=0.1)
window_height = widgets.FloatSlider(value=node.window_height, min=0, max=3, step=0.1)
def on_value_change(change):
node.number_of_windows = change['new']
def on_period_change(change):
node.active.period = change['new']
duration.max = change['new']
def on_duration_change(change):
node.active.start = emitters_node.presence.present_times[0][0] - change['new'] / 60
node.active.duration = change['new']
def on_opening_length_change(change):
node.opening_length = change['new']
def on_window_height_change(change):
node.window_height = change['new']
number_of_windows.observe(on_value_change, names=['value'])
frequency.observe(on_period_change, names=['value'])
duration.observe(on_duration_change, names=['value'])
opening_length.observe(on_opening_length_change, names=['value'])
window_height.observe(on_window_height_change, names=['value'])
outsidetemp_widgets = {
'Fixed': self._build_outsidetemp(node.outside_temp),
'Meteorological data': self._build_month(node),
}
outsidetemp_w = widgets.Dropdown(
options=outsidetemp_widgets.keys(),
)
def toggle_outsidetemp(value):
for name, widget_group in outsidetemp_widgets.items():
widget_group.set_visible(False)
widget_group = outsidetemp_widgets[value]
widget_group.set_visible(True)
outsidetemp_w.observe(lambda event: toggle_outsidetemp(event['new']), 'value')
toggle_outsidetemp(outsidetemp_w.value)
auto_width = widgets.Layout(width='auto', justify_content='space-between')
result = WidgetGroup(
(
(
widgets.Label('Number of windows ', layout=auto_width),
number_of_windows,
),
(
widgets.Label('Opening distance (meters)', layout=auto_width),
opening_length,
),
(
widgets.Label('Window height (meters)', layout=auto_width),
window_height,
),
(
widgets.Label('Frequency (minutes)', layout=auto_width),
frequency,
),
(
widgets.Label('Duration (minutes)', layout=auto_width),
duration,
),
(
widgets.Label('Outside temperature scheme', layout=auto_width),
outsidetemp_w,
),
),
)
for sub_group in outsidetemp_widgets.values():
result.add_pairs(sub_group.pairs())
return widgets.VBox([window_w, widgets.HBox(list(window_widgets.values())), result.build()])
def _build_q_air_mech(self, node):
q_air_mech = widgets.FloatSlider(value=node.q_air_mech, min=0, max=5000, step=25)
def on_q_air_mech_change(change):
node.q_air_mech = change['new']
q_air_mech.observe(on_q_air_mech_change, names=['value'])
return widgets.HBox([q_air_mech, widgets.Label('m³/h')])
def _build_ach(self, node):
air_exch = widgets.IntSlider(value=node.air_exch, min=0, max=20, step=1)
def on_air_exch_change(change):
node.air_exch = change['new']
air_exch.observe(on_air_exch_change, names=['value'])
return widgets.HBox([air_exch, widgets.Label('h⁻¹')])
def _build_mechanical(self, node):
mechanical_widgets = {
'HVACMechanical': self._build_q_air_mech(node._states['HVACMechanical']),
'AirChange': self._build_ach(node._states['AirChange']),
}
for name, widget in mechanical_widgets.items():
widget.layout.visible = False
mechanival_w = widgets.RadioButtons(
options=list(zip(['Air supply flow rate (m³/h)', 'Air changes per hour (h⁻¹)'], mechanical_widgets.keys())),
button_style='info',
)
def toggle_mechanical(value):
for name, widget in mechanical_widgets.items():
widget.layout.visible = False
widget.layout.display = 'none'
node.dcs_select(value)
widget = mechanical_widgets[value]
widget.layout.visible = True
widget.layout.display = 'flex'
mechanival_w.observe(lambda event: toggle_mechanical(event['new']), 'value')
toggle_mechanical(mechanival_w.value)
return widgets.VBox([mechanival_w, widgets.HBox(list(mechanical_widgets.values()))])
def _build_no_ventilation(self, node):
return widgets.HBox([])
class MultiModelView(View):
def __init__(self, controller: CO2Application):
self._controller = controller
self.widget = widgets.Tab()
self.widget.observe(self._on_tab_change, 'selected_index')
self._tab_model_ids: typing.List[int] = []
self._tab_widgets: typing.List[widgets.Widget] = []
self._tab_model_views: typing.List[ModelWidgets] = []
def scenarios_updated(
self,
model_scenarios: typing.Sequence[ScenarioType],
active_scenario_index: int
):
"""
Called when a scenario is added/removed/renamed etc.
Note: Not called when the model state is modified.
"""
model_scenario_ids = []
for i, (scenario_name, model) in enumerate(model_scenarios):
if id(model) not in self._tab_model_ids:
self.add_tab(scenario_name, model)
model_scenario_ids.append(id(model))
tab_index = self._tab_model_ids.index(id(model))
self.widget.set_title(tab_index, scenario_name)
# Any remaining model_scenario_ids are no longer needed, so remove
# their tabs.
for tab_index, tab_scenario_id in enumerate(self._tab_model_ids[:]):
if tab_scenario_id not in model_scenario_ids:
self.remove_tab(tab_index)
assert self._tab_model_ids == model_scenario_ids
self.widget.selected_index = active_scenario_index
def add_tab(self, name, model):
self._tab_model_views.append(ModelWidgets(model))
self._tab_model_ids.append(id(model))
tab_idx = len(self._tab_model_ids) - 1
tab_widget = widgets.VBox(
children=(
self._build_settings_menu(name, model),
self._tab_model_views[tab_idx].widget,
)
)
self._tab_widgets.append(tab_widget)
self.update_tab_widget()
def remove_tab(self, tab_index):
assert 0 <= tab_index < len(self._tab_model_ids)
assert len(self._tab_model_ids) > 1
self._tab_model_ids.pop(tab_index)
self._tab_widgets.pop(tab_index)
self._tab_model_views.pop(tab_index)
self.update_tab_widget()
def update_tab_widget(self):
self.widget.children = tuple(self._tab_widgets)
def _on_tab_change(self, change):
self._controller.set_active_scenario(
self._tab_model_ids[change['new']]
)
def _build_settings_menu(self, name, model):
delete_button = widgets.Button(description='Delete Scenario', button_style='danger')
rename_text_field = widgets.Text(description='Rename Scenario:', value=name,
style={'description_width': 'auto'})
duplicate_button = widgets.Button(description='Replicate Scenario', button_style='success')
model_id = id(model)
def on_delete_click(b):
self._controller.remove_scenario(model_id)
def on_rename_text_field(change):
self._controller.rename_scenario(model_id, new_name=change['new'])
def on_duplicate_click(b):
tab_index = self._tab_model_ids.index(model_id)
name = self.widget.get_title(tab_index)
self._controller.add_scenario(f'{name} (copy)', model)
delete_button.on_click(on_delete_click)
duplicate_button.on_click(on_duplicate_click)
rename_text_field.observe(on_rename_text_field, 'value')
buttons_w_delete = widgets.HBox(children=(duplicate_button, delete_button))
buttons = duplicate_button if len(self._tab_model_ids) < 2 else buttons_w_delete
return widgets.VBox(children=(buttons, rename_text_field))
class CAIMIRACO2StateBuilder(CAIMIRAStateBuilder):
def __init__(self, selected_ventilation: str):
self.selected_ventilation = selected_ventilation
def build_type__VentilationBase(self, _: dataclasses.Field):
s: state.DataclassStateNamed = state.DataclassStateNamed(
states={
'HVACMechanical': self.build_generic(models.HVACMechanical),
'Sliding window': self.build_generic(models.WindowOpening),
'No ventilation': self.build_generic(models.AirChange),
'AirChange': self.build_generic(models.AirChange),
'Hinged window': self.build_generic(models.WindowOpening),
},
base_type=self.selected_ventilation,
state_builder=self,
)
s._states['HVACMechanical'].dcs_update_from(baseline_model.ventilation)
#Initialise the "Sliding window" state
s._states['Sliding window'].dcs_update_from(
models.SlidingWindow(active=models.PeriodicInterval(period=120, duration=15, start=8-(15/60)),
outside_temp=models.PiecewiseConstant((0,24.), (283.15,)),
window_height=1.6, opening_length=0.6,
),
)
#Initialise the "Hinged window" state
s._states['Hinged window'].dcs_update_from(
models.HingedWindow(active=models.PeriodicInterval(period=120, duration=15, start=8-(15/60)),
outside_temp=models.PiecewiseConstant((0,24.), (283.15,)),
window_height=1.6, opening_length=0.6,
window_width=10.
),
)
s._states['AirChange'].dcs_update_from(
models.AirChange(models.PeriodicInterval(period=24*60, duration=24*60), 10.)
)
# Initialize the "No ventilation" state
s._states['No ventilation'].dcs_update_from(
models.AirChange(active=models.PeriodicInterval(period=60, duration=60), air_exch=0.)
)
return s
def models_start_end(models: typing.Sequence[models.CO2ConcentrationModel]) -> typing.Tuple[float, float]:
"""
Returns the earliest start and latest end time of a collection of v objects
"""
emitters_start = min(model.CO2_emitters.presence.boundaries()[0][0] for model in models)
emitters_finish = min(model.CO2_emitters.presence.boundaries()[-1][1] for model in models)
return emitters_start, emitters_finish

View file

@ -0,0 +1,69 @@
{
"cells": [
{
"attachments": {},
"cell_type": "markdown",
"metadata": {},
"source": [
"<div style=\"text-align: center;\" align=\"center\" >\n",
"<img src=\"./files/static/images/header_image.png\" style=\"height:5em\"></img></div>\n",
"<p style=\"text-align: center;\">\n",
"Please see the <a href=\"https://caimira.web.cern.ch/\">CAiMIRA homepage</a> for details on the methodology, assumptions and limitations of CAiMIRA.</p>"
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [
{
"data": {
"application/vnd.jupyter.widget-view+json": {
"model_id": "888611458e044cd3b7cda7dfcf1fdbe4",
"version_major": 2,
"version_minor": 0
},
"text/plain": [
"HBox(children=(Tab(children=(VBox(children=(VBox(children=(Button(button_style='success', description='Duplica…"
]
},
"execution_count": 1,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import caimira.apps\n",
"\n",
"app = caimira.apps.CO2Application()\n",
"app.widget"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3.9.6 64-bit ('caimira')",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.9.6"
},
"vscode": {
"interpreter": {
"hash": "c77495895472738765eb97c8f848f37a4e60c741d594ab92dd40b6b8f4cac818"
}
}
},
"nbformat": 4,
"nbformat_minor": 4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View file

@ -32,7 +32,7 @@
<div class="d-flex flex-row" >
<div class="pr-3"><a href="{{ get_calculator_url() }}" role="button" class="btn btn-lg btn-outline-primary"><div class="d-flex d-row"><i class="icon-calculator"></i><span class="pl-2">Calculator</div></a></div>
<br>
<div class="expert_app_button"><a href="/expert-app" role="button" class="btn btn-lg btn-outline-secondary"><div class="d-flex d-row"><i class="icon-expert"></i><span class="pl-2">Expert (beta)</div></a></div>
<div class="expert_app_button"><a href="/expert-app" role="button" class="btn btn-lg btn-outline-secondary"><div class="d-flex d-row"><i class="icon-expert"></i><span class="pl-2">Expert app</div></a></div>
</div>
<br>
</div>

View file

@ -45,7 +45,8 @@
<ul class="dropdown-menu dropwown-navbar-colors" style="min-width: 12rem;" aria-labelledby="navbarDropdown">
<li><a href="{{ get_calculator_url() }}" class="{{ "header-navbar nav-link active" if "calculator/" == active_page else "header-navbar nav-link" }}">Calculator</a></li>
<li><div class="d-flex"><span class="d-flex align-self-center submenu-division"></span><a href="{{ get_calculator_url() }}/user-guide" class="{{ "header-navbar nav-link active" if "user-guide" in active_page else "header-navbar nav-link" }}">User Guide</a></div></li>
<li><a href="/expert-app" class="{{ "header-navbar nav-link active" if "/expert-app" == active_page else "header-navbar nav-link" }}">Expert app (beta)</a></li>
<li><a href="/expert-app" class="{{ "header-navbar nav-link active" if "/expert-app" == active_page else "header-navbar nav-link" }}">Expert app</a></li>
<li><a href="/co2-app" class="{{ "header-navbar nav-link active" if "/co2-app" == active_page else "header-navbar nav-link" }}">CO₂ Simulator</a></li>
</ul>
</li>
</div>

View file

@ -1009,7 +1009,7 @@ class _ConcentrationModelBase:
return self.min_background_concentration()/self.normalization_factor()
V = self.room.volume
RR = self.removal_rate(time)
return (1. / (RR * V) + self.min_background_concentration()/
self.normalization_factor())
@ -1210,7 +1210,9 @@ class CO2ConcentrationModel(_ConcentrationModelBase):
return self.CO2_emitters
def removal_rate(self, time: float) -> _VectorisedFloat:
return self.ventilation.air_exchange(self.room, time)
# Setting minimum air exchange rate to 1e-6 to avoid divisions by
# zero when computing the CO2 concentration.
return np.maximum(1e-6,self.ventilation.air_exchange(self.room, time))
def min_background_concentration(self) -> _VectorisedFloat:
"""

View file

@ -319,18 +319,18 @@ class DataclassStateNamed(DataclassState[Datamodel_T]):
"""
def __init__(self,
states: typing.Dict[str, DataclassState[Datamodel_T]],
base_type: str,
**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: str = None # type: ignore
# Pick the first choice until we know otherwise.
self.dcs_select(enabled)
self.dcs_select(base_type)
def __getattr__(self, name):
try:

View file

@ -167,11 +167,14 @@ def test_DCS_predefined():
def test_DCS_named():
opt1 = DCSimpleSubclass('a', 1, 3.14)
opt2 = DCAnother(4.2)
s = state.DataclassStateNamed({
s = state.DataclassStateNamed(
states={
# Entirely different types possible.
'option 1': state.DataclassInstanceState(DCSimple),
'option 2': state.DataclassInstanceState(DCAnother),
})
},
base_type='option 1'
)
assert s._selected == 'option 1'
with pytest.raises(ValueError):

View file

@ -1,105 +0,0 @@
anyio==3.6.2
appnope==0.1.3
argon2-cffi==21.3.0
argon2-cffi-bindings==21.2.0
asttokens==2.2.1
attrs==22.2.0
Babel==2.11.0
backcall==0.2.0
beautifulsoup4==4.11.2
bleach==6.0.0
certifi==2022.12.7
cffi==1.15.1
charset-normalizer==3.0.1
cloudpickle==2.2.1
comm==0.1.2
contourpy==1.0.7
cycler==0.11.0
debugpy==1.6.6
decorator==5.1.1
defusedxml==0.7.1
entrypoints==0.4
executing==1.2.0
fastjsonschema==2.16.2
fonttools==4.38.0
h3==3.7.6
idna==3.4
importlib-metadata==6.0.0
importlib-resources==5.12.0
ipykernel==6.21.2
ipympl==0.9.3
ipython==8.10.0
ipython-genutils==0.2.0
ipywidgets==7.7.3
jedi==0.18.2
Jinja2==3.1.2
joblib==1.2.0
json5==0.9.11
jsonschema==4.17.3
jupyter-client==7.4.1
jupyter-core==5.2.0
jupyter-server==1.23.6
jupyterlab-pygments==0.2.2
jupyterlab-server==2.19.0
jupyterlab-widgets==1.1.2
kiwisolver==1.4.4
loky==3.3.0
MarkupSafe==2.1.2
matplotlib==3.7.0
matplotlib-inline==0.1.6
memoization==0.4.0
mistune==2.0.5
nbclassic==0.5.2
nbclient==0.7.2
nbconvert==7.2.9
nbformat==5.7.3
nest-asyncio==1.5.6
notebook==6.5.2
notebook-shim==0.2.2
numpy==1.24.2
packaging==23.0
pandas==1.5.3
pandocfilters==1.5.0
parso==0.8.3
pexpect==4.8.0
pickleshare==0.7.5
Pillow==9.4.0
platformdirs==3.0.0
prometheus-client==0.16.0
prompt-toolkit==3.0.37
psutil==5.9.4
ptyprocess==0.7.0
pure-eval==0.2.2
py==1.11.0
pycparser==2.21
Pygments==2.14.0
pyparsing==3.0.9
pyrsistent==0.19.3
python-dateutil==2.8.2
pytz==2022.7.1
pyzmq==25.0.0
requests==2.28.2
retry==0.9.2
scikit-learn==1.2.1
scipy==1.10.1
Send2Trash==1.8.0
six==1.16.0
sniffio==1.3.0
soupsieve==2.4
stack-data==0.6.2
terminado==0.17.1
threadpoolctl==3.1.0
timezonefinder==6.1.9
tinycss2==1.2.1
tornado==6.2
traitlets==5.9.0
types-retry==0.9.9.2
urllib3==1.26.14
voila==0.4.0
wcwidth==0.2.6
webencodings==0.5.1
websocket-client==1.5.1
websockets==10.4
wheel==0.36.2
widgetsnbextension==3.6.2
zipp==3.14.0