Skip to main content

Thermal simulation of opaque constructive systems (walls and roofs) from EPW data — 1D and 2D

Project description

EnerHabitat

PyPI version Python versions License: MIT

EnerHabitat is a Python package for the thermal simulation of opaque constructive systems (walls and roofs) driven by EPW weather data. It solves the time-dependent heat conduction equation across multi-layer systems and produces indoor temperatures and air-conditioning energy demands for an average day of a chosen month.

Besides the 1-D multi-layer path (System), EnerHabitat also models 2-D cross-sections of units that are heterogeneous across their width — concrete hollow-block walls and joist-and-block (vigueta y bovedilla) roofs, with air cavities — through System2D (see 2D systems).

Contents

Overview

EnerHabitat models the heat transfer through opaque constructive systems without windows, ventilation or infiltration. Each layer of the system is described by a material name and three thermal properties:

  • thermal conductivity k (W/m·K)
  • density rho (kg/m³)
  • specific heat c (J/kg·K) — written c_p in the heat equation below

These three names (k, rho, c) are the exact keys expected in materials.ini; they are case-sensitive and Greek letters are not accepted.

Given an EPW file and a constructive system, EnerHabitat computes:

Symbol Description
Ta Outdoor ambient temperature
Tsa Sun-air temperature
Ti Indoor temperature
Tn Adaptive comfort (neutral) temperature
Ig Global horizontal irradiance
Ib Direct normal irradiance
Id Diffuse horizontal irradiance
Is Solar irradiance on the tilted surface

Theoretical background

EnerHabitat solves the 1-D, time-dependent heat conduction equation across the constructive system:

              ∂T          ∂²T
    ρ   c_p   ──  =  k   ────
              ∂t          ∂x²

The exterior boundary condition uses the sun-air temperature, which combines convection, short-wave solar gain and long-wave radiative losses:

    T_sa = T_o + (I_s   a) / h_o + RF

where:

  • T_o — outdoor ambient temperature
  • I_s — solar irradiance incident on the surface
  • a — external solar absorptance
  • h_o — outdoor convective heat transfer coefficient
  • RF — long-wave radiative loss factor (°C). EnerHabitat uses RF = -3.9 °C for horizontal surfaces (tilt = 0, e.g. a roof, where the surface sees the cold sky) and RF = 0 °C for vertical walls (tilt = 90).

The equation is discretised with finite control volumes and solved with the TDMA (Tri-Diagonal Matrix Algorithm). The simulation runs over an average day of a selected month — built from the EPW — and is iterated until a periodic (oscillatory) steady state is reached.

Two solution modes are available:

  • Free-runningsolve(): no air conditioning is applied; the indoor temperature follows the dynamics of the constructive system.
  • Air-conditionedsolveAC(): the indoor temperature is held at a comfort setpoint derived from the Humphreys & Nicol adaptive comfort model combined with Morillón's comfort-zone amplitude proposal. EnerHabitat then applies the cooling or heating needed at every time step to keep Ti at that setpoint, and reports the resulting cooling_energy and heating_energy demands.

Installation

pip install enerhabitat

With uv (we love it and warmly encourage its use — it is fast, reproducible, and our recommended way to install EnerHabitat):

uv add enerhabitat

EnerHabitat requires Python ≥ 3.10.

Recommended folder structure

materials.ini is required — EnerHabitat ships with no default materials, so you must provide this file (see Materials for its format).

project/
├── main.py
├── materials.ini      # Material properties (REQUIRED — user-provided)
└── epw/
    ├── ...
    └── example.epw

Key concepts

  • Location reads an EPW file and computes the average day with meanDay().
  • System combines a Location and a list of layers and computes Tsa(), solve() and solveAC().
  • config is a global instance whose attributes (materials file, discretisation, convection coefficients, time step) affect every subsequent computation.

Quickstart

EnerHabitat does not ship with pre-loaded materials. Before running anything, create a materials.ini file (see Materials) in your working directory or point eh.config.file to its location.

import enerhabitat as eh

# 1) Materials file (required — no defaults are bundled)
eh.config.file = "./materials.ini"

# 2) Location from an EPW file
loc = eh.Location("./epw/example.epw")

# 3) Define the constructive system
wall = eh.System(location=loc)
wall.azimuth = 90
wall.absortance = 0.3
wall.layers = [("Adobe", 0.20)]      # outside → inside

# 4) Average day and solar inputs
loc.meanDay(month=5, year=2025)
wall.Tsa()

# 5) Solve
ti = wall.solve()

Workflow

To simulate a wall (or roof) you need to:

  1. Geolocate it — pass an EPW file to Location.
  2. Orient it — set azimuth (and tilt if needed).
  3. Define its color — set absortance.
  4. Define its layers — set layers from outside to inside.
  5. Choose the period — call location.meanDay(month, year).
  6. Compute Tsa(), then choose one solver:
    • solve()without air conditioning (free-running): the indoor temperature Ti evolves freely with the dynamics of the constructive system.
    • solveAC()with air conditioning: the indoor temperature is held at a comfort setpoint and the cooling/heating energy required is reported.

Both solve() and solveAC() return pandas DataFrames indexed by time of day.

Examples

Two-layer system without air conditioning

import enerhabitat as eh
import pandas as pd

epw_file = "epw/MEX_CAM_Campeche-Ignacio.766961_TMYx.epw"

wall = eh.System(eh.Location(epw_file))
wall.azimuth = 90
wall.absortance = 0.3
wall.layers = [("Mortero", 0.025), ("Ladrillo", 0.10)]
wall.location.meanDay(month=5, year=2025)
wall.Tsa()

# Free-running solution
data = wall.solve()

# Attach Tsa to the result. Note that Tsa is a function of color, tilt,
# orientation, month and location, so it must be recomputed whenever any of
# those inputs change. Tsa() and solve() share the same dt grid, so a plain
# concat aligns without NaN.
data = pd.concat([data, wall.Tsa()], axis=1)

Two-layer system with air conditioning

import enerhabitat as eh
import pandas as pd

epw_file = "epw/MEX_CAM_Campeche-Ignacio.766961_TMYx.epw"

wall = eh.System(eh.Location(epw_file))
wall.azimuth = 90
wall.absortance = 0.3
wall.layers = [("Mortero", 0.025), ("Ladrillo", 0.10)]
wall.location.meanDay(month=5, year=2025)
wall.Tsa()

# Air-conditioned solution: setpoint at the upper comfort bound
data = wall.solveAC()
data = pd.concat([data, wall.Tsa()], axis=1)

# Cooling and heating energy demands, in J/(m²·day) over one average day
print(wall.cooling_energy, wall.heating_energy)

API reference

import enerhabitat as eh

Location

loc = eh.Location("./epw/example.epw")

Attributes

The EPW path is stored in file. The following are read-only and recovered from the EPW header — change file to update them:

  • citystr, city from the EPW header
  • latitudefloat, degrees
  • longitudefloat, degrees
  • altitudefloat, metres
  • timezonepytz.timezone
loc.file = "./epw/other.epw"

Methods

  • meanDay(month, year) — average-day DataFrame (Ta, Ig, Ib, Id, Tn)
  • copy() — returns a copy of the instance
  • info() — prints instance attributes
  • flag()dict with metadata of the last meanDay() call
loc.meanDay(month=6, year=2020).info()
loc.info()
print(loc.flag()["date"])

System

loc = eh.Location("./epw/example.epw")
wall = eh.System(location=loc)

Attributes

  • location — associated Location

  • tiltfloat, degrees from horizontal (0 = roof, 90 = vertical wall)

  • azimuthfloat, surface azimuth in degrees (pvlib convention, clockwise from north):

    Direction Azimuth
    North 0
    East 90
    South 180
    West 270
  • absortancefloat in [0, 1]

  • layerslist[tuple[str, float]] of (material, thickness_m), ordered from outside to inside

wall.location = loc_2
wall.tilt = 0
wall.azimuth = 45
wall.absortance = 0.3

wall.layers = [("Adobe", 0.10), ("Acero", 0.05), ("Ladrillo", 0.02)]

wall.add_layer("Mortero", 0.20)   # appended at the inside
wall.remove_layer(2)              # removes layer at index 2

Read-only result attributes (all expressed in J/(m²·day) — energy per unit surface area, accumulated over one converged average day):

  • energy_transfer — total energy transferred to the indoor side from solve()
  • heating_energy — heating demand from solveAC()
  • cooling_energy — cooling demand from solveAC()

Units: hi · Δt · ΔT with hi in W/(m²·K), Δt in seconds and ΔT in K yields J/m², and the loop accumulates these contributions over the 24 h of the average day, so the reported value is J/(m²·day). Divide by 3600 to get Wh/(m²·day) or by 3.6e6 to get kWh/(m²·day).

Methods

  • Tsa() — sun-air temperature and Is from Location.meanDay()
  • solve() — indoor temperature Ti (free-running)
  • solveAC() — cooling and heating energy with constant indoor setpoint
  • copy() — returns a copy of the instance
  • info() — prints attributes
  • flag() — reports whether the cached value was recomputed
wall.Tsa().info()

ti = wall.solve()
energy = wall.energy_transfer

wall.solveAC()
c_energy = wall.cooling_energy
h_energy = wall.heating_energy

Note: Tsa depends on absortance (color), tilt, azimuth (orientation), meanDay (month) and Location. It must be recomputed whenever any of these inputs change. Tsa() and solve() share the same dt time grid, so attach it to a result DataFrame with a plain data = pd.concat([data, wall.Tsa()], axis=1) (no resampling, no NaN).

2D systems (walls and roofs with cavities)

Real masonry units are not homogeneous across their width: a concrete hollow block has air cavities and webs, and a joist-and-block (vigueta y bovedilla) roof alternates concrete ribs, filler blocks and air cavities. EnerHabitat models these as a 2-D cross-section (width × thickness) and solves the same transient conduction problem in two dimensions, adding the cavity physics: radiation between the four cavity walls plus Nusselt convection through a lumped cavity-air node (wall correlation for tilt = 90, Rayleigh roof correlation for tilt = 0).

System2D is used exactly like System — same Location, tilt, azimuth, absortance, Tsa(), solve(), solveAC() and result attributes (energy_transfer, cooling_energy, heating_energy). The only difference is that its layers list contains, besides the usual homogeneous (material, thickness) tuples, exactly one 2-D element that captures the in-width heterogeneity:

  • HollowBlock — concrete hollow block, for walls (tilt = 90).
  • Slab — joist-and-block, for roofs (tilt = 0).

Method dispatch is by type: wall.solve() / wall.solveAC() run the 2-D solver because wall is a System2D (there is no separate solve2D name). The element's thickness is derived from its geometry, so it is not repeated as a layer thickness, and System2D validates orientation (HollowBlock requires tilt = 90, Slab requires tilt = 0).

Hollow-block wall

Cross-section of the repeating cell (x = width, y = thickness, outside on top). A single material with one air cavity; the left/right sides are adiabatic (symmetry), so the full inner web is a12 = 2·web. Thickness = cover_top + cavity + cover_bottom.

Hollow-block wall cross-section

import enerhabitat as eh
import pandas as pd

eh.config.file = "./materials.ini"
epw_file = "epw/example.epw"

# 1) Define the 2-D element (a concrete block with one air cavity)
block = eh.HollowBlock(
    material   = "Concreto",          # single material of the block
    emissivity = 0.9,                 # cavity-wall emissivity (radiation)
    geometry   = {                    # cell measures, in metres
        "web":          0.02,         # half web (rib) thickness
        "block_width":  0.16,         # cavity width
        "cover_top":    0.02,         # outer shell
        "cavity":       0.08,         # cavity height
        "cover_bottom": 0.02,         # inner shell
    },
)

# 2) Insert it into the layer stack (outside → inside)
wall = eh.System2D(eh.Location(epw_file))
wall.tilt = 90                        # walls only (required for HollowBlock)
wall.azimuth = 90
wall.absortance = 0.6
wall.layers = [("Mortero", 0.02), block, ("Yeso", 0.01)]

wall.location.meanDay(month=5, year=2025)
wall.Tsa()

ti = wall.solve()                     # free-running
data = pd.concat([ti, wall.Tsa()], axis=1)
print(wall.energy_transfer)           # Qin, J/(m²·day)

The cavity can also be filled with a solid material (e.g. an insulating core) instead of air — pass fill_type=eh.Fill.SOLID and the fill_material:

block = eh.HollowBlock(
    material      = "Concreto",
    fill_type     = eh.Fill.SOLID,    # solid fill instead of air
    fill_material = "EPS",            # insulating core
    geometry      = {"web": 0.02, "block_width": 0.16,
                     "cover_top": 0.02, "cavity": 0.08, "cover_bottom": 0.02},
)

(Filling with the same material as the shell makes the block solid, i.e. equivalent to a homogeneous 1-D layer.)

Joist-and-block roof

The roof slab has three solids (compression topping, an L-shaped concrete rib, and the filler block) plus N equal cavities that can be air (Fill.AIR) or a solid fill (Fill.SOLID). The L-shaped rib (web + foot) sits at each cell edge; its web rises through everything except the top topping_cap of the topping. Cross-section of the repeating cell (x = width, y = thickness, outside on top; 3 cavities shown):

Joist-and-block roof cross-section

  • width = 2·(web + foot) + (n+1)·shoulder + n·cavity_width
  • thickness = topping + cover_top + cavity + cover_bottom
import enerhabitat as eh

slab = eh.Slab(
    rib_material     = "Concreto",       # joist/rib (L-shaped: web + foot)
    block_material   = "Bovedilla",      # filler block around the cavities
    topping_material = "Concreto",       # compression topping
    fill_type        = eh.Fill.AIR,      # or eh.Fill.SOLID (solid fill)
    fill_material    = None,             # required if SOLID
    emissivity       = 0.9,              # required if AIR
    geometry = {
        "web":          0.025,   # rib web (d1)
        "foot":         0.025,   # rib foot (d2)
        "shoulder":     0.050,   # block between rib and cavities (d3)
        "n_cavities":   3,
        "cavity_width": 0.103,   # cavity width (d4)
        "topping":       0.100,   # compression topping (L2+L3)
        "topping_cap":   0.050,   # topping cap above the rib web (L2)
        "cover_top":    0.030,   # block above the cavity (L4)
        "cavity":       0.040,   # cavity height (L5)
        "cover_bottom": 0.030,   # block below the cavity (L6)
    },
)

roof = eh.System2D(eh.Location("epw/example.epw"))
roof.tilt = 0                            # roofs only (required for Slab)
roof.absortance = 0.3
roof.layers = [("Impermeabilizante", 0.003), slab, ("Yeso", 0.015)]
roof.location.meanDay(month=5, year=2025)
roof.Tsa()

ti = roof.solve()                        # free-running
roof.solveAC()                           # or air-conditioned
print(roof.cooling_energy, roof.heating_energy)

Inspecting the section

Before solving you can inspect, to scale, how materials and node types are laid out on the 2-D mesh:

wall.section_report()              # table: node types + materials (k, ρc, y-range)
wall.preview()                     # to-scale panels (node type, k, ρc)
wall.preview(field="materials", backend="ascii")   # terminal fallback
sec = wall.section()               # NT / k / rhoc arrays + mesh

matplotlib is an optional extra (pip install enerhabitat[viz]); without it the inspector falls back to a to-scale ASCII drawing.

2-D solver config (config2d)

config2d holds the 2-D-only parameters (mesh and convergence); the physics (La, ho, hi, dt, air properties) is reused from config.

Attribute Default Description
nx 80 mesh nodes across the cell width (sides are adiabatic)
ny 160 mesh nodes through the thickness (outside → inside)
tol_inner 1e-10 inner (line-by-line) loop tolerance
tol_day 5e-4 day-to-day convergence tolerance
max_days 60 cap on the day-to-day iterations
from enerhabitat import config2d

config2d.nx, config2d.ny = 120, 160

Each solve is single-threaded. For volume (many configurations), parallelise at the process level — run many independent solve() calls in separate processes (e.g. multiprocessing/joblib), each serial. This scales near-linearly (≈6× at 8 processes on an 18-core machine); a per-solve thread parallelism was measured and dropped as not worthwhile.

Config (global)

config is a global singleton that stores parameters shared by every Location and System. Changing it affects all subsequent computations.

Attributes (defaults shown; all are writable except dt, which is fixed)

Attribute Default Description
file "materials.ini" Path to the .ini file with material properties
La 2.5 m Length of the fictional indoor space
Nx 200 Number of control volumes used to discretise the system
ho 13 W/(m²·K) Outdoor convective heat transfer coefficient
hi 8.6 W/(m²·K) Indoor convective heat transfer coefficient
dt 10 s (fixed) Time step — not configurable (assignments are ignored)

dt is held fixed at 10 s and cannot be changed. The indoor-air node is advanced with an explicit (forward-Euler) step whose stability requires the air Fourier number Fo = hi·dt / (ρ_air·c_air·La) to stay below 1 (≈ 0.03 at dt = 10 s). Allowing a larger dt would make the indoor temperature — and hence the reported energy_transfer — non-physical, so the parameter is locked.

The default values for ho and hi are those prescribed by the Mexican energy efficiency standards NOM-020-ENER and NOM-008-ENER for the thermal envelope of buildings — both norms specify the same coefficients. They can be overridden at any time:

eh.config.file = "./materials.ini"

# Inspect current values
eh.config.ho      # 13.0
eh.config.hi      # 8.6

# Other configuration parameters
eh.config.La = 2.0
eh.config.Nx = 300

# dt is fixed at 10 s and cannot be changed (assignments are ignored)
eh.config.dt          # 10

# Override the NOM-prescribed coefficients (NOM defaults are not enforced)
eh.config.ho = 12
eh.config.hi = 8.3

# Restore all defaults at any time (the NOM values for ho/hi included)
eh.config.reset()

config.materials is a read-only dict keyed by material name:

adobe = eh.config.materials["Adobe"]
adobe.k     # W/m·K
adobe.rho   # kg/m³
adobe.c     # J/kg·K

Methods

  • info() — prints current config values
  • to_dict() — returns parameters as a dict
  • reset() — restores default values
  • materials_list() — list of material names defined in file
  • materials_dict() — dict of material properties

Materials

EnerHabitat does not bundle any default materials. You must supply a materials.ini file — by default the package looks for materials.ini in the current working directory; otherwise set eh.config.file to the path you want to use.

Material properties are declared in an .ini file, with the material name as the section header and k, rho and c as keys. Inline comments (# or ;) are allowed.

The example below defines every material used in this README, so you can run all the snippets as-is. The values are typical/illustrative — replace them with your own data:

[Adobe]
k   = 0.58    # Thermal conductivity, W/m·K
rho = 1500    # Density, kg/m³
c   = 1480    # Specific heat, J/kg·K

[Mortero]     ; cement mortar / render
k   = 0.70
rho = 1800
c   = 1000

[Ladrillo]    ; fired-clay brick
k   = 0.85
rho = 1600
c   = 840

[Acero]       ; steel
k   = 45.0
rho = 7850
c   = 460

[Concreto]    ; structural concrete (ribs, topping, hollow block)
k   = 1.80
rho = 2200
c   = 880

[Bovedilla]   ; lightweight filler block
k   = 0.50
rho = 1200
c   = 900

[Yeso]        ; gypsum plaster
k   = 0.37
rho = 900
c   = 1000

[Impermeabilizante]   ; asphaltic waterproofing membrane
k   = 0.17
rho = 1100
c   = 1000

Point config.file to a different file when you need to switch material sets:

eh.config.file = "./config/new_materials.ini"

If config.file points at a missing file, EnerHabitat will report Error: <path> not found and materials will be empty — System.solve() will fail because the layer materials cannot be resolved.

Dependencies

Direct dependencies (declared in pyproject.toml):

Pulled in transitively and used internally:

Authors

Developed at the Instituto de Energías Renovables, UNAM.

Source code: https://github.com/Ener-Habitat/EnerHabitat

Issues: https://github.com/Ener-Habitat/EnerHabitat/issues

License

Released under the MIT License.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

enerhabitat-0.2.0.tar.gz (2.1 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

enerhabitat-0.2.0-py3-none-any.whl (47.0 kB view details)

Uploaded Python 3

File details

Details for the file enerhabitat-0.2.0.tar.gz.

File metadata

  • Download URL: enerhabitat-0.2.0.tar.gz
  • Upload date:
  • Size: 2.1 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for enerhabitat-0.2.0.tar.gz
Algorithm Hash digest
SHA256 2c01c5c198078af589b8f403df103fba852c81e52f64d8293e04fec9dbca8a8e
MD5 3bd2c32e36938d34213dbf0170729207
BLAKE2b-256 dcb12b9d7cfc97c6a49e32d424f85e540c4e19bc068abbb4f0b348f96bd68188

See more details on using hashes here.

File details

Details for the file enerhabitat-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: enerhabitat-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 47.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for enerhabitat-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 985887a47f0df231ba214dff57237c62b2f4a5d47025ff4cb0f08c31752e75ca
MD5 724ab2a31e055897b4860a182dbaac5f
BLAKE2b-256 929c877f9ce5f878f545c250a871ab8454365dc49c0f9bc4442d57fa3520ff68

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page