Usage: 2D
The 2D model resolves constructive systems whose section is not a simple stack of homogeneous layers: hollow blocks (HollowBlock, walls) and joist-and-block slabs (Slab, roofs). The Quickstart steps are the same as in 1D. The differences are the class (System2D), that tilt must be 90 (HollowBlock) or 0 (Slab), and that the 2D element goes inside the layer stack.
As in Usage: 1D, the examples show real results for the average day of May in Cuernavaca. Building the systems and inspecting their sections is executed live when this documentation is rendered. The solves are pre-computed by docs/run_examples.py, because a default-mesh 2D solve takes about 25 minutes in a modern CPU; this page reads and plots its outputs (in docs/data/results/).
2D geometries
The 2D elements (HollowBlock for walls, Slab for joist-and-block roofs) are defined by a geometry dictionary, with measures in metres. The schemes below name every key, drawn to scale with the same values used in the examples further down (shown in mm for readability):
The rib is an L: its web rises through the whole element (stopping below the full-width topping_cap) and its foot widens it only along the cover_bottom band, forming the ledge the filler block rests on.
The OUTSIDE face exchanges heat with the sun–air temperature and the INSIDE face with the indoor air; the lateral cuts are adiabatic symmetry planes of the repeating pattern. The boundary conditions are stated, with their domain figure, in the 2D model.
Examples
Free-running and air-conditioned, for a hollow-block wall and a joist-and-block roof. Setup shared by all the examples (the materials are defined in the materials file):
import enerhabitat as eh
import pandas as pd
import matplotlib.pyplot as plt # Comment or delete if not creating visualization
import matplotlib.dates as mdates # Comment or delete if not creating visualization
eh.config.file = "data/materials.ini"
epw_file = "data/MEX_MOR_Cuernavaca-Matamoros.Intl.AP.767260_TMYx.2004-2018.epw"Plot helper used by the examples (click to show)
EH_COLORS = {"Ti": "#2a78d6", "Tsa": "#eb6834", "Ta": "#008300", "Tn": "#6b7280"}
def plot_day(df, title=""):
"""Temperatures of the average day (with the comfort zone Tn ± DeltaTn)
and the solar irradiance components carried by the same dataframe."""
fig, (ax, axr) = plt.subplots(2, 1, figsize=(7.2, 5.8), sharex=True,
height_ratios=[3, 2])
# --- temperatures ---
# comfort zone: Tn and DeltaTn are columns (constant along the day)
ax.fill_between(df.index, df["Tn"] - df["DeltaTn"], df["Tn"] + df["DeltaTn"],
color=EH_COLORS["Tn"], alpha=0.12, lw=0)
ax.plot(df["Tn"], color=EH_COLORS["Tn"], ls="--", lw=1, label="Tn ± ΔTn")
ax.plot(df["Ta"], color=EH_COLORS["Ta"], lw=2, label="Ta")
ax.plot(df["Tsa"], color=EH_COLORS["Tsa"], lw=2, label="Tsa")
ax.plot(df["Ti"], color=EH_COLORS["Ti"], lw=2, label="Ti")
ax.set_ylabel("temperature (°C)")
ax.set_title(title, fontsize=10)
ax.legend(frameon=False, fontsize=9, loc="upper left")
# --- solar irradiance (same dataframe: Ig, Ib, Id from the EPW; Is on the
# tilted/oriented surface, computed by Tsa()) ---
axr.plot(df["Ig"], color="#f59e0b", lw=1.5, label="Ig (global horizontal)")
axr.plot(df["Ib"], color="#a16207", lw=1.5, label="Ib (direct normal)")
axr.plot(df["Id"], color="#60a5fa", lw=1.5, label="Id (diffuse horizontal)")
axr.plot(df["Is"], color="#dc2626", lw=2, label="Is (on the surface)")
axr.set_ylabel("irradiance (W/m²)")
axr.set_xlabel("hour of the average day (civil time)")
axr.legend(frameon=False, fontsize=8, loc="upper left")
# tz=... keeps the labels in local time (matplotlib defaults to UTC)
axr.xaxis.set_major_locator(mdates.HourLocator(byhour=[0, 6, 12, 18], tz=df.index.tz))
axr.xaxis.set_major_formatter(mdates.DateFormatter("%H", tz=df.index.tz))
for a in (ax, axr):
a.grid(alpha=0.25, lw=0.5)
a.spines["top"].set_visible(False)
a.spines["right"].set_visible(False)
fig.tight_layout()
plt.show()2D hollow-block wall, free-running
A concrete block with one air cavity per cell. The 2D element goes inside the layer stack; finishes are ordinary 1D layers around it. Building the system is instantaneous (only solve() is expensive):
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
},
)
wall2d = eh.System2D(eh.Location(epw_file))
wall2d.tilt = 90 # walls only (required for HollowBlock)
wall2d.azimuth = 90
wall2d.absortance = 0.6
wall2d.layers = [("Mortero", 0.02), block, ("Yeso", 0.01)]
# 2D stacks accept at most 7 layers INCLUDING the 2D element (fixed slots
# inherited from the C engine); exceeding it raises ValueError. 1D has no limit.The section scheme, to scale, with the outside on top. This is one of several ways to check the geometry before solving: preview() can also draw the node types and the k and ρc fields, section_report() prints a verification table, and there is an ASCII backend for terminals, all demonstrated in Inspecting the section:
import numpy as np # Required to visualize temperature field, comment if not needed
import json # Required to save results and be read again later
wall2d.location.meanDay(month=5, year=2025)
wall2d.Tsa()
ti = wall2d.solve() # free-running — ~25 min (default mesh)
# Look at the results before saving them:
print(wall2d.energy_transfer) # J/(m²·day), per unit indoor area
print(wall2d.days) # days iterated to reach the periodic regime
# A 2D solve is expensive: SAVE the results, so you pay that time only once.
# Ti and the surface/cavity Series concatenate with Tsa(), as in the 1D;
# Tfield is the (nx, ny) °C field of the last (converged) day.
data = pd.concat([ti, wall2d.Tso, wall2d.Tsi, wall2d.Thueco, wall2d.Tsa()], axis=1)
data.to_csv("data/results/hollow_free.csv")
np.save("data/results/hollow_free_Tfield.npy", wall2d.Tfield)
# The scalar results are attributes, not columns of the CSV: keep them too.
scalars = {"energy_transfer": wall2d.energy_transfer, "days": wall2d.days}
json.dump(scalars, open("data/results/hollow_free_scalars.json", "w"))Reading the stored files back follows the same workflow: same columns, same plots, no re-solving. That is exactly what this page does; its files were written by docs/run_examples.py with the code above, with one difference of bookkeeping: instead of one JSON per case, it gathers the scalars of the three cases (energies, convergence days, runtime, and the mesh extent in mm that the Tfield plots need) into a single data/results/summary.json, which the cells below read:
# The 2D results were computed and saved beforehand by docs/run_examples.py;
# json reads the stored summary (energies, convergence days, runtimes).
import json
RES = "data/results"
summary = json.load(open(f"{RES}/summary.json"))
hollow = pd.read_csv(f"{RES}/hollow_free.csv", index_col=0, parse_dates=True)
s = summary["hollow_free"]
print(f"energy_transfer = {s['energy_transfer']:,.1f} J/(m²·day)"
f" = {s['energy_transfer']/3600:,.1f} Wh/(m²·day)"
f" · converged in {s['days']} days")
plot_day(hollow, title="Hollow-block wall (2D), east, free-running — May, Cuernavaca")energy_transfer = 47,610.0 J/(m²·day) = 13.2 Wh/(m²·day) · converged in 4 days
Besides Ti, the 2D solver keeps the full temperature field of the last (converged) day: wall2d.Tfield, an (nx, ny) array in °C on the section mesh, with the same orientation as preview() (outside on top):
import numpy as np
T = np.load(f"{RES}/hollow_free_Tfield.npy")
s = summary["hollow_free"]
fig, ax = plt.subplots(figsize=(7.0, 3.8))
im = ax.imshow(T.T, extent=[0, s["X_mm"], s["Y_mm"], 0], aspect="equal",
cmap="inferno", interpolation="nearest")
fig.colorbar(im, ax=ax, label="T (°C)")
ax.set_xlabel("width [mm]")
ax.set_ylabel("thickness [mm]")
ax.set_title("Tfield — hollow-block wall, end of the converged day", fontsize=10)
fig.tight_layout()
plt.show()2D hollow-block wall, air-conditioned
tiAC = wall2d.solveAC() # Ti held at Tn
print(wall2d.cooling_energy, wall2d.heating_energy)
pd.concat([tiAC, wall2d.Tso, wall2d.Tsi, wall2d.Thueco, wall2d.Tsa()],
axis=1).to_csv("data/results/hollow_ac.csv") # save, as beforehollowAC = pd.read_csv(f"{RES}/hollow_ac.csv", index_col=0, parse_dates=True)
s = summary["hollow_ac"]
print(f"cooling_energy = {s['cooling_energy']:,.0f} J/(m²·day)"
f" = {s['cooling_energy']/3600:,.1f} Wh/(m²·day)")
print(f"heating_energy = {s['heating_energy']:,.0f} J/(m²·day)"
f" = {s['heating_energy']/3600:,.1f} Wh/(m²·day)")
plot_day(hollowAC, title="Hollow-block wall (2D), air-conditioned — May, Cuernavaca")cooling_energy = 1,574,884 J/(m²·day) = 437.5 Wh/(m²·day)
heating_energy = 287,568 J/(m²·day) = 79.9 Wh/(m²·day)
Joist-and-block roof (Slab)
Three solids (compression topping, L-shaped rib, filler block) plus N equal cavities that can be air (Fill.AIR) or a solid fill (Fill.SOLID).
slab = eh.Slab(
rib_material = "ConcretoAltaDensidad", # 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 (+ fill_material)
emissivity = 0.9, # required if AIR
geometry = {
"web": 0.025, # rib web
"foot": 0.025, # rib foot
"shoulder": 0.050, # block between rib and cavities
"n_cavities": 3,
"cavity_width": 0.103,
"topping": 0.100, # compression topping
"topping_cap": 0.050, # topping above the rib web
"cover_top": 0.030, # block above the cavity
"cavity": 0.040, # cavity height
"cover_bottom": 0.030, # block below the cavity
},
)
roof = eh.System2D(eh.Location(epw_file))
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() # or roof.solveAC()
print(roof.energy_transfer) # J/(m²·day)
print(roof.days) # days to the periodic regime
# Same recommendation as with the wall: save, then analyse from the files.
data = pd.concat([ti, roof.Tso, roof.Tsi, roof.Thueco, roof.Tsa()], axis=1)
data.to_csv("data/results/slab_free.csv")
np.save("data/results/slab_free_Tfield.npy", roof.Tfield)Recovered from the stored files:
slab_df = pd.read_csv(f"{RES}/slab_free.csv", index_col=0, parse_dates=True)
s = summary["slab_free"]
print(f"energy_transfer = {s['energy_transfer']:,.0f} J/(m²·day)"
f" = {s['energy_transfer']/3600:,.1f} Wh/(m²·day)"
f" · converged in {s['days']} days")
plot_day(slab_df, title="Joist-and-block roof (2D), free-running — May, Cuernavaca")energy_transfer = 26,545 J/(m²·day) = 7.4 Wh/(m²·day) · converged in 6 days
Note how the roof’s Tsa drops below Ta at night: that is the long-wave radiation factor (\(RF = 3.9\) °C for horizontal surfaces) at work. The roof sees the cold sky (see the 1D model).
The 2D results go beyond Ti: the read-only Series Tso (outdoor surface), Tsi (indoor surface) and Thueco (cavity air) live on the same time grid, plotted here alongside Ti:
fig, ax = plt.subplots(figsize=(7.2, 3.6))
ax.plot(slab_df["Tso"], color="#eb6834", lw=2, label="Tso (outdoor surface)")
ax.plot(slab_df["Thueco"], color="#9467bd", lw=2, label="Thueco (cavity air)")
ax.plot(slab_df["Tsi"], color="#0d9488", lw=2, label="Tsi (indoor surface)")
ax.plot(slab_df["Ti"], color="#2a78d6", lw=2, label="Ti (indoor air)")
ax.xaxis.set_major_locator(mdates.HourLocator(byhour=[0, 6, 12, 18], tz=slab_df.index.tz))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H", tz=slab_df.index.tz))
ax.set_xlabel("hour of the average day")
ax.set_ylabel("temperature (°C)")
ax.set_title("Roof surfaces: the cavity damps the outdoor swing", fontsize=10)
ax.legend(frameon=False, fontsize=9, loc="upper left")
ax.grid(alpha=0.25, lw=0.5)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
fig.tight_layout()
plt.show()And its temperature field, where the joists show up as thermal bridges through the cavity band:
T = np.load(f"{RES}/slab_free_Tfield.npy")
s = summary["slab_free"]
fig, ax = plt.subplots(figsize=(8.5, 3.8))
im = ax.imshow(T.T, extent=[0, s["X_mm"], s["Y_mm"], 0], aspect="equal",
cmap="inferno", interpolation="nearest")
fig.colorbar(im, ax=ax, label="T (°C)")
ax.set_xlabel("width [mm]")
ax.set_ylabel("thickness [mm]")
ax.set_title("Tfield — joist-and-block roof, end of the converged day", fontsize=10)
fig.tight_layout()
plt.show()Filled cavities (Fill.SOLID)
The cavity of a HollowBlock (or the cavities of a Slab) can be filled with a solid material instead of air, e.g. an insulating core:
block = eh.HollowBlock(
material = "Concreto",
fill_type = eh.Fill.SOLID,
fill_material = "EPS",
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 unit homogeneous, equivalent to a 1D layer (this is one of the package’s validation checks).
Inspecting the section
Before solving you can inspect, to scale, how materials and node types are laid out on the 2D mesh. The section schemes shown with each example above were drawn by preview(field="materials"). section_report() prints the verification table:
wall2d.section_report() # table: node types + materials (k, ρc, y-range)2D section 80×160 X=200.0 mm Y=150.0 mm dx=2.500 dy=0.938 mm
Block/cavity: i∈[8,72) j∈[42,127)
Node types (NT):
NT 0 air 5440 nodes
NT 1 corner 1 nodes
NT 2 corner 1 nodes
NT 3 corner 1 nodes
NT 4 corner 1 nodes
NT 5 outer edge 78 nodes
NT 6 side 158 nodes
NT 7 side 158 nodes
NT 8 inner edge 78 nodes
NT 9 top wall 64 nodes
NT 10 bottom wall 64 nodes
NT 11 left wall 85 nodes
NT 12 right wall 85 nodes
NT 13 interior 6586 nodes
Assigned materials (by k, ρc):
Mortero 1760 nodes y∈[0.0,20.6] mm (k=0.7, ρc=1.8e+06)
Concreto 4800 nodes y∈[20.6,140.6] mm (k=1.8, ρc=1.936e+06)
Yeso 800 nodes y∈[140.6,150.0] mm (k=0.37, ρc=900000)
Air (cavity) 5440 nodes y∈[39.4,119.1] mm
The default preview() draws three panels: the node types (the finite-volume classification each mesh node gets: interior, cavity walls, corners, air), and the k and ρc fields actually assigned to the mesh:
matplotlib is an optional extra (pip install enerhabitat[viz]). Without it, or with backend="ascii", the inspector falls back to a to-scale ASCII drawing, handy on a remote terminal. Note that the drawing is a resampling of the mesh, not the mesh itself: one character is not one node. Each character shows the node nearest to its position, and the header states the resolution (chars vs. mesh nodes, and the size in mm each character covers):
roof.preview(field="materials", backend="ascii") [materials] to-scale cut X=609mm × Y=218mm (outside on top)
60×11 chars resampled from the 80×160 mesh (1 char ≈ 10.2×19.8 mm, nearest node)
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ← OUTSIDE
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒
▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒
▒▒++++++++++++++++++++++++++++++++++++++++++++++++++++++++▒▒
▒▒++++++++++++++++++++++++++++++++++++++++++++++++++++++++▒▒
▒▒++++++++··········+++++··········+++++··········++++++++▒▒
▒▒++++++++··········+++++··········+++++··········++++++++▒▒
▒▒▒▒▒++++++++++++++++++++++++++++++++++++++++++++++++++▒▒▒▒▒
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ← INSIDE
legend:
'█' Impermeabilizante
'▓' Concreto
'▒' ConcretoAltaDensidad
'░' Yeso
'+' Bovedilla
'·' Air (cavity)
The raw arrays are also available: sec = wall2d.section() returns the node-type, k and ρc fields together with the mesh.
The 2D mesh and convergence parameters (nx, ny, tolerances, iteration caps) live in config2d, documented with the rest of the configuration in Usage — setup.










