diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ccc4e3a2..9db7ddd3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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: diff --git a/README.md b/README.md index 6ae24d2b..771a94f6 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/app-config/caimira-public-docker-image/Dockerfile b/app-config/caimira-public-docker-image/Dockerfile index ee69552b..6e2c6914 100644 --- a/app-config/caimira-public-docker-image/Dockerfile +++ b/app-config/caimira-public-docker-image/Dockerfile @@ -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 diff --git a/app-config/caimira-public-docker-image/nginx.conf b/app-config/caimira-public-docker-image/nginx.conf index 88cd8e2b..dd9595d9 100644 --- a/app-config/caimira-public-docker-image/nginx.conf +++ b/app-config/caimira-public-docker-image/nginx.conf @@ -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; } diff --git a/app-config/caimira-public-docker-image/run_caimira.sh b/app-config/caimira-public-docker-image/run_caimira.sh index 22d80533..5f12ca46 100755 --- a/app-config/caimira-public-docker-image/run_caimira.sh +++ b/app-config/caimira-public-docker-image/run_caimira.sh @@ -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 diff --git a/app-config/caimira-webservice/Dockerfile b/app-config/calculator-app/Dockerfile similarity index 93% rename from app-config/caimira-webservice/Dockerfile rename to app-config/calculator-app/Dockerfile index 0a652800..273326e1 100644 --- a/app-config/caimira-webservice/Dockerfile +++ b/app-config/calculator-app/Dockerfile @@ -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" \ ] diff --git a/app-config/caimira-webservice/app.sh b/app-config/calculator-app/app.sh similarity index 78% rename from app-config/caimira-webservice/app.sh rename to app-config/calculator-app/app.sh index 40380a7e..8555379e 100755 --- a/app-config/caimira-webservice/app.sh +++ b/app-config/calculator-app/app.sh @@ -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 diff --git a/app-config/docker-compose.yml b/app-config/docker-compose.yml index b2046c21..d9ddb34a 100644 --- a/app-config/docker-compose.yml +++ b/app-config/docker-compose.yml @@ -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 diff --git a/app-config/nginx/nginx.conf b/app-config/nginx/nginx.conf index 2fef5b56..5e0ed708 100644 --- a/app-config/nginx/nginx.conf +++ b/app-config/nginx/nginx.conf @@ -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; } } } diff --git a/app-config/openshift/deploymentconfig.yaml b/app-config/openshift/deploymentconfig.yaml index 456a2d4b..e96d65a1 100644 --- a/app-config/openshift/deploymentconfig.yaml +++ b/app-config/openshift/deploymentconfig.yaml @@ -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 diff --git a/app-config/openshift/imagestreams.yaml b/app-config/openshift/imagestreams.yaml index 86a4780d..9f6ec534 100644 --- a/app-config/openshift/imagestreams.yaml +++ b/app-config/openshift/imagestreams.yaml @@ -30,7 +30,7 @@ kind: ImageStream apiVersion: image.openshift.io/v1 metadata: - name: caimira-webservice + name: calculator-app spec: lookupPolicy: local: False diff --git a/app-config/openshift/services.yaml b/app-config/openshift/services.yaml index d6aaf814..f32ba062 100644 --- a/app-config/openshift/services.yaml +++ b/app-config/openshift/services.yaml @@ -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' diff --git a/caimira/apps/__init__.py b/caimira/apps/__init__.py index da903ca1..26b7f5d3 100644 --- a/caimira/apps/__init__.py +++ b/caimira/apps/__init__.py @@ -1,4 +1,4 @@ from .expert import ExpertApplication +from .expert_co2 import CO2Application - -__all__ = ['ExpertApplication'] +__all__ = ['ExpertApplication', 'CO2Application'] diff --git a/caimira/apps/calculator/__init__.py b/caimira/apps/calculator/__init__.py index c36e56ec..8399bd9d 100644 --- a/caimira/apps/calculator/__init__.py +++ b/caimira/apps/calculator/__init__.py @@ -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): diff --git a/caimira/apps/expert.py b/caimira/apps/expert.py index 842fd7a3..c8955512 100644 --- a/caimira/apps/expert.py +++ b/caimira/apps/expert.py @@ -214,7 +214,7 @@ class ExposureModelResult(View): self.html_output.value = '
\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): diff --git a/caimira/apps/expert/caimira.ipynb b/caimira/apps/expert/caimira.ipynb index 747ab1e9..9200ce2a 100644 --- a/caimira/apps/expert/caimira.ipynb +++ b/caimira/apps/expert/caimira.ipynb @@ -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, diff --git a/caimira/apps/expert_co2.py b/caimira/apps/expert_co2.py new file mode 100644 index 00000000..cade8f72 --- /dev/null +++ b/caimira/apps/expert_co2.py @@ -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 diff --git a/caimira/apps/expert_co2/caimira.ipynb b/caimira/apps/expert_co2/caimira.ipynb new file mode 100644 index 00000000..259328ac --- /dev/null +++ b/caimira/apps/expert_co2/caimira.ipynb @@ -0,0 +1,69 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "
\n", + "

\n", + "Please see the CAiMIRA homepage for details on the methodology, assumptions and limitations of CAiMIRA.

" + ] + }, + { + "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 +} diff --git a/caimira/apps/expert_co2/static/images/header_image.png b/caimira/apps/expert_co2/static/images/header_image.png new file mode 100644 index 00000000..5f2a2702 Binary files /dev/null and b/caimira/apps/expert_co2/static/images/header_image.png differ diff --git a/caimira/apps/templates/base/index.html.j2 b/caimira/apps/templates/base/index.html.j2 index 835bb7fa..65f19037 100644 --- a/caimira/apps/templates/base/index.html.j2 +++ b/caimira/apps/templates/base/index.html.j2 @@ -32,7 +32,7 @@
Calculator

-
Expert (beta)
+
Expert app

diff --git a/caimira/apps/templates/base/layout.html.j2 b/caimira/apps/templates/base/layout.html.j2 index 60983242..6a2f592c 100644 --- a/caimira/apps/templates/base/layout.html.j2 +++ b/caimira/apps/templates/base/layout.html.j2 @@ -45,7 +45,8 @@ diff --git a/caimira/models.py b/caimira/models.py index a9e5ae01..5483596b 100644 --- a/caimira/models.py +++ b/caimira/models.py @@ -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: """ diff --git a/caimira/state.py b/caimira/state.py index b990e7cf..f284ca4d 100644 --- a/caimira/state.py +++ b/caimira/state.py @@ -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: diff --git a/caimira/tests/test_state.py b/caimira/tests/test_state.py index f984136d..ac0341fe 100644 --- a/caimira/tests/test_state.py +++ b/caimira/tests/test_state.py @@ -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): diff --git a/requirements.new.txt b/requirements.new.txt deleted file mode 100644 index f6112691..00000000 --- a/requirements.new.txt +++ /dev/null @@ -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