├── demand_ninja ├── test │ ├── __init__.py │ └── test_util.py ├── _version.py ├── __init__.py ├── diurnal_profiles.csv ├── util.py └── core.py ├── requirements.txt ├── .gitignore ├── MANIFEST.in ├── CHANGELOG.md ├── pyproject.toml ├── setup.py ├── LICENSE └── README.md /demand_ninja/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pandas >= 1.4, < 1.5 2 | -------------------------------------------------------------------------------- /demand_ninja/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | demand_ninja.egg-info 3 | -------------------------------------------------------------------------------- /demand_ninja/__init__.py: -------------------------------------------------------------------------------- 1 | from demand_ninja.core import demand 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE pyproject.toml requirements.txt 2 | include demand_ninja/diurnal_profiles.csv -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v1.0 2 | 3 | Version used in the published paper: Iain Staffell, Stefan Pfenninger and Nathan Johnson (2023). _A global model of hourly space heating and cooling demand at multiple spatial scales._ Nature Energy. 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ['py310'] 4 | include = '\.pyi?$' 5 | exclude = ''' 6 | /( 7 | \.eggs 8 | | \.git 9 | | \.github 10 | | \.mypy_cache 11 | | \.pytest_cache 12 | | \.vscode 13 | | _build 14 | | build 15 | | dist 16 | | .*\.egg-info 17 | )/ 18 | ''' 19 | -------------------------------------------------------------------------------- /demand_ninja/diurnal_profiles.csv: -------------------------------------------------------------------------------- 1 | hour,heating,cooling 2 | 0,0.997244632,0.891535713 3 | 1,1.012675902,0.798235095 4 | 2,1.015879447,0.722202491 5 | 3,1.010765676,0.657787709 6 | 4,1.011188438,0.615076019 7 | 5,1.031789189,0.582014616 8 | 6,1.074739407,0.564530139 9 | 7,1.101617499,0.593167757 10 | 8,1.105359242,0.680261835 11 | 9,1.083165599,0.772875711 12 | 10,1.043839148,0.858327123 13 | 11,1.005706506,0.976353468 14 | 12,0.967625742,1.1146086 15 | 13,0.927169478,1.243364558 16 | 14,0.898209898,1.353110899 17 | 15,0.895691219,1.437291256 18 | 16,0.938954533,1.481545646 19 | 17,1.018606413,1.469218901 20 | 18,1.033534142,1.420482088 21 | 19,0.980349542,1.348767925 22 | 20,0.943274961,1.268066451 23 | 21,0.957762077,1.178519869 24 | 22,0.975039406,1.089695339 25 | 23,0.972567273,0.99142508 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from pathlib import Path 4 | from setuptools import setup, find_packages 5 | 6 | exec(Path("demand_ninja/_version.py").read_text()) # Sets the __version__ variable 7 | 8 | requirements = Path("requirements.txt").read_text().strip().split("\n") 9 | 10 | setup( 11 | name="demand_ninja", 12 | version=__version__, 13 | description="Simulates heating and cooling demand", 14 | license="BSD-3-Clause", 15 | packages=find_packages(), 16 | install_requires=requirements, 17 | include_package_data=True, 18 | classifiers=[ 19 | "Intended Audience :: Science/Research", 20 | "License :: OSI Approved :: Apache Software License", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3 :: Only", 24 | ], 25 | ) 26 | -------------------------------------------------------------------------------- /demand_ninja/test/test_util.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pandas as pd 3 | 4 | import demand_ninja 5 | from demand_ninja.util import smooth_temperature, get_cdd, get_hdd 6 | 7 | 8 | class TestUtil: 9 | def test_smooth_temperature(self): 10 | temperature = pd.Series([0, 0, 1, 1, 1, 0.5, 0, 0, 0, 1, 2, 3, 4]) 11 | smoothing = [0.50, 0.25] 12 | smoothed_temperature = pd.Series( 13 | [ 14 | 0, 15 | 0, 16 | 0.5714, 17 | 0.8571, 18 | 1, 19 | 0.7143, 20 | 0.2857, 21 | 0.0714, 22 | 0, 23 | 0.5714, 24 | 1.4286, 25 | 2.4286, 26 | 3.4286, 27 | ] 28 | ) 29 | pd.testing.assert_series_equal( 30 | smooth_temperature(temperature, smoothing).round(4), smoothed_temperature 31 | ) 32 | 33 | def test_get_hdd(self): 34 | assert get_hdd([-10, 0, 10, 20, 30], 14) == [24, 14, 4, 0, 0] 35 | 36 | def test_get_cdd(self): 37 | assert get_cdd([-10, 0, 10, 20, 30], 14) == [0, 0, 0, 6, 16] 38 | -------------------------------------------------------------------------------- /demand_ninja/util.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | 4 | def smooth_temperature(temperature: pd.Series, weights: list) -> pd.Series: 5 | """ 6 | Smooth a temperature series over time with the given weighting for previous days. 7 | 8 | Params 9 | ------ 10 | 11 | temperature : pd.Series 12 | weights : list 13 | The weights for smoothing. The first element is how much 14 | yesterday's temperature will be, the 2nd element is 2 days ago, etc. 15 | 16 | """ 17 | assert isinstance(temperature, pd.Series) 18 | lag = temperature.copy() 19 | smooth = temperature.copy() 20 | 21 | # Run through each weight in turn going one time-step backwards each time 22 | for w in weights: 23 | # Create a time series of temperatures the day before 24 | lag = lag.shift(1, fill_value=lag[0]) 25 | 26 | # Add on these lagged temperatures multiplied by this smoothing factor 27 | if w != 0: 28 | smooth = (smooth + (lag * w)).reindex() 29 | 30 | smooth = smooth.reindex().dropna() 31 | 32 | # Renormalise and return 33 | return smooth / (1 + sum(weights)) 34 | 35 | 36 | def get_hdd(temperature: list, threshold: float) -> list: 37 | return (threshold - pd.Series(temperature)).clip(lower=0).to_list() 38 | 39 | 40 | def get_cdd(temperature: list, threshold: float) -> list: 41 | return (pd.Series(temperature) - threshold).clip(lower=0).to_list() 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Stefan Pfenninger 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Demand.ninja 2 | 3 | The Demand.ninja model delivers an hourly time-series of energy demand for a given location, for your chosen temperaure thresholds and BAIT (building-adjusted internal temperature) parameters which describe the characteristics of a building and its occupants. 4 | 5 | This code also runs online on the [Renewables.ninja service](https://www.renewables.ninja/). 6 | 7 | ## Example use 8 | 9 | ```python 10 | 11 | import demand_ninja 12 | 13 | # `inputs` has to be a pandas.DataFrame with four columns, 14 | # humidity, radiation_global_horizontal, temperature, 15 | # and wind_speed_2m 16 | inputs = pd.DataFrame(...) 17 | 18 | # `result`` will be a pandas.DataFrame 19 | # setting raw=True includes the input data and intermediate results 20 | # in the final DataFrame 21 | result = demand_ninja.demand(inputs, raw=True) 22 | 23 | ``` 24 | 25 | ## Version history 26 | 27 | See [CHANGELOG.md](CHANGELOG.md). 28 | 29 | ## Credits and contact 30 | 31 | Contact [Iain Staffell](mailto:i.staffell@imperial.ac.uk) and [Nathan Johnson](mailto:nathan.johnson17@imperial.ac.uk) if you have questions about Demand.ninja. Demand.ninja is a component of the [Renewables.ninja](https://renewables.ninja) project, developed by Stefan Pfenninger and Iain Staffell. 32 | 33 | ## Citation 34 | 35 | If you use Demand.ninja or code derived from it in academic work, please cite: 36 | 37 | Iain Staffell, Stefan Pfenninger and Nathan Johnson (2023). _A global model of hourly space heating and cooling demand at multiple spatial scales._ Nature Energy. 38 | 39 | ## License 40 | 41 | The code is available under a BSD-3-Clause license. 42 | -------------------------------------------------------------------------------- /demand_ninja/core.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import numpy as np 4 | import pandas as pd 5 | 6 | from demand_ninja.util import smooth_temperature, get_cdd, get_hdd 7 | 8 | 9 | DIURNAL_PROFILES = pd.read_csv( 10 | os.path.join(os.path.dirname(__file__), "diurnal_profiles.csv"), index_col=0 11 | ) 12 | 13 | 14 | def _bait( 15 | weather: pd.DataFrame, 16 | smoothing: float, 17 | solar_gains: float, 18 | wind_chill: float, 19 | humidity_discomfort: float, 20 | ) -> pd.Series: 21 | 22 | # We compute 'setpoint' values for wind, sun and humidity 23 | # these are the 'average' values for the given temperature 24 | # and are used to decide if it is windier than average, 25 | # sunnier than aveage, etc. this makes N correlate roughly 26 | # 1:1 with T, rather than is biased above or below it. 27 | T = weather["temperature"] 28 | setpoint_S = 100 + 7 * T # W/m2 29 | setpoint_W = 4.5 - 0.025 * T # m/s 30 | setpoint_H = (1.1 + 0.06 * T).rpow(np.e) # g water per kg air 31 | setpoint_T = 16 # degrees - around which 'discomfort' is measured 32 | 33 | # Calculate the unsmoothed ninja temperature index 34 | # this is a 'feels like' index - how warm does it 'feel' to your building 35 | 36 | # Initialise it to temperature 37 | N = weather["temperature"].copy() 38 | 39 | # If it's sunny, it feels warmer 40 | N = N + (weather["radiation_global_horizontal"] - setpoint_S) * solar_gains 41 | 42 | # If it's windy, it feels colder 43 | N = N + (weather["wind_speed_2m"] - setpoint_W) * wind_chill 44 | 45 | # If it's humid, both hot and cold feel more extreme 46 | discomfort = N - setpoint_T 47 | N = ( 48 | setpoint_T 49 | + discomfort 50 | + ( 51 | discomfort 52 | # Convert humidity from g/kg to kg/kg 53 | * ((weather["humidity"] / 1000) - setpoint_H) 54 | * humidity_discomfort 55 | ) 56 | ) 57 | 58 | # Apply temporal smoothing to our temperatures over the last two days 59 | # we assume 2nd day smoothing is the square of the first day (i.e. compounded decay) 60 | N = smooth_temperature(N, weights=[smoothing, smoothing**2]) 61 | 62 | # Blend the smoothed BAIT with raw temperatures to account for occupant 63 | # behaviour changing with the weather (i.e. people open windows when it's hot) 64 | 65 | # These are fixed parameters we don't expose the user to 66 | lower_blend = 15 # *C at which we start blending T into N 67 | upper_blend = 23 # *C at which we have fully blended T into N 68 | max_raw_var = 0.5 # maximum amount of T that gets blended into N 69 | 70 | # Transform this window to a sigmoid function, mapping lower & upper onto -5 and +5 71 | avg_blend = (lower_blend + upper_blend) / 2 72 | dif_blend = upper_blend - lower_blend 73 | blend = (weather["temperature"] - avg_blend) * 10 / dif_blend 74 | blend = max_raw_var / (1 + (-blend).rpow(np.e)) 75 | 76 | # Apply the blend 77 | N = (weather["temperature"] * blend) + (N * (1 - blend)) 78 | 79 | return N 80 | 81 | 82 | def _energy_demand_from_bait( 83 | bait: pd.Series, 84 | heating_threshold: float, 85 | cooling_threshold: float, 86 | base_power: float, 87 | heating_power: float, 88 | cooling_power: float, 89 | use_diurnal_profile: bool, 90 | ) -> pd.DataFrame: 91 | """ 92 | Convert temperatures into energy demand. 93 | 94 | """ 95 | output = pd.DataFrame(index=bait.index.copy()) 96 | output["hdd"] = 0 97 | output["cdd"] = 0 98 | output["heating_demand"] = 0 99 | output["cooling_demand"] = 0 100 | output["total_demand"] = 0 101 | 102 | # Add demand for heating 103 | if heating_power > 0: 104 | output["hdd"] = get_hdd(bait, heating_threshold) 105 | output["heating_demand"] = output["hdd"] * heating_power 106 | 107 | # Add demand for cooling 108 | if cooling_power > 0: 109 | output["cdd"] = get_cdd(bait, cooling_threshold) 110 | output["cooling_demand"] = output["cdd"] * cooling_power 111 | 112 | # Apply the diurnal profiles if wanted 113 | if use_diurnal_profile: 114 | # Get the hour of day for each timestep - which we use as an array index 115 | hours = output.index.hour 116 | profiles = DIURNAL_PROFILES.loc[hours, :] 117 | profiles.index = output.index 118 | 119 | # Convolute 120 | output["heating_demand"] = output["heating_demand"] * profiles.loc[:, "heating"] 121 | output["cooling_demand"] = output["cooling_demand"] * profiles.loc[:, "cooling"] 122 | 123 | # Sum total demand 124 | output["total_demand"] = ( 125 | base_power + output["heating_demand"] + output["cooling_demand"] 126 | ) 127 | 128 | return output 129 | 130 | 131 | def demand( 132 | hourly_inputs: pd.DataFrame, 133 | heating_threshold: float = 14, 134 | cooling_threshold: float = 20, 135 | base_power: float = 0, 136 | heating_power: float = 0.3, 137 | cooling_power: float = 0.15, 138 | smoothing: float = 0.5, 139 | solar_gains: float = 0.012, 140 | wind_chill: float = -0.20, 141 | humidity_discomfort: float = 0.05, 142 | use_diurnal_profile: bool = True, 143 | raw: bool = False, 144 | ) -> pd.DataFrame: 145 | """ 146 | Returns a pd.DataFrame of heating_demand, cooling_demand, and total_demand. If 147 | `raw` is True (default False), then also returns the input data and the 148 | intermediate BAIT. 149 | 150 | Params 151 | ------ 152 | 153 | hourly_inputs : pd.DataFrame 154 | Must contain humidity, radiation_global_horizontal, temperature 155 | and wind_speed_2m columns 156 | 157 | """ 158 | assert list(sorted(hourly_inputs.columns)) == [ 159 | "humidity", 160 | "radiation_global_horizontal", 161 | "temperature", 162 | "wind_speed_2m", 163 | ] 164 | 165 | daily_inputs = hourly_inputs.resample("1D").mean() 166 | 167 | # Calculate BAIT 168 | daily_bait = _bait( 169 | daily_inputs, 170 | smoothing, 171 | solar_gains, 172 | wind_chill, 173 | humidity_discomfort, 174 | ) 175 | 176 | # Upsample BAIT to hourly 177 | daily_bait.index = pd.date_range( 178 | daily_bait.index[0] + pd.Timedelta("12H"), 179 | daily_bait.index[-1] + pd.Timedelta("12H"), 180 | freq="1D", 181 | ) 182 | hourly_inputs["bait"] = daily_bait.reindex(hourly_inputs.index).interpolate( 183 | method="cubicspline", limit_direction="both" 184 | ) 185 | 186 | # Transform to degree days and energy demand 187 | result = _energy_demand_from_bait( 188 | hourly_inputs["bait"], 189 | heating_threshold, 190 | cooling_threshold, 191 | base_power, 192 | heating_power, 193 | cooling_power, 194 | use_diurnal_profile, 195 | ) 196 | 197 | result = result.loc[:, ["total_demand", "heating_demand", "cooling_demand"]] 198 | 199 | if raw: 200 | result = pd.concat((result, hourly_inputs), axis=1) 201 | 202 | return result 203 | --------------------------------------------------------------------------------