├── custom_components ├── __init__.py └── multizone_thermostat │ ├── UKF_filter │ ├── __init__.py │ ├── cholesky.py │ ├── unscented_transform.py │ ├── discretization.py │ ├── helpers.py │ ├── sigma_points.py │ └── UKF.py │ ├── __init__.py │ ├── manifest.json │ ├── services.py │ ├── UKF_config.py │ ├── services.yaml │ ├── const.py │ ├── pid_controller.py │ ├── validations.py │ ├── platform_schema.py │ └── pwm_nesting.py ├── .vscode └── settings.json ├── .gitignore ├── hacs.json ├── .github └── workflows │ ├── hassfest.yaml │ └── validation.yaml ├── examples ├── single thermostat - on_off.yaml └── multizone thermostat - explained.yaml └── README.md /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/UKF_filter/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "C:\\Anaconda\\python.exe" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Multizone thermostat", 3 | "content_in_root": false, 4 | "render_readme": true 5 | } -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/__init__.py: -------------------------------------------------------------------------------- 1 | """The multizone_thermostat component.""" 2 | 3 | DOMAIN = "multizone_thermostat" 4 | PLATFORMS = ["climate"] 5 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /.github/workflows/validation.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: integration -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "multizone_thermostat", 3 | "name": "MultiZone thermostat", 4 | "codeowners": ["@vindaalex"], 5 | "dependencies": ["input_number", "sensor", "switch", "number"], 6 | "documentation": "https://github.com/vindaalex/multizone_thermostat", 7 | "iot_class": "local_polling", 8 | "issue_tracker": "https://github.com/vindaalex/multizone-thermostat/issues", 9 | "requirements": ["numpy"], 10 | "version": "0.7.2" 11 | } 12 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/UKF_filter/cholesky.py: -------------------------------------------------------------------------------- 1 | from math import sqrt 2 | import numpy as np 3 | 4 | def cholesky(A, upper=True): 5 | """Performs a Cholesky decomposition of A, which must 6 | be a symmetric and positive definite matrix.""" 7 | 8 | if upper: 9 | """ 10 | https://github.com/TayssirDo/Cholesky-decomposition/blob/master/cholesky.py 11 | Compute the cholesky decomposition of a SPD matrix M. 12 | :param M: (N, N) real valued matrix. 13 | :return: R: (N, N) upper triangular matrix with positive diagonal entries if M is SPD. 14 | """ 15 | M = np.copy(A) 16 | n = A.shape[0] 17 | R = np.zeros_like(M) 18 | 19 | for k in range(n): 20 | R[k, k] = sqrt(M[k, k]) 21 | R[k, k + 1:] = M[k, k + 1:] / R[k, k] 22 | for j in range(k + 1, n): 23 | M[j, j:] = M[j, j:] - R[k, j] * R[k, j:] 24 | 25 | return R 26 | else: 27 | """ 28 | https://www.quantstart.com/articles/Cholesky-Decomposition-in-Python-and-NumPy/ 29 | This function returns the lower variant triangular matrix, 30 | """ 31 | n = len(A) 32 | 33 | # Create zero matrix for L 34 | L = [[0.0] * n for i in range(n)] 35 | 36 | # Perform the Cholesky decomposition 37 | for i in range(n): 38 | for k in range(i+1): 39 | tmp_sum = sum(L[i][j] * L[k][j] for j in range(k)) 40 | 41 | if (i == k): # Diagonal elements 42 | # LaTeX: l_{kk} = \sqrt{ a_{kk} - \sum^{k-1}_{j=1} l^2_{kj}} 43 | L[i][k] = sqrt(A[i][i] - tmp_sum) 44 | else: 45 | # LaTeX: l_{ik} = \frac{1}{l_{kk}} \left( a_{ik} - \sum^{k-1}_{j=1} l_{ij} l_{kj} \right) 46 | L[i][k] = (1.0 / L[k][k] * (A[i][k] - tmp_sum)) 47 | return L 48 | 49 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/services.py: -------------------------------------------------------------------------------- 1 | import voluptuous as vol 2 | import homeassistant.helpers.config_validation as cv 3 | from homeassistant.helpers import entity_platform 4 | from homeassistant.components.climate import ( 5 | ATTR_HVAC_MODE, 6 | ATTR_PRESET_MODE, 7 | PRESET_NONE, 8 | HVACMode, 9 | ) 10 | 11 | from .const import * 12 | 13 | SUPPORTED_PRESET_MODES = [PRESET_NONE, PRESET_EMERGENCY, PRESET_RESTORE] 14 | SUPPORTED_HVAC_MODES = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF] 15 | 16 | 17 | def register_services(custom_presets): 18 | platform = entity_platform.current_platform.get() 19 | assert platform 20 | 21 | platform.async_register_entity_service( # type: ignore 22 | "set_preset_mode", 23 | { 24 | vol.Required(ATTR_PRESET_MODE): vol.In( 25 | custom_presets + SUPPORTED_PRESET_MODES 26 | ), 27 | vol.Required(ATTR_HVAC_MODE): vol.In(SUPPORTED_HVAC_MODES), 28 | }, 29 | "async_set_preset_mode", 30 | ) 31 | 32 | platform.async_register_entity_service( # type: ignore 33 | "detailed_output", 34 | { 35 | vol.Required(ATTR_HVAC_MODE): vol.In(SUPPORTED_HVAC_MODES), 36 | vol.Required("new_mode"): cv.boolean, 37 | }, 38 | "set_detailed_output", 39 | ) 40 | 41 | platform.async_register_entity_service( # type: ignore 42 | "pwm_threshold", 43 | { 44 | vol.Required(ATTR_HVAC_MODE): vol.In(SUPPORTED_HVAC_MODES), 45 | vol.Required("new_threshold"): vol.Coerce(float), 46 | }, 47 | "async_set_pwm_threshold", 48 | ) 49 | 50 | platform.async_register_entity_service( # type: ignore 51 | "set_pid", 52 | { 53 | vol.Required(ATTR_HVAC_MODE): vol.In(SUPPORTED_HVAC_MODES), 54 | vol.Optional(ATTR_KP): vol.Coerce(float), 55 | vol.Optional(ATTR_KI): vol.Coerce(float), 56 | vol.Optional(ATTR_KD): vol.Coerce(float), 57 | vol.Optional("update", default=True): vol.Boolean, 58 | }, 59 | "async_set_pid", 60 | ) 61 | 62 | platform.async_register_entity_service( # type: ignore 63 | "set_filter_mode", 64 | { 65 | vol.Optional("mode"): vol.Coerce(int), 66 | }, 67 | "async_set_filter_mode", 68 | ) 69 | 70 | platform.async_register_entity_service( # type: ignore 71 | "set_integral", 72 | { 73 | vol.Required(ATTR_HVAC_MODE): vol.In(SUPPORTED_HVAC_MODES), 74 | vol.Required("integral"): vol.Coerce(float), 75 | }, 76 | "async_set_integral", 77 | ) 78 | 79 | platform.async_register_entity_service( # type: ignore 80 | "set_ka_kb", 81 | { 82 | vol.Required(ATTR_HVAC_MODE): vol.In(SUPPORTED_HVAC_MODES), 83 | vol.Optional(ATTR_KA): vol.Coerce(float), 84 | vol.Optional(ATTR_KB): vol.Coerce(float), 85 | }, 86 | "async_set_ka_kb", 87 | ) 88 | 89 | platform.async_register_entity_service( # type: ignore 90 | "satelite_mode", 91 | { 92 | vol.Required(ATTR_CONTROL_MODE): vol.Coerce(OperationMode), 93 | vol.Optional(ATTR_CONTROL_OFFSET): vol.Coerce(float), 94 | vol.Optional("sat_id"): vol.Coerce(int), 95 | vol.Optional("pwm_start_time"): vol.Coerce(float), 96 | vol.Optional("master_delay"): vol.Coerce(float), 97 | }, 98 | "async_set_satelite_mode", 99 | ) 100 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/UKF_config.py: -------------------------------------------------------------------------------- 1 | """module to initiate UKF filter for temperature readings""" 2 | import time 3 | 4 | import numpy as np 5 | 6 | from .UKF_filter.discretization import Q_discrete_white_noise 7 | from .UKF_filter.sigma_points import MerweScaledSigmaPoints 8 | from .UKF_filter.UKF import UnscentedKalmanFilter 9 | 10 | 11 | class UKFFilter: 12 | """initiate the UKF filter for thermostat""" 13 | 14 | def __init__(self, current_temp, timedelta, filter_mode): 15 | """init Unscented kalman filter""" 16 | self._interval = 0 17 | self._last_update = time.time() 18 | self._mode = filter_mode 19 | sigmas = MerweScaledSigmaPoints(n=2, alpha=0.001, beta=2, kappa=0) 20 | self._kf_temp = UnscentedKalmanFilter( 21 | dim_x=2, dim_z=1, dt=timedelta, hx=hx, fx=fx, points=sigmas 22 | ) 23 | self._kf_temp.x = np.array([float(current_temp), 0.0]) 24 | self._kf_temp.P *= 0.2 # initial uncertainty 25 | self.interval = timedelta 26 | 27 | def kf_predict(self): 28 | """ 29 | run UKF prediction with variable timestep 30 | https://github.com/rlabbe/filterpy/issues/196 31 | """ 32 | timedelta = time.time() - self._last_update 33 | self._last_update = time.time() 34 | self._kf_temp.predict(dt=timedelta) 35 | 36 | def kf_update(self, current_temp): 37 | """run UKF update""" 38 | self._kf_temp.update(float(current_temp)) 39 | 40 | @property 41 | def get_temp(self): 42 | """return filtered temperature""" 43 | return float(self._kf_temp.x[0]) 44 | 45 | @property 46 | def get_vel(self): 47 | """return filtered velocity""" 48 | return float(self._kf_temp.x[1]) 49 | 50 | def set_Q_R(self, timedelta=None): # pylint: disable=invalid-name 51 | """process noise""" 52 | # default Q .002 R 4 53 | if timedelta: 54 | tmp_interval = timedelta 55 | else: 56 | tmp_interval = self._interval 57 | self._kf_temp.Q = Q_discrete_white_noise( 58 | dim=2, 59 | dt=tmp_interval, 60 | var=((0.01 / self.filter_mode) / (tmp_interval**1.2)) ** 2, 61 | ) 62 | 63 | # measurement noise std**2 64 | self._kf_temp.R = np.diag( 65 | [(self.filter_mode * (1800 / tmp_interval) ** 0.8) ** 2] 66 | ) 67 | 68 | @property 69 | def interval(self): 70 | """return time step""" 71 | return self._interval 72 | 73 | @interval.setter 74 | def interval(self, timedelta): # pylint: disable=invalid-name 75 | """set time step""" 76 | if timedelta != self._interval: 77 | self._interval = timedelta 78 | self.set_Q_R() 79 | 80 | @property 81 | def filter_mode(self): 82 | """return current filter mode""" 83 | return self._mode 84 | 85 | def set_filter_mode(self, val, timedelta=None): 86 | """set current filter mode""" 87 | if val != self._mode: 88 | self._mode = val 89 | self.set_Q_R(timedelta=timedelta) 90 | 91 | 92 | def fx(x, dt): # pylint: disable=invalid-name 93 | # xout = np.empty_like(x) 94 | # xout[0] = x[1] * dt + x[0] 95 | # xout[1] = x[1] 96 | # return xout 97 | 98 | F = np.array([[1, dt], [0, 1]], dtype=float) # pylint: disable=invalid-name 99 | # F = np.array([ 100 | # [1, dt,0.5*dt**2], 101 | # [0, 1,dt], 102 | # [0,0,1]], dtype=float) 103 | return np.dot(F, x) 104 | 105 | 106 | def hx(x): # pylint: disable=invalid-name 107 | return x[:1] # return position [x] 108 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/services.yaml: -------------------------------------------------------------------------------- 1 | reload: 2 | description: Reload all multizone_thermostat entities 3 | 4 | #proportional 5 | set_pwm_threshold: 6 | description: Set a new 'minimal_diff' before change switch 7 | fields: 8 | entity_id: 9 | description: Thermostat entity_id 10 | example: climate.study 11 | hvac_mode: 12 | description: hvac mode 13 | example: heat or cool 14 | new_threshold: 15 | description: minimum difference value of proportional controller 16 | example: 2 17 | 18 | set_preset_mode: 19 | name: Set preset mode 20 | description: Set preset mode for climate device 21 | fields: 22 | entity_id: 23 | description: Thermostat entity_id 24 | example: climate.master 25 | hvac_mode: 26 | description: hvac mode 27 | example: heat or cool 28 | preset_mode: 29 | description: New value of preset mode 30 | example: "holiday" 31 | 32 | 33 | # Describes the format for available PIDThermostat services 34 | # PID 35 | set_pid: 36 | description: Set a new Kp value 37 | fields: 38 | entity_id: 39 | description: Thermostat entity_id 40 | example: climate.study 41 | hvac_mode: 42 | description: hvac mode 43 | example: heat or cool 44 | kp: 45 | description: kp value of PID controller 46 | example: 2 47 | ki: 48 | description: kp value of PID controller 49 | example: 2 50 | kd: 51 | description: kp value of PID controller 52 | example: 2 53 | update: 54 | description: force values to controller 55 | example: true (default) or false 56 | 57 | 58 | set_integral: 59 | description: Set a new integral value 60 | fields: 61 | entity_id: 62 | description: Thermostat entity_id 63 | example: climate.study 64 | hvac_mode: 65 | description: hvac mode 66 | example: heat or cool 67 | integral: 68 | description: integral value of PID controller 69 | example: 5 70 | 71 | #weather mode 72 | set_ka_kb: 73 | description: Set a new KA value 74 | fields: 75 | entity_id: 76 | description: Thermostat entity_id 77 | example: climate.study 78 | hvac_mode: 79 | description: hvac mode 80 | example: heat or cool 81 | ka: 82 | description: KA value of weather mode controller 83 | example: 8 84 | kb: 85 | description: KB value of weather mode controller 86 | example: 9 87 | 88 | #filter mode 89 | set_filter_mode: 90 | description: Set a new filter variable 91 | fields: 92 | entity_id: 93 | description: Thermostat entity_id 94 | example: climate.study 95 | mode: 96 | description: mode of filter (integral) 97 | example: 5 98 | 99 | # controlled by master 100 | satelite_mode: 101 | description: Set satelite under control of master 102 | fields: 103 | entity_id: 104 | description: Thermostat entity_id 105 | example: climate.study 106 | control_mode: 107 | description: True(under control of master), False(not), None (no change) 108 | example: True 109 | pwm_time: 110 | description: interval time for pwm cycle 111 | example: 50 112 | offset: 113 | description: time offset to delay start in seconds 114 | example: 18 115 | pwm_timer: 116 | description: reference start time 117 | example: 1161611 118 | master_delay: 119 | description: master valve opening delay 120 | example: 60 121 | 122 | detailed_output: 123 | description: Include detailed output (PID, control output) in attributes 124 | fields: 125 | entity_id: 126 | description: Thermostat entity_id 127 | example: climate.study 128 | hvac_mode: 129 | description: hvac mode 130 | example: heat or cool 131 | new_mode: 132 | description: Include detailed output 133 | example: True or False 134 | 135 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/UKF_filter/unscented_transform.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=invalid-name, too-many-arguments 3 | 4 | """Copyright 2015 Roger R Labbe Jr. 5 | 6 | FilterPy library. 7 | http://github.com/rlabbe/filterpy 8 | 9 | Documentation at: 10 | https://filterpy.readthedocs.org 11 | 12 | Supporting book at: 13 | https://github.com/rlabbe/Kalman-and-Bayesian-Filters-in-Python 14 | 15 | This is licensed under an MIT license. See the readme.MD file 16 | for more information. 17 | """ 18 | 19 | import numpy as np 20 | 21 | 22 | def unscented_transform(sigmas, Wm, Wc, noise_cov=None, 23 | mean_fn=None, residual_fn=None): 24 | r""" 25 | Computes unscented transform of a set of sigma points and weights. 26 | returns the mean and covariance in a tuple. 27 | 28 | This works in conjunction with the UnscentedKalmanFilter class. 29 | 30 | 31 | Parameters 32 | ---------- 33 | 34 | sigmas: ndarray, of size (n, 2n+1) 35 | 2D array of sigma points. 36 | 37 | Wm : ndarray [# sigmas per dimension] 38 | Weights for the mean. 39 | 40 | 41 | Wc : ndarray [# sigmas per dimension] 42 | Weights for the covariance. 43 | 44 | noise_cov : ndarray, optional 45 | noise matrix added to the final computed covariance matrix. 46 | 47 | mean_fn : callable (sigma_points, weights), optional 48 | Function that computes the mean of the provided sigma points 49 | and weights. Use this if your state variable contains nonlinear 50 | values such as angles which cannot be summed. 51 | 52 | .. code-block:: Python 53 | 54 | def state_mean(sigmas, Wm): 55 | x = np.zeros(3) 56 | sum_sin, sum_cos = 0., 0. 57 | 58 | for i in range(len(sigmas)): 59 | s = sigmas[i] 60 | x[0] += s[0] * Wm[i] 61 | x[1] += s[1] * Wm[i] 62 | sum_sin += sin(s[2])*Wm[i] 63 | sum_cos += cos(s[2])*Wm[i] 64 | x[2] = atan2(sum_sin, sum_cos) 65 | return x 66 | 67 | residual_fn : callable (x, y), optional 68 | 69 | Function that computes the residual (difference) between x and y. 70 | You will have to supply this if your state variable cannot support 71 | subtraction, such as angles (359-1 degreees is 2, not 358). x and y 72 | are state vectors, not scalars. 73 | 74 | .. code-block:: Python 75 | 76 | def residual(a, b): 77 | y = a[0] - b[0] 78 | y = y % (2 * np.pi) 79 | if y > np.pi: 80 | y -= 2*np.pi 81 | return y 82 | 83 | Returns 84 | ------- 85 | 86 | x : ndarray [dimension] 87 | Mean of the sigma points after passing through the transform. 88 | 89 | P : ndarray 90 | covariance of the sigma points after passing throgh the transform. 91 | 92 | Examples 93 | -------- 94 | 95 | See my book Kalman and Bayesian Filters in Python 96 | https://github.com/rlabbe/Kalman-and-Bayesian-Filters-in-Python 97 | """ 98 | 99 | kmax, n = sigmas.shape 100 | 101 | try: 102 | if mean_fn is None: 103 | # new mean is just the sum of the sigmas * weight 104 | x = np.dot(Wm, sigmas) # dot = \Sigma^n_1 (W[k]*Xi[k]) 105 | else: 106 | x = mean_fn(sigmas, Wm) 107 | except: 108 | print(sigmas) 109 | raise 110 | 111 | 112 | # new covariance is the sum of the outer product of the residuals 113 | # times the weights 114 | 115 | # this is the fast way to do this - see 'else' for the slow way 116 | if residual_fn is np.subtract or residual_fn is None: 117 | y = sigmas - x[np.newaxis, :] 118 | P = np.dot(y.T, np.dot(np.diag(Wc), y)) 119 | else: 120 | P = np.zeros((n, n)) 121 | for k in range(kmax): 122 | y = residual_fn(sigmas[k], x) 123 | P += Wc[k] * np.outer(y, y) 124 | 125 | if noise_cov is not None: 126 | P += noise_cov 127 | 128 | return (x, P) 129 | -------------------------------------------------------------------------------- /examples/single thermostat - on_off.yaml: -------------------------------------------------------------------------------- 1 | # This on-off thermostat cannot be used for the multizone operation 2 | 3 | input_boolean: 4 | oo_heater_bool_simpl: 5 | name: Fake heater input simpl 6 | initial: off 7 | oo_heater_bool_det: 8 | name: Fake heater input det 9 | initial: off 10 | oo_ac_bool_det: 11 | name: Fake ac input 12 | initial: off 13 | 14 | input_number: 15 | oo_sensor_temperature_1: 16 | initial: 20 17 | min: -20 18 | max: 35 19 | step: 0.01 20 | 21 | sensor: 22 | - platform: template 23 | sensors: 24 | oo_sensor_1: 25 | unit_of_measurement: "degrees" 26 | value_template: "{{ states('input_number.oo_sensor_temperature_1') | float(0) }}" 27 | 28 | switch: 29 | - platform: template 30 | switches: 31 | oo_heater_switch_simpl: 32 | value_template: "{{ is_state('input_boolean.oo_heater_bool_simpl', 'on') }}" 33 | turn_on: 34 | service: input_boolean.turn_on 35 | entity_id: input_boolean.oo_heater_bool_simpl 36 | turn_off: 37 | service: input_boolean.turn_off 38 | entity_id: input_boolean.oo_heater_bool_simpl 39 | oo_heater_switch_det: 40 | value_template: "{{ is_state('input_boolean.oo_heater_bool_det', 'on') }}" 41 | turn_on: 42 | service: input_boolean.turn_on 43 | entity_id: input_boolean.oo_heater_bool_det 44 | turn_off: 45 | service: input_boolean.turn_off 46 | entity_id: input_boolean.oo_heater_bool_det 47 | oo_ac_switch_det: 48 | value_template: "{{ is_state('input_boolean.oo_ac_bool_det', 'on') }}" 49 | turn_on: 50 | service: input_boolean.turn_on 51 | entity_id: input_boolean.oo_ac_bool_det 52 | turn_off: 53 | service: input_boolean.turn_off 54 | entity_id: input_boolean.oo_ac_bool_det 55 | 56 | 57 | 58 | climate: 59 | # on-off mode stand-alone - heat only - minimum definition 60 | - platform: multizone_thermostat 61 | name: simple_hysteric_heat_only 62 | unique_id: hysteric_heat_only 63 | sensor: sensor.oo_sensor_1 # sensor for room temperature 64 | 65 | # configuration for heating 66 | heat: 67 | entity_id: switch.oo_heater_switch_simpl 68 | on_off_mode: 69 | hysteresis_on: 0.5 70 | hysteresis_off: 1 71 | 72 | # on-off mode stand-alone - heat only - detailed example 73 | - platform: multizone_thermostat 74 | name: detailed_hysteric_heat_only 75 | unique_id: hysteric_detailed 76 | precision: 1 77 | initial_hvac_mode: "heat" 78 | 79 | sensor: sensor.oo_sensor_1 # sensor for room temperature 80 | 81 | # in case of sensors with irregular updates or inaccurate sensors (for instance sensors with a battery) the filter wll 82 | # average and smoothen the output. For thermostat with hysteris advised to try first without filter 83 | # and only use in case of inaccurate measurements 84 | filter_mode: 0 85 | 86 | # activate emergency mode (pause thermostat operation) 87 | # when sensor did not provide update 88 | sensor_stale_duration: 89 | hours: 4 90 | 91 | restore_from_old_state: True 92 | restore_parameters: False 93 | 94 | # configuration for heating 95 | heat: 96 | entity_id: switch.oo_heater_switch_det 97 | 98 | # setpoint limits 99 | min_target_temp: 5 100 | max_target_temp: 25 101 | initial_target_temp: 19 102 | 103 | # hysteris controller 104 | on_off_mode: 105 | 106 | # switches on when temp is 'hysteresis_on' below setpoint 107 | hysteresis_on: 0.5 108 | # switches off when temp is 'hysteresis_off' below setpoint 109 | hysteresis_off: 1 110 | 111 | # avoid short valve opening and quick on-off changes 112 | min_cycle_duration: 113 | minutes: 5 114 | 115 | # check each interval if valve needs to opened or closed 116 | control_interval: 117 | minutes: 3 118 | 119 | # configuration for heating 120 | cool: 121 | entity_id: switch.oo_ac_switch_det 122 | 123 | # setpoint limits 124 | min_target_temp: 15 125 | max_target_temp: 40 126 | initial_target_temp: 30 127 | 128 | # hysteris controller 129 | on_off_mode: 130 | # switches on when temp is 'hysteresis_on' above (cooling) setpoint 131 | hysteresis_on: 0.5 132 | # switches off when temp is 'hysteresis_off' above (cooling) setpoint 133 | hysteresis_off: 1 134 | 135 | # avoid short valve opening and quick on-off changes 136 | min_cycle_duration: 137 | minutes: 3 138 | 139 | # check each interval if valve needs to opened or closed 140 | control_interval: 141 | minutes: 15 142 | 143 | 144 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/const.py: -------------------------------------------------------------------------------- 1 | """Multizone constants.""" 2 | from datetime import timedelta 3 | from enum import StrEnum 4 | 5 | # general 6 | DEFAULT_TARGET_TEMP_HEAT = 19.0 7 | DEFAULT_TARGET_TEMP_COOL = 28.0 8 | DEFAULT_MAX_TEMP_HEAT = 24 9 | DEFAULT_MIN_TEMP_HEAT = 5 10 | DEFAULT_MAX_TEMP_COOL = 40 11 | DEFAULT_MIN_TEMP_COOL = 15 12 | DEFAULT_DETAILED_OUTPUT = False 13 | DEFAULT_SENSOR_FILTER = 0 14 | DEFAULT_AREA = 0 15 | DEFAULT_INCLUDE_VALVE_LAG = timedelta(seconds=0) 16 | 17 | # on_off switch type 18 | NC_SWITCH_MODE = "NC" 19 | NO_SWITCH_MODE = "NO" 20 | 21 | # MASTER 22 | DEFAULT_MIN_LOAD = 0.15 # min heat load % room area 23 | DEFAULT_MIN_VALVE_PWM = 0 # factor of master pwm 24 | 25 | # safety routines 26 | DEFAULT_PASSIVE_SWITCH = False 27 | DEFAULT_PASSIVE_SWITCH_OPEN_TIME = timedelta(seconds=60) 28 | DEFAULT_PASSIVE_CHECK_TIME = "02:00" 29 | 30 | # restore old states 31 | DEFAULT_OLD_STATE = False 32 | DEFAULT_RESTORE_PARAMETERS = False 33 | DEFAULT_RESTORE_INTEGRAL = False 34 | 35 | # on_off mode 36 | DEFAULT_HYSTERESIS_TOLERANCE = 0.5 37 | 38 | # PWM/PID controller 39 | DEFAULT_PWM_SCALE = 100 40 | DEFAULT_MIN_DIFF = 0 41 | DEFAULT_PWM = 0 42 | DEFAULT_PWM_RESOLUTION = 50 43 | DEFAULT_MASTER_SCALE_BOUND = 1 44 | 45 | # MASTER 46 | DEFAULT_OPERATION = "on_off" 47 | 48 | # configuration variables 49 | CONF_INITIAL_HVAC_MODE = "initial_hvac_mode" 50 | CONF_INITIAL_PRESET_MODE = "initial_preset_mode" 51 | CONF_SWITCH_MODE = "switch_mode" 52 | CONF_PASSIVE_SWITCH_CHECK = "passive_switch_check" 53 | CONF_DETAILED_OUTPUT = "detailed_output" 54 | 55 | CONF_SENSOR = "sensor" 56 | CONF_FILTER_MODE = "filter_mode" 57 | 58 | ATTR_HVAC_DEFINITION = "hvac_def" 59 | ATTR_SELF_CONTROLLED = "self_controlled" 60 | ATTR_SAT_ALLOWED = "satelite_allowed" 61 | ATTR_CONTROL_MODE = "control_mode" 62 | ATTR_CURRENT_TEMP_VEL = "current_temperature_velocity" 63 | ATTR_CURRENT_OUTDOOR_TEMPERATURE = "current_outdoor_temp" 64 | ATTR_FILTER_MODE = "filter_mode" 65 | ATTR_DETAILED_OUTPUT = "detailed_output" 66 | ATTR_EMERGENCY_MODE = "emergency mode" 67 | ATTR_UPDATE_NEEDED = "update satelite" 68 | ATTR_LAST_SWITCH_CHANGE = "switch_last_change" 69 | ATTR_STUCK_LOOP = "stuck_loop" 70 | 71 | PRESET_EMERGENCY = "emergency" 72 | PRESET_RESTORE = "restore" 73 | 74 | # only required for hvac_settings 75 | CONF_TARGET_TEMP_INIT = "initial_target_temp" 76 | CONF_TARGET_TEMP_MIN = "min_target_temp" 77 | CONF_TARGET_TEMP_MAX = "max_target_temp" 78 | 79 | CONF_PRECISION = "precision" 80 | CONF_AREA = "room_area" 81 | CONF_ENABLE_OLD_STATE = "restore_from_old_state" 82 | CONF_ENABLE_OLD_PARAMETERS = "restore_parameters" 83 | CONF_ENABLE_OLD_INTEGRAL = "restore_integral" 84 | CONF_STALE_DURATION = "sensor_stale_duration" 85 | 86 | CONF_EXTRA_PRESETS = "extra_presets" 87 | 88 | CONF_PASSIVE_SWITCH_DURATION = "passive_switch_duration" 89 | CONF_PASSIVE_SWITCH_OPEN_TIME = "passive_switch_opening_time" 90 | CONF_PASSIVE_CHECK_TIME = "passive_switch_check_time" 91 | CONF_INCLUDE_VALVE_LAG = "compensate_valve_lag" 92 | 93 | ATTR_CONTROL_OUTPUT = "control_output" # offset and pwm_output 94 | ATTR_CONTROL_PWM_OUTPUT = "pwm_out" 95 | ATTR_CONTROL_OFFSET = "offset" 96 | 97 | # proportional valve control (pwm) 98 | SERVICE_SET_VALUE = "set_value" 99 | ATTR_VALUE = "value" 100 | PLATFORM_INPUT_NUMBER = "input_number" 101 | 102 | # controller config 103 | CONF_CONTROL_REFRESH_INTERVAL = "control_interval" 104 | CONF_PWM_DURATION = "pwm_duration" 105 | CONF_PWM_SCALE = "pwm_scale" 106 | CONF_PWM_SCALE_LOW = "pwm_scale_low" 107 | CONF_PWM_SCALE_HIGH = "pwm_scale_high" 108 | CONF_PWM_RESOLUTION = "pwm_resolution" 109 | CONF_PWM_THRESHOLD = "pwm_threshold" 110 | CONF_MASTER_SCALE_BOUND = "bounded_scale_to_master" 111 | 112 | 113 | ATTR_PWM_THRESHOLD = "pwm_threshold" 114 | 115 | # on_off thermostat 116 | CONF_ON_OFF_MODE = "on_off_mode" 117 | CONF_MIN_CYCLE_DURATION = "min_cycle_duration" 118 | CONF_HYSTERESIS_TOLERANCE_ON = "hysteresis_on" 119 | CONF_HYSTERESIS_TOLERANCE_OFF = "hysteresis_off" 120 | 121 | # proportional mode 122 | CONF_PROPORTIONAL_MODE = "proportional_mode" 123 | 124 | # PID controller 125 | CONF_PID_MODE = "PID_mode" 126 | 127 | PID_CONTROLLER = "PID_controller" 128 | CONF_KP = "kp" 129 | CONF_KI = "ki" 130 | CONF_KD = "kd" 131 | CONF_WINDOW_OPEN_TEMPDROP = "window_open_tempdrop" 132 | 133 | ATTR_KP = "kp" 134 | ATTR_KI = "ki" 135 | ATTR_KD = "kd" 136 | 137 | # weather compensating mode 138 | CONF_WC_MODE = "weather_mode" 139 | CONF_SENSOR_OUT = "sensor_out" 140 | CONF_KA = "ka" 141 | CONF_KB = "kb" 142 | ATTR_KA = "ka" 143 | ATTR_KB = "kb" 144 | 145 | # Master mode 146 | CONF_MASTER_MODE = "master_mode" 147 | CONF_MASTER_OPERATION_MODE = "operation_mode" 148 | CONF_SATELITES = "satelites" 149 | CONF_MIN_VALVE = "min_opening_for_propvalve" 150 | CONF_CONTINUOUS_LOWER_LOAD = "lower_load_scale" 151 | 152 | # nesting 153 | ATTR_ROOMS = "rooms" 154 | ATTR_SCALED_PWM = "scaled_pwm" 155 | ATTR_ROUNDED_PWM = "rounded_pwm" 156 | 157 | 158 | class NestingMode(StrEnum): 159 | """Modes for nesting.""" 160 | 161 | MASTER_MIN_ON = "minimal_on" 162 | MASTER_BALANCED = "balanced" 163 | MASTER_CONTINUOUS = "continuous" 164 | 165 | 166 | # control constants 167 | CONTROL_START_DELAY = 1 # # seconds, control loop start delay rel to time() 168 | MASTER_CONTROL_LEAD = 1 # 0.1 # seconds, time between last sat and master control 169 | SAT_CONTROL_LEAD = 0.5 # 0.15 # seconds, time between control loop sats 170 | PWM_LAG = 0.5 # 0.05 # seconds 171 | PWM_UPDATE_CHANGE = 0.05 # percentage, pwm difference above which an update is needed 172 | CLOSE_TO_PWM = 0.1 # percentage, if time is close to next pwm loop 173 | MIN_MASTER_LOAD = 0.25 # min load for nesting 174 | NESTING_DOMINANCE = 0.75 # limit dominant room in nesting 175 | 176 | NESTING_MARGIN = 1.1 # margin for continuous opening 177 | START_MISALINGMENT = ( 178 | 30 # seconds , skip switch toggle when in near future switch operated 179 | ) 180 | 181 | NESTING_MATRIX = 20 182 | NESTING_BALANCE = 0.1 183 | 184 | 185 | class OperationMode(StrEnum): 186 | """Operation modes for satelite thermostats.""" 187 | 188 | PENDING = "pending" 189 | MASTER = "master" 190 | SELF = "self_controlled" 191 | NO_CHANGE = "no change" 192 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/UKF_filter/discretization.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Copyright 2015 Roger R Labbe Jr. 3 | 4 | FilterPy library. 5 | http://github.com/rlabbe/filterpy 6 | 7 | Documentation at: 8 | https://filterpy.readthedocs.org 9 | 10 | Supporting book at: 11 | https://github.com/rlabbe/Kalman-and-Bayesian-Filters-in-Python 12 | 13 | This is licensed under an MIT license. See the readme.MD file 14 | for more information. 15 | """ 16 | 17 | #pylint:disable=invalid-name, bad-whitespace 18 | 19 | # from __future__ import (absolute_import, division, print_function, 20 | # unicode_literals) 21 | 22 | 23 | from numpy import zeros, eye, array, kron 24 | # from numpy import zeros, vstack, eye, array 25 | # from numpy.core.shape_base import block 26 | # from numpy.linalg import inv 27 | # from scipy.linalg import expm, block_diag 28 | 29 | 30 | def order_by_derivative(Q, dim, block_size): 31 | """ 32 | Given a matrix Q, ordered assuming state space 33 | [x y z x' y' z' x'' y'' z''...] 34 | 35 | return a reordered matrix assuming an ordering of 36 | [ x x' x'' y y' y'' z z' y''] 37 | 38 | This works for any covariance matrix or state transition function 39 | 40 | Parameters 41 | ---------- 42 | Q : np.array, square 43 | The matrix to reorder 44 | 45 | dim : int >= 1 46 | 47 | number of independent state variables. 3 for x, y, z 48 | 49 | block_size : int >= 0 50 | Size of derivatives. Second derivative would be a block size of 3 51 | (x, x', x'') 52 | 53 | 54 | """ 55 | 56 | N = dim * block_size 57 | 58 | D = zeros((N, N)) 59 | 60 | Q = array(Q) 61 | for i, x in enumerate(Q.ravel()): 62 | f = eye(block_size) * x 63 | 64 | ix, iy = (i // dim) * block_size, (i % dim) * block_size 65 | D[ix:ix+block_size, iy:iy+block_size] = f 66 | 67 | return D 68 | 69 | 70 | 71 | def Q_discrete_white_noise(dim, dt=1., var=1., block_size=1, order_by_dim=True): 72 | """ 73 | Returns the Q matrix for the Discrete Constant White Noise 74 | Model. dim may be either 2, 3, or 4 dt is the time step, and sigma 75 | is the variance in the noise. 76 | 77 | Q is computed as the G * G^T * variance, where G is the process noise per 78 | time step. In other words, G = [[.5dt^2][dt]]^T for the constant velocity 79 | model. 80 | 81 | Parameters 82 | ----------- 83 | 84 | dim : int (2, 3, or 4) 85 | dimension for Q, where the final dimension is (dim x dim) 86 | 87 | dt : float, default=1.0 88 | time step in whatever units your filter is using for time. i.e. the 89 | amount of time between innovations 90 | 91 | var : float, default=1.0 92 | variance in the noise 93 | 94 | block_size : int >= 1 95 | If your state variable contains more than one dimension, such as 96 | a 3d constant velocity model [x x' y y' z z']^T, then Q must be 97 | a block diagonal matrix. 98 | 99 | order_by_dim : bool, default=True 100 | Defines ordering of variables in the state vector. `True` orders 101 | by keeping all derivatives of each dimensions) 102 | 103 | [x x' x'' y y' y''] 104 | 105 | whereas `False` interleaves the dimensions 106 | 107 | [x y z x' y' z' x'' y'' z''] 108 | 109 | 110 | Examples 111 | -------- 112 | >>> # constant velocity model in a 3D world with a 10 Hz update rate 113 | >>> Q_discrete_white_noise(2, dt=0.1, var=1., block_size=3) 114 | array([[0.000025, 0.0005 , 0. , 0. , 0. , 0. ], 115 | [0.0005 , 0.01 , 0. , 0. , 0. , 0. ], 116 | [0. , 0. , 0.000025, 0.0005 , 0. , 0. ], 117 | [0. , 0. , 0.0005 , 0.01 , 0. , 0. ], 118 | [0. , 0. , 0. , 0. , 0.000025, 0.0005 ], 119 | [0. , 0. , 0. , 0. , 0.0005 , 0.01 ]]) 120 | 121 | References 122 | ---------- 123 | 124 | Bar-Shalom. "Estimation with Applications To Tracking and Navigation". 125 | John Wiley & Sons, 2001. Page 274. 126 | """ 127 | 128 | if dim not in [2, 3, 4]: 129 | raise ValueError("dim must be between 2 and 4") 130 | 131 | if dim == 2: 132 | Q = [[.25*dt**4, .5*dt**3], 133 | [ .5*dt**3, dt**2]] 134 | elif dim == 3: 135 | Q = [[.25*dt**4, .5*dt**3, .5*dt**2], 136 | [ .5*dt**3, dt**2, dt], 137 | [ .5*dt**2, dt, 1]] 138 | else: 139 | Q = [[(dt**6)/36, (dt**5)/12, (dt**4)/6, (dt**3)/6], 140 | [(dt**5)/12, (dt**4)/4, (dt**3)/2, (dt**2)/2], 141 | [(dt**4)/6, (dt**3)/2, dt**2, dt], 142 | [(dt**3)/6, (dt**2)/2 , dt, 1.]] 143 | 144 | if order_by_dim: 145 | # return block_diag(*[Q]*block_size) * var 146 | return kron(eye(block_size),Q) * var 147 | return order_by_derivative(array(Q), dim, block_size) * var 148 | 149 | 150 | def Q_continuous_white_noise(dim, dt=1., spectral_density=1., 151 | block_size=1, order_by_dim=True): 152 | """ 153 | Returns the Q matrix for the Discretized Continuous White Noise 154 | Model. dim may be either 2, 3, 4, dt is the time step, and sigma is the 155 | variance in the noise. 156 | 157 | Parameters 158 | ---------- 159 | 160 | dim : int (2 or 3 or 4) 161 | dimension for Q, where the final dimension is (dim x dim) 162 | 2 is constant velocity, 3 is constant acceleration, 4 is 163 | constant jerk 164 | 165 | dt : float, default=1.0 166 | time step in whatever units your filter is using for time. i.e. the 167 | amount of time between innovations 168 | 169 | spectral_density : float, default=1.0 170 | spectral density for the continuous process 171 | 172 | block_size : int >= 1 173 | If your state variable contains more than one dimension, such as 174 | a 3d constant velocity model [x x' y y' z z']^T, then Q must be 175 | a block diagonal matrix. 176 | 177 | order_by_dim : bool, default=True 178 | Defines ordering of variables in the state vector. `True` orders 179 | by keeping all derivatives of each dimensions) 180 | 181 | [x x' x'' y y' y''] 182 | 183 | whereas `False` interleaves the dimensions 184 | 185 | [x y z x' y' z' x'' y'' z''] 186 | 187 | Examples 188 | -------- 189 | 190 | >>> # constant velocity model in a 3D world with a 10 Hz update rate 191 | >>> Q_continuous_white_noise(2, dt=0.1, block_size=3) 192 | array([[0.00033333, 0.005 , 0. , 0. , 0. , 0. ], 193 | [0.005 , 0.1 , 0. , 0. , 0. , 0. ], 194 | [0. , 0. , 0.00033333, 0.005 , 0. , 0. ], 195 | [0. , 0. , 0.005 , 0.1 , 0. , 0. ], 196 | [0. , 0. , 0. , 0. , 0.00033333, 0.005 ], 197 | [0. , 0. , 0. , 0. , 0.005 , 0.1 ]]) 198 | """ 199 | 200 | if dim not in [2, 3, 4]: 201 | raise ValueError("dim must be between 2 and 4") 202 | 203 | if dim == 2: 204 | Q = [[(dt**3)/3., (dt**2)/2.], 205 | [(dt**2)/2., dt]] 206 | elif dim == 3: 207 | Q = [[(dt**5)/20., (dt**4)/8., (dt**3)/6.], 208 | [ (dt**4)/8., (dt**3)/3., (dt**2)/2.], 209 | [ (dt**3)/6., (dt**2)/2., dt]] 210 | 211 | else: 212 | Q = [[(dt**7)/252., (dt**6)/72., (dt**5)/30., (dt**4)/24.], 213 | [(dt**6)/72., (dt**5)/20., (dt**4)/8., (dt**3)/6.], 214 | [(dt**5)/30., (dt**4)/8., (dt**3)/3., (dt**2)/2.], 215 | [(dt**4)/24., (dt**3)/6., (dt**2/2.), dt]] 216 | 217 | if order_by_dim: 218 | # return block_diag(*[Q]*block_size) * spectral_density 219 | return kron(eye(block_size),Q) * spectral_density 220 | return order_by_derivative(array(Q), dim, block_size) * spectral_density 221 | 222 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/pid_controller.py: -------------------------------------------------------------------------------- 1 | """module with PID controller. 2 | 3 | Based on Arduino PID Library 4 | See https://github.com/br3ttb/Arduino-PID-Library 5 | """ 6 | from datetime import datetime 7 | import logging 8 | 9 | from . import DOMAIN 10 | 11 | 12 | class PIDController: 13 | """A proportional-integral-derivative controller.""" 14 | 15 | def __init__( 16 | self, 17 | name: str, 18 | PID_type: str, # pylint: disable=invalid-name 19 | sampletime: float, 20 | kp: float | None, # pylint: disable=invalid-name 21 | ki: float | None, # pylint: disable=invalid-name 22 | kd: float | None, # pylint: disable=invalid-name 23 | time: datetime, 24 | out_min: float = float("-inf"), 25 | out_max: float = float("inf"), 26 | ) -> None: 27 | """Prepare the pid controller.""" 28 | if kp is None: 29 | raise ValueError("kp must be specified") 30 | if ki is None: 31 | raise ValueError("ki must be specified") 32 | if kd is None: 33 | raise ValueError("kd must be specified") 34 | if sampletime <= 0: 35 | raise ValueError("sampletime must be greater than 0") 36 | if out_min >= out_max: 37 | raise ValueError("out_min must be less than out_max") 38 | 39 | self._name = name + "." + PID_type 40 | self._logger = logging.getLogger(DOMAIN).getChild(self._name) 41 | 42 | self._Kp = kp # pylint: disable=invalid-name 43 | self._Ki = ki # pylint: disable=invalid-name 44 | self._Kd = kd # pylint: disable=invalid-name 45 | self.p_var = 0 46 | self.i_var = 0 47 | self.d_var = 0 48 | self._sampletime = sampletime 49 | self._out_min = out_min 50 | self._out_max = out_max 51 | self._integral = 0 52 | self._differential = 0 53 | self._windupguard = 1 54 | self._last_input = 0 55 | # self._old_setpoint = None 56 | self._last_output = 0 57 | self._last_calc_timestamp = None 58 | self._time = time 59 | 60 | def calc(self, input_val: float, setpoint, force: bool = False) -> float: 61 | """Calculate pid for given input_val and setpoint.""" 62 | if not setpoint: 63 | self._logger.warning( 64 | "No setpoint specified, return with previous control value %s", 65 | self._last_output, 66 | ) 67 | return self._last_output 68 | 69 | if not input_val: 70 | self._logger.warning( 71 | "no current value specified, return with previous control value %.2f", 72 | self._last_output, 73 | ) 74 | return self._last_output 75 | 76 | now = self._time() 77 | time_diff = None 78 | if self._last_calc_timestamp is not None: 79 | time_diff = now - self._last_calc_timestamp 80 | 81 | # reset previous result in case filter mode changed the output 82 | # between temp only and temp + velocity 83 | if type(input_val) is not type(self._last_input): 84 | self._last_input = input_val 85 | 86 | # UKF temp + velocity 87 | if isinstance(input_val, list): 88 | current_temp, self._differential = input_val 89 | self._logger.debug( 90 | "current temp '%.2f'; velocity %.4f", 91 | current_temp, 92 | self._differential, 93 | ) 94 | # when only current temp is provided 95 | else: 96 | current_temp = input_val 97 | if self._last_calc_timestamp is not None and time_diff is not None: 98 | input_diff = current_temp - self._last_input 99 | self._differential = input_diff / time_diff 100 | 101 | # Compute all the working error variables 102 | error = setpoint - current_temp 103 | 104 | self.calc_integral(error, time_diff) 105 | self.p_var = self._Kp * error 106 | self.i_var = self._Ki * self._integral 107 | self.d_var = self._Kd * self._differential 108 | 109 | # Compute PID Output 110 | self._last_output = self.p_var + self.i_var + self.d_var 111 | self._last_output = min(self._last_output, self._out_max) 112 | self._last_output = max(self._last_output, self._out_min) 113 | 114 | # Log some debug info 115 | self._logger.debug( 116 | "Contribution P: %.4f; I: %.4f; D: %.4f; Output: %.2f", 117 | self.p_var, 118 | self.i_var, 119 | self.d_var, 120 | self._last_output, 121 | ) 122 | 123 | # fully open if error is too high 124 | if ( # heating 125 | ( 126 | self._Kp > 0 127 | and ( 128 | (error > 0 and error > self._out_max / self._Kp) 129 | or (error > 1.5) 130 | ) 131 | ) 132 | # cooling 133 | or ( 134 | self._Kp < 0 135 | and ( 136 | (error < 0 and error < self._out_max / self._Kp) 137 | or (error < -1.5) 138 | ) 139 | ) 140 | ): 141 | # when temp is too low when heating or too high when cooling set fully open 142 | # similar as honeywell TPI 143 | self._logger.debug( 144 | "setpoint %.2f, current temp %.2f: too low temp open to max: %.2f", 145 | setpoint, 146 | current_temp, 147 | self._out_max, 148 | ) 149 | self._last_output = self._out_max 150 | 151 | # fully close if error is too low 152 | elif ( 153 | # heating 154 | (self._Kp > 0 and error < -1.5) 155 | or 156 | # cooling 157 | (self._Kp < 0 and error > 1.5) 158 | ): 159 | # when temp is too low when heating or too high when cooling set fully open 160 | # similar as honeywell TPI 161 | self._logger.debug( 162 | "setpoint %.2f, current temp %.2f: too low temp open to max: %.2f", 163 | setpoint, 164 | current_temp, 165 | self._out_max, 166 | ) 167 | self._last_output = self._out_min 168 | 169 | # Remember some variables for next time 170 | self._last_input = input_val 171 | self._last_calc_timestamp = now 172 | return self._last_output 173 | 174 | def calc_integral(self, error: float, time_diff: datetime | None) -> float | None: 175 | """Calcualte integral. 176 | 177 | In order to prevent windup, only integrate if the process is not saturated 178 | """ 179 | if time_diff is None or self._last_calc_timestamp is None: 180 | return 181 | 182 | if self._Ki: 183 | self._integral += time_diff * error 184 | self._integral = min( 185 | self._integral, 186 | self._out_max / (self._windupguard * abs(self._Ki)), 187 | ) 188 | self._integral = max( 189 | self._integral, 190 | self._out_min / (self._windupguard * abs(self._Ki)), 191 | ) 192 | 193 | def reset_time(self) -> None: 194 | """Reset time to void large intergrl buildup.""" 195 | 196 | if self._last_calc_timestamp != 0: 197 | self._logger.debug("reset PID integral reference time") 198 | self._last_calc_timestamp = self._time() 199 | 200 | @property 201 | def integral(self) -> float: 202 | """Return integral.""" 203 | return self._integral 204 | 205 | @integral.setter 206 | def integral(self, integral: float) -> None: 207 | """Set integral.""" 208 | self._logger.info("Forcing new integral: %s", integral) 209 | self._integral = integral / self._Ki 210 | 211 | @property 212 | def differential(self) -> float: 213 | """Get differential.""" 214 | return self._differential 215 | 216 | def set_pid_param( 217 | self, kp: float | None = None, ki: float | None = None, kd: float | None = None 218 | ): # pylint: disable=invalid-name 219 | """Set PID parameters.""" 220 | if kp is not None: 221 | self._Kp = kp 222 | if ki is not None: 223 | self._Ki = ki 224 | if kd is not None: 225 | self._Kd = kd 226 | 227 | @property 228 | def get_PID_parts(self) -> dict: 229 | """PID component.""" 230 | return {"p": self.p_var, "i": self.i_var, "d": self.d_var} 231 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/validations.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from datetime import datetime, timedelta 3 | from typing import Any 4 | 5 | import voluptuous as vol 6 | 7 | from homeassistant.components.climate import HVACMode 8 | 9 | from .const import ( 10 | CONF_CONTROL_REFRESH_INTERVAL, 11 | CONF_EXTRA_PRESETS, 12 | CONF_FILTER_MODE, 13 | CONF_INITIAL_HVAC_MODE, 14 | CONF_INITIAL_PRESET_MODE, 15 | CONF_MASTER_MODE, 16 | CONF_ON_OFF_MODE, 17 | CONF_PASSIVE_CHECK_TIME, 18 | CONF_PID_MODE, 19 | CONF_PROPORTIONAL_MODE, 20 | CONF_PWM_DURATION, 21 | CONF_SATELITES, 22 | CONF_SENSOR, 23 | CONF_SENSOR_OUT, 24 | CONF_WC_MODE, 25 | CONF_WINDOW_OPEN_TEMPDROP, 26 | ) 27 | 28 | 29 | def validate_initial_control_mode(*keys: str) -> Callable: 30 | """If an initial preset mode has been set, check if the values are set in both modes.""" 31 | 32 | def validate(obj: dict[str, Any]) -> dict[str, Any]: 33 | """Check this condition.""" 34 | for hvac_mode in [HVACMode.COOL, HVACMode.HEAT]: 35 | if hvac_mode in obj: 36 | if all( 37 | x in obj[hvac_mode] 38 | for x in [CONF_ON_OFF_MODE, CONF_PROPORTIONAL_MODE] 39 | ): 40 | raise vol.Invalid( 41 | "The on_off and proportional mode have both been set {} mode".format( 42 | hvac_mode 43 | ) 44 | ) 45 | return obj 46 | 47 | return validate 48 | 49 | 50 | def validate_window(*keys: str) -> Callable: 51 | """Check if filter is active when setting window open detection.""" 52 | 53 | def validate(obj: dict[str, Any]) -> dict[str, Any]: 54 | """Check this condition.""" 55 | for hvac_mode in [HVACMode.COOL, HVACMode.HEAT]: 56 | if hvac_mode in obj and CONF_FILTER_MODE not in obj: 57 | try: 58 | if ( 59 | CONF_WINDOW_OPEN_TEMPDROP 60 | in obj[hvac_mode][CONF_PROPORTIONAL_MODE][CONF_PID_MODE] 61 | ): 62 | raise vol.Invalid( 63 | "window open check included for {} mode but required temperature filter not set".format( 64 | hvac_mode 65 | ) 66 | ) 67 | except Exception: 68 | pass 69 | 70 | return obj 71 | 72 | return validate 73 | 74 | 75 | def validate_initial_sensors(*keys: str) -> Callable: 76 | """If an initial preset mode has been set, check if the values are set in both modes.""" 77 | 78 | def validate(obj: dict[str, Any]) -> dict[str, Any]: 79 | """Check this condition.""" 80 | for hvac_mode in [HVACMode.HEAT, HVACMode.COOL]: 81 | if hvac_mode in obj: 82 | if CONF_ON_OFF_MODE in obj[hvac_mode] and not CONF_SENSOR in obj: 83 | raise vol.Invalid( 84 | "on-off control defined but no temperature sensor for {} mode".format( 85 | hvac_mode 86 | ) 87 | ) 88 | if CONF_PROPORTIONAL_MODE in obj[hvac_mode]: 89 | if ( 90 | CONF_PID_MODE in obj[hvac_mode][CONF_PROPORTIONAL_MODE] 91 | and not CONF_SENSOR in obj 92 | ): 93 | raise vol.Invalid( 94 | "PID control defined but no temperature sensor for {} mode".format( 95 | hvac_mode 96 | ) 97 | ) 98 | if ( 99 | CONF_WC_MODE in obj[hvac_mode][CONF_PROPORTIONAL_MODE] 100 | and not CONF_SENSOR_OUT in obj 101 | ): 102 | raise vol.Invalid( 103 | "Weather control defined but no outdoor temperature sensor for {} mode".format( 104 | hvac_mode 105 | ) 106 | ) 107 | if CONF_MASTER_MODE in obj[hvac_mode]: 108 | if CONF_SATELITES not in obj[hvac_mode][CONF_MASTER_MODE]: 109 | raise vol.Invalid( 110 | "Master mode defined but no satelite thermostats for {} mode".format( 111 | hvac_mode 112 | ) 113 | ) 114 | pwm_duration = timedelta( 115 | seconds=obj[hvac_mode][CONF_MASTER_MODE][CONF_PWM_DURATION].get( 116 | "seconds", 0 117 | ), 118 | hours=obj[hvac_mode][CONF_MASTER_MODE][CONF_PWM_DURATION].get( 119 | "hours", 0 120 | ), 121 | ) 122 | cntrl_duration = timedelta( 123 | seconds=obj[hvac_mode][CONF_MASTER_MODE][ 124 | CONF_CONTROL_REFRESH_INTERVAL 125 | ].get("seconds", 0), 126 | hours=obj[hvac_mode][CONF_MASTER_MODE][ 127 | CONF_CONTROL_REFRESH_INTERVAL 128 | ].get("hours", 0), 129 | ) 130 | if pwm_duration.seconds > 0 and pwm_duration != cntrl_duration: 131 | raise vol.Invalid( 132 | "Master mode {} ({} sec) not equal {} ({} sec)".format( 133 | str(CONF_PWM_DURATION), 134 | pwm_duration.seconds, 135 | str(CONF_CONTROL_REFRESH_INTERVAL), 136 | cntrl_duration.seconds, 137 | ), 138 | ) 139 | 140 | return obj 141 | 142 | return validate 143 | 144 | 145 | def validate_initial_preset_mode(*keys: str) -> Callable: 146 | """If an initial preset mode has been set, check if it is defined in the hvac mode.""" 147 | 148 | def validate(obj: dict[str, Any]) -> dict[str, Any]: 149 | """Check this condition.""" 150 | if CONF_INITIAL_PRESET_MODE in obj: 151 | if obj[CONF_INITIAL_PRESET_MODE] == "none": 152 | return obj 153 | elif CONF_INITIAL_HVAC_MODE not in obj: 154 | raise vol.Invalid( 155 | "no initial hvac mode while specifying initial preset '{}'".format( 156 | obj[CONF_INITIAL_PRESET_MODE] 157 | ) 158 | ) 159 | elif obj[CONF_INITIAL_HVAC_MODE] not in [HVACMode.HEAT, HVACMode.COOL]: 160 | raise vol.Invalid( 161 | "initial hvac mode 'off' not valid while specifying initial preset '{}'".format( 162 | obj[CONF_INITIAL_PRESET_MODE] 163 | ) 164 | ) 165 | elif ( 166 | obj[CONF_INITIAL_PRESET_MODE] 167 | not in obj[CONF_INITIAL_HVAC_MODE][CONF_EXTRA_PRESETS] 168 | ): 169 | raise vol.Invalid( 170 | "initial preset '{}' not valid for hvac mode {} mode".format( 171 | obj[CONF_INITIAL_PRESET_MODE], obj[CONF_INITIAL_HVAC_MODE] 172 | ) 173 | ) 174 | 175 | else: 176 | return obj 177 | else: 178 | return obj 179 | 180 | return validate 181 | 182 | 183 | def validate_initial_hvac_mode(*keys: str) -> Callable: 184 | """If an initial hvac mode has been set, check if this mode has been configured.""" 185 | 186 | def validate(obj: dict[str, Any]) -> dict[str, Any]: 187 | """Check this condition.""" 188 | if ( 189 | CONF_INITIAL_HVAC_MODE in obj 190 | and obj[CONF_INITIAL_HVAC_MODE] != HVACMode.OFF 191 | and obj[CONF_INITIAL_HVAC_MODE] not in obj.keys() 192 | ): 193 | raise vol.Invalid( 194 | "You cannot set an initial HVAC mode if you did not configure this mode {}".format( 195 | obj[CONF_INITIAL_HVAC_MODE] 196 | ) 197 | ) 198 | return obj 199 | 200 | return validate 201 | 202 | 203 | # datetime.strptime(passive_switch_time, "%H:%M:%S") 204 | def validate_stuck_time(*keys: str) -> Callable: 205 | """Convert the time string to a datetime.""" 206 | 207 | def validate(obj: dict[str, Any]) -> dict[str, Any]: 208 | """Check this condition.""" 209 | try: 210 | obj[CONF_PASSIVE_CHECK_TIME] = datetime.strptime( 211 | obj[CONF_PASSIVE_CHECK_TIME], "%H:%M" 212 | ) 213 | return obj 214 | 215 | except ValueError: 216 | raise vol.Invalid( 217 | "Stuck switch check provided time %s not valid", 218 | obj[CONF_PASSIVE_CHECK_TIME], 219 | ) 220 | 221 | return validate 222 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/platform_schema.py: -------------------------------------------------------------------------------- 1 | """Constants used for multizone thermostat.""" 2 | import voluptuous as vol 3 | 4 | from homeassistant.components.climate import PLATFORM_SCHEMA, PRESET_NONE, HVACMode 5 | from homeassistant.const import ( 6 | CONF_ENTITY_ID, 7 | CONF_NAME, 8 | CONF_UNIQUE_ID, 9 | PRECISION_HALVES, 10 | PRECISION_TENTHS, 11 | PRECISION_WHOLE, 12 | ) 13 | import homeassistant.helpers.config_validation as cv 14 | 15 | from . import validations as val 16 | from .const import ( 17 | CONF_AREA, 18 | CONF_CONTINUOUS_LOWER_LOAD, 19 | CONF_CONTROL_REFRESH_INTERVAL, 20 | CONF_DETAILED_OUTPUT, 21 | CONF_ENABLE_OLD_INTEGRAL, 22 | CONF_ENABLE_OLD_PARAMETERS, 23 | CONF_ENABLE_OLD_STATE, 24 | CONF_EXTRA_PRESETS, 25 | CONF_FILTER_MODE, 26 | CONF_HYSTERESIS_TOLERANCE_OFF, 27 | CONF_HYSTERESIS_TOLERANCE_ON, 28 | CONF_INCLUDE_VALVE_LAG, 29 | CONF_INITIAL_HVAC_MODE, 30 | CONF_INITIAL_PRESET_MODE, 31 | CONF_KA, 32 | CONF_KB, 33 | CONF_KD, 34 | CONF_KI, 35 | CONF_KP, 36 | CONF_MASTER_MODE, 37 | CONF_MASTER_OPERATION_MODE, 38 | CONF_MASTER_SCALE_BOUND, 39 | CONF_MIN_CYCLE_DURATION, 40 | CONF_MIN_VALVE, 41 | CONF_ON_OFF_MODE, 42 | CONF_PASSIVE_CHECK_TIME, 43 | CONF_PASSIVE_SWITCH_CHECK, 44 | CONF_PASSIVE_SWITCH_DURATION, 45 | CONF_PASSIVE_SWITCH_OPEN_TIME, 46 | CONF_PID_MODE, 47 | CONF_PRECISION, 48 | CONF_PROPORTIONAL_MODE, 49 | CONF_PWM_DURATION, 50 | CONF_PWM_RESOLUTION, 51 | CONF_PWM_SCALE, 52 | CONF_PWM_SCALE_HIGH, 53 | CONF_PWM_SCALE_LOW, 54 | CONF_PWM_THRESHOLD, 55 | CONF_SATELITES, 56 | CONF_SENSOR, 57 | CONF_SENSOR_OUT, 58 | CONF_STALE_DURATION, 59 | CONF_SWITCH_MODE, 60 | CONF_TARGET_TEMP_INIT, 61 | CONF_TARGET_TEMP_MAX, 62 | CONF_TARGET_TEMP_MIN, 63 | CONF_WC_MODE, 64 | CONF_WINDOW_OPEN_TEMPDROP, 65 | DEFAULT_AREA, 66 | DEFAULT_DETAILED_OUTPUT, 67 | DEFAULT_INCLUDE_VALVE_LAG, 68 | DEFAULT_MASTER_SCALE_BOUND, 69 | DEFAULT_MAX_TEMP_COOL, 70 | DEFAULT_MAX_TEMP_HEAT, 71 | DEFAULT_MIN_DIFF, 72 | DEFAULT_MIN_LOAD, 73 | DEFAULT_MIN_TEMP_COOL, 74 | DEFAULT_MIN_TEMP_HEAT, 75 | DEFAULT_MIN_VALVE_PWM, 76 | DEFAULT_OLD_STATE, 77 | DEFAULT_OPERATION, 78 | DEFAULT_PASSIVE_CHECK_TIME, 79 | DEFAULT_PASSIVE_SWITCH, 80 | DEFAULT_PASSIVE_SWITCH_OPEN_TIME, 81 | DEFAULT_PWM, 82 | DEFAULT_PWM_RESOLUTION, 83 | DEFAULT_PWM_SCALE, 84 | DEFAULT_RESTORE_INTEGRAL, 85 | DEFAULT_RESTORE_PARAMETERS, 86 | DEFAULT_SENSOR_FILTER, 87 | DEFAULT_TARGET_TEMP_COOL, 88 | DEFAULT_TARGET_TEMP_HEAT, 89 | NC_SWITCH_MODE, 90 | NO_SWITCH_MODE, 91 | NestingMode, 92 | OperationMode, 93 | ) 94 | 95 | SUPPORTED_HVAC_MODES = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF] 96 | 97 | # Configuration of thermostats 98 | hvac_control_options = { 99 | vol.Required(CONF_ENTITY_ID): cv.entity_id, # switch to control 100 | vol.Optional(CONF_SWITCH_MODE, default=NC_SWITCH_MODE): vol.In( 101 | [NC_SWITCH_MODE, NO_SWITCH_MODE] 102 | ), 103 | vol.Optional(CONF_PASSIVE_SWITCH_DURATION): vol.All( 104 | cv.time_period, cv.positive_timedelta 105 | ), 106 | vol.Optional( 107 | CONF_PASSIVE_SWITCH_OPEN_TIME, default=DEFAULT_PASSIVE_SWITCH_OPEN_TIME 108 | ): vol.All(cv.time_period, cv.positive_timedelta), 109 | vol.Optional(CONF_EXTRA_PRESETS, default={}): vol.Schema(dict), 110 | } 111 | 112 | controller_config = { 113 | vol.Required(CONF_CONTROL_REFRESH_INTERVAL): vol.All( 114 | cv.time_period, cv.positive_timedelta 115 | ), 116 | vol.Optional(CONF_PWM_DURATION, default=DEFAULT_PWM): vol.All( 117 | cv.time_period, cv.positive_timedelta 118 | ), 119 | vol.Optional(CONF_PWM_SCALE, default=DEFAULT_PWM_SCALE): vol.Coerce(float), 120 | vol.Optional(CONF_PWM_RESOLUTION, default=DEFAULT_PWM_RESOLUTION): vol.Coerce( 121 | float 122 | ), 123 | vol.Optional(CONF_PWM_THRESHOLD, default=DEFAULT_MIN_DIFF): vol.Coerce(float), 124 | vol.Optional( 125 | CONF_MASTER_SCALE_BOUND, default=DEFAULT_MASTER_SCALE_BOUND 126 | ): cv.positive_float, 127 | } 128 | 129 | 130 | PID_control_options = { 131 | vol.Required(CONF_KP): vol.Coerce(float), 132 | vol.Required(CONF_KI): vol.Coerce(float), 133 | vol.Required(CONF_KD): vol.Coerce(float), 134 | vol.Optional(CONF_PWM_SCALE_LOW): vol.Coerce(float), 135 | vol.Optional(CONF_PWM_SCALE_HIGH): vol.Coerce(float), 136 | vol.Optional(CONF_WINDOW_OPEN_TEMPDROP): vol.Coerce(float), 137 | } 138 | 139 | WC_control_options = { 140 | vol.Required(CONF_KA): vol.Coerce(float), 141 | vol.Required(CONF_KB): vol.Coerce(float), 142 | vol.Optional(CONF_PWM_SCALE_LOW): vol.Coerce(float), 143 | vol.Optional(CONF_PWM_SCALE_HIGH): vol.Coerce(float), 144 | } 145 | 146 | # on_off 147 | on_off = { 148 | vol.Required(CONF_HYSTERESIS_TOLERANCE_ON): vol.Coerce(float), 149 | vol.Required(CONF_HYSTERESIS_TOLERANCE_OFF): vol.Coerce(float), 150 | vol.Optional(CONF_MIN_CYCLE_DURATION): vol.All( 151 | cv.time_period, cv.positive_timedelta 152 | ), 153 | vol.Optional(CONF_CONTROL_REFRESH_INTERVAL): vol.All( 154 | cv.time_period, cv.positive_timedelta 155 | ), 156 | } 157 | 158 | temp_set_heat = { 159 | vol.Optional(CONF_TARGET_TEMP_MIN, default=DEFAULT_MIN_TEMP_HEAT): vol.Coerce( 160 | float 161 | ), 162 | vol.Optional(CONF_TARGET_TEMP_MAX, default=DEFAULT_MAX_TEMP_HEAT): vol.Coerce( 163 | float 164 | ), 165 | vol.Optional(CONF_TARGET_TEMP_INIT, default=DEFAULT_TARGET_TEMP_HEAT): vol.Coerce( 166 | float 167 | ), 168 | } 169 | 170 | temp_set_cool = { 171 | vol.Optional(CONF_TARGET_TEMP_MIN, default=DEFAULT_MIN_TEMP_COOL): vol.Coerce( 172 | float 173 | ), 174 | vol.Optional(CONF_TARGET_TEMP_MAX, default=DEFAULT_MAX_TEMP_COOL): vol.Coerce( 175 | float 176 | ), 177 | vol.Optional(CONF_TARGET_TEMP_INIT, default=DEFAULT_TARGET_TEMP_COOL): vol.Coerce( 178 | float 179 | ), 180 | } 181 | 182 | on_off_heat = {vol.Optional(CONF_ON_OFF_MODE): vol.Schema({**on_off})} 183 | on_off_cool = {vol.Optional(CONF_ON_OFF_MODE): vol.Schema({**on_off})} 184 | 185 | # proportional mode" 186 | prop = { 187 | **controller_config, 188 | vol.Optional(CONF_PID_MODE): vol.Schema(PID_control_options), 189 | vol.Optional(CONF_WC_MODE): vol.Schema(WC_control_options), 190 | } 191 | 192 | prop_heat = {vol.Optional(CONF_PROPORTIONAL_MODE): vol.Schema({**prop})} 193 | prop_cool = {vol.Optional(CONF_PROPORTIONAL_MODE): vol.Schema({**prop})} 194 | 195 | master = { 196 | vol.Optional(CONF_MASTER_MODE): vol.Schema( 197 | { 198 | vol.Required(CONF_SATELITES): cv.ensure_list, 199 | vol.Optional(CONF_MASTER_OPERATION_MODE, default=DEFAULT_OPERATION): vol.In( 200 | [ 201 | NestingMode.MASTER_BALANCED, 202 | NestingMode.MASTER_MIN_ON, 203 | NestingMode.MASTER_CONTINUOUS, 204 | ] 205 | ), 206 | vol.Optional( 207 | CONF_INCLUDE_VALVE_LAG, default=DEFAULT_INCLUDE_VALVE_LAG 208 | ): vol.All(cv.time_period, cv.positive_timedelta), 209 | **controller_config, 210 | vol.Optional( 211 | CONF_CONTINUOUS_LOWER_LOAD, default=DEFAULT_MIN_LOAD 212 | ): vol.Coerce(float), 213 | vol.Optional(CONF_MIN_VALVE, default=DEFAULT_MIN_VALVE_PWM): vol.Coerce( 214 | float 215 | ), 216 | } 217 | ) 218 | } 219 | 220 | hvac_control_heat = { 221 | **hvac_control_options, 222 | **temp_set_heat, 223 | **on_off_heat, 224 | **prop_heat, 225 | **master, 226 | } 227 | 228 | hvac_control_cool = { 229 | **hvac_control_options, 230 | **temp_set_cool, 231 | **on_off_cool, 232 | **prop_cool, 233 | **master, 234 | } 235 | 236 | PLATFORM_SCHEMA = vol.All( 237 | cv.has_at_least_one_key(HVACMode.HEAT, HVACMode.COOL), 238 | val.validate_initial_hvac_mode(), 239 | val.validate_initial_preset_mode(), 240 | val.validate_initial_control_mode(), 241 | val.validate_initial_sensors(), 242 | val.validate_window(), 243 | PLATFORM_SCHEMA.extend( 244 | { 245 | vol.Optional(CONF_NAME, default=OperationMode.MASTER): cv.string, 246 | vol.Optional(CONF_UNIQUE_ID): cv.string, 247 | vol.Optional(CONF_SENSOR): cv.entity_id, 248 | vol.Optional(CONF_FILTER_MODE, default=DEFAULT_SENSOR_FILTER): vol.Coerce( 249 | int 250 | ), 251 | vol.Optional(CONF_SENSOR_OUT): cv.entity_id, 252 | vol.Optional(CONF_INITIAL_HVAC_MODE, default=HVACMode.OFF): vol.In( 253 | SUPPORTED_HVAC_MODES 254 | ), 255 | vol.Optional(CONF_INITIAL_PRESET_MODE, default=PRESET_NONE): cv.string, 256 | vol.Optional(CONF_PRECISION): vol.In( 257 | [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] 258 | ), 259 | vol.Optional(CONF_AREA, default=DEFAULT_AREA): vol.Coerce(float), 260 | vol.Optional( 261 | CONF_DETAILED_OUTPUT, default=DEFAULT_DETAILED_OUTPUT 262 | ): cv.boolean, 263 | vol.Optional(CONF_STALE_DURATION): vol.All( 264 | cv.time_period, cv.positive_timedelta 265 | ), 266 | vol.Optional( 267 | CONF_PASSIVE_SWITCH_CHECK, default=DEFAULT_PASSIVE_SWITCH 268 | ): cv.boolean, 269 | vol.Optional( 270 | CONF_PASSIVE_CHECK_TIME, default=DEFAULT_PASSIVE_CHECK_TIME 271 | ): vol.Datetime(format="%H:%M"), 272 | vol.Optional(CONF_ENABLE_OLD_STATE, default=DEFAULT_OLD_STATE): cv.boolean, 273 | vol.Optional( 274 | CONF_ENABLE_OLD_PARAMETERS, default=DEFAULT_RESTORE_PARAMETERS 275 | ): cv.boolean, 276 | vol.Optional( 277 | CONF_ENABLE_OLD_INTEGRAL, default=DEFAULT_RESTORE_INTEGRAL 278 | ): cv.boolean, 279 | vol.Optional(str(HVACMode.HEAT)): vol.Schema(hvac_control_heat), 280 | vol.Optional(str(HVACMode.COOL)): vol.Schema(hvac_control_cool), 281 | } 282 | ), 283 | val.validate_stuck_time(), 284 | ) 285 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/UKF_filter/helpers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | #pylint: disable=invalid-name, bare-except 3 | 4 | """Copyright 2015 Roger R Labbe Jr. 5 | 6 | FilterPy library. 7 | http://github.com/rlabbe/filterpy 8 | 9 | Documentation at: 10 | https://filterpy.readthedocs.org 11 | 12 | Supporting book at: 13 | https://github.com/rlabbe/Kalman-and-Bayesian-Filters-in-Python 14 | 15 | This is licensed under an MIT license. See the readme.MD file 16 | for more information. 17 | """ 18 | 19 | from collections import defaultdict 20 | import copy 21 | import inspect 22 | import numpy as np 23 | 24 | class Saver(object): 25 | """ 26 | Helper class to save the states of any filter object. 27 | Each time you call save() all of the attributes (state, covariances, etc) 28 | are appended to lists. 29 | 30 | Generally you would do this once per epoch - predict/update. 31 | 32 | Then, you can access any of the states by using the [] syntax or by 33 | using the . operator. 34 | 35 | .. code-block:: Python 36 | 37 | my_saver = Saver() 38 | ... do some filtering 39 | 40 | x = my_saver['x'] 41 | x = my_save.x 42 | 43 | Either returns a list of all of the state `x` values for the entire 44 | filtering process. 45 | 46 | If you want to convert all saved lists into numpy arrays, call to_array(). 47 | 48 | 49 | Parameters 50 | ---------- 51 | 52 | kf : object 53 | any object with a __dict__ attribute, but intended to be one of the 54 | filtering classes 55 | 56 | save_current : bool, default=False 57 | save the current state of `kf` when the object is created; 58 | 59 | skip_private: bool, default=False 60 | Control skipping any private attribute (anything starting with '_') 61 | Turning this on saves memory, but slows down execution a bit. 62 | 63 | skip_callable: bool, default=False 64 | Control skipping any attribute which is a method. Turning this on 65 | saves memory, but slows down execution a bit. 66 | 67 | ignore: (str,) tuple of strings 68 | list of keys to ignore. 69 | 70 | Examples 71 | -------- 72 | 73 | .. code-block:: Python 74 | 75 | kf = KalmanFilter(...whatever) 76 | # initialize kf here 77 | 78 | saver = Saver(kf) # save data for kf filter 79 | for z in zs: 80 | kf.predict() 81 | kf.update(z) 82 | saver.save() 83 | 84 | x = np.array(s.x) # get the kf.x state in an np.array 85 | plt.plot(x[:, 0], x[:, 2]) 86 | 87 | # ... or ... 88 | s.to_array() 89 | plt.plot(s.x[:, 0], s.x[:, 2]) 90 | 91 | """ 92 | 93 | def __init__(self, kf, save_current=False, 94 | skip_private=False, 95 | skip_callable=False, 96 | ignore=()): 97 | """ Construct the save object, optionally saving the current 98 | state of the filter""" 99 | #pylint: disable=too-many-arguments 100 | 101 | self._kf = kf 102 | self._DL = defaultdict(list) 103 | self._skip_private = skip_private 104 | self._skip_callable = skip_callable 105 | self._ignore = ignore 106 | self._len = 0 107 | 108 | # need to save all properties since it is possible that the property 109 | # is computed only on access. I use this trick a lot to minimize 110 | # computing unused information. 111 | self.properties = inspect.getmembers( 112 | type(kf), lambda o: isinstance(o, property)) 113 | 114 | if save_current: 115 | self.save() 116 | 117 | def save(self): 118 | """ save the current state of the Kalman filter""" 119 | 120 | kf = self._kf 121 | 122 | # force all attributes to be computed. this is only necessary 123 | # if the class uses properties that compute data only when 124 | # accessed 125 | for prop in self.properties: 126 | self._DL[prop[0]].append(getattr(kf, prop[0])) 127 | 128 | v = copy.deepcopy(kf.__dict__) 129 | 130 | if self._skip_private: 131 | for key in list(v.keys()): 132 | if key.startswith('_'): 133 | print('deleting', key) 134 | del v[key] 135 | 136 | if self._skip_callable: 137 | for key in list(v.keys()): 138 | if callable(v[key]): 139 | del v[key] 140 | 141 | for ig in self._ignore: 142 | if ig in v: 143 | del v[ig] 144 | 145 | for key in list(v.keys()): 146 | self._DL[key].append(v[key]) 147 | 148 | self.__dict__.update(self._DL) 149 | self._len += 1 150 | 151 | def __getitem__(self, key): 152 | return self._DL[key] 153 | 154 | def __len__(self): 155 | return self._len 156 | 157 | @property 158 | def keys(self): 159 | """ list of all keys""" 160 | return list(self._DL.keys()) 161 | 162 | def to_array(self): 163 | """ 164 | Convert all saved attributes from a list to np.array. 165 | 166 | This may or may not work - every saved attribute must have the 167 | same shape for every instance. i.e., if `K` changes shape due to `z` 168 | changing shape then the call will raise an exception. 169 | 170 | This can also happen if the default initialization in __init__ gives 171 | the variable a different shape then it becomes after a predict/update 172 | cycle. 173 | """ 174 | for key in self.keys: 175 | try: 176 | self.__dict__[key] = np.array(self._DL[key]) 177 | except: 178 | # get back to lists so we are in a valid state 179 | self.__dict__.update(self._DL) 180 | 181 | raise ValueError( 182 | "could not convert {} into np.array".format(key)) 183 | 184 | def flatten(self): 185 | """ 186 | Flattens any np.array of column vectors into 1D arrays. Basically, 187 | this makes data readable for humans if you are just inspecting via 188 | the REPL. For example, if you have saved a KalmanFilter object with 89 189 | epochs, self.x will be shape (89, 9, 1) (for example). After flatten 190 | is run, self.x.shape == (89, 9), which displays nicely from the REPL. 191 | 192 | There is no way to unflatten, so it's a one way trip. 193 | """ 194 | 195 | for key in self.keys: 196 | try: 197 | arr = self.__dict__[key] 198 | shape = arr.shape 199 | if shape[2] == 1: 200 | self.__dict__[key] = arr.reshape(shape[0], shape[1]) 201 | except: 202 | # not an ndarray or not a column vector 203 | pass 204 | 205 | def __repr__(self): 206 | return ''.format( 207 | hex(id(self)), ' '.join(self.keys)) 208 | 209 | 210 | def runge_kutta4(y, x, dx, f): 211 | """computes 4th order Runge-Kutta for dy/dx. 212 | 213 | Parameters 214 | ---------- 215 | 216 | y : scalar 217 | Initial/current value for y 218 | x : scalar 219 | Initial/current value for x 220 | dx : scalar 221 | difference in x (e.g. the time step) 222 | f : ufunc(y,x) 223 | Callable function (y, x) that you supply to compute dy/dx for 224 | the specified values. 225 | 226 | """ 227 | 228 | k1 = dx * f(y, x) 229 | k2 = dx * f(y + 0.5*k1, x + 0.5*dx) 230 | k3 = dx * f(y + 0.5*k2, x + 0.5*dx) 231 | k4 = dx * f(y + k3, x + dx) 232 | 233 | return y + (k1 + 2*k2 + 2*k3 + k4) / 6. 234 | 235 | 236 | def pretty_str(label, arr): 237 | """ 238 | Generates a pretty printed NumPy array with an assignment. Optionally 239 | transposes column vectors so they are drawn on one line. Strictly speaking 240 | arr can be any time convertible by `str(arr)`, but the output may not 241 | be what you want if the type of the variable is not a scalar or an 242 | ndarray. 243 | 244 | Examples 245 | -------- 246 | >>> pprint('cov', np.array([[4., .1], [.1, 5]])) 247 | cov = [[4. 0.1] 248 | [0.1 5. ]] 249 | 250 | >>> print(pretty_str('x', np.array([[1], [2], [3]]))) 251 | x = [[1 2 3]].T 252 | """ 253 | 254 | def is_col(a): 255 | """ return true if a is a column vector""" 256 | try: 257 | return a.shape[0] > 1 and a.shape[1] == 1 258 | except (AttributeError, IndexError): 259 | return False 260 | 261 | if label is None: 262 | label = '' 263 | 264 | if label: 265 | label += ' = ' 266 | 267 | if is_col(arr): 268 | return label + str(arr.T).replace('\n', '') + '.T' 269 | 270 | rows = str(arr).split('\n') 271 | if not rows: 272 | return '' 273 | 274 | s = label + rows[0] 275 | pad = ' ' * len(label) 276 | for line in rows[1:]: 277 | s = s + '\n' + pad + line 278 | 279 | return s 280 | 281 | 282 | def pprint(label, arr, **kwargs): 283 | """ pretty prints an NumPy array using the function pretty_str. Keyword 284 | arguments are passed to the print() function. 285 | 286 | See Also 287 | -------- 288 | pretty_str 289 | 290 | Examples 291 | -------- 292 | >>> pprint('cov', np.array([[4., .1], [.1, 5]])) 293 | cov = [[4. 0.1] 294 | [0.1 5. ]] 295 | """ 296 | 297 | print(pretty_str(label, arr), **kwargs) 298 | 299 | 300 | def reshape_z(z, dim_z, ndim): 301 | """ ensure z is a (dim_z, 1) shaped vector""" 302 | 303 | z = np.atleast_2d(z) 304 | if z.shape[1] == dim_z: 305 | z = z.T 306 | 307 | if z.shape != (dim_z, 1): 308 | raise ValueError('z (shape {}) must be convertible to shape ({}, 1)'.format(z.shape, dim_z)) 309 | 310 | if ndim == 1: 311 | z = z[:, 0] 312 | 313 | if ndim == 0: 314 | z = z[0, 0] 315 | 316 | return z 317 | 318 | 319 | def inv_diagonal(S): 320 | """ 321 | Computes the inverse of a diagonal NxN np.array S. In general this will 322 | be much faster than calling np.linalg.inv(). 323 | 324 | However, does NOT check if the off diagonal elements are non-zero. So long 325 | as S is truly diagonal, the output is identical to np.linalg.inv(). 326 | 327 | Parameters 328 | ---------- 329 | S : np.array 330 | diagonal NxN array to take inverse of 331 | 332 | Returns 333 | ------- 334 | S_inv : np.array 335 | inverse of S 336 | 337 | 338 | Examples 339 | -------- 340 | 341 | This is meant to be used as a replacement inverse function for 342 | the KalmanFilter class when you know the system covariance S is 343 | diagonal. It just makes the filter run faster, there is 344 | 345 | >>> kf = KalmanFilter(dim_x=3, dim_z=1) 346 | >>> kf.inv = inv_diagonal # S is 1x1, so safely diagonal 347 | """ 348 | 349 | S = np.asarray(S) 350 | 351 | if S.ndim != 2 or S.shape[0] != S.shape[1]: 352 | raise ValueError('S must be a square Matrix') 353 | 354 | si = np.zeros(S.shape) 355 | for i in range(len(S)): 356 | si[i, i] = 1. / S[i, i] 357 | return si 358 | 359 | 360 | def outer_product_sum(A, B=None): 361 | r""" 362 | Computes the sum of the outer products of the rows in A and B 363 | 364 | P = \Sum {A[i] B[i].T} for i in 0..N 365 | 366 | Notionally: 367 | 368 | P = 0 369 | for y in A: 370 | P += np.outer(y, y) 371 | 372 | This is a standard computation for sigma points used in the UKF, ensemble 373 | Kalman filter, etc., where A would be the residual of the sigma points 374 | and the filter's state or measurement. 375 | 376 | The computation is vectorized, so it is much faster than the for loop 377 | for large A. 378 | 379 | Parameters 380 | ---------- 381 | A : np.array, shape (M, N) 382 | rows of N-vectors to have the outer product summed 383 | 384 | B : np.array, shape (M, N) 385 | rows of N-vectors to have the outer product summed 386 | If it is `None`, it is set to A. 387 | 388 | Returns 389 | ------- 390 | P : np.array, shape(N, N) 391 | sum of the outer product of the rows of A and B 392 | 393 | Examples 394 | -------- 395 | 396 | Here sigmas is of shape (M, N), and x is of shape (N). The two sets of 397 | code compute the same thing. 398 | 399 | >>> P = outer_product_sum(sigmas - x) 400 | >>> 401 | >>> P = 0 402 | >>> for s in sigmas: 403 | >>> y = s - x 404 | >>> P += np.outer(y, y) 405 | """ 406 | 407 | if B is None: 408 | B = A 409 | 410 | outer = np.einsum('ij,ik->ijk', A, B) 411 | return np.sum(outer, axis=0) 412 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/UKF_filter/sigma_points.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=invalid-name, too-many-instance-attributes 3 | 4 | """Copyright 2015 Roger R Labbe Jr. 5 | 6 | FilterPy library. 7 | http://github.com/rlabbe/filterpy 8 | 9 | Documentation at: 10 | https://filterpy.readthedocs.org 11 | 12 | Supporting book at: 13 | https://github.com/rlabbe/Kalman-and-Bayesian-Filters-in-Python 14 | 15 | This is licensed under an MIT license. See the readme.MD file 16 | for more information. 17 | """ 18 | import numpy as np 19 | from .cholesky import cholesky 20 | from .helpers import pretty_str 21 | 22 | class MerweScaledSigmaPoints(object): 23 | 24 | """ 25 | Generates sigma points and weights according to Van der Merwe's 26 | 2004 dissertation[1] for the UnscentedKalmanFilter class.. It 27 | parametizes the sigma points using alpha, beta, kappa terms, and 28 | is the version seen in most publications. 29 | 30 | Unless you know better, this should be your default choice. 31 | 32 | Parameters 33 | ---------- 34 | 35 | n : int 36 | Dimensionality of the state. 2n+1 weights will be generated. 37 | 38 | alpha : float 39 | Determins the spread of the sigma points around the mean. 40 | Usually a small positive value (1e-3) according to [3]. 41 | 42 | beta : float 43 | Incorporates prior knowledge of the distribution of the mean. For 44 | Gaussian x beta=2 is optimal, according to [3]. 45 | 46 | kappa : float, default=0.0 47 | Secondary scaling parameter usually set to 0 according to [4], 48 | or to 3-n according to [5]. 49 | 50 | sqrt_method : function(ndarray), default=scipy.linalg.cholesky 51 | Defines how we compute the square root of a matrix, which has 52 | no unique answer. Cholesky is the default choice due to its 53 | speed. Typically your alternative choice will be 54 | scipy.linalg.sqrtm. Different choices affect how the sigma points 55 | are arranged relative to the eigenvectors of the covariance matrix. 56 | Usually this will not matter to you; if so the default cholesky() 57 | yields maximal performance. As of van der Merwe's dissertation of 58 | 2004 [6] this was not a well reseached area so I have no advice 59 | to give you. 60 | 61 | If your method returns a triangular matrix it must be upper 62 | triangular. Do not use numpy.linalg.cholesky - for historical 63 | reasons it returns a lower triangular matrix. The SciPy version 64 | does the right thing. 65 | 66 | subtract : callable (x, y), optional 67 | Function that computes the difference between x and y. 68 | You will have to supply this if your state variable cannot support 69 | subtraction, such as angles (359-1 degreees is 2, not 358). x and y 70 | are state vectors, not scalars. 71 | 72 | Attributes 73 | ---------- 74 | 75 | Wm : np.array 76 | weight for each sigma point for the mean 77 | 78 | Wc : np.array 79 | weight for each sigma point for the covariance 80 | 81 | Examples 82 | -------- 83 | 84 | See my book Kalman and Bayesian Filters in Python 85 | https://github.com/rlabbe/Kalman-and-Bayesian-Filters-in-Python 86 | 87 | 88 | References 89 | ---------- 90 | 91 | .. [1] R. Van der Merwe "Sigma-Point Kalman Filters for Probabilitic 92 | Inference in Dynamic State-Space Models" (Doctoral dissertation) 93 | 94 | """ 95 | 96 | 97 | def __init__(self, n, alpha, beta, kappa, sqrt_method=None, subtract=None): 98 | #pylint: disable=too-many-arguments 99 | 100 | self.n = n 101 | self.alpha = alpha 102 | self.beta = beta 103 | self.kappa = kappa 104 | if sqrt_method is None: 105 | self.sqrt = cholesky 106 | else: 107 | self.sqrt = sqrt_method 108 | 109 | if subtract is None: 110 | self.subtract = np.subtract 111 | else: 112 | self.subtract = subtract 113 | 114 | self._compute_weights() 115 | 116 | 117 | def num_sigmas(self): 118 | """ Number of sigma points for each variable in the state x""" 119 | return 2*self.n + 1 120 | 121 | 122 | def sigma_points(self, x, P): 123 | """ Computes the sigma points for an unscented Kalman filter 124 | given the mean (x) and covariance(P) of the filter. 125 | Returns tuple of the sigma points and weights. 126 | 127 | Works with both scalar and array inputs: 128 | sigma_points (5, 9, 2) # mean 5, covariance 9 129 | sigma_points ([5, 2], 9*eye(2), 2) # means 5 and 2, covariance 9I 130 | 131 | Parameters 132 | ---------- 133 | 134 | x : An array-like object of the means of length n 135 | Can be a scalar if 1D. 136 | examples: 1, [1,2], np.array([1,2]) 137 | 138 | P : scalar, or np.array 139 | Covariance of the filter. If scalar, is treated as eye(n)*P. 140 | 141 | Returns 142 | ------- 143 | 144 | sigmas : np.array, of size (n, 2n+1) 145 | Two dimensional array of sigma points. Each column contains all of 146 | the sigmas for one dimension in the problem space. 147 | 148 | Ordered by Xi_0, Xi_{1..n}, Xi_{n+1..2n} 149 | """ 150 | 151 | if self.n != np.size(x): 152 | raise ValueError("expected size(x) {}, but size is {}".format( 153 | self.n, np.size(x))) 154 | 155 | n = self.n 156 | 157 | if np.isscalar(x): 158 | x = np.asarray([x]) 159 | 160 | if np.isscalar(P): 161 | P = np.eye(n)*P 162 | else: 163 | P = np.atleast_2d(P) 164 | 165 | lambda_ = self.alpha**2 * (n + self.kappa) - n 166 | # print('fupy -sigma',lambda_ , n,P) 167 | U = np.array(self.sqrt((lambda_ + n)*P)) 168 | # print('fupy sigma',type(U)) 169 | 170 | sigmas = np.zeros((2*n+1, n)) 171 | sigmas[0] = x 172 | for k in range(n): 173 | # pylint: disable=bad-whitespace 174 | sigmas[k+1] = self.subtract(x, -U[k]) 175 | sigmas[n+k+1] = self.subtract(x, U[k]) 176 | 177 | return sigmas 178 | 179 | 180 | def _compute_weights(self): 181 | """ Computes the weights for the scaled unscented Kalman filter. 182 | 183 | """ 184 | 185 | n = self.n 186 | lambda_ = self.alpha**2 * (n +self.kappa) - n 187 | 188 | c = .5 / (n + lambda_) 189 | self.Wc = np.full(2*n + 1, c) 190 | self.Wm = np.full(2*n + 1, c) 191 | self.Wc[0] = lambda_ / (n + lambda_) + (1 - self.alpha**2 + self.beta) 192 | self.Wm[0] = lambda_ / (n + lambda_) 193 | 194 | 195 | 196 | def __repr__(self): 197 | 198 | return '\n'.join([ 199 | 'MerweScaledSigmaPoints object', 200 | pretty_str('n', self.n), 201 | pretty_str('alpha', self.alpha), 202 | pretty_str('beta', self.beta), 203 | pretty_str('kappa', self.kappa), 204 | pretty_str('Wm', self.Wm), 205 | pretty_str('Wc', self.Wc), 206 | pretty_str('subtract', self.subtract), 207 | pretty_str('sqrt', self.sqrt) 208 | ]) 209 | 210 | 211 | class JulierSigmaPoints(object): 212 | """ 213 | Generates sigma points and weights according to Simon J. Julier 214 | and Jeffery K. Uhlmann's original paper[1]. It parametizes the sigma 215 | points using kappa. 216 | 217 | Parameters 218 | ---------- 219 | 220 | n : int 221 | Dimensionality of the state. 2n+1 weights will be generated. 222 | 223 | kappa : float, default=0. 224 | Scaling factor that can reduce high order errors. kappa=0 gives 225 | the standard unscented filter. According to [Julier], if you set 226 | kappa to 3-dim_x for a Gaussian x you will minimize the fourth 227 | order errors in x and P. 228 | 229 | sqrt_method : function(ndarray), default=scipy.linalg.cholesky 230 | Defines how we compute the square root of a matrix, which has 231 | no unique answer. Cholesky is the default choice due to its 232 | speed. Typically your alternative choice will be 233 | scipy.linalg.sqrtm. Different choices affect how the sigma points 234 | are arranged relative to the eigenvectors of the covariance matrix. 235 | Usually this will not matter to you; if so the default cholesky() 236 | yields maximal performance. As of van der Merwe's dissertation of 237 | 2004 [6] this was not a well reseached area so I have no advice 238 | to give you. 239 | 240 | If your method returns a triangular matrix it must be upper 241 | triangular. Do not use numpy.linalg.cholesky - for historical 242 | reasons it returns a lower triangular matrix. The SciPy version 243 | does the right thing. 244 | 245 | subtract : callable (x, y), optional 246 | Function that computes the difference between x and y. 247 | You will have to supply this if your state variable cannot support 248 | subtraction, such as angles (359-1 degreees is 2, not 358). x and y 249 | 250 | Attributes 251 | ---------- 252 | 253 | Wm : np.array 254 | weight for each sigma point for the mean 255 | 256 | Wc : np.array 257 | weight for each sigma point for the covariance 258 | 259 | References 260 | ---------- 261 | 262 | .. [1] Julier, Simon J.; Uhlmann, Jeffrey "A New Extension of the Kalman 263 | Filter to Nonlinear Systems". Proc. SPIE 3068, Signal Processing, 264 | Sensor Fusion, and Target Recognition VI, 182 (July 28, 1997) 265 | """ 266 | 267 | def __init__(self, n, kappa=0., sqrt_method=None, subtract=None): 268 | 269 | self.n = n 270 | self.kappa = kappa 271 | if sqrt_method is None: 272 | self.sqrt = cholesky 273 | else: 274 | self.sqrt = sqrt_method 275 | 276 | if subtract is None: 277 | self.subtract = np.subtract 278 | else: 279 | self.subtract = subtract 280 | 281 | self._compute_weights() 282 | 283 | 284 | def num_sigmas(self): 285 | """ Number of sigma points for each variable in the state x""" 286 | return 2*self.n + 1 287 | 288 | 289 | def sigma_points(self, x, P): 290 | r""" Computes the sigma points for an unscented Kalman filter 291 | given the mean (x) and covariance(P) of the filter. 292 | kappa is an arbitrary constant. Returns sigma points. 293 | 294 | Works with both scalar and array inputs: 295 | sigma_points (5, 9, 2) # mean 5, covariance 9 296 | sigma_points ([5, 2], 9*eye(2), 2) # means 5 and 2, covariance 9I 297 | 298 | Parameters 299 | ---------- 300 | 301 | x : array-like object of the means of length n 302 | Can be a scalar if 1D. 303 | examples: 1, [1,2], np.array([1,2]) 304 | 305 | P : scalar, or np.array 306 | Covariance of the filter. If scalar, is treated as eye(n)*P. 307 | 308 | kappa : float 309 | Scaling factor. 310 | 311 | Returns 312 | ------- 313 | 314 | sigmas : np.array, of size (n, 2n+1) 315 | 2D array of sigma points :math:`\chi`. Each column contains all of 316 | the sigmas for one dimension in the problem space. They 317 | are ordered as: 318 | 319 | .. math:: 320 | :nowrap: 321 | 322 | \begin{eqnarray} 323 | \chi[0] = &x \\ 324 | \chi[1..n] = &x + [\sqrt{(n+\kappa)P}]_k \\ 325 | \chi[n+1..2n] = &x - [\sqrt{(n+\kappa)P}]_k 326 | \end{eqnarray} 327 | 328 | """ 329 | 330 | if self.n != np.size(x): 331 | raise ValueError("expected size(x) {}, but size is {}".format( 332 | self.n, np.size(x))) 333 | 334 | n = self.n 335 | 336 | if np.isscalar(x): 337 | x = np.asarray([x]) 338 | 339 | n = np.size(x) # dimension of problem 340 | 341 | if np.isscalar(P): 342 | P = np.eye(n) * P 343 | else: 344 | P = np.atleast_2d(P) 345 | 346 | sigmas = np.zeros((2*n+1, n)) 347 | 348 | # implements U'*U = (n+kappa)*P. Returns lower triangular matrix. 349 | # Take transpose so we can access with U[i] 350 | U = self.sqrt((n + self.kappa) * P) 351 | 352 | sigmas[0] = x 353 | for k in range(n): 354 | # pylint: disable=bad-whitespace 355 | sigmas[k+1] = self.subtract(x, -U[k]) 356 | sigmas[n+k+1] = self.subtract(x, U[k]) 357 | return sigmas 358 | 359 | 360 | def _compute_weights(self): 361 | """ Computes the weights for the unscented Kalman filter. In this 362 | formulation the weights for the mean and covariance are the same. 363 | """ 364 | 365 | n = self.n 366 | k = self.kappa 367 | 368 | self.Wm = np.full(2*n+1, .5 / (n + k)) 369 | self.Wm[0] = k / (n+k) 370 | self.Wc = self.Wm 371 | 372 | 373 | def __repr__(self): 374 | 375 | return '\n'.join([ 376 | 'JulierSigmaPoints object', 377 | pretty_str('n', self.n), 378 | pretty_str('kappa', self.kappa), 379 | pretty_str('Wm', self.Wm), 380 | pretty_str('Wc', self.Wc), 381 | pretty_str('subtract', self.subtract), 382 | pretty_str('sqrt', self.sqrt) 383 | ]) 384 | 385 | 386 | class SimplexSigmaPoints(object): 387 | 388 | """ 389 | Generates sigma points and weights according to the simplex 390 | method presented in [1]. 391 | 392 | Parameters 393 | ---------- 394 | 395 | n : int 396 | Dimensionality of the state. n+1 weights will be generated. 397 | 398 | sqrt_method : function(ndarray), default=scipy.linalg.cholesky 399 | Defines how we compute the square root of a matrix, which has 400 | no unique answer. Cholesky is the default choice due to its 401 | speed. Typically your alternative choice will be 402 | scipy.linalg.sqrtm 403 | 404 | If your method returns a triangular matrix it must be upper 405 | triangular. Do not use numpy.linalg.cholesky - for historical 406 | reasons it returns a lower triangular matrix. The SciPy version 407 | does the right thing. 408 | 409 | subtract : callable (x, y), optional 410 | Function that computes the difference between x and y. 411 | You will have to supply this if your state variable cannot support 412 | subtraction, such as angles (359-1 degreees is 2, not 358). x and y 413 | are state vectors, not scalars. 414 | 415 | Attributes 416 | ---------- 417 | 418 | Wm : np.array 419 | weight for each sigma point for the mean 420 | 421 | Wc : np.array 422 | weight for each sigma point for the covariance 423 | 424 | References 425 | ---------- 426 | 427 | .. [1] Phillippe Moireau and Dominique Chapelle "Reduced-Order 428 | Unscented Kalman Filtering with Application to Parameter 429 | Identification in Large-Dimensional Systems" 430 | DOI: 10.1051/cocv/2010006 431 | """ 432 | 433 | def __init__(self, n, alpha=1, sqrt_method=None, subtract=None): 434 | self.n = n 435 | self.alpha = alpha 436 | if sqrt_method is None: 437 | self.sqrt = cholesky 438 | else: 439 | self.sqrt = sqrt_method 440 | 441 | if subtract is None: 442 | self.subtract = np.subtract 443 | else: 444 | self.subtract = subtract 445 | 446 | self._compute_weights() 447 | 448 | 449 | def num_sigmas(self): 450 | """ Number of sigma points for each variable in the state x""" 451 | return self.n + 1 452 | 453 | 454 | def sigma_points(self, x, P): 455 | """ 456 | Computes the implex sigma points for an unscented Kalman filter 457 | given the mean (x) and covariance(P) of the filter. 458 | Returns tuple of the sigma points and weights. 459 | 460 | Works with both scalar and array inputs: 461 | sigma_points (5, 9, 2) # mean 5, covariance 9 462 | sigma_points ([5, 2], 9*eye(2), 2) # means 5 and 2, covariance 9I 463 | 464 | Parameters 465 | ---------- 466 | 467 | x : An array-like object of the means of length n 468 | Can be a scalar if 1D. 469 | examples: 1, [1,2], np.array([1,2]) 470 | 471 | P : scalar, or np.array 472 | Covariance of the filter. If scalar, is treated as eye(n)*P. 473 | 474 | Returns 475 | ------- 476 | 477 | sigmas : np.array, of size (n, n+1) 478 | Two dimensional array of sigma points. Each column contains all of 479 | the sigmas for one dimension in the problem space. 480 | 481 | Ordered by Xi_0, Xi_{1..n} 482 | """ 483 | 484 | if self.n != np.size(x): 485 | raise ValueError("expected size(x) {}, but size is {}".format( 486 | self.n, np.size(x))) 487 | 488 | n = self.n 489 | 490 | if np.isscalar(x): 491 | x = np.asarray([x]) 492 | x = x.reshape(-1, 1) 493 | 494 | if np.isscalar(P): 495 | P = np.eye(n) * P 496 | else: 497 | P = np.atleast_2d(P) 498 | 499 | U = self.sqrt(P) 500 | 501 | lambda_ = n / (n + 1) 502 | Istar = np.array([[-1/np.sqrt(2*lambda_), 1/np.sqrt(2*lambda_)]]) 503 | 504 | for d in range(2, n+1): 505 | row = np.ones((1, Istar.shape[1] + 1)) * 1. / np.sqrt(lambda_*d*(d + 1)) # pylint: disable=unsubscriptable-object 506 | row[0, -1] = -d / np.sqrt(lambda_ * d * (d + 1)) 507 | Istar = np.r_[np.c_[Istar, np.zeros((Istar.shape[0]))], row] # pylint: disable=unsubscriptable-object 508 | 509 | I = np.sqrt(n)*Istar 510 | scaled_unitary = (U.T).dot(I) 511 | 512 | sigmas = self.subtract(x, -scaled_unitary) 513 | return sigmas.T 514 | 515 | 516 | def _compute_weights(self): 517 | """ Computes the weights for the scaled unscented Kalman filter. """ 518 | 519 | n = self.n 520 | c = 1. / (n + 1) 521 | self.Wm = np.full(n + 1, c) 522 | self.Wc = self.Wm 523 | 524 | 525 | def __repr__(self): 526 | return '\n'.join([ 527 | 'SimplexSigmaPoints object', 528 | pretty_str('n', self.n), 529 | pretty_str('alpha', self.alpha), 530 | pretty_str('Wm', self.Wm), 531 | pretty_str('Wc', self.Wc), 532 | pretty_str('subtract', self.subtract), 533 | pretty_str('sqrt', self.sqrt) 534 | ]) 535 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) 2 | 3 | # custom_components/multizone_thermostat 4 | see also https://community.home-assistant.io/t/multizone-thermostat-incl-various-control-options 5 | 6 | This is a home assistant custom component. It is a thermostat including various control options, such as: on-off, PID, weather controlled. The thermostat can be used in stand-alone mode or as zoned heating (master with satellites). 7 | 8 | Note: 9 | This is only the required software to create a (zoned) thermostat. Especially zoned heating systems will affect the flow in your heating system vy closing and opening valves. Please check your heating system if modifications are requried to handle the flow variations, such as: pump settings, bypass valves etc. 10 | 11 | ## Installation: 12 | 1. Go to default /homeassistant/.homeassistant/ (it's where your configuration.yaml is) 13 | 2. Create /custom_components/ directory if it does not already exist 14 | 3. Clone this repository content into /custom_components/ 15 | 4. Reboot HA without any configuration using the code to install requirements 16 | 5. Set up the multizone_thermostat and have fun 17 | 18 | 19 | ## thanx to: 20 | by borrowing and continuouing on code from: 21 | - DB-CL https://github.com/DB-CL/home-assistant/tree/new_generic_thermostat stale pull request with detailed seperation of hvac modes 22 | - fabian degger (PID thermostat) originator PID control in generic thermostat, https://github.com/aendle/custom_components 23 | 24 | 25 | # Explanatory notes 26 | Within this readme several abbreviations are used to describe the working and used methodolgy. Hereunder the most relevant are described. 27 | 28 | ## Pulse Width Modulation (PWM) 29 | "PWM" stands for Pulse Width Modulation. Pulse Width Modulation is a technique often used in conjunction with PID controllers to regulate the amount of power delivered to a system, such as the heating elements in each zone of your underfloor heating system. 30 | 31 | Pulse Width Modulation (PWM) is used to adjust the amount of power delivered to an electronic device by effectively turning the heating (or cooling) on and off at a "fast" rate. The "width" of the "on" time (the pulse) is varied (modulated) to represent a specific power delivery level. When the pulse is wider (meaning the device is on for a longer period), more power is delivered to the heating element, increasing the temperature. Conversely, a narrower pulse delivers less power, reducing the temperature. 32 | 33 | https://en.wikipedia.org/wiki/Pulse-width_modulation 34 | 35 | ## proportional–integral–derivative controller (PID) 36 | 37 | The PID controller calculates the difference between a desired setpoint (the target temperature) and the actual temperature measured by the sensors in each zone. Based on this difference (the error), and the rate of temperature change, the PID controller adjusts the PWM signal to increase or decrease the heat output, aiming to minimize the error over time and maintain a stable temperature in each zone. 38 | 39 | The use of PWM in your underfloor heating system allows for precise control over the temperature in each zone by adjusting the duty cycle of the electrical power to the heating elements. This method is efficient and can lead to more uniform temperature control and potentially lower energy consumption, as it adjusts the heating output to the actual need in each zone. 40 | 41 | PID controller explained: 42 | - https://en.wikipedia.org/wiki/PID_controller 43 | - [https://controlguru.com/table-of-contents/](https://controlguru.com/table-of-contents/) 44 | 45 | config examples: 46 | slow low temperature underfloor heating: 47 | PID_mode: 48 | kp: 30 49 | ki: 0.005 50 | kd: -24000 51 | 52 | high temperature radiator: 53 | PID_mode: 54 | kp: 80 55 | ki: 0.09 56 | kd: -5000 57 | 58 | * underfloor heating parameter optimisation: https://www.google.nl/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&cad=rja&uact=8&ved=2ahUKEwi5htyg5_buAhXeQxUIHaZHB5QQFjAAegQIBBAD&url=https%3A%2F%2Fwww.mdpi.com%2F1996-1073%2F13%2F8%2F2068%2Fhtml&usg=AOvVaw3CukGrgPjpIO2eKM619BIn 59 | 60 | # Operation modes 61 | The multizone thermostat can operate in two modes: 62 | - thermostats can operate stand-alone, thus without interaction with others 63 | - thermostats can operate under the control of a master controller scheduling and balancing the heat request 64 | 65 | Per room a thermostat needs to be configured. A thermostat can operate by either hyesteris (on-off mode) or proportional mode (weather compensation and PID mode). The PID and weather compensation can be combined or one of both can be used. Only a satellite operating in proportional mode can be used as satellite as hysteris operation (on-off by a dT) cannot run in synchronised mode with other satellites and the master. 66 | 67 | When a master controller is included it will coordinate for all enlisted satellites the valve opening and closures. When the master hvac mode is heat or cool it will trigger the satellites to update their controller and from that moment it interacts with the master. A satellite interaction with the master will be updated when the master is activated or switched off. When the master is activated to heat or cool, the controller routines of all satellites are synced to the master controller. When the master is switched off the satellite will return to their stand-alone mode with individual settings. The master itself receives the satellite state (PWM signal) and return the moment the satellite has to open or close valves. The master determines the moment when the satellite valves is opened, the satellite itself still determines the valve opening time. 68 | 69 | # Examples 70 | See the examples folder for examples. 71 | The '\examples\multizone thermostat - explained.yaml' shows an worked-out multizone example including explanation. 72 | The '\examples\single thermostat - on_off.yaml' shows an worked-out single operating hysteris thermostat with explanation. 73 | 74 | # Room thermostat configuration (not for master config) 75 | This thermostat is used for satellite or stand-alone operation mode. 76 | The thermostat can be configured for a wide variation of hardware specifications and options: 77 | - Operation for heating and cool are specified indiviually 78 | - The switch can be a on-off switch or proportional (0-100) valve 79 | - The switch can be of the type normally closed (NC) or normally opened (NO) 80 | - For sensors with irregular update intervals such as battery operated sensors an optional uncented kalman filter is included 81 | - Window open detection can be included 82 | - Valve stuck prevention 83 | - Restore operational configuration after HA reboot 84 | 85 | ## Thermostat configuration 86 | * platform (Required): 'multizone_thermostat' 87 | * name (Required): Name of thermostat. In case of master the name is overruled to 'master'. 88 | * unique_id (Optional): specify name for entity in registry else unique name is based on specified sensors and switches 89 | * room_area (Optional): Required when operating in satellite mode. The room area is needed to determine the scale effect of the room to the total heat requirement. Default = 0 (only stand alone mode possible, not allowed for satellite mode) 90 | 91 | sensors (at least one sensor needs to be specified): 92 | * sensor (Optional): entity_id of the temperature sensor, sensor.state must be temperature (float). Not required when running in weather compensation only. 93 | * filter_mode (Optional): unscented kalman filter can be used to smoothen the temperature sensor readings. Especially usefull in case of irregular sensor updates such as battery operated devices (for instance battery operated zigbee sensor). Default = 0 (off) (see section 'sensor filter' for more details) 94 | * sensor_out (Optional): entity_id for a outdoor temperature sensor, sensor_out.state must be temperature (float). Only required when running weather mode. No filtering possible. 95 | 96 | * initial_hvac_mode (Optional): Set the initial operation mode. Valid values are 'off', 'cool' or 'heat'. Default = off 97 | * initial_preset_mode (Optional): Set the default mode. Default is normal operational mode. Allowed alternative is any in 'extra_presets'. The 'inital_preset_mode' needs to be present in the 'extra_presets' of the 'initial_hvac_mode' 98 | 99 | * precision (Optional): specifiy setpoint precision: 0.1, 0.5 or 1 100 | * detailed_output (Optional): include detailed control output including PID contributions and sub-control (PWM) output. To include detailed output use 'True'. Use this option limited for debugging and tuning only as it increases the database size. Default = False 101 | 102 | checks for sensor and switch: 103 | * sensor_stale_duration (Optional): safety routine "emergency mode" to turn switches off when sensor has not updated for a specified time period. Specify time period. Activation of emergency mode is visible via a forced climate preset state. Default is not activated. 104 | * passive_switch_check (Optional): Include check of the switch to time it was operated for a secified time ('passive_switch_duration' per hvac_mode defined) to avoid stuck/jammed valve. Per hvac_mode the duration (where switch is specified) is specified and optionally the time when to check. When in master-satellite mode the switch is only activated when master is idle or off. Specify 'True' to activate. Default is False (not activated). 105 | * passive_switch_check_time (Optional): specify the time to perform the check. Default 02:00 AM. Input format HH:MM' 106 | 107 | recovery of settings 108 | * restore_from_old_state (Optional): restore certain old configuration and modes after restart. Specify 'True' to activate. (setpoints, KP,KI,PD values, modes). Default = False 109 | * restore_parameters (Optional): specify if previous controller parameters need to be restored. Specify 'True' to activate. Default = False 110 | * restore_integral (Optional): If PID integral needs to be restored. Avoid long restoration times. Specify 'True' to activate. Default = False 111 | 112 | ### HVAC modes: heat or cool (sub entity config) 113 | The control is specified per hvac mode (heat, cool). At least 1 to be included. 114 | EAch HVAC mode should include one of the control modes: on-off, proportional or master. 115 | 116 | Generic HVAC mode setting: 117 | * entity_id (Required): This can be an on-off switch or a proportional valve(input_number, etc) 118 | * switch_mode (Optional): Specify if switch (valve) is normally closed 'NC' or normally open 'NO'. Default = 'NC' 119 | 120 | * min_target_temp (Optional): Lower limit temperature setpoint. Default heat=14, cool=20 121 | * max_target_temp (Optional): Upper limit temperature setpoint. Default for heat=24, cool=35 122 | * initial_target_temp (Optional): Initial setpoint at start. Default for heat=19, cool=28 123 | * extra_presets (Optional): A list of custom presets. Needs to be in to form of a list of name and value. Defining 'extra_presets' will make away preset available. default no preset mode available. 124 | 125 | * passive_switch_duration (Optional): specifiy per switch the maximum time before forcing toggle to avoid jammed valve. Specify a time period. Default is not activated. 126 | * passive_switch_opening_time (Optional): specify the minium opening time of valve when running passive switch operation. Specify a time period. Default 1 minute. 127 | 128 | 129 | #### on-off mode (Optional) (sub of hvac mode) 130 | The thermostat will switch on or off depending the setpoint and specified hysteris. Configured under 'on_off_mode:' 131 | with the data (as sub of 'on_off_mode:'): 132 | * hysteresis_on (Required): Lower bound: temperature offset to switch on. default is 0.5 133 | * hysteresis_off (Required): Upper bound: temperature offset to switch off. default is 0.5 134 | * min_cycle_duration (Optional): Min duration to change switch status. If this is not specified, min_cycle_duration feature will not get activated. Specify a time period. 135 | * control_interval (Optional): Min duration to re-run an update cycle. If this is not specified, feature will not get activated. Specify a time period. 136 | 137 | #### proportional mode (Optional) (sub of hvac mode) 138 | Configured under 'proportional_mode:' 139 | Two control modes are included to control the proportional thermostat. A single one can be specfied or combined. The control output of both are summed. 140 | - PID controller: control by setpoint and room temperature 141 | - Weather compensating: control by room- and outdoor temperature 142 | 143 | The proportional controller is called periodically and specified by control_interval. 144 | If no PWM interval is defined, it will set the state of "heater" from 0 to "PWM_scale" value as for a proportional valve. Else, when "PWM_duration" is specified it will operate in on-off mode and will switch proportionally with the PWM signal. 145 | 146 | * control_interval (Required): interval that controller is updated. The satellites should have a control_interval equal to the master or the master control_interval should be dividable by the satellite control_interval. Specify a time period. 147 | * PWM_duration (Optional): Set period time for PWM signal. If it's not set, PWM is sending proportional value to switch. Specify a time period. For a on-off valve the control_interval should be equal or multiplication of the "control_interval". Default = 0 (proportional valve) 148 | * PWM_scale (Optional): Set analog output offset to 0. Example: If it's 500 the output value can be between 0 and 500. Proportional valve might have 99 as upper max, use 99 in such case. Default = 100 149 | * PWM_resolution (optional): Set the resolution of the PWM_scale between min and max difference. Default = 50 (50 steps between 0 and PWM_scale) 150 | * PWM_threshold (Optional): Set the minimal difference before activating switch. To avoid very short off-on-off or on-off-on changes. Default is not acitvated 151 | * bounded_scale_to_master(Optional): scale proporitional valves with the master's PWM. 'bounded_scale_to_master' defines the scale limit. For example: 152 | - bounded scale = 3 153 | - prop valve PWM = 15 154 | - master PWM = 25 155 | - master PWM scale = 100 156 | - valve PWM output = 15 / min( 25/100, 3) 157 | 158 | Default = 1 (no scaling) 159 | 160 | ##### PID controller (Optional) (sub of proportional mode) 161 | PID controller. Configured under 'PID_mode:' 162 | error = setpoint - room temp 163 | output = error * Kp + sum(error * dT) * Ki + error / dT * Kd 164 | 165 | heat mode: Kp & Ki positive, Kd negative 166 | cool mode: Kp & Ki negative, Kd positive 167 | 168 | with the data (as sub): 169 | * kp (Required): Set PID parameter, p control value. 170 | * ki (Required): Set PID parameter, i control value. 171 | * kd (Required): Set PID parameter, d control value. 172 | * PWM_scale_low (Optional): Overide lower bound PWM scale for this mode. Default = 0 173 | * PWM_scale_high (Optional): Overide upper bound PWM scale for this mode. Default = 'PWM_scale' 174 | * window_open_tempdrop (Optional): notice temporarily opened window. Define minimum temperature drop speed below which PID is frozen to avoid integral and derative build-up. drop in Celcius per hour. Should be negative value. Default = off. 175 | 176 | ##### Weather compensating controller (Optional) (sub of proportional mode) 177 | Weather compensation controller. Configured under 'weather_mode:' 178 | error = setpoint - outdoor_temp 179 | output = error * ka + kb 180 | 181 | heat mode: ka positive, kb negative 182 | cool mode: ka negitive, kb positive 183 | 184 | with the data (as sub): 185 | * ka (Required): Set PID parameter, ka control value. 186 | * kb (Required): Set PID parameter, kb control value. 187 | * PWM_scale_low (Optional): Overide lower bound PWM scale for this mode. Default = PWM_scale * -1 188 | * PWM_scale_high (Optional): Overide upper bound PWM scale for this mode. Default = 'PWM_scale' 189 | 190 | # Master configuration 191 | The configuration scheme is similar as for a satellite only with the following differences. 192 | 193 | * name: Specify 'master'. For master mode the user defined name is overruled by thermostat to 'master' 194 | * room_area (Required): For master it should be equal to the total heated area. 195 | For master mode not applicable 196 | * sensor 197 | * filter_mode 198 | * sensor_out 199 | * precision 200 | * sensor_stale_duration 201 | 202 | ## HVAC modes: heat or cool (sub entity config) 203 | The control is specified per hvac mode (heat, cool). At least 1 to be included. 204 | EAch HVAC mode should include one of the control modes: on-off, proportional or master. 205 | 206 | Generic HVAC mode setting: 207 | For master mode not applicable: 208 | * min_target_temp 209 | * max_target_temp 210 | * initial_target_temp 211 | 212 | ### on-off mode (Optional) (sub of hvac mode) 213 | For master mode not applicable 214 | 215 | ### proportional mode (Optional) (sub of hvac mode) 216 | For master mode not applicable 217 | 218 | ### Master configuration (Required) (sub of hvac mode) 219 | Specify the control of the satellites. Configured under 'master_mode:' 220 | 221 | Referenced thermostats (satellites) will be linked to this controller. The heat or cool requirement will be read from the satellites and are processed to determine master valve opening and adjust timing of satellite openings. 222 | 223 | The master will check satellite states and group them in on-off and proportional valves. The govering group will define the opening time of the master valve. 224 | 225 | The preset mode changes on the master will be synced to the satellites. 226 | 227 | The master can operate in 'minimal_on', 'balanced' or 'continuous' mode. This will determine the satellite timing scheduling. For the minimal_on mode the master valve is opened as short as possible, for balanced mode the opening time is balanced between heating power and duration and for continuous mode the valve opening time is extended as long as possible. All satellite valves operating as on-off switch are used for the nesting are scheduled in time to get a balanced heat requirement. In 'continuous' mode the satellite timing is scheduled aimed such that a continuous heat requirement is created. The master valve will be opened continuous when sufficient heat is needed. In low demand conditions an on-off mode is maintained. 228 | 229 | The controller is called periodically and specified by control_interval. 230 | If no PWM interval is defined, it will set the state of "heater" from 0 to "PWM_scale" value as for a proportional valve. Else, when PWM is specified it will operate in on-off mode and will switch proportionally with the PWM signal. 231 | 232 | with the data (as sub): 233 | * satelites (Required): between square brackets defined list of thermostats by their name 234 | * operation_mode (Optional): satellite nesting method: "minimal_on", "balanced" or "continuous". Default = "balanced" 235 | * lower_load_scale (Optional): For nesting assumed minimum required load heater. Default = 0.15. (a minimum heating capacity of 15% assumed based on 100% when all rooms required heat) 236 | * control_interval (Required): interval that controller is updated. The satellites should have a control_interval equal to the master or the master control_interval should be dividable by the satellite control_interval. Specify a time period. 237 | * PWM_duration (Optional): Set period time for PWM signal. If it's not set, PWM is sending proportional value to switch. Specify a time period. Default = 0 238 | * PWM_scale (Optional): Set analog output offset to 0. Example: If it's 500 the output value can be between 0 and 500. Default = 100 239 | * PWM_resolution (optional): Set the resolution of the PWM_scale between min and max difference. Default = 50 (50 steps between 0 and 100) 240 | * PWM_threshold (Optional): Set the minimal difference before activating switch. To avoid very short off-on-off or on-off-on changes. Default is not acitvated 241 | * min_opening_for_propvalve (optional): Set the minimal percentage (between 0 and 1) active PWM when a proportional valve requires heat. Default 0 (* PWM_scale) 242 | * compensate_valve_lag (optional): Delay the opening of the master valve to assure that flow is guaranteed. Specify a time period. Default no delay. 243 | 244 | 245 | # Sensor filter (filter_mode): 246 | An unscented kalman filter is present to smoothen the temperature readings in case of of irregular updates. This could be the case for battery operated temperature sensors such as zigbee devices. This can be usefull in case of PID controller where derivative is controlled (speed of temperature change). 247 | The filter intesity is defined by a factor between 0 to 5 (integer). 248 | 0 = no filter 249 | 5 = max smoothing 250 | 251 | # DEBUGGING: 252 | debugging is possible by enabling logger in configuration with following configuration 253 | ``` 254 | logger: 255 | default: info 256 | logs: 257 | multizone_thermostat: debug 258 | ``` 259 | # Services callable from HA: 260 | Several services are included to change the active configuration of a satellite or master. 261 | ## set_mid_diff: 262 | Change the 'minimal_diff' 263 | ## set_preset_mode: 264 | Change the 'preset' 265 | ## set_pid: 266 | Change the current kp, ki, kd values of the PID or Valve PID controller 267 | ## set_integral: 268 | Change the PID integral value contribution 269 | ## set_ka_kb: 270 | Change the ka and kb for the weather controller 271 | ## set_filter_mode: 272 | change the UKF filter level for the temperature sensor 273 | ## detailed_output: 274 | Control the attribute output for PID-, WC-contributions and control output 275 | -------------------------------------------------------------------------------- /examples/multizone thermostat - explained.yaml: -------------------------------------------------------------------------------- 1 | input_boolean: 2 | mz_heater_master: 3 | name: mz heater master 4 | initial: off 5 | mz_heater_input1: 6 | name: mz heater1 input 7 | initial: off 8 | mz_heater_input2: 9 | name: mz heater2 input 10 | initial: off 11 | mz_heater_input3: 12 | name: mz heater3 input 13 | initial: off 14 | mz_heater_input4: 15 | name: mz heater4 input 16 | initial: off 17 | mz_heater_input5: 18 | name: mz heater5 input 19 | initial: off 20 | mz_heater_input6: 21 | name: mz heater6 input 22 | initial: off 23 | 24 | input_number: 25 | # room temperature sensors 26 | mz_sensor_temperature_1: 27 | initial: 20 28 | min: -20 29 | max: 35 30 | step: 0.01 31 | mz_sensor_temperature_2: 32 | initial: 20 33 | min: -20 34 | max: 35 35 | step: 0.01 36 | mz_sensor_temperature_3: 37 | initial: 20 38 | min: -20 39 | max: 35 40 | step: 0.01 41 | mz_sensor_temperature_4: 42 | initial: 20 43 | min: -20 44 | max: 35 45 | step: 0.01 46 | mz_sensor_temperature_5: 47 | initial: 20 48 | min: -20 49 | max: 35 50 | step: 0.01 51 | mz_sensor_temperature_6: 52 | initial: 20 53 | min: -20 54 | max: 35 55 | step: 0.01 56 | mz_sensor_outdoor_temperature_1: 57 | initial: 0 58 | min: -20 59 | max: 35 60 | step: 0.5 61 | 62 | 63 | # proportional valves 64 | mz_pwm_master: 65 | initial: 0 66 | min: 0 67 | max: 100 68 | mz_pwm_heat1: 69 | initial: 0 70 | min: 0 71 | max: 100 72 | mz_pwm_heat2: 73 | initial: 0 74 | min: 0 75 | max: 100 76 | mz_pwm_heat3: 77 | initial: 0 78 | min: 0 79 | max: 100 80 | mz_pwm_heat4: 81 | initial: 0 82 | min: 0 83 | max: 100 84 | mz_pwm_heat5: 85 | initial: 0 86 | min: 0 87 | max: 100 88 | mz_pwm_heat6: 89 | initial: 0 90 | min: 0 91 | max: 100 92 | 93 | sensor: 94 | - platform: template 95 | sensors: 96 | mz_sensor_1: 97 | unit_of_measurement: "°C" 98 | value_template: "{{ states('input_number.mz_sensor_temperature_1') | float(0) }}" 99 | mz_sensor_2: 100 | unit_of_measurement: "°C" 101 | value_template: "{{ states('input_number.mz_sensor_temperature_2') | float(0) }}" 102 | mz_sensor_3: 103 | unit_of_measurement: "°C" 104 | value_template: "{{ states('input_number.mz_sensor_temperature_3') | float(0) }}" 105 | mz_sensor_4: 106 | unit_of_measurement: "°C" 107 | value_template: "{{ states('input_number.mz_sensor_temperature_4') | float(0) }}" 108 | mz_sensor_5: 109 | unit_of_measurement: "°C" 110 | value_template: "{{ states('input_number.mz_sensor_temperature_5') | float(0) }}" 111 | mz_sensor_6: 112 | unit_of_measurement: "°C" 113 | value_template: "{{ states('input_number.mz_sensor_temperature_6') | float(0) }}" 114 | mz_sensor_out_1: 115 | unit_of_measurement: "°C" 116 | value_template: "{{ states('input_number.mz_sensor_outdoor_temperature_1') | float(0) }}" 117 | 118 | filt_sensor1: 119 | friendly_name: "filt pid temp1" 120 | value_template: "{{ state_attr('climate.pid1', 'current_temp_filt') | float(0) }}" 121 | unit_of_measurement: "°C" 122 | filt_sensor2: 123 | friendly_name: "filt pid temp2" 124 | value_template: "{{ state_attr('climate.pid2', 'current_temp_filt') | float(0) }}" 125 | unit_of_measurement: "°C" 126 | filt_sensor3: 127 | friendly_name: "filt pid temp3" 128 | value_template: "{{ state_attr('climate.pid3', 'current_temp_filt') | float(0) }}" 129 | unit_of_measurement: "°C" 130 | filt_sensor4: 131 | friendly_name: "filt pid temp4" 132 | value_template: "{{ state_attr('climate.pid4', 'current_temp_filt') | float(0) }}" 133 | unit_of_measurement: "°C" 134 | filt_sensor5: 135 | friendly_name: "filt pid temp5" 136 | value_template: "{{ state_attr('climate.pid5', 'current_temp_filt') | float(0) }}" 137 | unit_of_measurement: "°C" 138 | filt_sensor6: 139 | friendly_name: "filt pid temp6" 140 | value_template: "{{ state_attr('climate.pid6', 'current_temp_filt') | float(0) }}" 141 | unit_of_measurement: "°C" 142 | 143 | master_pwm: 144 | value_template: "{{ state_attr('climate.master','hvac_def')['heat']['control_output']['pwm_out'] | float(0)}}" 145 | pid1_pwm: 146 | value_template: "{{ state_attr('climate.pid1','hvac_def')['heat']['control_output']['pwm_out']}}" 147 | pid2_pwm: 148 | value_template: "{{ state_attr('climate.pid2','hvac_def')['heat']['control_output']['pwm_out']}}" 149 | pid3_pwm: 150 | value_template: "{{ state_attr('climate.pid3','hvac_def')['heat']['control_output']['pwm_out']}}" 151 | pid4_pwm: 152 | value_template: "{{ state_attr('climate.pid4','hvac_def')['heat']['control_output']['pwm_out']}}" 153 | pid5_pwm: 154 | value_template: "{{ state_attr('climate.pid5','hvac_def')['heat']['control_output']['pwm_out']}}" 155 | pid6_pwm: 156 | value_template: "{{ state_attr('climate.pid6','hvac_def')['heat']['control_output']['pwm_out']}}" 157 | 158 | switch: 159 | - platform: template 160 | switches: 161 | mz_heater_master: 162 | value_template: "{{ is_state('input_boolean.mz_heater_master', 'on') }}" 163 | turn_on: 164 | service: input_boolean.turn_on 165 | entity_id: input_boolean.mz_heater_master 166 | turn_off: 167 | service: input_boolean.turn_off 168 | entity_id: input_boolean.mz_heater_master 169 | mz_heater_switch1: 170 | value_template: "{{ is_state('input_boolean.mz_heater_input1', 'on') }}" 171 | turn_on: 172 | service: input_boolean.turn_on 173 | entity_id: input_boolean.mz_heater_input1 174 | turn_off: 175 | service: input_boolean.turn_off 176 | entity_id: input_boolean.mz_heater_input1 177 | mz_heater_switch2: 178 | value_template: "{{ is_state('input_boolean.mz_heater_input2', 'on') }}" 179 | turn_on: 180 | service: input_boolean.turn_on 181 | entity_id: input_boolean.mz_heater_input2 182 | turn_off: 183 | service: input_boolean.turn_off 184 | entity_id: input_boolean.mz_heater_input2 185 | mz_heater_switch3: 186 | value_template: "{{ is_state('input_boolean.mz_heater_input3', 'on') }}" 187 | turn_on: 188 | service: input_boolean.turn_on 189 | entity_id: input_boolean.mz_heater_input3 190 | turn_off: 191 | service: input_boolean.turn_off 192 | entity_id: input_boolean.mz_heater_input3 193 | mz_heater_switch4: 194 | value_template: "{{ is_state('input_boolean.mz_heater_input4', 'on') }}" 195 | turn_on: 196 | service: input_boolean.turn_on 197 | entity_id: input_boolean.mz_heater_input4 198 | turn_off: 199 | service: input_boolean.turn_off 200 | entity_id: input_boolean.mz_heater_input4 201 | mz_heater_switch5: 202 | value_template: "{{ is_state('input_boolean.mz_heater_input5', 'on') }}" 203 | turn_on: 204 | service: input_boolean.turn_on 205 | entity_id: input_boolean.mz_heater_input5 206 | turn_off: 207 | service: input_boolean.turn_off 208 | entity_id: input_boolean.mz_heater_input5 209 | mz_heater_switch6: 210 | value_template: "{{ is_state('input_boolean.mz_heater_input6', 'on') }}" 211 | turn_on: 212 | service: input_boolean.turn_on 213 | entity_id: input_boolean.mz_heater_input6 214 | turn_off: 215 | service: input_boolean.turn_off 216 | entity_id: input_boolean.mz_heater_input6 217 | 218 | 219 | # The presented options are only a selection of the possible configurations. See the README for more 220 | # more options and additional explanation. 221 | # Some settings use a default when not included in the configuration. In below examples 222 | # several parameters are shown with the default which would not have been necessary. These are 223 | # included for explanatory use only. 224 | climate: 225 | 226 | # MASTER - SATELLITE WORKING EXAMPLE 227 | # this thermostat synchronizes and coordinates the satellites valve opening 228 | # only one master is allowed 229 | - platform: multizone_thermostat 230 | name: master # used name is overruled to master to assure satellites can find i 231 | unique_id: mz_master 232 | initial_hvac_mode: "off" # or heat or cool 233 | initial_preset_mode: "none" # or one from 'custom_presets' 234 | room_area: 165 # sum of all areas 235 | 236 | # be careful for the master stuck switch check 237 | # depending the configuration and connction to heater/cooling device 238 | # it could trigger the heating or cooling device to start 239 | passive_switch_check: False 240 | 241 | # show detail control output (use only for config 242 | # or debug to avoid log size issues) 243 | detailed_output: False 244 | 245 | restore_from_old_state: True # restore changes made via service calls 246 | restore_parameters: False # yaml config is overrulled by services modified settings 247 | restore_integral: True # PID integral restored to continue with previous pid setpoint 248 | 249 | heat: 250 | 251 | # a main valve supplying heat or cooling 252 | # or relay to start heater/cooling 253 | entity_id: switch.mz_heater_master 254 | 255 | # specify valve is by default closed 256 | switch_mode: "NC" 257 | 258 | # not advised for master 259 | # passive_switch_duration: 260 | # days: 5 261 | 262 | # user defined presets. each mode needs to be defined per satellite. 263 | # temperatures may differ per thermostat. 264 | # temperatures defined at master are ignored. 265 | extra_presets: 266 | test: 10 267 | tes: 20 268 | 269 | master_mode: # configuration required for master 270 | 271 | # list of satellites to control 272 | satelites: [pid1, pid2, pid3, pid4, pid5, pid6] 273 | 274 | # nesting routine. 275 | # "continuous": tries to create a continuous master valve opening when sufficient heat is required 276 | # "minimal_on": opens the master valve as short as possible 277 | # "balanced": schedules satellites such that a continuous minumum heat requirement is achieved, 278 | # then increases pwm duration. When continuous master valve opening is possible 279 | # control_val = pwm_scale it then start to open satellites at teh same time whereby 280 | # heating power rises. Scheduling of satellites is such that in time heating power is balanced. 281 | operation_mode: "balanced" 282 | 283 | # delayed opening to assure that sattelites have opened first 284 | compensate_valve_lag: 285 | seconds: 30 286 | 287 | # time interval where pwm control output is calculated 288 | # advised to set for master equal to 'pwm_duration' 289 | control_interval: 290 | # minutes: 30 291 | minutes: 5 292 | 293 | # interval time for pwm. within this example the valve is openened 294 | # each 30 minutes whne heat is required 295 | # equal with 'control_interval' 296 | pwm_duration: 297 | # minutes: 30 298 | minutes: 5 299 | 300 | # scale wherein pwm operates. Normally 100, it will operate between 0-100 where 301 | # 0 is valve closed and 100 valve open during whole pwm duration 302 | pwm_scale: 100 303 | 304 | # number of steps in pwm scale. a resolution of 50 305 | # and pwm scale 100 results in stepsize 2 306 | pwm_resolution: 50 307 | 308 | # minimum pwm before opening 309 | pwm_threshold: 5 310 | 311 | # For nesting assumed minimum required load heater. Percentage of total rooms 312 | # relative to total area that request heat should be more than 'lower_load_scale' 313 | lower_load_scale: 0.15 314 | 315 | # percentage of pwm_scale to open when a proportional valve requires heat. 316 | # avoids for proportional valves short opening of master valve whereby no heat is 317 | # arriving at require room 318 | min_opening_for_propvalve: 0.1 319 | 320 | 321 | # slow responding water under floor heating 322 | # PID start point settings 323 | # on-off valve (thermostatic wax valve actuator) 324 | # room not influenced by outdoor temperature 325 | - platform: multizone_thermostat 326 | name: pid1 327 | unique_id: mz_pid1 328 | room_area: 60 # floor area of room 329 | initial_hvac_mode: "off" 330 | precision: 0.1 # stepsize for setpoint 331 | 332 | sensor: sensor.mz_sensor_1 # room sensor 333 | 334 | # in case of sensors with irregular updates or inaccurate sensors (for instance sensors with a battery) the filter wll 335 | # average and smoothen the output. this improves the derative term for the pid controller 336 | # advised to start with filter_mode 1 337 | filter_mode: 1 338 | 339 | # activate emergency mode (pause thermostat operation) when sensor did not provide update 340 | sensor_stale_duration: 341 | hours: 3 342 | 343 | # daily check if the valve has not been idle for too long 344 | passive_switch_check: True 345 | 346 | # choose the time to perform check 347 | passive_switch_check_time: "20:50" 348 | 349 | restore_from_old_state: True 350 | restore_parameters: False 351 | restore_integral: True 352 | 353 | # configuration for heating mode 354 | heat: 355 | 356 | # setpoint limits 357 | min_target_temp: 5 358 | max_target_temp: 25 359 | initial_target_temp: 18 # initial setpoint, overruled by 'restore_parameters: True' 360 | 361 | extra_presets: # user defined presets. 362 | night: 15 363 | holiday: 5 364 | 365 | # on-off switch 366 | entity_id: switch.mz_heater_switch1 367 | 368 | # specify valve is by default closed 369 | switch_mode: "NC" 370 | 371 | # check if valve is set to open within specified days. 372 | # if switch not operated within specified duration it will be opened shortly. 373 | # only operated when master is not heating or cooling. 374 | passive_switch_duration: 375 | days: 30 376 | 377 | # specify how long switch should be opened 378 | passive_switch_opening_time: 379 | minutes: 4 380 | 381 | # controller definition required for satellite 382 | proportional_mode: 383 | 384 | # time duration. pwm_duration should be divideable by control_interval 385 | control_interval: 386 | # minutes: 15 387 | minutes: 2.5 388 | 389 | pwm_duration: 390 | # minutes: 30 # on-off switch = equal to master 391 | minutes: 5 # on-off switch = equal to master 392 | 393 | pwm_scale: 100 394 | pwm_resolution: 50 395 | pwm_threshold: 5 396 | 397 | # pid controller config (optional) 398 | PID_mode: 399 | kp: 35 400 | ki: 0.004 401 | kd: -250000 402 | 403 | # check for sudden temperature drop, for instance by open window 404 | # -3.6 degrees celcius per hour is good start point 405 | window_open_tempdrop: -3.6 406 | 407 | 408 | # well isolated room with slow responding water under floor heating 409 | # PID and weather compensation start point settings 410 | # on-off valve (thermostatic wax valve actuator) 411 | - platform: multizone_thermostat 412 | name: pid2 413 | unique_id: mz_pid2 414 | room_area: 30 415 | initial_hvac_mode: "off" 416 | precision: 0.1 417 | 418 | sensor: sensor.mz_sensor_2 419 | filter_mode: 1 420 | 421 | # entity with outdoor temperature. used by weather compensation controller 422 | sensor_out: sensor.mz_sensor_out_1 423 | 424 | sensor_stale_duration: 425 | hours: 3 426 | 427 | passive_switch_check: True 428 | 429 | restore_from_old_state: True 430 | restore_parameters: False 431 | restore_integral: True 432 | 433 | # configuration for heating mode 434 | heat: 435 | 436 | extra_presets: # user defined presets. 437 | night: 15 438 | holiday: 5 439 | 440 | # on-off switch 441 | entity_id: switch.mz_heater_switch2 442 | 443 | # specify valve is by default closed 444 | switch_mode: "NC" 445 | 446 | passive_switch_duration: 447 | hours: 30 448 | 449 | proportional_mode: 450 | 451 | # time duration. pwm_duration should be divideable by control_interval 452 | control_interval: 453 | # minutes: 15 454 | minutes: 2.5 455 | 456 | pwm_duration: 457 | # minutes: 30 # on-off switch = equal to master 458 | minutes: 5 # on-off switch = equal to master 459 | 460 | pwm_scale: 100 461 | pwm_resolution: 50 462 | pwm_threshold: 5 463 | 464 | # pid controller config (optional) 465 | PID_mode: 466 | kp: 35 467 | ki: 0.0004 468 | kd: -250000 469 | 470 | # check for sudden temperature drop, for instance by open window 471 | # -3.6 degrees celcius per hour is good start point 472 | window_open_tempdrop: -3.6 473 | 474 | # weather compensation controller 475 | # well isolated house 476 | weather_mode: 477 | ka: 2 478 | kb: -10 479 | 480 | # exmaple to show nesting behaviour 481 | # PID and weather compensation start point settings 482 | # on-off valve (thermostatic wax valve actuator) 483 | - platform: multizone_thermostat 484 | name: pid3 485 | unique_id: mz_pid3 486 | room_area: 15 487 | initial_hvac_mode: "off" 488 | precision: 0.1 489 | 490 | sensor: sensor.mz_sensor_3 491 | filter_mode: 1 492 | 493 | # entity with outdoor temperature. used by weather compensation controller 494 | sensor_out: sensor.mz_sensor_out_1 495 | 496 | sensor_stale_duration: 497 | hours: 3 498 | 499 | passive_switch_check: True 500 | 501 | restore_from_old_state: True 502 | restore_parameters: False 503 | restore_integral: True 504 | 505 | # configuration for heating mode 506 | heat: 507 | 508 | extra_presets: # user defined presets. 509 | night: 15 510 | holiday: 5 511 | 512 | # on-off switch 513 | entity_id: switch.mz_heater_switch3 514 | 515 | # specify valve is by default closed 516 | switch_mode: "NO" 517 | 518 | passive_switch_duration: 519 | hours: 30 520 | 521 | proportional_mode: 522 | 523 | # time duration. pwm_duration should be divideable by control_interval 524 | control_interval: 525 | # minutes: 15 526 | minutes: 2.5 527 | 528 | pwm_duration: 529 | # minutes: 30 # on-off switch = equal to master 530 | minutes: 5 # on-off switch = equal to master 531 | 532 | pwm_scale: 100 533 | pwm_resolution: 50 534 | pwm_threshold: 5 535 | 536 | # pid controller config (optional) 537 | PID_mode: 538 | kp: 25 539 | ki: 0.0004 540 | kd: -250000 541 | 542 | # check for sudden temperature drop, for instance by open window 543 | # -3.6 degrees celcius per hour is good start point 544 | window_open_tempdrop: -3.6 545 | 546 | # weather compensation controller 547 | # well isolated house 548 | weather_mode: 549 | ka: 1.75 550 | kb: -10 551 | 552 | # example to show nesting behaviour 553 | # PID and weather compensation start point settings 554 | # on-off valve (thermostatic wax valve actuator) 555 | - platform: multizone_thermostat 556 | name: pid4 557 | unique_id: mz_pid4 558 | room_area: 20 559 | initial_hvac_mode: "off" 560 | precision: 0.1 561 | 562 | sensor: sensor.mz_sensor_4 563 | filter_mode: 1 564 | 565 | # entity with outdoor temperature. used by weather compensation controller 566 | sensor_out: sensor.mz_sensor_out_1 567 | 568 | sensor_stale_duration: 569 | hours: 3 570 | 571 | passive_switch_check: True 572 | 573 | restore_from_old_state: True 574 | restore_parameters: False 575 | restore_integral: True 576 | 577 | # configuration for heating mode 578 | heat: 579 | 580 | extra_presets: # user defined presets. 581 | night: 15 582 | holiday: 5 583 | 584 | # on-off switch 585 | entity_id: switch.mz_heater_switch4 586 | 587 | # specify valve is by default closed 588 | switch_mode: "NC" 589 | 590 | passive_switch_duration: 591 | hours: 30 592 | 593 | proportional_mode: 594 | 595 | # time duration. pwm_duration should be divideable by control_interval 596 | control_interval: 597 | # minutes: 15 598 | minutes: 2.5 599 | 600 | pwm_duration: 601 | # minutes: 30 # on-off switch = equal to master 602 | minutes: 5 # on-off switch = equal to master 603 | 604 | pwm_scale: 100 605 | pwm_resolution: 50 606 | pwm_threshold: 5 607 | 608 | # pid controller config (optional) 609 | PID_mode: 610 | kp: 35 611 | ki: 0.0004 612 | kd: -250000 613 | 614 | # check for sudden temperature drop, for instance by open window 615 | # -3.6 degrees celcius per hour is good start point 616 | window_open_tempdrop: -3.6 617 | 618 | # weather compensation controller 619 | # well isolated house 620 | weather_mode: 621 | ka: 2 622 | kb: -10 623 | 624 | # high temperature water radiator/convector 625 | # quite well isolated room 626 | # proportional valve (opening varies between closed and fully open, eurotronic spirit) 627 | - platform: multizone_thermostat 628 | name: pid5 629 | unique_id: mz_pid5 630 | room_area: 30 631 | initial_hvac_mode: "off" 632 | precision: 0.1 633 | 634 | sensor: sensor.mz_sensor_5 635 | filter_mode: 1 636 | sensor_out: sensor.mz_sensor_out_1 637 | sensor_stale_duration: 638 | hours: 3 639 | 640 | passive_switch_check: True 641 | 642 | restore_from_old_state: True 643 | restore_parameters: False 644 | restore_integral: True 645 | 646 | # configuration for heating mode 647 | heat: 648 | 649 | # setpoint limits 650 | min_target_temp: 5 651 | max_target_temp: 25 652 | initial_target_temp: 15 # initial setpoint, overruled by 'restore_parameters: True' 653 | 654 | extra_presets: # user defined presets. 655 | night: 15 656 | holiday: 10 657 | 658 | # proportional valve 659 | entity_id: input_number.mz_pwm_heat5 660 | 661 | passive_switch_duration: 662 | days: 15 663 | passive_switch_opening_time: 664 | minutes: 1 665 | 666 | proportional_mode: 667 | # this valve can be updated more frequently as 668 | # it is not (or limited) affecting nesting routine. 669 | # advised to choose that master control_interval is dividable 670 | # by control_interval 671 | control_interval: 672 | minutes: 15 # 30min=master 673 | 674 | # no pwm thus pwm=0 for proportional valve 0-100 675 | # some valves have a upper scale limit of 99 676 | pwm_scale: 99 677 | pwm_resolution: 50 678 | pwm_threshold: 5 679 | 680 | # scale proporitional valves with the master's pwm. 681 | # for low master opening time the room valve will be opened proportional 682 | # with the master valve opening. 'bounded_scale_to_master' defines scale limit 683 | bounded_scale_to_master: 4 684 | 685 | PID_mode: 686 | kp: 2 687 | ki: 0.0003 688 | kd: -30000 689 | window_open_tempdrop: -3.6 690 | 691 | # example of room with slightly more heat loss than pid2 692 | weather_mode: 693 | ka: 1.5 694 | kb: -15 695 | 696 | # low temperature water radiator/convector 697 | # quite well isolated room 698 | # proportional valve (opening varies between closed and fully open, eurotronic spirit) 699 | - platform: multizone_thermostat 700 | name: pid6 701 | unique_id: mz_pid6 702 | room_area: 10 703 | initial_hvac_mode: "off" 704 | precision: 0.1 705 | 706 | sensor: sensor.mz_sensor_6 707 | filter_mode: 1 708 | sensor_out: sensor.mz_sensor_out_1 709 | sensor_stale_duration: 710 | hours: 3 711 | 712 | passive_switch_check: True 713 | 714 | restore_from_old_state: True 715 | restore_parameters: False 716 | restore_integral: True 717 | 718 | # configuration for heating mode 719 | heat: 720 | 721 | # setpoint limits 722 | min_target_temp: 5 723 | max_target_temp: 25 724 | initial_target_temp: 15 # initial setpoint, overruled by 'restore_parameters: True' 725 | 726 | extra_presets: # user defined presets. 727 | night: 15 728 | holiday: 10 729 | 730 | # proportional valve 731 | entity_id: input_number.mz_pwm_heat6 732 | 733 | passive_switch_duration: 734 | days: 15 735 | passive_switch_opening_time: 736 | minutes: 1 737 | 738 | proportional_mode: 739 | # this valve can be updated more frequently as 740 | # it is not (or limited) affecting nesting routine. 741 | # advised to choose that master control_interval is dividable 742 | # by control_interval 743 | control_interval: 744 | minutes: 15 # 30min=master 745 | 746 | # no pwm thus pwm=0 for proportional valve 0-100 747 | # some valves have a upper scale limit of 99 748 | pwm_scale: 99 749 | pwm_resolution: 50 750 | pwm_threshold: 5 751 | 752 | # scale proporitional valves with the master's pwm. 753 | # for low master opening time the room valve will be opened proportional 754 | # with the master valve opening. 'bounded_scale_to_master' defines scale limit 755 | bounded_scale_to_master: 4 756 | 757 | # no PID example valves known 758 | # not tested values 759 | PID_mode: 760 | kp: 20 761 | ki: 0.001 762 | kd: -100000 763 | window_open_tempdrop: -3.6 764 | 765 | # example of room with slightly more heat loss than pid2 766 | weather_mode: 767 | ka: 1.75 768 | kb: -15 769 | 770 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/UKF_filter/UKF.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=invalid-name 3 | 4 | """Copyright 2015 Roger R Labbe Jr. 5 | 6 | FilterPy library. 7 | http://github.com/rlabbe/filterpy 8 | 9 | Documentation at: 10 | https://filterpy.readthedocs.org 11 | 12 | Supporting book at: 13 | https://github.com/rlabbe/Kalman-and-Bayesian-Filters-in-Python 14 | 15 | This is licensed under an MIT license. See the readme.MD file 16 | for more information. 17 | """ 18 | from copy import deepcopy 19 | from math import log, exp, sqrt 20 | import sys 21 | import numpy as np 22 | from numpy import eye, zeros, dot, isscalar, outer 23 | from .unscented_transform import unscented_transform 24 | from .helpers import pretty_str 25 | from .cholesky import cholesky 26 | 27 | 28 | def logpdf(x, mean, cov): 29 | """http://gregorygundersen.com/blog/2020/12/12/group-multivariate-normal-pdf/""" 30 | vals, vecs = np.linalg.eigh(cov) 31 | logdet = np.sum(np.log(vals)) 32 | valsinv = 1.0 / vals 33 | U = vecs * np.sqrt(valsinv) 34 | dim = len(vals) 35 | dev = x - mean 36 | maha = np.square(np.dot(dev, U)).sum() 37 | log2pi = np.log(2 * np.pi) 38 | return -0.5 * (dim * log2pi + maha + logdet) 39 | 40 | 41 | class UnscentedKalmanFilter(object): 42 | # pylint: disable=too-many-instance-attributes 43 | # pylint: disable=invalid-name 44 | r""" 45 | Implements the Scaled Unscented Kalman filter (UKF) as defined by 46 | Simon Julier in [1], using the formulation provided by Wan and Merle 47 | in [2]. This filter scales the sigma points to avoid strong nonlinearities. 48 | 49 | 50 | Parameters 51 | ---------- 52 | 53 | dim_x : int 54 | Number of state variables for the filter. For example, if 55 | you are tracking the position and velocity of an object in two 56 | dimensions, dim_x would be 4. 57 | 58 | 59 | dim_z : int 60 | Number of of measurement inputs. For example, if the sensor 61 | provides you with position in (x,y), dim_z would be 2. 62 | 63 | This is for convience, so everything is sized correctly on 64 | creation. If you are using multiple sensors the size of `z` can 65 | change based on the sensor. Just provide the appropriate hx function 66 | 67 | 68 | dt : float 69 | Time between steps in seconds. 70 | 71 | 72 | 73 | hx : function(x,**hx_args) 74 | Measurement function. Converts state vector x into a measurement 75 | vector of shape (dim_z). 76 | 77 | fx : function(x,dt,**fx_args) 78 | function that returns the state x transformed by the 79 | state transition function. dt is the time step in seconds. 80 | 81 | points : class 82 | Class which computes the sigma points and weights for a UKF 83 | algorithm. You can vary the UKF implementation by changing this 84 | class. For example, MerweScaledSigmaPoints implements the alpha, 85 | beta, kappa parameterization of Van der Merwe, and 86 | JulierSigmaPoints implements Julier's original kappa 87 | parameterization. See either of those for the required 88 | signature of this class if you want to implement your own. 89 | 90 | sqrt_fn : callable(ndarray), default=None (implies scipy.linalg.cholesky) 91 | Defines how we compute the square root of a matrix, which has 92 | no unique answer. Cholesky is the default choice due to its 93 | speed. Typically your alternative choice will be 94 | scipy.linalg.sqrtm. Different choices affect how the sigma points 95 | are arranged relative to the eigenvectors of the covariance matrix. 96 | Usually this will not matter to you; if so the default cholesky() 97 | yields maximal performance. As of van der Merwe's dissertation of 98 | 2004 [6] this was not a well reseached area so I have no advice 99 | to give you. 100 | 101 | If your method returns a triangular matrix it must be upper 102 | triangular. Do not use numpy.linalg.cholesky - for historical 103 | reasons it returns a lower triangular matrix. The SciPy version 104 | does the right thing as far as this class is concerned. 105 | 106 | x_mean_fn : callable (sigma_points, weights), optional 107 | Function that computes the mean of the provided sigma points 108 | and weights. Use this if your state variable contains nonlinear 109 | values such as angles which cannot be summed. 110 | 111 | .. code-block:: Python 112 | 113 | def state_mean(sigmas, Wm): 114 | x = np.zeros(3) 115 | sum_sin, sum_cos = 0., 0. 116 | 117 | for i in range(len(sigmas)): 118 | s = sigmas[i] 119 | x[0] += s[0] * Wm[i] 120 | x[1] += s[1] * Wm[i] 121 | sum_sin += sin(s[2])*Wm[i] 122 | sum_cos += cos(s[2])*Wm[i] 123 | x[2] = atan2(sum_sin, sum_cos) 124 | return x 125 | 126 | z_mean_fn : callable (sigma_points, weights), optional 127 | Same as x_mean_fn, except it is called for sigma points which 128 | form the measurements after being passed through hx(). 129 | 130 | residual_x : callable (x, y), optional 131 | residual_z : callable (x, y), optional 132 | Function that computes the residual (difference) between x and y. 133 | You will have to supply this if your state variable cannot support 134 | subtraction, such as angles (359-1 degreees is 2, not 358). x and y 135 | are state vectors, not scalars. One is for the state variable, 136 | the other is for the measurement state. 137 | 138 | .. code-block:: Python 139 | 140 | def residual(a, b): 141 | y = a[0] - b[0] 142 | if y > np.pi: 143 | y -= 2*np.pi 144 | if y < -np.pi: 145 | y += 2*np.pi 146 | return y 147 | 148 | state_add: callable (x, y), optional, default np.add 149 | Function that subtracts two state vectors, returning a new 150 | state vector. Used during update to compute `x + K@y` 151 | You will have to supply this if your state variable does not 152 | suport addition, such as it contains angles. 153 | 154 | Attributes 155 | ---------- 156 | 157 | x : numpy.array(dim_x) 158 | state estimate vector 159 | 160 | P : numpy.array(dim_x, dim_x) 161 | covariance estimate matrix 162 | 163 | x_prior : numpy.array(dim_x) 164 | Prior (predicted) state estimate. The *_prior and *_post attributes 165 | are for convienence; they store the prior and posterior of the 166 | current epoch. Read Only. 167 | 168 | P_prior : numpy.array(dim_x, dim_x) 169 | Prior (predicted) state covariance matrix. Read Only. 170 | 171 | x_post : numpy.array(dim_x) 172 | Posterior (updated) state estimate. Read Only. 173 | 174 | P_post : numpy.array(dim_x, dim_x) 175 | Posterior (updated) state covariance matrix. Read Only. 176 | 177 | z : ndarray 178 | Last measurement used in update(). Read only. 179 | 180 | R : numpy.array(dim_z, dim_z) 181 | measurement noise matrix 182 | 183 | Q : numpy.array(dim_x, dim_x) 184 | process noise matrix 185 | 186 | K : numpy.array 187 | Kalman gain 188 | 189 | y : numpy.array 190 | innovation residual 191 | 192 | log_likelihood : scalar 193 | Log likelihood of last measurement update. 194 | 195 | likelihood : float 196 | likelihood of last measurment. Read only. 197 | 198 | Computed from the log-likelihood. The log-likelihood can be very 199 | small, meaning a large negative value such as -28000. Taking the 200 | exp() of that results in 0.0, which can break typical algorithms 201 | which multiply by this value, so by default we always return a 202 | number >= sys.float_info.min. 203 | 204 | mahalanobis : float 205 | mahalanobis distance of the measurement. Read only. 206 | 207 | inv : function, default numpy.linalg.inv 208 | If you prefer another inverse function, such as the Moore-Penrose 209 | pseudo inverse, set it to that instead: 210 | 211 | .. code-block:: Python 212 | 213 | kf.inv = np.linalg.pinv 214 | 215 | 216 | Examples 217 | -------- 218 | 219 | Simple example of a linear order 1 kinematic filter in 2D. There is no 220 | need to use a UKF for this example, but it is easy to read. 221 | 222 | >>> def fx(x, dt): 223 | >>> # state transition function - predict next state based 224 | >>> # on constant velocity model x = vt + x_0 225 | >>> F = np.array([[1, dt, 0, 0], 226 | >>> [0, 1, 0, 0], 227 | >>> [0, 0, 1, dt], 228 | >>> [0, 0, 0, 1]], dtype=float) 229 | >>> return np.dot(F, x) 230 | >>> 231 | >>> def hx(x): 232 | >>> # measurement function - convert state into a measurement 233 | >>> # where measurements are [x_pos, y_pos] 234 | >>> return np.array([x[0], x[2]]) 235 | >>> 236 | >>> dt = 0.1 237 | >>> # create sigma points to use in the filter. This is standard for Gaussian processes 238 | >>> points = MerweScaledSigmaPoints(4, alpha=.1, beta=2., kappa=-1) 239 | >>> 240 | >>> kf = UnscentedKalmanFilter(dim_x=4, dim_z=2, dt=dt, fx=fx, hx=hx, points=points) 241 | >>> kf.x = np.array([-1., 1., -1., 1]) # initial state 242 | >>> kf.P *= 0.2 # initial uncertainty 243 | >>> z_std = 0.1 244 | >>> kf.R = np.diag([z_std**2, z_std**2]) # 1 standard 245 | >>> kf.Q = Q_discrete_white_noise(dim=2, dt=dt, var=0.01**2, block_size=2) 246 | >>> 247 | >>> zs = [[i+randn()*z_std, i+randn()*z_std] for i in range(50)] # measurements 248 | >>> for z in zs: 249 | >>> kf.predict() 250 | >>> kf.update(z) 251 | >>> print(kf.x, 'log-likelihood', kf.log_likelihood) 252 | 253 | For in depth explanations see my book Kalman and Bayesian Filters in Python 254 | https://github.com/rlabbe/Kalman-and-Bayesian-Filters-in-Python 255 | 256 | Also see the filterpy/kalman/tests subdirectory for test code that 257 | may be illuminating. 258 | 259 | References 260 | ---------- 261 | 262 | .. [1] Julier, Simon J. "The scaled unscented transformation," 263 | American Control Converence, 2002, pp 4555-4559, vol 6. 264 | 265 | Online copy: 266 | https://www.cs.unc.edu/~welch/kalman/media/pdf/ACC02-IEEE1357.PDF 267 | 268 | .. [2] E. A. Wan and R. Van der Merwe, “The unscented Kalman filter for 269 | nonlinear estimation,” in Proc. Symp. Adaptive Syst. Signal 270 | Process., Commun. Contr., Lake Louise, AB, Canada, Oct. 2000. 271 | 272 | Online Copy: 273 | https://www.seas.harvard.edu/courses/cs281/papers/unscented.pdf 274 | 275 | .. [3] S. Julier, J. Uhlmann, and H. Durrant-Whyte. "A new method for 276 | the nonlinear transformation of means and covariances in filters 277 | and estimators," IEEE Transactions on Automatic Control, 45(3), 278 | pp. 477-482 (March 2000). 279 | 280 | .. [4] E. A. Wan and R. Van der Merwe, “The Unscented Kalman filter for 281 | Nonlinear Estimation,” in Proc. Symp. Adaptive Syst. Signal 282 | Process., Commun. Contr., Lake Louise, AB, Canada, Oct. 2000. 283 | 284 | https://www.seas.harvard.edu/courses/cs281/papers/unscented.pdf 285 | 286 | .. [5] Wan, Merle "The Unscented Kalman Filter," chapter in *Kalman 287 | Filtering and Neural Networks*, John Wiley & Sons, Inc., 2001. 288 | 289 | .. [6] R. Van der Merwe "Sigma-Point Kalman Filters for Probabilitic 290 | Inference in Dynamic State-Space Models" (Doctoral dissertation) 291 | """ 292 | 293 | def __init__( 294 | self, 295 | dim_x, 296 | dim_z, 297 | dt, 298 | hx, 299 | fx, 300 | points, 301 | sqrt_fn=None, 302 | x_mean_fn=None, 303 | z_mean_fn=None, 304 | residual_x=None, 305 | residual_z=None, 306 | state_add=None, 307 | ): 308 | """ 309 | Create a Kalman filter. You are responsible for setting the 310 | various state variables to reasonable values; the defaults below will 311 | not give you a functional filter. 312 | 313 | """ 314 | 315 | # pylint: disable=too-many-arguments 316 | 317 | self.x = zeros(dim_x) 318 | self.P = eye(dim_x) 319 | self.x_prior = np.copy(self.x) 320 | self.P_prior = np.copy(self.P) 321 | self.Q = eye(dim_x) 322 | self.R = eye(dim_z) 323 | self._dim_x = dim_x 324 | self._dim_z = dim_z 325 | self.points_fn = points 326 | self._dt = dt 327 | self._num_sigmas = points.num_sigmas() 328 | self.hx = hx 329 | self.fx = fx 330 | self.x_mean = x_mean_fn 331 | self.z_mean = z_mean_fn 332 | 333 | # Only computed only if requested via property 334 | self._log_likelihood = log(sys.float_info.min) 335 | self._likelihood = sys.float_info.min 336 | self._mahalanobis = None 337 | 338 | if sqrt_fn is None: 339 | self.msqrt = cholesky 340 | else: 341 | self.msqrt = sqrt_fn 342 | 343 | # weights for the means and covariances. 344 | self.Wm, self.Wc = points.Wm, points.Wc 345 | 346 | if residual_x is None: 347 | self.residual_x = np.subtract 348 | else: 349 | self.residual_x = residual_x 350 | 351 | if residual_z is None: 352 | self.residual_z = np.subtract 353 | else: 354 | self.residual_z = residual_z 355 | 356 | if state_add is None: 357 | self.state_add = np.add 358 | else: 359 | self.state_add = state_add 360 | 361 | # sigma points transformed through f(x) and h(x) 362 | # variables for efficiency so we don't recreate every update 363 | 364 | self.sigmas_f = zeros((self._num_sigmas, self._dim_x)) 365 | self.sigmas_h = zeros((self._num_sigmas, self._dim_z)) 366 | 367 | self.K = np.zeros((dim_x, dim_z)) # Kalman gain 368 | self.y = np.zeros((dim_z)) # residual 369 | self.z = np.array([[None] * dim_z]).T # measurement 370 | self.S = np.zeros((dim_z, dim_z)) # system uncertainty 371 | self.SI = np.zeros((dim_z, dim_z)) # inverse system uncertainty 372 | 373 | self.inv = np.linalg.inv 374 | 375 | # these will always be a copy of x,P after predict() is called 376 | self.x_prior = self.x.copy() 377 | self.P_prior = self.P.copy() 378 | 379 | # these will always be a copy of x,P after update() is called 380 | self.x_post = self.x.copy() 381 | self.P_post = self.P.copy() 382 | 383 | def predict(self, dt=None, UT=None, fx=None, **fx_args): 384 | r""" 385 | Performs the predict step of the UKF. On return, self.x and 386 | self.P contain the predicted state (x) and covariance (P). ' 387 | 388 | Important: this MUST be called before update() is called for the first 389 | time. 390 | 391 | Parameters 392 | ---------- 393 | 394 | dt : double, optional 395 | If specified, the time step to be used for this prediction. 396 | self._dt is used if this is not provided. 397 | 398 | fx : callable f(x, dt, **fx_args), optional 399 | State transition function. If not provided, the default 400 | function passed in during construction will be used. 401 | 402 | UT : function(sigmas, Wm, Wc, noise_cov), optional 403 | Optional function to compute the unscented transform for the sigma 404 | points passed through hx. Typically the default function will 405 | work - you can use x_mean_fn and z_mean_fn to alter the behavior 406 | of the unscented transform. 407 | 408 | **fx_args : keyword arguments 409 | optional keyword arguments to be passed into f(x). 410 | """ 411 | 412 | if dt is None: 413 | dt = self._dt 414 | 415 | if UT is None: 416 | UT = unscented_transform 417 | # print('fupy xy',dt, fx, **fx_args) 418 | 419 | # calculate sigma points for given mean and covariance 420 | self.compute_process_sigmas(dt, fx, **fx_args) 421 | 422 | # and pass sigmas through the unscented transform to compute prior 423 | self.x, self.P = UT( 424 | self.sigmas_f, self.Wm, self.Wc, self.Q, self.x_mean, self.residual_x 425 | ) 426 | 427 | # update sigma points to reflect the new variance of the points 428 | self.sigmas_f = self.points_fn.sigma_points(self.x, self.P) 429 | 430 | # save prior 431 | self.x_prior = np.copy(self.x) 432 | self.P_prior = np.copy(self.P) 433 | 434 | def update(self, z, R=None, UT=None, hx=None, **hx_args): 435 | """ 436 | Update the UKF with the given measurements. On return, 437 | self.x and self.P contain the new mean and covariance of the filter. 438 | 439 | Parameters 440 | ---------- 441 | 442 | z : numpy.array of shape (dim_z) 443 | measurement vector 444 | 445 | R : numpy.array((dim_z, dim_z)), optional 446 | Measurement noise. If provided, overrides self.R for 447 | this function call. 448 | 449 | UT : function(sigmas, Wm, Wc, noise_cov), optional 450 | Optional function to compute the unscented transform for the sigma 451 | points passed through hx. Typically the default function will 452 | work - you can use x_mean_fn and z_mean_fn to alter the behavior 453 | of the unscented transform. 454 | 455 | hx : callable h(x, **hx_args), optional 456 | Measurement function. If not provided, the default 457 | function passed in during construction will be used. 458 | 459 | **hx_args : keyword argument 460 | arguments to be passed into h(x) after x -> h(x, **hx_args) 461 | """ 462 | 463 | if z is None: 464 | self.z = np.array([[None] * self._dim_z]).T 465 | self.x_post = self.x.copy() 466 | self.P_post = self.P.copy() 467 | return 468 | 469 | if hx is None: 470 | hx = self.hx 471 | 472 | if UT is None: 473 | UT = unscented_transform 474 | 475 | if R is None: 476 | R = self.R 477 | elif isscalar(R): 478 | R = eye(self._dim_z) * R 479 | 480 | # pass prior sigmas through h(x) to get measurement sigmas 481 | # the shape of sigmas_h will vary if the shape of z varies, so 482 | # recreate each time 483 | sigmas_h = [] 484 | for s in self.sigmas_f: 485 | sigmas_h.append(hx(s, **hx_args)) 486 | 487 | self.sigmas_h = np.atleast_2d(sigmas_h) 488 | 489 | # mean and covariance of prediction passed through unscented transform 490 | zp, self.S = UT( 491 | self.sigmas_h, self.Wm, self.Wc, R, self.z_mean, self.residual_z 492 | ) 493 | self.SI = self.inv(self.S) 494 | 495 | # compute cross variance of the state and the measurements 496 | Pxz = self.cross_variance(self.x, zp, self.sigmas_f, self.sigmas_h) 497 | 498 | self.K = dot(Pxz, self.SI) # Kalman gain 499 | self.y = self.residual_z(z, zp) # residual 500 | 501 | # update Gaussian state estimate (x, P) 502 | self.x = self.state_add(self.x, dot(self.K, self.y)) 503 | self.P = self.P - dot(self.K, dot(self.S, self.K.T)) 504 | 505 | # save measurement and posterior state 506 | self.z = deepcopy(z) 507 | self.x_post = self.x.copy() 508 | self.P_post = self.P.copy() 509 | 510 | # set to None to force recompute 511 | self._log_likelihood = None 512 | self._likelihood = None 513 | self._mahalanobis = None 514 | 515 | def cross_variance(self, x, z, sigmas_f, sigmas_h): 516 | """ 517 | Compute cross variance of the state `x` and measurement `z`. 518 | """ 519 | 520 | Pxz = zeros((sigmas_f.shape[1], sigmas_h.shape[1])) 521 | N = sigmas_f.shape[0] 522 | for i in range(N): 523 | dx = self.residual_x(sigmas_f[i], x) 524 | dz = self.residual_z(sigmas_h[i], z) 525 | Pxz += self.Wc[i] * outer(dx, dz) 526 | return Pxz 527 | 528 | def compute_process_sigmas(self, dt, fx=None, **fx_args): 529 | """ 530 | computes the values of sigmas_f. Normally a user would not call 531 | this, but it is useful if you need to call update more than once 532 | between calls to predict (to update for multiple simultaneous 533 | measurements), so the sigmas correctly reflect the updated state 534 | x, P. 535 | """ 536 | 537 | if fx is None: 538 | fx = self.fx 539 | 540 | # calculate sigma points for given mean and covariance 541 | sigmas = self.points_fn.sigma_points(self.x, self.P) 542 | 543 | for i, s in enumerate(sigmas): 544 | self.sigmas_f[i] = fx(s, dt, **fx_args) 545 | 546 | def batch_filter(self, zs, Rs=None, dts=None, UT=None, saver=None): 547 | """ 548 | Performs the UKF filter over the list of measurement in `zs`. 549 | 550 | Parameters 551 | ---------- 552 | 553 | zs : list-like 554 | list of measurements at each time step `self._dt` Missing 555 | measurements must be represented by 'None'. 556 | 557 | Rs : None, np.array or list-like, default=None 558 | optional list of values to use for the measurement error 559 | covariance R. 560 | 561 | If Rs is None then self.R is used for all epochs. 562 | 563 | If it is a list of matrices or a 3D array where 564 | len(Rs) == len(zs), then it is treated as a list of R values, one 565 | per epoch. This allows you to have varying R per epoch. 566 | 567 | dts : None, scalar or list-like, default=None 568 | optional value or list of delta time to be passed into predict. 569 | 570 | If dtss is None then self.dt is used for all epochs. 571 | 572 | If it is a list where len(dts) == len(zs), then it is treated as a 573 | list of dt values, one per epoch. This allows you to have varying 574 | epoch durations. 575 | 576 | UT : function(sigmas, Wm, Wc, noise_cov), optional 577 | Optional function to compute the unscented transform for the sigma 578 | points passed through hx. Typically the default function will 579 | work - you can use x_mean_fn and z_mean_fn to alter the behavior 580 | of the unscented transform. 581 | 582 | saver : filterpy.common.Saver, optional 583 | filterpy.common.Saver object. If provided, saver.save() will be 584 | called after every epoch 585 | 586 | Returns 587 | ------- 588 | 589 | means: ndarray((n,dim_x,1)) 590 | array of the state for each time step after the update. Each entry 591 | is an np.array. In other words `means[k,:]` is the state at step 592 | `k`. 593 | 594 | covariance: ndarray((n,dim_x,dim_x)) 595 | array of the covariances for each time step after the update. 596 | In other words `covariance[k,:,:]` is the covariance at step `k`. 597 | 598 | Examples 599 | -------- 600 | 601 | .. code-block:: Python 602 | 603 | # this example demonstrates tracking a measurement where the time 604 | # between measurement varies, as stored in dts The output is then smoothed 605 | # with an RTS smoother. 606 | 607 | zs = [t + random.randn()*4 for t in range (40)] 608 | 609 | (mu, cov, _, _) = ukf.batch_filter(zs, dts=dts) 610 | (xs, Ps, Ks) = ukf.rts_smoother(mu, cov) 611 | 612 | """ 613 | # pylint: disable=too-many-arguments 614 | 615 | try: 616 | z = zs[0] 617 | except TypeError: 618 | raise TypeError("zs must be list-like") 619 | 620 | if self._dim_z == 1: 621 | if not (isscalar(z) or (z.ndim == 1 and len(z) == 1)): 622 | raise TypeError("zs must be a list of scalars or 1D, 1 element arrays") 623 | else: 624 | if len(z) != self._dim_z: 625 | raise TypeError( 626 | "each element in zs must be a 1D array of length {}".format( 627 | self._dim_z 628 | ) 629 | ) 630 | 631 | z_n = np.size(zs, 0) 632 | if Rs is None: 633 | Rs = [self.R] * z_n 634 | 635 | if dts is None: 636 | dts = [self._dt] * z_n 637 | 638 | # mean estimates from Kalman Filter 639 | if self.x.ndim == 1: 640 | means = zeros((z_n, self._dim_x)) 641 | else: 642 | means = zeros((z_n, self._dim_x, 1)) 643 | 644 | # state covariances from Kalman Filter 645 | covariances = zeros((z_n, self._dim_x, self._dim_x)) 646 | 647 | for i, (z, r, dt) in enumerate(zip(zs, Rs, dts)): 648 | self.predict(dt=dt, UT=UT) 649 | self.update(z, r, UT=UT) 650 | means[i, :] = self.x 651 | covariances[i, :, :] = self.P 652 | 653 | if saver is not None: 654 | saver.save() 655 | 656 | return (means, covariances) 657 | 658 | def rts_smoother(self, Xs, Ps, Qs=None, dts=None, UT=None): 659 | """ 660 | Runs the Rauch-Tung-Striebel Kalman smoother on a set of 661 | means and covariances computed by the UKF. The usual input 662 | would come from the output of `batch_filter()`. 663 | 664 | Parameters 665 | ---------- 666 | 667 | Xs : numpy.array 668 | array of the means (state variable x) of the output of a Kalman 669 | filter. 670 | 671 | Ps : numpy.array 672 | array of the covariances of the output of a kalman filter. 673 | 674 | Qs: list-like collection of numpy.array, optional 675 | Process noise of the Kalman filter at each time step. Optional, 676 | if not provided the filter's self.Q will be used 677 | 678 | dt : optional, float or array-like of float 679 | If provided, specifies the time step of each step of the filter. 680 | If float, then the same time step is used for all steps. If 681 | an array, then each element k contains the time at step k. 682 | Units are seconds. 683 | 684 | UT : function(sigmas, Wm, Wc, noise_cov), optional 685 | Optional function to compute the unscented transform for the sigma 686 | points passed through hx. Typically the default function will 687 | work - you can use x_mean_fn and z_mean_fn to alter the behavior 688 | of the unscented transform. 689 | 690 | Returns 691 | ------- 692 | 693 | x : numpy.ndarray 694 | smoothed means 695 | 696 | P : numpy.ndarray 697 | smoothed state covariances 698 | 699 | K : numpy.ndarray 700 | smoother gain at each step 701 | 702 | Examples 703 | -------- 704 | 705 | .. code-block:: Python 706 | 707 | zs = [t + random.randn()*4 for t in range (40)] 708 | 709 | (mu, cov, _, _) = kalman.batch_filter(zs) 710 | (x, P, K) = rts_smoother(mu, cov, fk.F, fk.Q) 711 | """ 712 | # pylint: disable=too-many-locals, too-many-arguments 713 | 714 | if len(Xs) != len(Ps): 715 | raise ValueError("Xs and Ps must have the same length") 716 | 717 | n, dim_x = Xs.shape 718 | 719 | if dts is None: 720 | dts = [self._dt] * n 721 | elif isscalar(dts): 722 | dts = [dts] * n 723 | 724 | if Qs is None: 725 | Qs = [self.Q] * n 726 | 727 | if UT is None: 728 | UT = unscented_transform 729 | 730 | # smoother gain 731 | Ks = zeros((n, dim_x, dim_x)) 732 | 733 | num_sigmas = self._num_sigmas 734 | 735 | xs, ps = Xs.copy(), Ps.copy() 736 | sigmas_f = zeros((num_sigmas, dim_x)) 737 | 738 | for k in reversed(range(n - 1)): 739 | # create sigma points from state estimate, pass through state func 740 | sigmas = self.points_fn.sigma_points(xs[k], ps[k]) 741 | for i in range(num_sigmas): 742 | sigmas_f[i] = self.fx(sigmas[i], dts[k]) 743 | 744 | xb, Pb = UT( 745 | sigmas_f, self.Wm, self.Wc, self.Q, self.x_mean, self.residual_x 746 | ) 747 | 748 | # compute cross variance 749 | Pxb = 0 750 | for i in range(num_sigmas): 751 | y = self.residual_x(sigmas_f[i], xb) 752 | z = self.residual_x(sigmas[i], Xs[k]) 753 | Pxb += self.Wc[i] * outer(z, y) 754 | 755 | # compute gain 756 | K = dot(Pxb, self.inv(Pb)) 757 | 758 | # update the smoothed estimates 759 | xs[k] += dot(K, self.residual_x(xs[k + 1], xb)) 760 | ps[k] += dot(K, ps[k + 1] - Pb).dot(K.T) # pylint: disable=no-member 761 | Ks[k] = K 762 | 763 | return (xs, ps, Ks) 764 | 765 | @property 766 | def log_likelihood(self): 767 | """ 768 | log-likelihood of the last measurement. 769 | """ 770 | if self._log_likelihood is None: 771 | self._log_likelihood = logpdf(self.y, 0, self.S) 772 | # self._log_likelihood = logpdf(x=self.y, 0, cov=self.S) 773 | return self._log_likelihood 774 | 775 | @property 776 | def likelihood(self): 777 | """ 778 | Computed from the log-likelihood. The log-likelihood can be very 779 | small, meaning a large negative value such as -28000. Taking the 780 | exp() of that results in 0.0, which can break typical algorithms 781 | which multiply by this value, so by default we always return a 782 | number >= sys.float_info.min. 783 | """ 784 | if self._likelihood is None: 785 | self._likelihood = exp(self.log_likelihood) 786 | if self._likelihood == 0: 787 | self._likelihood = sys.float_info.min 788 | return self._likelihood 789 | 790 | @property 791 | def mahalanobis(self): 792 | """ " 793 | Mahalanobis distance of measurement. E.g. 3 means measurement 794 | was 3 standard deviations away from the predicted value. 795 | 796 | Returns 797 | ------- 798 | mahalanobis : float 799 | """ 800 | if self._mahalanobis is None: 801 | self._mahalanobis = sqrt(float(dot(dot(self.y.T, self.SI), self.y))) 802 | return self._mahalanobis 803 | 804 | def __repr__(self): 805 | return "\n".join( 806 | [ 807 | "UnscentedKalmanFilter object", 808 | pretty_str("x", self.x), 809 | pretty_str("P", self.P), 810 | pretty_str("x_prior", self.x_prior), 811 | pretty_str("P_prior", self.P_prior), 812 | pretty_str("Q", self.Q), 813 | pretty_str("R", self.R), 814 | pretty_str("S", self.S), 815 | pretty_str("K", self.K), 816 | pretty_str("y", self.y), 817 | pretty_str("log-likelihood", self.log_likelihood), 818 | pretty_str("likelihood", self.likelihood), 819 | pretty_str("mahalanobis", self.mahalanobis), 820 | pretty_str("sigmas_f", self.sigmas_f), 821 | pretty_str("h", self.sigmas_h), 822 | pretty_str("Wm", self.Wm), 823 | pretty_str("Wc", self.Wc), 824 | pretty_str("residual_x", self.residual_x), 825 | pretty_str("residual_z", self.residual_z), 826 | pretty_str("msqrt", self.msqrt), 827 | pretty_str("hx", self.hx), 828 | pretty_str("fx", self.fx), 829 | pretty_str("x_mean", self.x_mean), 830 | pretty_str("z_mean", self.z_mean), 831 | ] 832 | ) 833 | -------------------------------------------------------------------------------- /custom_components/multizone_thermostat/pwm_nesting.py: -------------------------------------------------------------------------------- 1 | """Nesting routine. 2 | 3 | Nesting of rooms by pwm and area size to get equal heat distribution 4 | and determine when master needs to be operated 5 | rooms switch delay are determined. 6 | """ 7 | 8 | import copy 9 | import itertools 10 | import logging 11 | from math import ceil, floor 12 | import time 13 | 14 | import numpy as np 15 | 16 | from . import DOMAIN 17 | from .const import ( 18 | ATTR_CONTROL_OFFSET, 19 | ATTR_CONTROL_PWM_OUTPUT, 20 | ATTR_ROOMS, 21 | ATTR_ROUNDED_PWM, 22 | ATTR_SCALED_PWM, 23 | CONF_AREA, 24 | CONF_PWM_DURATION, 25 | CONF_PWM_SCALE, 26 | NESTING_BALANCE, 27 | NESTING_DOMINANCE, 28 | NESTING_MARGIN, 29 | NESTING_MATRIX, 30 | NestingMode, 31 | ) 32 | 33 | 34 | class Nesting: 35 | """Nest rooms by area size and pwm in order to get equal heat requirement.""" 36 | 37 | def __init__( 38 | self, 39 | name: str, 40 | operation_mode: NestingMode, 41 | master_pwm: float, 42 | tot_area: float, 43 | min_load: float, 44 | pwm_threshold: float, 45 | min_prop_valve_opening: float, 46 | ) -> None: 47 | """Prepare nesting config. 48 | 49 | pwm max is equal to pwm scale 50 | all provided pwm per room are equal in pwm scale 51 | """ 52 | self._logger = logging.getLogger(DOMAIN).getChild(name + ".nesting") 53 | self.operation_mode = operation_mode 54 | 55 | self.master_pwm = master_pwm 56 | self.master_pwm_scale = NESTING_MATRIX / self.master_pwm 57 | self.min_area = min_load * NESTING_MATRIX 58 | self.pwm_threshold = pwm_threshold / self.master_pwm * NESTING_MATRIX 59 | self.min_prop_valve_opening = min_prop_valve_opening * NESTING_MATRIX 60 | self.area_scale = NESTING_MATRIX / tot_area 61 | 62 | self.packed = [] 63 | self.scale_factor = {} 64 | self.offset = {} 65 | self.cleaned_rooms = [] 66 | self.area = [] 67 | self.rooms = [] 68 | self.pwm = [] 69 | self.real_pwm = [] 70 | self.start_time = [] 71 | 72 | # proportional valves 73 | self.prop_pwm = [] 74 | self.prop_area = [] 75 | 76 | @property 77 | def load_on_off(self): 78 | """Nesting sum product of room area and pwm.""" 79 | if len(self.pwm) == 0: 80 | return 0 81 | else: 82 | return sum([pwm_i * area_i for pwm_i, area_i in zip(self.area, self.pwm)]) 83 | 84 | @property 85 | def load_prop(self): 86 | """Nesting proportional valves,sum product of room area and pwm.""" 87 | if len(self.prop_pwm) == 0: 88 | return 0 89 | else: 90 | return sum( 91 | [pwm_i * area_i for pwm_i, area_i in zip(self.prop_pwm, self.prop_area)] 92 | ) 93 | 94 | @property 95 | def load_total(self): 96 | """Nesting all rooms, sum product of room area and pwm.""" 97 | return self.load_on_off + self.load_prop 98 | 99 | @property 100 | def area_avg_prop(self): 101 | """Continuous heat request from rooms with prop valves.""" 102 | return self.load_prop / NESTING_MATRIX 103 | 104 | @property 105 | def max_all_pwm(self): 106 | """Max pwm of all rooms.""" 107 | if self.pwm or self.pwm_prop: 108 | return max(*self.pwm, *self.prop_pwm) 109 | else: 110 | return 0 111 | 112 | @property 113 | def min_pwm_on_off(self): 114 | """Min pwm value of on-off valves.""" 115 | if len(self.pwm) == 0: 116 | return 0 117 | 118 | none_zero_pwm = [i for i in self.pwm if i != 0] 119 | if len(none_zero_pwm) == 1: 120 | return 0 121 | 122 | return min(none_zero_pwm) 123 | 124 | @property 125 | def max_pwm_on_off(self): 126 | """Maxmimum pwm for on-off valves.""" 127 | return max(self.pwm) 128 | 129 | @property 130 | def sum_pwm_on_off(self): 131 | """Sum pwm for on-off valves.""" 132 | return sum(self.pwm) 133 | 134 | @property 135 | def min_area_on_off(self): 136 | """Minimum loading required by on-off valves.""" 137 | return max(0, self.min_area - self.area_avg_prop) 138 | 139 | def pwm_for_continuous_mode(self): 140 | """Calculate nesting pwm for continous mode.""" 141 | if self.min_area_on_off > 0: 142 | sum_pwm = 0 143 | load_area_rest = 0 144 | for i, a_i in enumerate(self.area): 145 | if a_i >= self.min_area_on_off: 146 | sum_pwm += self.pwm[i] 147 | else: 148 | load_area_rest = sum( 149 | [a * b for a, b in zip(self.area[i:], self.pwm[i:])] 150 | ) 151 | break 152 | 153 | # calc pwm duration for rest based on min_load compensated by prop valves 154 | pwm_rest = load_area_rest / self.min_area_on_off 155 | return_value = sum_pwm + pwm_rest 156 | 157 | # no minimum defined thus check if continuous is possible 158 | # when sum is less than pwm scale 159 | # use reduced pwm scale instead 160 | else: 161 | # no prop valves 162 | return_value = self.sum_pwm_on_off 163 | 164 | return return_value 165 | 166 | def pwm_for_balanced_mode(self): 167 | """Calculate nesting pwm for balanced mode.""" 168 | # max area with pwm > 0 169 | max_area = max([a_i for i, a_i in enumerate(self.area) if self.pwm[i] != 0]) 170 | 171 | # min domain nesting due to max area and max pwm 172 | load_envelope = max_area * self.max_pwm_on_off 173 | 174 | # shortest possible nesting 175 | min_pwm_nesting = self.max_pwm_on_off + self.min_pwm_on_off 176 | 177 | # pwm in case heat requirement with lowest continuous load 178 | # no nesting options 179 | if self.min_pwm_on_off == 0: 180 | return self.max_pwm_on_off 181 | 182 | # largest room is dominant: too little freedom for nesting 183 | # max area = peak load; max pwm is duration; if load_area is less nesting in pwm_max 184 | if load_envelope / self.load_on_off > NESTING_DOMINANCE: 185 | return self.max_pwm_on_off 186 | 187 | # min pwm extension would result in too low load 188 | if self.load_on_off / min_pwm_nesting < self.min_area_on_off > 0: 189 | return self.max_pwm_on_off 190 | 191 | # on-off needs nesting above minimum limit 192 | if self.min_area_on_off > 0: 193 | area_on_off = min(self.min_area_on_off, max_area) 194 | return_value = max(self.max_pwm_on_off, self.load_on_off / area_on_off) 195 | else: 196 | return_value = self.load_on_off / max_area 197 | 198 | return max(min_pwm_nesting, return_value) 199 | 200 | def pwm_for_minimum_mode(self): 201 | """Calculate nesting pwm for minimal on mode.""" 202 | nested_pwm = self.load_on_off / NESTING_MATRIX 203 | return_value = max(self.max_pwm_on_off, nested_pwm) 204 | 205 | # pwm is lower than threshold without lower load 206 | if return_value < self.pwm_threshold and self.min_area_on_off == 0: 207 | return_value = self.pwm_threshold 208 | 209 | # pwm is lower than threshold with lower load 210 | elif return_value < self.pwm_threshold and self.min_area_on_off > 0: 211 | if ( 212 | min(self.load_on_off / self.min_area_on_off, self.sum_pwm_on_off) 213 | > self.pwm_threshold 214 | ): 215 | return_value = self.pwm_threshold 216 | else: 217 | return_value = 0 218 | 219 | return return_value 220 | 221 | @property 222 | def pwm_for_nesting(self) -> int: 223 | """Determine size of pwm for nesting.""" 224 | return_value = 0 225 | 226 | # no heat required 227 | if self.max_pwm_on_off == 0: 228 | return 0 229 | 230 | # # check if load requirement is too low 231 | if self.pwm_threshold > 0 and self.min_area_on_off > 0: 232 | if self.pwm_threshold * self.min_area > self.load_total: 233 | return 0 234 | 235 | # continuous operation possible due to prop valves 236 | if ( 237 | self.operation_mode 238 | in [NestingMode.MASTER_CONTINUOUS, NestingMode.MASTER_BALANCED] 239 | and self.area_avg_prop > self.min_area > 0 240 | ): 241 | return NESTING_MATRIX 242 | 243 | # pwm as high as possible 244 | if self.operation_mode == NestingMode.MASTER_CONTINUOUS: 245 | return_value = self.pwm_for_continuous_mode() 246 | 247 | # balanced pwm duration versus heat requirement 248 | elif self.operation_mode == NestingMode.MASTER_BALANCED: 249 | return_value = self.pwm_for_balanced_mode() 250 | 251 | # max of pwm signals 252 | elif self.operation_mode == NestingMode.MASTER_MIN_ON: 253 | return_value = self.pwm_for_minimum_mode() 254 | 255 | # pwm below threshold 256 | if self.min_area_on_off > 0 and return_value < self.pwm_threshold > 0: 257 | return_value = 0 258 | 259 | # bound output minimal to max pwm and nesting matrix 260 | return_value = min(return_value, NESTING_MATRIX) 261 | 262 | # avoid too short off period when pwm threshold is specified 263 | if ( 264 | self.pwm_threshold > 0 265 | and return_value != NESTING_MATRIX 266 | and return_value + self.pwm_threshold > NESTING_MATRIX 267 | ): 268 | return_value = NESTING_MATRIX - self.pwm_threshold 269 | 270 | return int(ceil(return_value)) 271 | 272 | def max_nested_pwm( 273 | self, dt: float | None = None, forced_room: int | None = None 274 | ) -> float: 275 | """Get max length of self.packed.""" 276 | if self.packed: 277 | max_packed = max([len(area_i) for lid in self.packed for area_i in lid]) 278 | else: 279 | max_packed = 0 280 | 281 | if forced_room is not None: 282 | # check new room heat requirement to stretch pwm 283 | if ( 284 | max_packed < dt + self.pwm[forced_room] 285 | and self.area[forced_room] < 0.15 * NESTING_MATRIX 286 | ): 287 | return max_packed 288 | # lengthen nested pwm when new room requires sufficient heat 289 | else: 290 | return dt + self.pwm[forced_room] 291 | else: 292 | return max_packed 293 | 294 | def satelite_data(self, sat_data: dict) -> None: 295 | """Convert new satelite data to correct format.""" 296 | # clear previous nesting 297 | self.area = [] 298 | self.rooms = [] 299 | self.pwm = [] 300 | self.real_pwm = [] 301 | self.scale_factor = {} 302 | new_data = {} 303 | 304 | self.prop_pwm = [] 305 | self.prop_area = [] 306 | 307 | if not sat_data: 308 | return 309 | 310 | new_data = { 311 | key: [] 312 | for key in [CONF_AREA, ATTR_ROOMS, ATTR_ROUNDED_PWM, ATTR_SCALED_PWM] 313 | } 314 | 315 | for room, data in sat_data.items(): 316 | # scale room pwm to master 317 | scale_factor = ( 318 | # self.master_pwm / data[CONF_PWM_SCALE] * self.master_pwm_scale 319 | NESTING_MATRIX / data[CONF_PWM_SCALE] 320 | ) 321 | self.scale_factor[room] = scale_factor 322 | 323 | # ignore proportional valves 324 | if data[CONF_PWM_DURATION] > 0: 325 | new_data[CONF_AREA].append(int(ceil(data[CONF_AREA] * self.area_scale))) 326 | new_data[ATTR_ROUNDED_PWM].append( 327 | int(ceil(data[ATTR_CONTROL_PWM_OUTPUT] * scale_factor)) 328 | ) 329 | new_data[ATTR_SCALED_PWM].append( 330 | data[ATTR_CONTROL_PWM_OUTPUT] * scale_factor 331 | ) 332 | new_data[ATTR_ROOMS].append(room) 333 | else: 334 | self.prop_pwm.append(data[ATTR_CONTROL_PWM_OUTPUT] * scale_factor) 335 | self.prop_area.append(int(ceil(data[CONF_AREA] * self.area_scale))) 336 | 337 | if bool([a for a in new_data.values() if a == []]): 338 | return 339 | 340 | # area is constant and thereby sort on area gives 341 | # more constant routine order, largest room is expected 342 | # to require most heat thus dominant and most important 343 | # self.area, self.rooms, self.pwm, self.real_area, self.real_pwm = zip( 344 | self.area, self.rooms, self.pwm, self.real_pwm = ( 345 | list(x) 346 | for x in zip( 347 | *sorted( 348 | zip( 349 | new_data[CONF_AREA], 350 | new_data[ATTR_ROOMS], 351 | new_data[ATTR_ROUNDED_PWM], 352 | new_data[ATTR_SCALED_PWM], 353 | ), 354 | reverse=True, 355 | ) 356 | ) 357 | ) 358 | 359 | def lid_segment(self, dt: int = None, forced_room: int | None = None) -> list: 360 | """Create a lid to store room nesting onto.""" 361 | if forced_room is not None or self.packed: 362 | return [None] * self.max_nested_pwm(dt, forced_room) 363 | 364 | if self.packed: 365 | return [None] * self.max_nested_pwm() 366 | else: 367 | return [None] * self.pwm_for_nesting 368 | 369 | def create_lid(self, room_index: int, dt: int | None = None) -> None: 370 | """Create a 2d array with length of pwm_max and rows equal to area. 371 | 372 | fill/est array with room id equal to required pwm 373 | """ 374 | if self.pwm_for_nesting == 0: 375 | return 376 | 377 | if dt is not None: 378 | # full nesting run 379 | time_shift = dt 380 | if ( 381 | self.max_nested_pwm() < time_shift 382 | and self.area[room_index] < 0.15 * NESTING_MATRIX 383 | ): 384 | return 385 | forced_room = room_index 386 | else: 387 | # during middle of pwm loop 388 | forced_room = None 389 | time_shift = 0 390 | 391 | # newly created lid 392 | new_lid = np.array( 393 | copy.deepcopy( 394 | [ 395 | self.lid_segment(dt, forced_room) 396 | for i in range(int(self.area[room_index])) 397 | ] 398 | ), 399 | dtype=object, 400 | ) 401 | 402 | max_len = min(self.pwm[room_index] + time_shift, new_lid.shape[1]) 403 | 404 | # fill new lid with current room pwm need 405 | new_lid[ 406 | :, # 0 : int(ceil(self.area[room_index])), 407 | time_shift:max_len, 408 | ] = self.rooms[room_index] 409 | self.packed.append(new_lid) 410 | 411 | def insert_room(self, room_index: int, dt: int = 0) -> bool: 412 | """Insert room to current nesting and return success.""" 413 | nested = False 414 | options = [] # temp storage of each free spaces per room-area segment 415 | opt_arr = [] # temp storage of available array area-pwm sizes 416 | 417 | # loop over all stored lids and area segments and 418 | # store free pwm per area segment 419 | for i_l, lid_i in enumerate(self.packed): 420 | # add list for each lid to store free space 421 | opt_arr.append([]) 422 | 423 | # loop over area segments (area size) 424 | for area_segment, _ in enumerate(lid_i): 425 | # loop over pwm (len = pwm max) 426 | try: 427 | start_empty = list(lid_i[area_segment]).index(None) 428 | start_empty = max( 429 | start_empty, dt 430 | ) # when run in the middle of pwm loop 431 | except ValueError: 432 | start_empty = None 433 | 434 | if ( 435 | start_empty is not None 436 | and list(reversed(lid_i[area_segment])).index(None) 437 | == 0 # only when end is clear 438 | ): 439 | options.append( 440 | [ 441 | i_l, 442 | area_segment, 443 | len(lid_i[area_segment]) - start_empty, 444 | ] 445 | ) 446 | 447 | # determine all possible free area-pwm size combinations 448 | if options: 449 | for lid_i, area_segment, pwm_i in options: 450 | if not opt_arr[lid_i]: 451 | # first loop: store first area step 452 | # [2] = 1 as start how many adjacent area segments have 453 | # at least the same free pwm space 454 | opt_arr[lid_i].append([lid_i, pwm_i, 1, area_segment]) 455 | else: 456 | # loop over all stored free space 457 | # and check if current area segment has at least the same free pwm space 458 | for arr in opt_arr[lid_i]: 459 | # if area segment is from same lid and pwm_i is equal or larger then 'arr' pwm size 460 | if pwm_i >= arr[1] and lid_i == arr[0]: 461 | # increase adjacent segment counter 462 | arr[2] += 1 463 | # when pwm_i is larger than other stored free space options 464 | # create a new options with pwm size equal to pwm_i 465 | if max(np.array(opt_arr[lid_i], dtype=object)[:, 1]) < pwm_i: 466 | opt_arr[lid_i].append([lid_i, pwm_i, 1, area_segment]) 467 | 468 | # loop over all free area-pwm options and check if room area-pwm fits free spaces 469 | for lid_i in opt_arr: 470 | # loop over all free spaces 471 | for arr in lid_i: 472 | # determine utilisation of free space for current room pwm 473 | if ( 474 | arr[1] >= self.pwm[room_index] 475 | and arr[2] >= self.area[room_index] 476 | ): 477 | arr.append( 478 | (self.pwm[room_index] / arr[1]) 479 | * (self.area[room_index] / arr[2]) 480 | ) 481 | else: 482 | # current room pwm does not fit this free space 483 | arr.append(-1) 484 | 485 | # when free space(s) are available 486 | # determine best fitting options 487 | if opt_arr: 488 | final_opt = [] 489 | lid_i = None 490 | y_width = None 491 | x_start = None 492 | 493 | # extract storage options 494 | # remove of options per lid and merge them 495 | for lid_id in opt_arr: 496 | for store_option in lid_id: 497 | if store_option[-1] != -1: 498 | final_opt.append(store_option) 499 | 500 | if not final_opt: 501 | return nested 502 | 503 | # when multiple storage options are present select the one with best fit 504 | if len(final_opt) > 1: 505 | fill_list = np.array(final_opt, dtype=object)[:, 4] 506 | best_index = fill_list.tolist().index(max(fill_list)) 507 | lid_i, y_width, _, x_start, _ = final_opt[best_index] 508 | 509 | # one option thus no choice 510 | elif len(final_opt[0]) == 5 and final_opt[0][-1] != -1: 511 | lid_i, y_width, _, x_start, _ = final_opt[0] 512 | 513 | # nest best found free space option with current room area-pwm 514 | if lid_i is not None: 515 | nested = True 516 | # fill free space with room pwm 517 | # select lid with free space 518 | mod_lid = self.packed[lid_i] 519 | lid_bckup = copy.deepcopy(mod_lid) 520 | 521 | # fill area segments and pwm space with room id 522 | try: 523 | mod_lid[ 524 | x_start : x_start + self.area[room_index], 525 | np.shape(mod_lid)[1] - y_width : np.shape(mod_lid)[1] 526 | - y_width 527 | + self.pwm[room_index], 528 | ] = self.rooms[room_index] 529 | except IndexError as e: 530 | nested = False 531 | mod_lid = lid_bckup 532 | self._logger.error("in nesting %s", mod_lid) 533 | self._logger.error("error: %s", str(e)) 534 | return nested 535 | 536 | def nest_rooms(self, data: dict = None) -> None: 537 | """Nest the rooms to get balanced heat requirement.""" 538 | self.start_time = time.time() 539 | self.packed = [] 540 | self.cleaned_rooms = [] 541 | self.offset = {} 542 | 543 | self.satelite_data(data) 544 | 545 | if self.area is None or all(pwm == 0 for pwm in self.pwm): 546 | return 547 | 548 | # loop through rooms 549 | # and create 2D arrays nested with room area-pwm 550 | # the maximum row size (pwm) is pwm_max 551 | # column size is variable and depends on nesting fit 552 | for i_r, _ in enumerate(self.rooms): 553 | if self.pwm[i_r] == 0: 554 | continue 555 | 556 | # first room in loop 557 | if not self.packed: 558 | self.create_lid(i_r) 559 | # check if current room area-pwm fits in any free space 560 | elif not self.insert_room(i_r): 561 | # no option thus create new lid to store room pwm 562 | self.create_lid(i_r) 563 | 564 | def distribute_nesting(self) -> None: 565 | """Shuffles packs to get best distribution.""" 566 | if not self.packed: 567 | return 568 | 569 | if len(self.packed) == 1: 570 | return 571 | 572 | if self.operation_mode == NestingMode.MASTER_CONTINUOUS: 573 | # shuffle list to mix start and ends 574 | for i, lid_i in enumerate(self.packed): 575 | if i % 2: 576 | for area_segment, _ in enumerate(lid_i): 577 | lid_i[area_segment] = list(reversed(lid_i[area_segment])) 578 | 579 | # create list of variations 580 | option_list = list( 581 | itertools.product([False, True], repeat=len(self.packed)) 582 | ) 583 | 584 | # remove duplicate options 585 | len_options = len(option_list) - 1 586 | for i_o, opt in enumerate(reversed(option_list)): 587 | if [not elem for elem in opt] in option_list: 588 | option_list.pop(len_options - i_o) 589 | 590 | # loop through all options 591 | for opt in option_list: 592 | test_set = copy.deepcopy(self.packed) 593 | 594 | # loop through lids and check if reverse is required 595 | for i_p, lid_i in enumerate(test_set): 596 | if opt[i_p]: 597 | for area_segment, _ in enumerate(lid_i): 598 | lid_i[area_segment] = list(reversed(lid_i[area_segment])) 599 | 600 | # check load balance 601 | balance_result = self.nesting_balance(test_set) 602 | 603 | # check if balance is small enough 604 | if balance_result is not None: 605 | if abs(balance_result) <= NESTING_BALANCE: 606 | self.packed = test_set 607 | self._logger.debug( 608 | "finished time %.4f, balance %.4f", 609 | time.time() - self.start_time, 610 | balance_result, 611 | ) 612 | return 613 | 614 | # balanced mode or min pwm 615 | else: 616 | # check current balance 617 | balance_result = self.nesting_balance(self.packed) 618 | if balance_result is not None: 619 | if abs(balance_result) <= NESTING_BALANCE: 620 | return 621 | 622 | for lid_i in reversed(self.packed): 623 | for area_segment, _ in enumerate(lid_i): 624 | lid_i[area_segment] = list(reversed(lid_i[area_segment])) 625 | 626 | # determine the equality over pwm 627 | balance_result = self.nesting_balance(self.packed) 628 | self._logger.debug( 629 | "nesting balance %.4f", 630 | balance_result, 631 | ) 632 | 633 | if balance_result is not None: 634 | if abs(balance_result) <= NESTING_BALANCE: 635 | return 636 | 637 | def nesting_balance(self, test_set: list) -> float | None: 638 | """Get balance of areas over pwm signal.""" 639 | cleaned_area = [] 640 | if not test_set: 641 | return None 642 | 643 | for i, lid in enumerate(test_set): 644 | # loop over pwm 645 | for i_2, _ in enumerate(lid[0]): 646 | # last lid add space to store .. 647 | if i == 0: 648 | cleaned_area.append(0) 649 | 650 | # extract unique rooms by fromkeys method 651 | rooms = list(dict.fromkeys(lid[:, i_2])) 652 | for room in rooms: 653 | if room is not None: 654 | room_area = self.area[self.rooms.index(room)] / self.area_scale 655 | cleaned_area[i_2] += room_area 656 | 657 | if 0 in cleaned_area: 658 | return 1 659 | 660 | moment_area = 0 661 | for i, area in enumerate(cleaned_area): 662 | moment_area += i * area 663 | self._logger.debug("area distribution \n %s", cleaned_area) 664 | 665 | return (moment_area / sum(cleaned_area) - (len(cleaned_area) - 1) / 2) / len( 666 | cleaned_area 667 | ) 668 | 669 | def get_nesting(self) -> dict: 670 | """Get offset per room with offset in satellite pwm scale.""" 671 | len_pwm = self.max_nested_pwm() 672 | if len_pwm == 0: 673 | return {} 674 | 675 | self.offset = {} 676 | self.cleaned_rooms = [[] for _ in range(len_pwm)] 677 | for lid in self.packed: 678 | # loop over pwm 679 | # first check if some are at end 680 | # extract unique rooms by fromkeys method 681 | if len_pwm == NESTING_MATRIX: 682 | # self.operation_mode == NestingMode.MASTER_CONTINUOUS 683 | # and self.pwm_for_nesting == NESTING_MATRIX 684 | rooms = list(dict.fromkeys(lid[:, -1])) 685 | rooms = [r_i for r_i in rooms if r_i is not None] 686 | if not rooms: 687 | continue 688 | for room in rooms: 689 | self.cleaned_rooms[len_pwm - 1].append(room) 690 | if room not in self.offset: 691 | room_pwm = self.real_pwm[self.rooms.index(room)] 692 | # offset in satellite pwm scale 693 | self.offset[room] = ( 694 | NESTING_MATRIX - room_pwm 695 | ) / self.scale_factor[room] 696 | 697 | # define offsets others 698 | for i_2 in range(lid.shape[1]): 699 | # last one already done 700 | if i_2 < len_pwm - 1: 701 | # extract unique rooms by fromkeys method 702 | rooms = list(dict.fromkeys(lid[:, i_2])) 703 | rooms = [r_i for r_i in rooms if r_i is not None] 704 | if not rooms: 705 | continue 706 | for room in rooms: 707 | if room not in self.cleaned_rooms[i_2]: 708 | self.cleaned_rooms[i_2].append(room) 709 | if room not in self.offset: 710 | self.offset[room] = i_2 / self.scale_factor[room] 711 | 712 | return self.offset 713 | 714 | def get_master_output(self) -> dict: 715 | """Control ouput (offset and pwm) for master.""" 716 | end_time = 0 717 | end_time_prop = 0 718 | master_offset = None 719 | # nested rooms present 720 | if ( 721 | self.cleaned_rooms is not None and len(self.cleaned_rooms) > 0 722 | # and self.rooms 723 | ): 724 | # loop over nesting to find start offset 725 | for pwm_i, rooms in enumerate(self.cleaned_rooms): 726 | if len(rooms) > 0 and master_offset is None: 727 | master_offset = pwm_i 728 | 729 | # find max end time 730 | room_end_time = [0] 731 | for i_r, room in enumerate(self.rooms): 732 | if room in self.offset: 733 | # take actual pwm into account and not rounded 734 | # scale offsets back to NESTING_MATRIX domain 735 | room_end = ( 736 | self.offset[room] * self.scale_factor[room] + self.real_pwm[i_r] 737 | ) 738 | room_end_time.append(room_end) 739 | 740 | end_time = max(room_end_time) 741 | self._logger.debug("pwm on-off '%s'", end_time / self.master_pwm_scale) 742 | 743 | if master_offset is None: 744 | master_offset = 0 745 | 746 | # proportional valves require heat 747 | if self.load_prop > 0: 748 | # prop valves are full cycle open 749 | 750 | # too much load 751 | if self.load_total / NESTING_MATRIX > end_time - master_offset: 752 | end_time_prop = self.load_total / NESTING_MATRIX 753 | 754 | # continuous operation possible due to prop valves 755 | if ( 756 | self.operation_mode 757 | in [NestingMode.MASTER_BALANCED, NestingMode.MASTER_CONTINUOUS] 758 | and self.area_avg_prop > self.min_area > 0 759 | ): 760 | end_time_prop = NESTING_MATRIX 761 | 762 | self._logger.debug( 763 | "pwm proportional '%s'", end_time_prop / self.master_pwm_scale 764 | ) 765 | # assure sufficient opening 766 | end_time_prop = max( 767 | end_time_prop, 768 | self.pwm_threshold, 769 | self.min_prop_valve_opening, 770 | ) 771 | 772 | end_time = max(end_time, end_time_prop) / self.master_pwm_scale 773 | master_offset /= self.master_pwm_scale 774 | self._logger.debug("master start '%s'; end '%s", master_offset, end_time) 775 | return { 776 | ATTR_CONTROL_OFFSET: master_offset, 777 | ATTR_CONTROL_PWM_OUTPUT: end_time - master_offset, 778 | } 779 | 780 | def remove_room(self, room: str) -> None: 781 | """Remove room from nesting when room changed hvac mode. 782 | 783 | room needs to be removed from: 784 | - cleaned_rooms, packed, offset 785 | """ 786 | self._logger.debug("'%s' removed from nesting", room) 787 | 788 | # update packed 789 | for i, pack in enumerate(self.packed): 790 | pack = np.where(pack != room, pack, None) 791 | 792 | len_pack = len(pack) - 1 793 | for j, sub_area in enumerate(reversed(pack)): 794 | if (sub_area == None).all(): # noqa: E711 795 | # new_pack = np.append(new_pack, [sub_area]) 796 | pack = np.delete(pack, len_pack - j, 0) 797 | 798 | self.packed[i] = copy.copy(pack) 799 | 800 | len_pack = len(self.packed) - 1 801 | 802 | # remove items from packed which are empty 803 | for i, pack in enumerate(reversed(self.packed)): 804 | if not pack.any(): 805 | self.packed.pop(len_pack - i) 806 | 807 | # update cleaned rooms 808 | for i, lid in enumerate(self.cleaned_rooms): 809 | for ii, room_i in enumerate(lid): 810 | if room_i == room: 811 | self.cleaned_rooms[i][ii] = "" 812 | 813 | self.cleaned_rooms = list(filter(None, self.cleaned_rooms)) 814 | 815 | # update list with offsets 816 | _ = self.offset.pop(room, None) 817 | 818 | def nesting_bounds(self, room: str) -> list: 819 | """Find room in nesting.""" 820 | index_start = None 821 | index_end = None 822 | free_space = 0 823 | # find current area 824 | for pack_i, lid in enumerate(self.packed): # noqa: B007 825 | # loop over pwm 826 | for area_segment in lid: 827 | # find start and end nesting 828 | if room in area_segment: 829 | index_start = list(area_segment).index(room) 830 | index_end = len(area_segment) - list(reversed(area_segment)).index( 831 | room 832 | ) 833 | 834 | # check free space 835 | if len(area_segment) > index_end: 836 | if all( 837 | pwm_i is None for pwm_i in area_segment[index_end + 1 :] 838 | ): 839 | free_space = len(area_segment) - index_end 840 | else: 841 | free_space = -1 842 | break 843 | 844 | return pack_i, index_start, index_end, free_space 845 | 846 | def update_nesting( 847 | self, 848 | lid_index: int, 849 | room_index: int, 850 | index_start: int, 851 | index_end: int, 852 | free_space: int, 853 | ) -> None: 854 | """Udpate room nestign with update.""" 855 | lid = self.packed[lid_index] 856 | old_pwm = index_end - index_start 857 | 858 | # extend when too short 859 | if old_pwm < self.pwm[room_index] and free_space > 0: 860 | if lid.shape[1] < self.max_nested_pwm(): 861 | new_length = self.max_nested_pwm() - lid.shape[1] 862 | lid = np.lib.pad( 863 | lid, 864 | ( 865 | (0, 0), 866 | (0, new_length), 867 | ), 868 | "constant", 869 | constant_values=(None), 870 | ) 871 | # fill new created area 872 | for area_segment in lid: 873 | max_fill = min(index_start + self.pwm[room_index], len(area_segment)) 874 | if room_index in area_segment: 875 | area_segment[index_start:max_fill] = room_index 876 | 877 | # when pwm has lowered 878 | elif old_pwm > self.pwm[room_index]: 879 | for area_segment in lid: 880 | if room_index in area_segment: 881 | area_segment[ 882 | index_start + self.pwm[room_index] + 1 : len(area_segment) 883 | ] = None 884 | 885 | def check_pwm(self, data: dict, dt: float = 0) -> None: 886 | """Check if nesting length is still right for each room.""" 887 | self.satelite_data(data) 888 | self._logger.debug("check nesting @ %s of pwm loop", round(dt, 2)) 889 | 890 | time_past = floor(dt * NESTING_MATRIX) 891 | 892 | # new satelite states result in no requirement 893 | if self.area is None: 894 | self.packed = [] 895 | self.cleaned_rooms = [] 896 | self.offset = {} 897 | return 898 | 899 | # remove nested rooms when not present 900 | if self.packed: 901 | current_rooms = list(self.offset.keys()) 902 | for room in current_rooms: 903 | if room not in self.rooms: 904 | self.remove_room(room) 905 | 906 | # check per room the nesting 907 | for room_i, room in enumerate(self.rooms): 908 | if self.pwm[room_i] == 0: 909 | self.remove_room(room) 910 | continue 911 | 912 | if not self.packed: 913 | self.create_lid(room_i, dt=time_past) 914 | else: 915 | # find room 916 | pack_i, index_start, index_end, free_space = self.nesting_bounds(room) 917 | 918 | # when the current room is not found 919 | if index_start is None or index_end is None: 920 | if not self.insert_room(room_i, dt=time_past): 921 | self.create_lid(room_i, dt=time_past) 922 | # modify existing nesting 923 | else: 924 | self.update_nesting( 925 | pack_i, room_i, index_start, index_end, free_space 926 | ) 927 | --------------------------------------------------------------------------------