# LuaCoolProp Lua API reference

This document describes the public Lua interface implemented by
`luacoolprop.lua`. It is intended for package maintainers, advanced users who
generate PGFPlots code programmatically, and contributors adding diagram types.
The TeX interface remains the recommended interface for ordinary documents.

The reference applies to LuaCoolProp 1.1.0. CoolProp is an external shared
library with its own versioned API. Always record both `lcp._VERSION` and
`lcp.version()` when reproducibility matters.
For published results, cite the underlying CoolProp paper and project site as
specified on its [official citation page](https://coolprop.org/citation.html).

LuaCoolProp was developed with substantial ChatGPT-assisted “vibe coding.” AI
assistance is not a correctness or safety guarantee: review this implementation
and independently validate thermodynamic results before high-consequence use.
Loading CoolProp through FFI executes native code in the current process, so
select only a trusted, architecture-compatible library, preferably by an
absolute `LUACOOLPROP_LIB` path. For TeX typesetting, the required full
`--shell-escape` mode also grants external-command capabilities to the entire
document and all loaded inputs; consult the user manual's installation and
security chapter before enabling it.

## Runtime and module loading

The module requires the FFI implementation supplied by LuaTeX or `texlua`. It
can be required from LuaLaTeX, plain LuaTeX, ConTeXt MkIV, or a `texlua`
program. LuaMetaTeX/LMTX does not supply this FFI module.

```lua
local lcp = require("luacoolprop")

local _, loaded_from = lcp.load_library()
io.write("LuaCoolProp ", lcp._VERSION, "\n")
io.write("CoolProp ", lcp.version(), "\n")
io.write("Library: ", loaded_from, "\n")
```

Use `local` for the module reference and for every intermediate value. Requiring
the module returns one table and does not intentionally install application
globals. The generic TeX layer keeps its own local module reference.

### Shared-library search order

`lcp.load_library(path)` tries candidates in this order:

1. the explicit `path` argument;
2. `LUACOOLPROP_LIB`;
3. the secondary `COOLPROP_LIB` variable;
4. platform filenames in the current directory;
5. the current TeX file resolver;
6. `CoolProp` and `libCoolProp` through the platform dynamic loader.

The first successful FFI handle is cached for the lifetime of the Lua process.
Later calls return that same handle; they do not replace the loaded library.
Set `LUACOOLPROP_LIB` before starting the engine when the exact dependency must
be controlled.

`lcp.loaded_library()` returns the successful path or loader name, or `nil`
before the first successful load. `lcp.C` contains the raw FFI handle after a
load, but it is an implementation escape hatch, not a stable package API.

## Units and numeric conventions

Low-level functions use CoolProp SI units without implicit conversion.

| Quantity | Lua value | SI unit |
| --- | ---: | --- |
| pressure `p` | number | Pa |
| temperature `T` | number | K |
| mass enthalpy `h` | number | J/kg |
| mass entropy `s` | number | J/(kg K) |
| mass density `rho` | number | kg/m³ |
| specific volume `v` | number | m³/kg |
| vapor quality `q` | number | dimensionless, normally 0–1 |

Diagram curve points additionally contain plotting coordinates. With the
defaults, PH uses `(h * 1e-3, p * 1e-5)`, PV uses `(v, p * 1e-5)`, TS uses
`(s * 1e-3, T)`, and HS uses `(s * 1e-3, h * 1e-3)`. The original SI values
remain available in every record.

Prefer semantic coordinate-unit options. They change the numerical conversion
and its declared unit as one operation:

| Coordinate property | Option | Accepted values |
| --- | --- | --- |
| pressure | `pressure_axis_unit` | `pa`, `kpa`, `mpa`, `bar` |
| enthalpy | `enthalpy_axis_unit` | `jkg`, `kjkg` |
| entropy | `entropy_axis_unit` | `jkgk`, `kjkgk` |
| specific volume | `specific_volume_axis_unit` | `m3kg`, `lkg` |
| temperature | `temperature_axis_unit` | `kelvin`, `celsius` |

The option is accepted only when that property is an axis of the selected
diagram. Celsius uses `(T - 273.15)`, not a multiplicative approximation.
Numeric `*_scale` fields remain an advanced compatibility interface; TeX then
labels the coordinate as an explicit scaled-SI value instead of claiming a
named unit.

All public numeric results are ordinary Lua numbers. Callers doing independent
calculations should reject NaN and infinities before serializing them. Diagram
renderers already discard thermodynamic points that CoolProp cannot evaluate.

## Errors and ownership

Programming errors, load failures, invalid process specifications, and reported
CoolProp C-API errors raise Lua errors. The module does not use `(nil, err)` for
these failures. Catch an error only at a boundary where the caller can add useful
context or recover.

```lua
local lcp = require("luacoolprop")

local ok, result = xpcall(function()
  return lcp.propsSI("H", "P", 1e5, "Q", 1, "R134a")
end, debug.traceback)

if not ok then
  io.stderr:write(result, "\n")
  os.exit(1)
end

io.write(string.format("h = %.3f kJ/kg\n", result * 1e-3))
```

Do not wrap every property call in `pcall`: that hides invalid models and makes
diagnostics less useful. The internal adaptive samplers deliberately use
protected calls at individual sample points because a curve may cross a region
where a property pair is undefined.

CoolProp `AbstractState` handles own native resources. Call `state:free()` as
soon as the state is no longer needed. The FFI finalizer is a safety net, not a
substitute for deterministic cleanup.

## Module topology

The returned module table has three principal layers:

| Namespace | Responsibility |
| --- | --- |
| `lcp` | library loading and thin CoolProp C-API wrappers |
| `lcp.diagram` | format-independent computation and serialization |
| `lcp.tex` | functions that write to the active LuaTeX input stream |

Application Lua code should prefer `lcp` and `lcp.diagram`. Only the generic TeX
layer should normally call `lcp.tex`.

## Library and metadata functions

### `load_library([path]) -> handle, loaded_from`

Loads and caches CoolProp. `path`, when present, must be a string. The first
return value is FFI cdata; the second is the path or loader name. Prefer the
second value for diagnostics and do not retain or call the raw handle unless a
function is genuinely absent from LuaCoolProp's wrapper. Surrounding
whitespace is removed from every path candidate, including a TeX key value or
environment variable; whitespace inside the path is preserved.

### `loaded_library() -> string|nil`

Reports the candidate used by the successful load. It does not trigger loading.

### `version() -> string` and `gitrevision() -> string`

Return metadata embedded in the loaded CoolProp library. Both calls trigger
library loading when necessary.

### String metadata

- `global_param_string(param[, buffer_length])`
- `parameter_information_string(param[, buffer_length])`
- `fluid_param_string(fluid, param[, buffer_length])`
- `fluid_param_string_len(fluid, param)`

The optional buffer length defaults to 4096 bytes. Query the required fluid
metadata length first only when a value can exceed that default.

```lua
local lcp = require("luacoolprop")

local aliases = lcp.fluid_param_string("R134a", "aliases")
local description = lcp.parameter_information_string("Hmass")
io.write(aliases, "\n", description, "\n")
```

## Thin CoolProp property wrappers

### `propsSI(output, name1, value1, name2, value2, fluid) -> number`

Calls CoolProp `PropsSI`. Property identifiers and numeric values are passed
unchanged. `PropsSI` is an alternate spelling for the same Lua function.

```lua
local lcp = require("luacoolprop")

local pressure = 2.5e5
local quality = 1.0
local h = lcp.propsSI("Hmass", "P", pressure, "Q", quality, "R134a")
local T = lcp.propsSI("T", "P", pressure, "Q", quality, "R134a")
```

### `props1SI(fluid, output) -> number`

Calls CoolProp `Props1SI` for a fluid constant. `Props1SI` is an alternate
alias.

### `hapropsSI(output, name1, value1, name2, value2, name3, value3) -> number`

Calls CoolProp `HAPropsSI` for humid air. `HAPropsSI` is an alternate spelling.

### `phaseSI(name1, value1, name2, value2, fluid[, buffer_length]) -> string`

Returns CoolProp's textual phase result. `PhaseSI` is an alternate spelling.

### Lookup and validation functions

- `is_valid_fluid_string(fluid) -> boolean`
- `param_index(param) -> number`
- `input_pair_index(pair) -> number`
- `saturation_ancillary(fluid, output, Q, input, value) -> number`

Indices belong to the loaded CoolProp version. Resolve them at startup when they
are useful for a hot loop, but never store them in generated files or use an
index obtained from a different CoolProp build.

### Process-wide CoolProp configuration

- `set_config_string(key, value)`
- `set_config_double(key, value)`
- `set_config_bool(key, value)`
- `set_debug_level(level)`
- `get_debug_level() -> number`

These functions mutate CoolProp process-wide state. Configure the library once,
before calculations, and document non-default values. For
`set_config_bool`, pass an actual boolean: Lua treats every value except `nil`
and `false` as true, including the string `"false"`.

### Enthalpy and entropy reference states

`set_reference_state(fluid[, reference[, library]]) -> reference` accepts `DEF`, `IIR`,
`ASHRAE`, or `NBP`; input is case-insensitive and the result is uppercase.
`reference_state(fluid) -> string|nil` reports the convention initialized
through LuaCoolProp.

Reference-state changes are initialization operations. The first diagram call
for a fluid locks its convention, and changing it later raises an error;
repeating the current convention is harmless. All diagram and process entry
points default to `DEF` and accept `reference_state` in their options. Process
metadata includes the resulting `reference_state`. Enthalpy and entropy are
relative properties, so compare only states computed with the same convention
and prefer enthalpy differences in energy balances.

## Managed `AbstractState`

### Construction

`AbstractState(backend, fluids) -> state` creates a native CoolProp state and a
Lua owner object. `State` is the shared method table and is exposed for
introspection; construct instances only through `AbstractState`.

### Methods

- `state:update(input_pair, value1, value2) -> state` updates the native state.
  `input_pair` may be a CoolProp name or an already resolved numeric index.
- `state:keyed_output(param) -> number` reads a property by name or index.
- `state:phase() -> number` returns CoolProp's numeric phase index.
- `state:fluid_names() -> string` returns the native fluid specification.
- `state:free()` releases the handle. It is safe to call more than once.

Use a single cleanup point when work between construction and release can fail:

```lua
local lcp = require("luacoolprop")
local state

local ok, result = xpcall(function()
  state = lcp.AbstractState("HEOS", "R134a")
  state:update("PT_INPUTS", 3e5, 293.15)
  return {
    h = state:keyed_output("Hmass"),
    s = state:keyed_output("Smass"),
    phase = state:phase(),
  }
end, debug.traceback)

if state ~= nil then
  state:free()
  state = nil
end

if not ok then
  error(result, 0)
end

io.write(string.format("h=%.3f kJ/kg, s=%.4f kJ/(kg K)\n",
  result.h * 1e-3, result.s * 1e-3))
```

Never copy `state.handle`, call native free functions directly, or use a state
after `free()`. Do not depend on finalizer order at engine shutdown.

## Fluid constants

`fluid_constants(fluid[, library]) -> constants` returns a new table. Its
fields are:

| Field | Meaning | Unit |
| --- | --- | --- |
| `fluid` | requested CoolProp fluid string | — |
| `pcrit` | critical pressure | Pa |
| `ptriple` | triple-point pressure | Pa |
| `Tcrit` | critical temperature | K |
| `Ttriple` | triple-point temperature | K |
| `Tmin` | lower temperature bound | K |
| `Tmax` | upper temperature bound | K |
| `rhocrit` | critical mass density | kg/m³ |

A property that is unavailable may be `nil`. Treat the table as a snapshot and
do not mutate it if it is also passed to a renderer through `opts.constants`.

## Structured diagram API

Diagram codes are uppercase coordinate identifiers such as `PH`, `PV`, `TS`,
`HS`, or `PT`.
The built-in implementation tables are `lcp.diagram.ph`, `lcp.diagram.pv`,
`lcp.diagram.ts`, `lcp.diagram.hs`, and `lcp.diagram.pt`.
Retrieve them through `lcp.diagram.get_type("PH")` or
`lcp.diagram.get_type("PV")`, `lcp.diagram.get_type("TS")`, or
`lcp.diagram.get_type("HS")`, or `lcp.diagram.get_type("PT")` when code should be independent of lowercase field
naming.

Every implementation passes through the same option-normalization and
capability boundary. No built-in type is the template or fallback for another.
The background-family matrix is:

| Type | Axis properties | Registered families |
| --- | --- | --- |
| `PH` | enthalpy, pressure | quality, isotherm, isentrope, isochore |
| `PV` | specific volume, pressure | quality, isotherm, isentrope |
| `TS` | entropy, temperature | quality, isenthalp |
| `HS` | entropy, enthalpy | quality, isochore, isotherm, isobar |
| `PT` | temperature, pressure | phase_envelope, isentrope, isenthalp, isochore |

Setting an unavailable family switch to true raises an error. Process paths
remain able to project all six conserved-property transformations in every
coordinate system.

Diagram calls enforce a pure-fluid boundary with CoolProp's `pure` metadata.
Explicit mixtures, `.mix` files, and predefined blends are rejected because
the samplers assume one critical point and one saturation dome. Thin wrappers
such as `propsSI` and managed `AbstractState` intentionally remain
mixture-capable.

The PH implementation exposes:

```text
axis_style(opts)                    -> pgfplots key string
plots(opts)                         -> pgfplots commands
family(opts, family_name)           -> pgfplots commands
process_points(opts)                -> points, metadata
process_plot(opts)                  -> pgfplots commands, metadata
quality_curve(opts, quality)        -> points
isotherm_curve(opts, temperature)   -> points
isentrope_curve(opts, entropy)      -> points
isochore_curve(opts, volume)        -> points
quality_plots(opts)                 -> pgfplots commands
isotherm_plots(opts)                -> pgfplots commands
isentrope_plots(opts)               -> pgfplots commands
isochore_plots(opts)                -> pgfplots commands
```

PV has the same core shape and these public families:

```text
axis_style(opts)                    -> pgfplots key string
plots(opts)                         -> pgfplots commands
family(opts, family_name)           -> pgfplots commands
process_points(opts)                -> points, metadata
process_plot(opts)                  -> pgfplots commands, metadata
quality_curve(opts, quality)        -> points
isotherm_curve(opts, temperature)   -> points
isentrope_curve(opts, entropy)      -> points
quality_plots(opts)                 -> pgfplots commands
isotherm_plots(opts)                -> pgfplots commands
isentrope_plots(opts)               -> pgfplots commands
```

TS has the same core shape and these public families:

```text
axis_style(opts)                    -> pgfplots key string
plots(opts)                         -> pgfplots commands
family(opts, family_name)           -> pgfplots commands
process_points(opts)                -> points, metadata
process_plot(opts)                  -> pgfplots commands, metadata
quality_curve(opts, quality)        -> points
isenthalp_curve(opts, enthalpy)     -> points
quality_plots(opts)                 -> pgfplots commands
isenthalp_plots(opts)               -> pgfplots commands
```

HS has the same core shape and these public families:

```text
axis_style(opts)                    -> pgfplots key string
plots(opts)                         -> pgfplots commands
family(opts, family_name)           -> pgfplots commands
process_points(opts)                -> points, metadata
process_plot(opts)                  -> pgfplots commands, metadata
quality_curve(opts, quality)        -> points
isochore_curve(opts, volume)        -> points
isotherm_curve(opts, temperature)   -> points
isobar_curve(opts, pressure)        -> points
quality_plots(opts)                 -> pgfplots commands
isochore_plots(opts)                -> pgfplots commands
isotherm_plots(opts)                -> pgfplots commands
isobar_plots(opts)                  -> pgfplots commands
```

PT has the same core shape and these public families:

```text
axis_style(opts)                    -> pgfplots key string
plots(opts)                         -> pgfplots commands
family(opts, family_name)           -> pgfplots commands
process_points(opts)                -> points, metadata
process_plot(opts)                  -> pgfplots commands, metadata
phase_envelope_curve(opts)          -> points
isentrope_curve(opts, entropy)      -> points
isenthalp_curve(opts, enthalpy)     -> points
isochore_curve(opts, volume)        -> points
phase_envelope_plots(opts)          -> pgfplots commands
isentrope_plots(opts)               -> pgfplots commands
isenthalp_plots(opts)               -> pgfplots commands
isochore_plots(opts)                -> pgfplots commands
```

Renderers are pure with respect to TeX: they return data or strings and never
call `tex.sprint`. They may load and call the external CoolProp library.

### Point records

Every curve function returns an array in plotting order. Every point has:

- `x`: scaled horizontal plot coordinate;
- `y`: scaled vertical plot coordinate;
- `p`: pressure in Pa when resolved; and
- `t`: the scalar sampling parameter (normally `log10(p)`).

PH points have `h` in J/kg and use `x = h * enthalpy_scale`. PV points have
`v` in m³/kg and `rho` in kg/m³ and use
`x = v * specific_volume_scale`. Both use `y = p * pressure_scale`. A
TS point uses `x = s * entropy_scale` and
`y = (T - temperature_offset) * temperature_scale`; the defaults produce
kJ/(kg K) and K. An HS
point uses `x = s * entropy_scale` and `y = h * enthalpy_scale`; its defaults
produce kJ/(kg K) and kJ/kg. A PT point uses
`x = (T - temperature_offset) * temperature_scale` and
`y = p * pressure_scale`. A family-specific point may additionally contain
`q`, `T`, `s`, `h`, `v`, or
`rho`, all in SI units.

Consumers must not assume uniformly spaced samples: subdivision is adaptive.
Each returned point array also carries non-array topology metadata:

- `segments`: the connected point arrays;
- `domain_gap_count`: the number of gaps between retained components;
- `omitted_sample_count`: failed CoolProp sample evaluations; and
- `partially_omitted`: true when valid points and omissions coexist.

The flat array view marks the first point after a gap with
`_break_before = true`. Serialization inserts `(nan,nan)` and
`unbounded coords=jump`, so two valid regions are never connected across an
invalid domain. `domain_policy` (or `discontinuity_policy`) accepts
`ignore`/`silent`, `warning`/`warn`, or `error` and controls the corresponding
diagnostic without changing curve topology.

Quality curves have a stronger sampling and endpoint contract. When their
range enters the upper half of the subcritical interval, the generator performs
a second adaptive pass in `log10((pcrit-p)/pcrit)`. This pass uses the same
family tolerance, recursion limit, and
projection-specific midpoint metric as the main sampler. It therefore resolves
the critical curvature for PH, PV, TS, and HS without a fluid-specific cutoff
or a fixed point table. If `pressure_max` reaches or exceeds `pcrit`, the
generator then appends a canonical critical-limit record. That final record has
`critical = true`,
`critical_limit = true`, `quality_defined = false`, `p = pcrit`, and the
critical `T`, `rho`, and `v`; it deliberately has no `q` field. Its `x` and `y`
values are bit-identical for every curve. This is a common graphical limit:
vapor quality is undefined at the critical state and the record does not claim
that every quality exists there. PH evaluates the exact critical enthalpy with the
`(Tcrit, rhocrit)` state because `(P, Q)` is singular there. A backend that
cannot evaluate that state uses the mean of the closest common saturated
liquid and vapour enthalpies and sets `critical_approximated = true`. PV uses
the canonical `v = 1/rhocrit` directly. TS evaluates critical entropy from
`(Tcrit, rhocrit)` and falls back to the mean of the closest saturated
entropies. HS evaluates both critical entropy and enthalpy at that unique state
and applies the corresponding saturation-mean fallbacks. If `pressure_max` is
below `pcrit`, no
critical record is added. At the triple-point pressure, saturated liquid and
vapour correctly remain distinct.

### Shared options and common capability fields

Lua option names use `snake_case`. The most important canonical fields are:

| Field | Default | Contract |
| --- | --- | --- |
| `fluid` | `"R134a"` | CoolProp fluid name |
| `library` | search order | explicit shared-library path |
| `reference_state` | `"DEF"` | locked enthalpy/entropy reference convention |
| `pressure_min` | fluid dependent | lower pressure in Pa |
| `pressure_max` | fluid dependent | upper pressure in Pa |
| `pressure_max_factor` | `1.10` | automatic upper limit relative to `pcrit` |
| `enthalpy_scale` | `1e-3` | multiplier from J/kg to plot `x` |
| `pressure_scale` | `1e-5` | multiplier from Pa to plot `y` |
| `enthalpy_axis_unit` | `"kjkg"` | coupled PH/HS coordinate unit (`jkg` or `kjkg`) |
| `pressure_axis_unit` | `"bar"` | coupled PH/PV/PT coordinate unit (`pa`, `kpa`, `mpa`, or `bar`) |
| `coord_digits` | `6` | numeric serialization precision |
| `initial_intervals` | family dependent | initial logarithmic-pressure intervals |
| `max_depth` | family dependent | maximum adaptive recursion depth |
| `tolerance` | family dependent | midpoint error threshold in plot space |
| `domain_policy` | `"ignore"` | omitted-domain diagnostic policy |
| `log_weight` | `30` | pressure contribution to midpoint error |
| `labels` | `false` | enable labels for enabled families |
| `label_placement` | `"autonode"` | `autonode` or explicit manual placement |
| `quality_symbol` | `"Q"` | TeX math material used in quality labels |
| `temperature_symbol` | `"T"` | TeX math material used in isotherm labels |
| `entropy_symbol` | `"s"` | TeX math material used in isentrope labels |
| `specific_volume_symbol` | `"v"` | TeX math material used in isochore labels |
| `quality` | `true` | enable quality curves |
| `isotherm` | `false` | enable isotherms |
| `isentrope` | `false` | enable isentropes |
| `isochore` | `false` | enable isochores |

The structured API maps `enthalpy_scale` and `pressure_scale` to the compact
fields `h_scale` and `p_scale`. Canonical names are recommended for generic
dispatch. Family-specific sampling fields prefix the generic name, for example
`isotherm_tolerance`, `isentrope_max_depth`, and
`isochore_initial_intervals`. Symbol strings are trusted TeX math material;
renderers add math delimiters, so callers must not include dollar signs.

### PV options and log-log sampling

PV uses the shared pressure, quality, temperature, entropy, styling,
label, autonode, and per-curve override fields. It does not expose
an `isochore` background family. Its coordinate-specific fields are:

| Field | Default | Contract |
| --- | --- | --- |
| `specific_volume_scale` | `1` | multiplier from m³/kg to plot `x` |
| `specific_volume_axis_unit` | `"m3kg"` | coupled coordinate unit (`m3kg` or `lkg`) |
| `pressure_scale` | `1e-5` | multiplier from Pa to plot `y` |
| `log_x_weight` | `30` | volume contribution to midpoint error in `log10(x)` |
| `quality` | `true` | enable quality curves |
| `isotherm` | `false` | enable isotherms |
| `isentrope` | `false` | enable isentropes |

The canonical implementation maps `specific_volume_scale` to `v_scale`.
Quality, isotherm, and isentrope curves are sampled in logarithmic pressure;
their midpoint error is evaluated in `(log10(v), log10(p))`. Subcritical
isotherms explicitly insert saturated vapour and saturated liquid at the same
pressure, yielding the exact two-phase plateau rather than asking CoolProp for
undefined intermediate single-phase states.

```lua
local lcp = require("luacoolprop")
local pv = lcp.diagram.get_type("PV")

local points = pv.isotherm_curve({
  fluid = "R134a",
  pressure_min = 1e5,
  pressure_max = 5e6,
  specific_volume_scale = 1,
  pressure_scale = 1e-5,
  isotherm_tolerance = 0.18,
  log_x_weight = 30,
}, 293.15)

for index, point in ipairs(points) do
  io.write(string.format("%d %.8g %.8g %.8g\n",
    index, point.x, point.y, point.rho))
end
```

### TS options and sampling

TS uses the shared fluid, pressure, quality, label, style, autonode, and
per-curve override fields. Its coordinate and isenthalp fields are:

| Field | Default | Contract |
| --- | --- | --- |
| `entropy_scale` | `1e-3` | multiplier from J/(kg K) to plot `x` |
| `entropy_axis_unit` | `"kjkgk"` | coupled coordinate unit (`jkgk` or `kjkgk`) |
| `temperature_scale` | `1` | multiplier from K to plot `y` |
| `temperature_axis_unit` | `"kelvin"` | coupled coordinate and label; `celsius` subtracts 273.15 |
| `quality` | `true` | enable quality curves |
| `isenthalp` | `false` | enable constant-enthalpy curves |
| `enthalpy_unit` | `"kjkg"` | unit used by grid values and labels; `"si"` selects J/kg |
| `enthalpy_values` | empty | comma-separated explicit enthalpy list |
| `enthalpy_min`, `enthalpy_max` | fluid dependent | automatic-grid limits |
| `enthalpy_step` | automatic | linear step |
| `enthalpy_count` | `8` | count used when no step is supplied |
| `isenthalp_tolerance` | `0.10` | adaptive midpoint threshold |
| `temperature_weight` | `0.01` | kelvin contribution to linear-axis midpoint error |

The canonical implementation maps `entropy_scale` to the internal `s_scale`.
Quality and isenthalp curves use logarithmic pressure as their main sampling
parameter, but midpoint errors are evaluated in TS plot coordinates. A quality
curve that reaches `pcrit` receives an additional adaptive reduced-pressure
pass; `tolerance`, `max_depth`, and `temperature_weight` continue to control
its refinement arbitrarily close to the critical limit. Quality curves append
one canonical critical state, exactly as their PH, PV, and HS counterparts.

```lua
local lcp = require("luacoolprop")
local ts = lcp.diagram.get_type("TS")

local points = ts.isenthalp_curve({
  fluid = "R134a",
  pressure_min = 1e5,
  pressure_max = 2e6,
  entropy_scale = 1e-3,
  temperature_scale = 1,
  isenthalp_tolerance = 0.10,
}, 300e3)

for index, point in ipairs(points) do
  io.write(string.format("%d %.8g %.8g %.8g\n",
    index, point.x, point.y, point.h))
end
```

### HS options and sampling

HS uses the shared fluid, pressure, quality, temperature, specific-volume,
label, style, autonode, and per-curve override fields. Its coordinate-specific and
constant-pressure fields are:

| Field | Default | Contract |
| --- | --- | --- |
| `entropy_scale` | `1e-3` | multiplier from J/(kg K) to plot `x` |
| `enthalpy_scale` | `1e-3` | multiplier from J/kg to plot `y` |
| `entropy_axis_unit` | `"kjkgk"` | coupled horizontal coordinate unit |
| `enthalpy_axis_unit` | `"kjkg"` | coupled vertical coordinate unit |
| `quality` | `true` | enable constant-quality curves |
| `isochore` | `false` | enable constant-specific-volume curves |
| `isotherm` | `false` | enable constant-temperature curves |
| `isobar` | `false` | enable constant-pressure curves |
| `isobar_unit` | `"bar"` | pressure-grid and label unit; also `pa`, `kpa`, `mpa`, or `si` |
| `isobar_values` | empty | comma-separated explicit pressure list |
| `isobar_min`, `isobar_max` | pressure bounds | automatic-grid limits in `isobar_unit` |
| `isobar_mode` | `"log"` | `log`, `linear`, or explicit `list` generation |
| `isobar_count` | `7` | automatic curve count when no step is given |
| `isobar_temperature_min`, `isobar_temperature_max` | fluid dependent | endpoints in `temperature_unit` |
| `isobar_tolerance` | `0.10` | adaptive midpoint threshold |
| `enthalpy_weight` | `0.01` | enthalpy contribution to linear-axis midpoint error |

The canonical implementation maps `entropy_scale` and `enthalpy_scale` to
`s_scale` and `h_scale`. Quality curves use an adaptive reduced-pressure pass
near `pcrit`; its midpoint test uses HS coordinates and remains controlled by
`tolerance`, `max_depth`, and `enthalpy_weight`. They end at the canonical
critical state.
Subcritical isotherms explicitly contain both saturation endpoints and their
straight mixture segment. Isobars are sampled with entropy as their independent
variable, so they cross the two-phase region continuously. Isochores use
logarithmic pressure sampling.

```lua
local lcp = require("luacoolprop")
local hs = lcp.diagram.get_type("HS")

local options = {
  fluid = "Water",
  pressure_min = 1e4,
  pressure_max = 3e7,
  entropy_scale = 1e-3,
  enthalpy_scale = 1e-3,
  temperature_unit = "celsius",
  isobar_temperature_min = 10,
  isobar_temperature_max = 500,
}

local points = hs.isobar_curve(options, 1e5)
for index, point in ipairs(points) do
  assert(point.x == point.s * 1e-3)
  assert(point.y == point.h * 1e-3)
  io.write(string.format("%d %.8g %.8g %.8g\n",
    index, point.x, point.y, point.p))
end
```

`hs.quality_curve`, `hs.isochore_curve`, `hs.isotherm_curve`, and
`hs.isobar_curve` accept their constant property in SI units. Their
corresponding `*_plots` functions and `hs.family(opts, name)` serialize one
family; `hs.plots` serializes every enabled family. `hs.axis_style` derives
padded linear bounds unless `entropy_axis_min/max` or
`enthalpy_axis_min/max` is provided.

### PT options and phase-equilibrium sampling

PT uses temperature on the horizontal axis and pressure on the vertical axis.
The TeX high-level renderer uses a linear temperature scale and logarithmic
pressure. Its default pressure interval starts at `ptriple` and ends at
`1.10 * pcrit`; the phase envelope itself is clipped to `[ptriple, pcrit]`.

| Field | Default | Contract |
| --- | --- | --- |
| `temperature_scale` | `1` | multiplier applied after the temperature offset |
| `temperature_axis_unit` | `"kelvin"` | `kelvin`, or `celsius` with an exact 273.15 K offset |
| `pressure_scale` | `1e-5` | multiplier from Pa to the vertical coordinate |
| `pressure_axis_unit` | `"bar"` | `pa`, `kpa`, `mpa`, or `bar` |
| `phase_envelope` | `true` | enable the liquid--vapour coexistence locus |
| `isentrope` | `false` | enable constant-specific-entropy curves |
| `isenthalp` | `false` | enable constant-specific-enthalpy curves |
| `isochore` | `false` | enable constant-specific-volume curves |
| `temperature_axis_min`, `temperature_axis_max` | sampled bounds | explicit plotted temperature limits |
| `phase_envelope_tolerance` | `0.08` | adaptive midpoint tolerance |

For a pure fluid, saturated liquid and saturated vapour share exactly the same
temperature and pressure. PT therefore exposes one `phase_envelope` family,
not a `quality` family. Its first and last records are the exact CoolProp triple
and critical constants and carry `triple=true` or `critical=true`;
`quality_defined=false` makes the thermodynamic degeneracy explicit. Between
them, both `P,Q=0` and `P,Q=1` evaluations must succeed and their temperatures
are averaged to suppress harmless solver roundoff. A second adaptive pass in
reduced pressure resolves the critical neighbourhood. At the opposite end,
the sampler checks that the first numerical saturation temperature is not
below the exact `Ttriple` constant. If a backend has a minute lower-bound
mismatch, a dimensionless pressure-offset bisection finds the closest
consistent sample; no fluid-specific threshold is used.

Isentropes, isenthalps, and isochores use the respective CoolProp pairs
`P,S`, `P,H`, and `P,Dmass` while logarithmic pressure is the sampling
parameter. Failed states produce explicit disconnected components under the
shared `domain_policy` contract. In the two-phase region, these curves may
coincide locally with the coexistence locus: a PT diagram cannot display
vapour quality because all qualities at one saturation pressure have the same
PT coordinates.

```lua
local lcp = require("luacoolprop")
local pt = lcp.diagram.get_type("PT")
local constants = lcp.fluid_constants("R134a")
local options = {
  fluid = "R134a",
  pressure_min = constants.ptriple,
  pressure_max = constants.pcrit * 1.1,
  temperature_axis_unit = "celsius",
  pressure_axis_unit = "bar",
}

local coexistence = pt.phase_envelope_curve(options)
assert(coexistence[1].triple)
assert(coexistence[#coexistence].critical)
local isentrope = pt.isentrope_curve(options, 1700) -- J/(kg K)
```

### Generating one curve

Single-curve functions require explicit positive `pressure_min` and
`pressure_max` values. Temperatures, entropies, enthalpies, and volumes passed
as the second argument are SI values.

```lua
local lcp = require("luacoolprop")
local ph = lcp.diagram.get_type("PH")

local points = ph.isotherm_curve({
  fluid = "R134a",
  pressure_min = 1e5,
  pressure_max = 2e6,
  enthalpy_scale = 1e-3,
  pressure_scale = 1e-5,
  isotherm_tolerance = 0.25,
}, 293.15)

for index, point in ipairs(points) do
  io.write(string.format("%d %.8g %.8g\n", index, point.x, point.y))
end
```

### Generating PGFPlots commands

```lua
local lcp = require("luacoolprop")
local ph = lcp.diagram.get_type("PH")

local code = ph.plots({
  fluid = "R134a",
  quality = true,
  isotherm = true,
  isentrope = false,
  isochore = false,
  quality_values = "0,0.25,0.5,0.75,1",
  temperature_values = "-20,0,20,40",
  temperature_unit = "celsius",
  labels = true,
})

io.write(code, "\n")
```

The returned string contains TeX and is not escaped. Values used as PGFPlots
styles or label text are trusted package input. Do not pass untrusted external
text directly to a renderer.

Generated labels defer number and unit presentation to private TeX frontend
hooks. Under LaTeX, `luacoolprop.sty` implements those hooks with siunitx:
unit-bearing values use `\qty`, unit-only axis labels use `\unit`, and
dimensionless values use `\num`. The generic layer supplies traditional
math-mode fallbacks for plain TeX and ConTeXt. Lua renderers must continue to
emit both the siunitx unit expression and the generic fallback rather than
assuming a document format.

## Process API

Every registered `implementation.process_points(opts)` resolves and samples
one process through the same state language. Required semantic fields are
`type`, `from`, and `to`; `fluid` defaults to `R134a`. Supported
canonical types are `isobar`, `isotherm`, `isentropic`, `isenthalpic`, `quality`,
and `isochore`.

State specifications are comma- or semicolon-separated strings. Bare numbers
use SI units. Input is locale independent and accepts only `.` as the decimal
separator. Signed decimal floating-point and scientific notation are valid, so
`pressure=1.2E5Pa`, `quality=.5`, and `temperature=-1.0e1C` are accepted.
Decimal commas are rejected. Recognized suffixes are pressure units (`Pa`,
`kPa`, `MPa`, `bar`, `mbar`), kelvin or Celsius (`K`, `kelvin`, `C`, `degC`,
`celsius`), `J/kg` or `kJ/kg`, `J/(kg K)` or `kJ/(kg K)`, `m3/kg`, `L/kg` or
`dm3/kg`, and `kg/m3`. A suffix is checked as a complete token: an unknown unit
is an error and never falls back to SI. Prefer unambiguous compact forms such as
`pressure=2bar` and `temperature=20C`.

Generic numeric options use the same finite decimal grammar. Boolean values,
grid modes, presets, units, and every member of an explicit numeric list are
validated; invalid values and reversed explicit bounds raise an error instead
of silently selecting a default or repairing the range.

```lua
local lcp = require("luacoolprop")
local ph = lcp.diagram.get_type("PH")

local points, metadata = ph.process_points({
  fluid = "R134a",
  type = "isentropic",
  from = "pressure=2bar,quality=1",
  to = "pressure=10bar",
})

io.write(metadata.id, " ", metadata.kind, "\n")
io.write(string.format("%d points, h2=%.3f kJ/kg\n",
  #points, metadata.to.h * 1e-3))
```

Metadata contains `id`, normalized `kind`, conserved `constant`, active
`reference_state`, and completed `from` and `to` state tables. Completed states
contain at least `p`, `h`, `x`, `y`, and `t`, plus every resolvable property
among `T`, `s`, `v`, and `q`.
When an endpoint lies in the two-phase region, quality is resolved first and
the resulting PQ pair is used to obtain the other state properties robustly.
When a state contains more than two explicit properties, the resolver freezes
the first available independent pair in this deterministic order: `P,Q`,
`T,Q`, `P,T`, `P,H`, `P,S`, `P,Dmass`, `T,H`, `T,S`, `T,Dmass`, then `H,S`.
It obtains every canonical property from that pair and checks each redundant
input. Process resolution also checks the conserved
property at both completed endpoints. A contradiction raises a diagnostic with
the supplied value, the CoolProp value, their absolute difference, and the
applied tolerance; inconsistent user values are never copied into metadata.

`ph.process_plot(opts)` returns the serialized plot followed by the same
metadata. Styling fields include `process_color`, `process_style`,
`mark_endpoints`, `marker_style`, `label`, `label_pos`, and `label_style`.
The returned metadata is the direct Lua interface to numerical endpoints;
`metadata.from.x`, `metadata.from.y`, `metadata.to.x`, and `metadata.to.y`
are the scaled coordinates serialized for PGFPlots.

`pv.process_points(opts)` follows the same state parser and metadata contract.
Its endpoint `x` fields are scaled specific volumes. Isobars and isochores are
exact two-point horizontal and vertical paths; isotherms, isentropes, qualities,
and isenthalps are sampled adaptively in log-log coordinates. The public PV
process API therefore supports all six canonical process types even though an
isochore is not a PV background family.

```lua
local lcp = require("luacoolprop")
local pv = lcp.diagram.get_type("PV")

local points, metadata = pv.process_points({
  fluid = "R134a",
  type = "isentropic",
  from = "pressure=2bar,quality=1",
  to = "pressure=12bar",
  specific_volume_scale = 1,
  pressure_scale = 1e-5,
})

io.write(string.format("%s: %.8g -> %.8g m3/kg (%d samples)\n",
  metadata.id, metadata.from.v, metadata.to.v, #points))
```

`ts.process_points(opts)` also supports all six process types. Isotherms and
isentropes are exact horizontal and vertical TS segments. Isobars are sampled
with entropy as the independent variable, which preserves a two-phase
constant-temperature plateau; isochores use temperature, and qualities and
isenthalps use logarithmic pressure. Completed endpoints use scaled entropy as
`x` and the selected scaled/offset temperature coordinate as `y`; raw `T`
remains absolute kelvin.

```lua
local lcp = require("luacoolprop")
local ts = lcp.diagram.get_type("TS")

local points, metadata = ts.process_points({
  fluid = "R134a",
  type = "isentropic",
  from = "pressure=2bar,quality=1",
  to = "pressure=12bar",
  entropy_scale = 1e-3,
  temperature_scale = 1,
})

io.write(string.format("%s: %.8g -> %.8g K (%d samples)\n",
  metadata.id, metadata.from.T, metadata.to.T, #points))
```

`hs.process_points(opts)` uses the same six process types and state parser.
Isentropes and isenthalps are exact vertical and horizontal HS segments.
Isobars use entropy sampling, isotherms insert their exact saturation segment,
isochores use temperature, and qualities use logarithmic pressure. Completed
endpoints use scaled entropy as `x` and scaled enthalpy as `y`.

```lua
local lcp = require("luacoolprop")
local hs = lcp.diagram.get_type("HS")

local points, metadata = hs.process_points({
  fluid = "Water",
  type = "isentropic",
  from = "pressure=1bar,quality=1",
  to = "pressure=10bar",
  entropy_scale = 1e-3,
  enthalpy_scale = 1e-3,
})

assert(metadata.from.x == metadata.to.x)
io.write(string.format("%s: %.8g -> %.8g kJ/kg (%d samples)\n",
  metadata.id, metadata.from.y, metadata.to.y, #points))
```

## Registering another diagram type

`diagram.register_type(code, implementation)` validates an uppercase code,
rejects duplicate registration, sets `implementation.type`, stores the table,
and returns it. `diagram.get_type(code)` returns the registered table or raises
an error listing available types.

A custom implementation should provide at least `plots`, `axis_style`, `family`,
`process_points`, `process_plot`, and a `families` table. Keep numerical
renderers independent of the global `tex` table.

```lua
local lcp = require("luacoolprop")

local xy = {
  plots = function(opts)
    assert(type(opts) == "table", "opts must be a table")
    return "% XY plots would be serialized here"
  end,
  axis_style = function(_)
    return "xlabel={$T$},ylabel={$p$}"
  end,
  families = {},
}

lcp.diagram.register_type("XY", xy)
```

Registration is process-local. An extension module should guard against being
loaded twice or let the duplicate-registration error expose a packaging bug.
It must not modify the built-in PH, PV, TS, HS, or PT implementations.

## TeX bridge

`lcp.tex.print_diagram(diagram_type, operation, opts[, family])` selects a
registered renderer and writes its result with `tex.sprint`. Operations are
`plots`, `axis_style`, `family`, and `process`. `axis_style` is wrapped in a
PGFPlots style update; `process` dispatches to `process_plot`.

For a process operation, `opts.export_coordinates` may contain a TeX
control-sequence prefix without a leading backslash. The bridge then emits
global definitions named `<prefix>FromX`, `<prefix>FromY`, `<prefix>ToX`, and
`<prefix>ToY`. Their decimal values use `opts.coord_digits` and therefore
match the coordinates in the rendered path exactly. Each endpoint also exports
the available suffixes `PressureSI`, `TemperatureK`, `EnthalpySI`, `EntropySI`,
`SpecificVolumeSI`, and `Quality`; for example,
`<prefix>ToTemperatureK`. An unavailable property is not defined. Prefixes are
restricted to letters, digits, `@`, colon, and underscore, and may not begin
with a digit.
Set `opts.log_coordinates` to a true boolean spelling to write the path ID and
both coordinate pairs through LuaTeX's `term and log` channel. Both facilities
are opt-in and apply uniformly to any registered diagram implementation whose
process metadata follows the required `from.x`, `from.y`, `to.x`, and `to.y`
contract.

This function requires an active LuaTeX `tex` table. It is not a standalone
`texlua` API. The flat `print_ph_*` functions support the generic TeX layer;
generic Lua integrations use `tex.print_diagram`.

## Alternate public names

The following aliases are available:

- `PropsSI`, `Props1SI`, `HAPropsSI`, and `PhaseSI` mirror CoolProp spelling;
- flat `diagram.ph_*` curve and plot functions provide direct PH access;
- flat `tex.print_ph_*` functions support existing TeX macros;
- abbreviated PH option fields such as `isoquality`, `isotherms`, `h_scale`,
  and `p_scale` are translated by the shared option normalizer before the PH
  implementation is called.

Generic Lua integrations should use lowercase function names, `diagram.get_type("PH")`,
`diagram.get_type("PV")`, `diagram.get_type("TS")`, or
`diagram.get_type("HS")`, singular family names, and full property names.

## Contributor checklist

When extending the Lua layer:

1. keep module state local and return APIs through `M`;
2. use `local` for functions and temporaries unless they are intentionally
   public;
3. validate public arguments at the boundary and include the argument name in
   errors;
4. preserve SI units at the CoolProp boundary;
5. release native handles deterministically and keep finalizers non-throwing;
6. do not catch errors unless the caller can recover or add useful context;
7. keep computation and serialization separate from `tex.sprint`;
8. return tables or strings instead of printing from diagram code;
9. document table fields, defaults, units, side effects, and failure behavior;
10. retain CoolProp as an external shared-library dependency;
11. delegate automatic label placement exclusively to the autonomous
    `pgfplots-autonode` library; and
12. test Lua syntax plus LuaLaTeX, plain LuaTeX, and ConTeXt MkIV builds.
