Mollier h,x-Diagram#
This example demonstrates how to use plot_mollier_hx to create
psychrometric charts (Mollier h,x-diagrams) for visualising the state
of moist air.
All plots are interactive — hover over data points to see details.
Tip
New to the h,x diagram? The h,x diagram in five minutes explains how to read it correctly (relative vs. absolute humidity, pressure, mould at cold surfaces), what the comfort zone and the warning bands mean and how to save energy, with worked examples.
Empty diagram (coordinate grid only)#
Pass comfort_zone=False to show only the iso-lines without a comfort zone.
from pyedautils.plots import plot_mollier_hx
from IPython.display import HTML
html = plot_mollier_hx(comfort_zone=False)
HTML(html)
With default comfort zone#
Calling plot_mollier_hx() without arguments adds the default comfort zone
(T: 20–26 °C, φ: 30–65 %, x: 0–11.5 g/kg). Pass "abs_humidity": None in
comfort_zone for a zone without an absolute-humidity limit — it is then
bounded by temperature and relative humidity only (the same as
hum_abs_band=None of comfort_compass_distribution).
html = plot_mollier_hx()
HTML(html)
Diagram with measured data#
Pass a DataFrame with columns [timestamp, humidity, temperature]
(humidity in %, temperature in °C) to overlay data points.
Points are automatically colour-coded by season.
import pandas as pd
from importlib import resources
# Anonymised hourly room climate (temperature + humidity) from a real sensor.
data_path = resources.files("pyedautils") / "data" / "room_climate_sample.csv"
df = pd.read_csv(data_path)
df.head()
| timestamp | humidity | temperature | |
|---|---|---|---|
| 0 | 2025-05-01 00:00:00 | 51.0 | 19.1 |
| 1 | 2025-05-01 01:00:00 | 51.0 | 19.1 |
| 2 | 2025-05-01 02:00:00 | 51.0 | 19.1 |
| 3 | 2025-05-01 03:00:00 | 51.0 | 19.1 |
| 4 | 2025-05-01 04:00:00 | 50.2 | 19.1 |
html = plot_mollier_hx(data=df)
HTML(html)
Frequency contour lines (“risk hours”)#
Set show_frequency=True to overlay cumulative-frequency contour lines on the
scatter. Each line labelled N encloses all but N units of the year, so the
outer lines mark the rare extremes and the inner lines the typical core.
frequency_unit—"hours"(default; line N = N hours/year outside) or"days"(line N = N distinct calendar days/year with at least one hour outside).frequency_smoothing— line rounding (~0.6 angular … ~9 very round, default4.0). Purely geometric: it rounds the curves but never enlarges the enclosed region.
Hover a line to read its value.
# Hours mode (default): each line N encloses all but N hours/year
html = plot_mollier_hx(data=df, show_frequency=True)
HTML(html)
# Days mode: each line N = N distinct calendar days/year with >=1 hour outside
html = plot_mollier_hx(data=df, show_frequency=True, frequency_unit="days")
HTML(html)
Customisation#
Set the altitude with altitude=: the air pressure for all psychrometric
relations follows from it (ISA barometric formula, pressure_from_altitude), and
both are written in the top-right corner of the chart. An explicit pressure=
(in Pa, e.g. a measured value) always wins; altitude is then only the label.
You can also adjust the comfort zone.
html = plot_mollier_hx(
data=df,
altitude=500, # m a.s.l.: sets the pressure (955 hPa), shown top-right
comfort_zone={
"temperature": (18, 24),
"rel_humidity": (0.20, 0.70),
"abs_humidity": (0, 0.012),
},
)
HTML(html)
Custom axis ranges#
Use domain_x and domain_y to zoom into a specific region of the diagram.
domain_x controls the absolute humidity range (kg/kg) and domain_y
controls the y-coordinate range (≈ temperature at x=0).
html = plot_mollier_hx(
data=df,
domain_x=(0.002, 0.014), # absolute humidity 2–14 g/kg
domain_y=(10.0, 35.0), # y-range ≈ 10–35 °C
)
HTML(html)
Toggling individual curve families#
Each iso-line family can be switched off individually (all are on by default). Hiding a family removes everything belonging to it — the lines, their value labels and the axis-edge caption:
show_temperature— blue iso-temperature lines (the temperature y-axis itself always stays, it is the chart’s coordinate base).show_density— grey iso-density curves +"Density ρ [kg/m³]".show_rel_humidity— the φ curves and their labels (the 100 % saturation curve / chart boundary is always drawn).show_enthalpy— iso-enthalpy lines +"Enthalpy [kJ/kg]".show_abs_humidity— vertical iso-lines of constant absolute humidity (at the 1 g/kg major-tick positions).
The caption centred below the bottom axis is controlled by
x_axis_title (default "absolute water content x [g/kg]"; pass "" to omit).
Seasonal scatter labels default to English. Pass a mapping via season_labels (e.g. pyedautils.plots._constants._SEASON_LABELS_DE) for German. All captions and texts can be replaced, see Captions and texts below.
# Hide density and enthalpy; keep temperature and humidity
html = plot_mollier_hx(
data=df,
show_density=False,
show_enthalpy=False,
)
HTML(html)
Warning bands, altitude & pressure#
Add up to two extra zones drawn as dashed outlines (no fill) on top of the
green comfort zone via comfort_zone_orange and comfort_zone_red (off by
default). Each key — temperature, rel_humidity, abs_humidity — takes either
a scalar: a ± offset around the comfort zone in K, rel.-humidity fraction and kg/kg, e.g.
{"temperature": 1, "rel_humidity": 0.05, "abs_humidity": 0.001}for ±1 K / ±5 %-points / ±1 g/kg; ora
(min, max)tuple: absolute bounds in °C, 0–1 and kg/kg, as forcomfort_zone.
Missing keys of an offset band get the default offsets (orange 1 K / 5 % / 1 g/kg,
red 2.5 K / 10 % / 2.5 g/kg — the severity stages of comfort_compass_distribution),
and True uses all defaults. "abs_humidity": None means no upper limit. The red
band is widened where needed so it always encloses the orange one. Optional keys:
label (legend caption) and color.
pyedautils.comfort.comfort_warning_bands(...) turns the compass stage limits into
these offsets, so the diagram and the comfort compass share one definition.
A temperature difference is expressed in kelvin, so label the bands in K.
The altitude (m a.s.l.) and pressure (hPa) are written in the top-right corner.
Pass altitude and the pressure follows from it (ISA barometric formula); pass
only pressure and the altitude shown is derived from it (inverse formula).
from pyedautils.comfort import comfort_warning_bands
# Same stage limits as comfort_compass_distribution (its defaults here)
bands = comfort_warning_bands(temp_stage_limits=(1.0, 2.5),
hum_stage_limits=(5.0, 10.0),
hum_abs_stage_limits=(0.001, 0.0025))
html = plot_mollier_hx(
data=df,
altitude=450,
comfort_zone={"temperature": (20, 26), "rel_humidity": (0.30, 0.65),
"abs_humidity": (0, 0.0115)},
comfort_zone_orange={**bands["orange"], "label": "± 1 K / 5 %"},
comfort_zone_red={**bands["red"], "label": "± 2.5 K / 10 %"},
)
HTML(html)
The absolute form still works, e.g. for a band that is not symmetric around the comfort zone:
html = plot_mollier_hx(
data=df,
comfort_zone_orange={"temperature": (19, 27), "rel_humidity": (0.25, 0.70),
"abs_humidity": (0, 0.012), "label": "19–27 °C / 25–70 %"},
)
HTML(html)
Convention: classical vs. Glück#
The y-axis transformation has two valid normalisations. 'classical'
(default) follows Mollier 1923 / Recknagel — enthalpy per kg of dry
air, isotherms tilt slightly up with x. 'glueck' follows the
Glück reference — enthalpy per kg of moist air, isotherms tilt slightly
down. Physical quantities (T, φ, ρ, h) are identical under either
convention; only the (x, y) parametrisation differs.
html = plot_mollier_hx(data=df, convention='glueck')
HTML(html)
Latest-record overlay#
When data is supplied, the row with the newest timestamp is overlaid as a
black circle on top of the seasonal scatter — you’ve already seen it in the
diagrams above. The overlay is controlled by two parameters:
highlight_latest(bool, defaultTrue) — show or hide the overlay.highlight_color(str | None, default'black') — any CSS colour, orNoneto fall back to the row’s season colour.
# Disable the latest-row overlay
html = plot_mollier_hx(data=df, highlight_latest=False)
HTML(html)
# Custom highlight colour
html = plot_mollier_hx(data=df, highlight_color='#e53935')
HTML(html)
Outside air and ventilation#
Pass the outside air as outdoor=(temperature °C, relative humidity %) — the same
units as the data columns. It is drawn as a hollow blue circle, joined to the room
point (the highlighted latest row) by a dashed ventilation line: when you air the
room, the room air moves along this line towards the outside state (mixing).
The note above the axes tells what airing does. It depends only on the absolute humidity: if the outside air holds more than 0.3 g/kg less water than the room air, airing dries the room — even when the outside air has 85 % relative humidity, as in this winter example; if it holds more, airing adds moisture; otherwise there is no noticeable change. Hover the points for their values.
html = plot_mollier_hx(data=df, outdoor=(4, 85))
HTML(html)
Use current=(t, rh) for a room point that is not part of data, e.g. a live
reading (it replaces the latest-row highlight and also works without data). The
same decision is available in Python as pyedautils.comfort.ventilation_effect.
A cool cellar in summer is the classic case where airing adds moisture:
from pyedautils.comfort import ventilation_effect
cellar, summer_air = (16, 70), (26, 60)
print(ventilation_effect(room=cellar, outdoor=summer_air))
html = plot_mollier_hx(current=cellar, outdoor=summer_air, height=500)
HTML(html)
humidifies
Points outside the axes are not lost: the room and the outside point become an arrow on the edge pointing towards them, labelled with their value. Measured data, the comfort zone and the warning bands are clipped to the axes.
Options: outdoor_color, ventilation_line=False (point only),
ventilation_note=False (line without note) and ventilation_threshold (kg/kg,
default 0.0003).
html = plot_mollier_hx(
data=df,
outdoor=(-25, 80), # below the -20 °C axis limit
domain_x=(0.0, 0.016),
comfort_zone_orange=True, comfort_zone_red=True,
)
HTML(html)
Captions and texts (other languages)#
Every visible text can be replaced; the defaults are English and no translations are built in:
captions— the axis captions, keystemperature,rel_humidity,density,enthalpy,x_axis,x_axis_title(seeMOLLIER_DEFAULT_CAPTIONS).""hides a caption. The captions on the right edge shrink to fit small charts.texts— everything else: outside air and ventilation note, the value label of edge arrows, tooltips, frequency lines, altitude / pressure and the legend (seeMOLLIER_DEFAULT_TEXTS).""hides the altitude / pressure line.
Both are merged over the defaults, so you only pass what you change; unknown keys
raise an error. The older x_axis_title, comfort_label and season_labels still
work and map onto the same keys.
from pyedautils.plots import MOLLIER_DEFAULT_CAPTIONS, MOLLIER_DEFAULT_TEXTS
captions_de = {
"temperature": "Temperatur ϑ [°C]",
"rel_humidity": "rel. Feuchte φ [%]",
"density": "Dichte ρ [kg/m³]",
"enthalpy": "Enthalpie h [kJ/kg]",
"x_axis_title": "Wassergehalt x [g/kg]",
}
texts_de = {
"outdoor": "Aussenluft", "room": "Raum", "now": "Jetzt", "latest": "Letzter Wert",
"ventilation_dries": "Lüften trocknet",
"ventilation_humidifies": "Lüften befeuchtet",
"ventilation_neutral": "Lüften: keine Änderung",
"tip_td": "Taupunkt",
"freq_hours": "{n} h/Jahr ausserhalb", "freq_days": "{n} Tage/Jahr ausserhalb",
"altitude": "{alt} m ü. M.",
"comfort_zone": "Komfortzone",
"season_spring": "Frühling", "season_summer": "Sommer", "season_fall": "Herbst",
}
print(sorted(MOLLIER_DEFAULT_TEXTS)) # all keys that can be translated
html = plot_mollier_hx(
data=df,
altitude=450,
outdoor=(4, 85),
comfort_zone_orange={**bands["orange"], "label": "± 1 K / 5 %"},
captions=captions_de,
texts=texts_de,
height=500,
)
HTML(html)
['altitude', 'comfort_zone', 'freq_days', 'freq_hours', 'latest', 'now', 'outdoor', 'pressure', 'room', 'season_fall', 'season_spring', 'season_summer', 'season_winter', 'state_value', 'tip_h', 'tip_phi', 'tip_t', 'tip_td', 'tip_x', 'ventilation_dries', 'ventilation_humidifies', 'ventilation_neutral']
Fast, updatable variant#
plot_mollier_hx_fast takes the same arguments (including outdoor, current,
captions and texts) and renders with the stateful MollierChart of d3-mollierhx,
which an embedding app can update in place (setData, setCurrent, setOutdoor,
setTexts, …) without redrawing the grid. It has no legend.
from pyedautils.plots import plot_mollier_hx_fast
html = plot_mollier_hx_fast(data=df, outdoor=(4, 85), height=500)
HTML(html)
Process chain (psychrosim-style)#
Use the states= argument to overlay a sequence of psychrometric state
points joined by arrows — a multi-process simulation in the spirit of
psychrosim.com. Build each state with the
state(...) factory in pyedautils.psychrometrics and chain them through the
process functions (heat, cool, humidify_adiabatic, …).
Below we also show how to derive the ambient pressure from a given
altitude with pressure_from_altitude (ISA barometric formula, valid up
to ~5000 m). The example below assumes a building at 450 m a.s.l.
from pyedautils.psychrometrics import (
state, heat, humidify_adiabatic, heat_recovery,
pressure_from_altitude,
)
# 450 m a.s.l. → atmospheric pressure via ISA
p = pressure_from_altitude(450)
print(f'p at 450 m a.s.l.: {p:.0f} Pa')
# Winter: outdoor -5 °C / 80 % RH, extract 22 °C / 40 % RH, 1500 m³/h
s1 = state(t=-5, phi=0.80, p=p, volume_flow=1500)
extract = state(t=22, phi=0.40, p=p, volume_flow=1500)
s2, _ = heat_recovery(s1, extract, eps_sensible=0.75)
s3, _ = heat(s2, t_out=21)
s4, _ = humidify_adiabatic(s3, phi_out=0.45)
html = plot_mollier_hx(
states=[s1, s2, s3, s4],
labels=['Outdoor', 'After HR', 'After heater', 'After humidifier'],
pressure=p,
domain_x=(0.0, 0.012),
domain_y=(-10, 30),
comfort_zone=False,
)
HTML(html)
p at 450 m a.s.l.: 96035 Pa