Custom Fluid Models (props="CUSTOM")
carbatpy normally uses REFPROP or TREND as the thermodynamic backend. With props="CUSTOM" you can plug in any Python callable as the property model — no external library required.
Typical use cases:
Simplified analytic models (constant cp, ideal gas, incompressible liquid)
Temperature- or pressure-dependent polynomials fitted to data
Interpolators built from measurement data
Testing or prototyping a cycle without a full EOS installation
Once defined, a custom fluid behaves exactly like any other carbatpy Fluid: set_state, print_state, val_dict, properties.enthalpy, set_state_v — all work unchanged.
The interface
You supply a callable with the signature
def my_props(values, given, fluid, composition, wanted):
...
return [T, P, H, V, S, Q, U] # SI, mass basis
Argument |
Type |
Description |
|---|---|---|
|
|
The two input values fixing the thermodynamic state |
|
|
Which two properties are given, e.g. |
|
|
Fluid string from |
|
|
Mole fractions from |
|
|
The property string requested by the caller (default |
If wanted is unchanged and remains at its default _THERMO_STRING, the return value must be a list/array in the order:
Index |
Property |
Unit |
|---|---|---|
0 |
Temperature T |
K |
1 |
Pressure P |
Pa |
2 |
Specific enthalpy H |
J/kg |
3 |
Specific volume V |
m³/kg |
4 |
Specific entropy S |
J/(kg·K) |
5 |
Vapour quality Q |
— (use −1 for single-phase) |
6 |
Specific internal energy U |
J/kg |
Transport properties (viscosity, thermal conductivity, …) may follow optionally in the following order:
Index |
Property |
Unit |
|---|---|---|
7 |
Viscosity VIS |
Pa*s |
8 |
Thermal conductivity TCX |
W/(m*K) |
9 |
Prandtl number PRANDTL |
|
10 |
Specific heat capacity CP |
J/(kg*K) |
11 |
Speed of Sound W |
m/s |
12 |
Molecular Mass M |
g/mol |
If wanted is customized, return the variables as defined.
The callable is passed to init_fluid via props_func=.
[70]:
import numpy as np
import carbatpy as cb
Example 1 — Analytic model: incompressible liquid with constant cp
A simple approximation for liquid water:
isobaric heat capacity: \(c_p = 4182\) J/(kg·K)
density: \(\rho = 997\) kg/m³ (incompressible)
reference state at \(T_\mathrm{ref} = 273.15\) K
All model parameters live inside the function — the callable is fully self-contained. The function supports both "TP" and "PH" input pairs; for "PH" the temperature is back-calculated from the enthalpy definition.
[71]:
def simple_water(values, given, fluid, composition, wanted):
"""Incompressible liquid water with constant cp."""
T_ref = 273.15 # K
cp = 4182.0 # J/(kg·K)
rho = 997.0 # kg/m³
if given == "TP":
T, P = values
elif given == "PH":
P, h_in = values
T = h_in / cp + T_ref
else:
raise NotImplementedError(f"Input pair '{given}' not supported by this model.")
h = cp * (T - T_ref)
v = 1.0 / rho
s = cp * np.log(T / T_ref)
u = h - P * v
q = -6667.0 # always single-phase
return [T, P, h, v, s, q, u] #use default order of _THERMO_STRING
Pass it to init_fluid with props="CUSTOM" and props_func=:
[72]:
water = cb.init_fluid("simple_water", [1.0], props="CUSTOM", props_func=simple_water)
water.set_state([350.0, 2e5], "TP")
water.print_state()
simple_water, composition: [1.0]
Temperature : 350.0
Pressure : 200000.0
spec_Enthalpy : 321386.70000000007
spec_Volume : 0.0010030090270812437
spec_Entropy : 1036.7682334217266
quality_mass : -6667.0
spec_internal_Energy : 321186.09819458384
x : None
y : None
z : None
The result is accessible as a dict, by named attribute, or as a plain array — exactly like REFPROP/TREND fluids:
[73]:
water.val_dict
[73]:
{'Temperature': 350.0,
'Pressure': 200000.0,
'spec_Enthalpy': 321386.70000000007,
'spec_Volume': 0.0010030090270812437,
'spec_Entropy': np.float64(1036.7682334217266),
'quality_mass': -6667.0,
'spec_internal_Energy': 321186.09819458384,
'x': None,
'y': None,
'z': None}
[74]:
# Named attribute access via FluidState
print(f"Enthalpy : {water.properties.enthalpy:.1f} J/kg")
print(f"Entropy : {water.properties.entropy:.2f} J/(kg·K)")
print(f"sp_volume: {water.properties.sp_volume:.6f} m³/kg")
Enthalpy : 321386.7 J/kg
Entropy : 1036.77 J/(kg·K)
sp_volume: 0.001003 m³/kg
[75]:
# Using a different input pair: PH
h_stored = water.properties.enthalpy
st2 = water.set_state([2e5, h_stored], "PH")
print(f"Round-trip T from PH: {st2[0]:.4f} K (expected 350.0 K)")
Round-trip T from PH: 350.0000 K (expected 350.0 K)
Example 2 — Temperature-polynomial model
When properties depend on the thermodynamic state, the independent variable changes depending on the input pair: for "TP" T is known directly; for "PH" it must first be recovered from H. This is handled naturally by branching on given.
Here we use a polynomial \(c_p(T) = a_0 + a_1 T\) (a linear fit). Enthalpy and entropy follow by integration:
Again, all coefficients live inside the function.
[76]:
def poly_fluid(values, given, fluid, composition, wanted):
"""Liquid with a linearly temperature-dependent cp(T) = a0 + a1*T."""
a0 = 1000.0 # J/(kg·K)
a1 = 2.5 # J/(kg·K²)
T_ref = 300.0 # K (h=0, s=0 reference)
rho = 1200.0 # kg/m³ (incompressible)
if given == "TP":
T, P = values
elif given == "PH":
P, h_in = values
# Invert h(T) = a0*(T-T_ref) + a1/2*(T²-T_ref²) → quadratic in T
C = -(a0 * T_ref + 0.5 * a1 * T_ref**2 + h_in)
T = (-a0 + np.sqrt(a0**2 - 4 * 0.5 * a1 * C)) / a1
else:
raise NotImplementedError(f"Input pair '{given}' not supported.")
h = a0 * (T - T_ref) + 0.5 * a1 * (T**2 - T_ref**2)
s = a0 * np.log(T / T_ref) + a1 * (T - T_ref)
v = 1.0 / rho
u = h - P * v
return [T, P, h, v, s, -6667.0, u]
poly = cb.init_fluid("poly_fluid", [1.0], props="CUSTOM", props_func=poly_fluid)
[77]:
poly.set_state([360.0, 3e5], "TP")
poly.print_state()
poly_fluid, composition: [1.0]
Temperature : 360.0
Pressure : 300000.0
spec_Enthalpy : 109500.0
spec_Volume : 0.0008333333333333334
spec_Entropy : 332.3215567939546
quality_mass : -6667.0
spec_internal_Energy : 109250.0
x : None
y : None
z : None
[78]:
# Verify round-trip consistency: TP → PH → T
h_stored = poly.properties.enthalpy
st_ph = poly.set_state([3e5, h_stored], "PH")
print(f"T from TP : 360.000000 K")
print(f"T from PH : {st_ph[0]:.6f} K")
T from TP : 360.000000 K
T from PH : 360.000000 K
Example 3 — Tabulated / measurement data via a callable class
Interpolators from measurement data are expensive to build and should be constructed once, not on every set_state call. A class with __init__ and __call__ is the natural fit: the interpolators are stored as instance attributes and reused across calls.
carbatpy accepts any callable, so a class instance works just as well as a plain function.
[79]:
from scipy.interpolate import RegularGridInterpolator
class TabulatedFluid:
"""
Property look-up via interpolation of a (T, P) property table.
Parameters
----------
T_tab : 1-D array (K)
P_tab : 1-D array (Pa)
H_tab : 2-D array (J/kg), shape (len(T_tab), len(P_tab))
V_tab : 2-D array (m³/kg), same shape
S_tab : 2-D array (J/(kg·K)), same shape
U_tab : 2-D array (J/kg), same shape
"""
def __init__(self, T_tab, P_tab, H_tab, V_tab, S_tab, U_tab):
kw = dict(bounds_error=True, method="linear")
axes = (T_tab, P_tab)
self._h = RegularGridInterpolator(axes, H_tab, **kw)
self._v = RegularGridInterpolator(axes, V_tab, **kw)
self._s = RegularGridInterpolator(axes, S_tab, **kw)
self._u = RegularGridInterpolator(axes, U_tab, **kw)
def __call__(self, values, given, fluid, composition, wanted):
if given != "TP":
raise NotImplementedError(f"Only 'TP' supported by this interpolator.")
T, P = values
pt = np.array([[T, P]])
return [
T, P,
float(self._h(pt)),
float(self._v(pt)),
float(self._s(pt)),
-6667.0,
float(self._u(pt)),
]
[80]:
# Build synthetic tabulated data — replace with real measurements
T_tab = np.linspace(280.0, 380.0, 20) # K
P_tab = np.linspace(0.5e5, 5.0e5, 10) # Pa
T_grid, P_grid = np.meshgrid(T_tab, P_tab, indexing="ij")
T_REF_TAB = 273.15
CP_TAB = 4000.0
RHO_0_TAB = 1000.0
H_tab = CP_TAB * (T_grid - T_REF_TAB)
V_tab = 1.0 / (RHO_0_TAB - 0.3 * (T_grid - T_REF_TAB)) # slight T-dependence
S_tab = CP_TAB * np.log(T_grid / T_REF_TAB)
U_tab = H_tab - P_grid * V_tab
# Construct the callable — interpolators are built once here
tab_props = TabulatedFluid(T_tab, P_tab, H_tab, V_tab, S_tab, U_tab)
tab_fluid = cb.init_fluid("tabulated", [1.0], props="CUSTOM", props_func=tab_props)
[81]:
tab_fluid.set_state([320.0, 2e5], "TP")
tab_fluid.print_state()
tabulated, composition: [1.0]
Temperature : 320.0
Pressure : 200000.0
spec_Enthalpy : 187400.0000000001
spec_Volume : 0.0010142559831652705
spec_Entropy : 633.0694664672109
quality_mass : -6667.0
spec_internal_Energy : 187197.14880336705
x : None
y : None
z : None
C:\Users\welp\AppData\Local\Temp\ipykernel_11608\3240967204.py:32: DeprecationWarning: Conversion of an array with ndim > 0 to a scalar is deprecated, and will error in future. Ensure you extract a single element from your array before performing this operation. (Deprecated NumPy 1.25.)
float(self._h(pt)),
C:\Users\welp\AppData\Local\Temp\ipykernel_11608\3240967204.py:33: DeprecationWarning: Conversion of an array with ndim > 0 to a scalar is deprecated, and will error in future. Ensure you extract a single element from your array before performing this operation. (Deprecated NumPy 1.25.)
float(self._v(pt)),
C:\Users\welp\AppData\Local\Temp\ipykernel_11608\3240967204.py:34: DeprecationWarning: Conversion of an array with ndim > 0 to a scalar is deprecated, and will error in future. Ensure you extract a single element from your array before performing this operation. (Deprecated NumPy 1.25.)
float(self._s(pt)),
C:\Users\welp\AppData\Local\Temp\ipykernel_11608\3240967204.py:36: DeprecationWarning: Conversion of an array with ndim > 0 to a scalar is deprecated, and will error in future. Ensure you extract a single element from your array before performing this operation. (Deprecated NumPy 1.25.)
float(self._u(pt)),
Example 4 — CoolProp backend via PropsSI
If CoolProp is installed, it can serve as the backend for a CUSTOM fluid. The callable maps carbatpy’s single-letter input codes (T, P, H, S, Q) to CoolProp’s property names and delegates every call to PropsSI.
Because carbatpy forwards the fluid name automatically via fluid, the same callable works for any CoolProp-supported substance — no model constants need to be hard-coded.
Be careful with mixtures! CoolProp might suggest a result even if it is not valid! See the Coolprop documentation for further information: https://coolprop.sourceforge.net/fluid_properties/Mixtures.html
[82]:
from CoolProp.CoolProp import PropsSI
# Maps carbatpy/REFPROP property codes → CoolProp names.
# Covers both input-pair codes (single char) and wanted-string codes.
_CP_MAP = {
'T': 'T',
'P': 'P',
'H': 'Hmass',
'V': 'Dmass', # density; inverted to specific volume below
'S': 'Smass',
'Q': 'Q', # used in given (input pair)
'QMASS': 'Q', # used in wanted string
'E': 'Umass',
'VIS': 'viscosity',
'TCX': 'conductivity',
'PRANDTL': 'Prandtl',
'CP': 'Cp0mass',
'W': 'speed_of_sound',
'M': 'molar_mass',
}
_CP_INVERT = {'V'} # specific volume = 1 / density
def coolprop_props(values, given, fluid, composition, wanted):
"""Fluid properties via CoolProp.PropsSI, iterating through *wanted*.
fluid must be a CoolProp-compatible name, e.g. 'Water', 'R134a', 'Propane'.
Supported input pairs: any combination of T, P, H, S, Q.
"""
name1 = _CP_MAP[given[0]]
name2 = _CP_MAP[given[1]]
v1, v2 = values
result = []
for code in wanted.split(";"):
cp_name = _CP_MAP[code]
val = PropsSI(cp_name, name1, v1, name2, v2, fluid)
if code in _CP_INVERT:
val = 1.0 / val
if code == 'QMASS' and not 0.0 <= val <= 1.0:
val = -6667.0
result.append(val)
return result
cp_water = cb.init_fluid("Water", [1.0], props="CUSTOM", props_func=coolprop_props)
cp_water.set_state([350.0, 2e5], "TP")
cp_water.print_state()
Water, composition: [1.0]
Temperature : 350.0
Pressure : 200000.0
spec_Enthalpy : 321918.3569311111
spec_Volume : 0.0010269339603223562
spec_Entropy : 1037.888228707492
quality_mass : -6667.0
spec_internal_Energy : 321712.97013907303
x : None
y : None
z : None
[83]:
cp_water.set_state([350.0, 2e5], "TP", wanted=cb.CB_DEFAULTS["Fluid_Defaults"]["TRANS_STRING"])
cp_water.print_state()
Water, composition: [1.0]
Temperature : 350.0
Pressure : 200000.0
spec_Enthalpy : 321918.3569311111
spec_Volume : 0.0010269339603223562
spec_Entropy : 1037.888228707492
quality_mass : -6667.0
spec_internal_Energy : 321712.97013907303
viscosity : 0.00036849611088197846
thermal_conductivity : 0.6649268689536709
Prandtl_number : 2.32441423502578
isobaric_heat_capacity : 1880.5502447228766
speed_of_sound : 1555.1376386761594
molecular_mass : 0.018015268
x : None
y : None
z : None
[84]:
cp_r134a = cb.init_fluid("R134a", [1.0], props="CUSTOM", props_func=coolprop_props)
cp_r134a.set_state([300.0, 6e5], "TP")
cp_r134a.print_state()
h_stored = cp_r134a.properties.enthalpy
st_ph = cp_r134a.set_state([6e5, h_stored], "PH")
print(f"\nRound-trip T from PH: {st_ph[0]:.4f} K (expected 300.0 K)")
R134a, composition: [1.0]
Temperature : 300.0
Pressure : 600000.0
spec_Enthalpy : 415849.56452239887
spec_Volume : 0.03536749623274023
spec_Entropy : 1735.2099107648696
quality_mass : -6667.0
spec_internal_Energy : 394629.06678279996
x : None
y : None
z : None
Round-trip T from PH: 300.0000 K (expected 300.0 K)
If you want to use a specific CoolProp-Fluid-Model, you can do so by defining the fluidname.
[85]:
# Maps carbatpy/REFPROP property codes → CoolProp incompressible-fluid names.
# Note: CoolProp uses different single-letter codes for incompressible fluids:
# 'V' = dynamic viscosity, 'L' = thermal conductivity, 'C' = isobaric cp.
# PRANDTL and W, M require special treatment (see function body).
_CP_MAP_INCOMP = {
'T': 'T',
'P': 'P',
'H': 'Hmass',
'V': 'Dmass', # density; inverted to specific volume (reuses _CP_INVERT)
'S': 'Smass',
'Q': 'Q', # for input pairs only; Q as state is not valid for incompressible
'QMASS': None, # always -6667.0 for incompressible fluids
'E': 'Umass',
'VIS': 'V', # CoolProp incompressible: dynamic viscosity
'TCX': 'L', # CoolProp incompressible: thermal conductivity
'CP': 'C', # CoolProp incompressible: isobaric heat capacity
'PRANDTL': None, # computed as VIS * CP / TCX (see below)
'W': None, # not available for incompressible fluids in CoolProp
'M': None, # not available for incompressible fluids in CoolProp
}
_CP_INVERT = {'V'} # specific volume = 1 / density
def coolprop_incomp_props(values, given, fluid, composition, wanted):
"""Fluid properties for incompressible fluids category via CoolProp.PropsSI.
Fluid must be of category incompressible fluids (see: https://coolprop.org/fluid_properties/Incompressibles.html).
fluid must be a CoolProp-compatible name WITHOUT the composition in brackets, e.g. "INCOMP::MEG".
composition must be defined in correct units specified by CoolProp, see CoolProp documentation.
Supported input pairs: any combination of T, P, H, S.
(Q is not allowed as input for incompressible fluids in CoolProp.)
"""
name1 = _CP_MAP_INCOMP[given[0]]
name2 = _CP_MAP_INCOMP[given[1]]
v1, v2 = values
fluid_extended = fluid + str(composition)
result = []
for code in wanted.split(";"):
if code == 'QMASS':
result.append(-6667.0)
elif code == 'PRANDTL':
mu = PropsSI('V', name1, v1, name2, v2, fluid_extended)
cp_v = PropsSI('C', name1, v1, name2, v2, fluid_extended)
lamb = PropsSI('L', name1, v1, name2, v2, fluid_extended)
result.append(mu * cp_v / lamb)
elif code in ('W', 'M'):
result.append(-1000.)
else:
cp_name = _CP_MAP_INCOMP[code]
val = PropsSI(cp_name, name1, v1, name2, v2, fluid_extended)
if code in _CP_INVERT:
val = 1.0 / val
result.append(val)
return result
cp_MEG = cb.init_fluid("INCOMP::MEG", [0.5], props="CUSTOM", props_func=coolprop_incomp_props)
cp_MEG.set_state([273.15, 1e5], "TP")
cp_MEG.print_state()
cp_MEG.set_state([273.15, 1e5], "TP", wanted=cb.CB_DEFAULTS["Fluid_Defaults"]["TRANS_STRING"])
cp_MEG.print_state()
INCOMP::MEG, composition: [0.5]
Temperature : 273.15
Pressure : 100000.0
spec_Enthalpy : -65160.29676241186
spec_Volume : 0.0009305583742099314
spec_Entropy : -230.17235690828966
quality_mass : -6667.0
spec_internal_Energy : -65253.352599832855
x : None
y : None
z : None
INCOMP::MEG, composition: [0.5]
Temperature : 273.15
Pressure : 100000.0
spec_Enthalpy : -65160.29676241186
spec_Volume : 0.0009305583742099314
spec_Entropy : -230.17235690828966
quality_mass : -6667.0
spec_internal_Energy : -65253.352599832855
viscosity : 0.007929773683300138
thermal_conductivity : 0.3768161213114663
Prandtl_number : 67.4018018553448
isobaric_heat_capacity : 3202.8764702355397
speed_of_sound : -1000.0
molecular_mass : -1000.0
x : None
y : None
z : None
Returning a FluidState object
As with any carbatpy fluid, passing output="FluidState" gives named attribute access:
[69]:
state_obj = water.set_state([330.0, 1e5], "TP", output="FluidState")
print(f"Type : {type(state_obj)}")
print(f"Enthalpy : {state_obj.enthalpy:.1f} J/kg")
print(f"Entropy : {state_obj.entropy:.3f} J/(kg·K)")
print(f"\nFull dict:\n{state_obj.state_to_dict()}")
Type : <class 'carbatpy.models.cb_fluids.fluid_props.FluidState'>
Enthalpy : 237746.7 J/kg
Entropy : 790.697 J/(kg·K)
Full dict:
{'Temperature': 330.0, 'Pressure': 100000.0, 'spec_Enthalpy': 237746.7000000001, 'spec_Volume': 0.0010030090270812437, 'spec_Entropy': np.float64(790.6972623258188), 'quality_mass': -6667.0, 'spec_internal_Energy': 237646.399097292, 'x': None, 'y': None, 'z': None}
Summary
Step |
Code |
|---|---|
Define your function |
|
Create the fluid |
|
Use it |
|
Tips
All units must be SI mass-based: K, Pa, J/kg, m³/kg, J/(kg·K)
fluid,composition, andwantedare always forwarded — declare them even if unusedUse
Q = -6667.0to indicate single-phaseRaise
NotImplementedErrorfor input pairs your function does not supportKeep all model constants inside the function body so the callable is self-contained
When the callable needs to hold expensive state (e.g. interpolators built from measurement data), use a class with
__init__and__call__