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, default 4.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; or

  • a (min, max) tuple: absolute bounds in °C, 0–1 and kg/kg, as for comfort_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, default True) — show or hide the overlay.

  • highlight_color (str | None, default 'black') — any CSS colour, or None to 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, keys temperature, rel_humidity, density, enthalpy, x_axis, x_axis_title (see MOLLIER_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 (see MOLLIER_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