Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

xpint

xpint extends Pint with quantity kinds and unit designations: semantic information that keeps energy distinct from torque and mL[blood]/mL[tissue] distinct from a plain ratio.

Status: 0.1.0b5 is a public beta. The API and the supported interoperability surface may change during beta.

Pint checks physical dimensions. Many mistakes pass that check: energy and torque are both newton-metres, an oxygen concentration and a carbon dioxide concentration are both mol/L, and 20 °C plus 20 °C is not 40 °C. xpint adds three kinds of meaning on top of Pint and checks them before Pint can erase the distinction:

  • Kinds say what a quantity is (Energy, Torque, Frequency).
  • Designations say which referent a unit factor belongs to (mmol["O2"], mL["blood"]).
  • Temperature roles separate an absolute temperature (a point on a scale) from a temperature difference.

Pint remains the unit engine: definitions, conversions, contexts, and NumPy support are Pint's own. xpint values are Pint quantities, and importing xpint does not change Pint's application registry.

Installation

python -m pip install "xpint==0.1.0b5"

xpint requires Python 3.12 or later and Pint >=0.25.3,<0.26. The Pint range is deliberately narrow because xpint hooks into Pint internals; Pint 0.26 is not supported yet. Scalars need only Pint. Install NumPy for arrays (2.5.2 is tested). Matplotlib, Dask, xarray, pint-xarray, Babel, and uncertainties are optional and are not installed by xpint.

Quick start

Unit symbols come from xpint.units, which binds them to one shared registry. The module exports common symbols and resolves any other Pint unit name or prefix on import, for example from xpint.units import kg, microgram.

from xpint.units import J, L, N, degC, degF, delta_degC, m, mL, mmol

# Kinds: same dimensions, different meaning
energy = (10 * J).as_kind("Energy")
torque = (10 * N * m).as_kind("Torque")
energy + torque  # TypeError: cannot add quantity kinds 'Energy' and 'Torque'
energy.to("kWh")  # the kind survives conversion
(N * m).compatible_kinds()  # ('Energy', 'Torque')

# Designations: same units, different referent
oxygen = 5 * mmol["O2"] / L
oxygen + 5 * mmol["CO2"] / L  # TypeError: different factor designations
oxygen.to("mol/L")  # 0.005 mole[O2] / liter

ratio = 0.6 * mL["blood"] / mL["tissue"]
ratio.dimensionless  # True: physically a ratio
ratio.is_semantic_unity  # False: the designations do not cancel
float(ratio)  # TypeError: remove the designations explicitly first

# Temperature roles: points and differences
room = 20 * degC
room + 5 * delta_degC  # 25 degC
room + room  # TypeError: cannot add two absolute temperatures
25 * degC - 68 * degF  # about 5 delta_degC

Quantity kinds

A kind is optional metadata naming what a quantity is. It is checked against the quantity's dimensions when attached, and only against other kinds afterwards:

  • as_kind(name) classifies a value without converting it; as_unkinded() removes the classification. Both return new quantities. You can also pass kind= to ureg.Quantity(...).
  • Addition, subtraction, assignment into arrays, and fills require equal kinds, including both being unkinded. A kinded value plus an unkinded one is rejected rather than silently adopting the kind; align them with as_kind() or as_unkinded().
  • Equality and ordering require exactly the same semantic state, so a kinded quantity is unequal to an otherwise identical unkinded one.
  • Multiplying or dividing by a plain number or a plain dimensionless value keeps the kind. Other products, quotients, and powers drop it: xpint does not guess that force times length is torque. energy / (2 * s) is an unkinded J / s.
  • Conversion keeps the kind. A context conversion that would change a kinded quantity's dimensions, such as wavelength to energy, requires as_unkinded() first.
  • compatible_kinds() on a unit, quantity, or registry lists the registered kinds that match the dimensions. It is a lookup, not inference.

The built-in catalogue covers the SI base and derived quantities and the common same-dimension collisions (energy/torque, frequency/angular velocity/radioactivity, absorbed/equivalent dose, entropy/heat capacity, and others). See ureg.kinds for the full list.

Designations

A designation is an opaque, case-sensitive label attached to one factor of a unit, such as the substance in mmol["O2"] / L:

  • Matching designations cancel; different ones stay in the quantity's designation signature even when the physical units cancel, as in mL["blood"] / mL["tissue"]. dimensionless keeps Pint's physical meaning; is_semantic_unity is true only when a value is dimensionless and has no designation left. It does not look at kinds.
  • Addition and ordering require matching designations, and equality with a mismatch is false. Scalar conversion (float(), int()) and undefined NumPy operations reject any retained designation.
  • Converting to a plain unit keeps the source's designations: oxygen.to("mol/L") is 0.005 mole[O2] / liter. A designated target must match exactly, so oxygen.to(mol["CO2"] / L) is rejected. The same rules apply to ito(), compact and preferred conversions, construction from another quantity, and registry convert().
  • m_as() returns a bare number, so it does not adopt designations: the target must name them. oxygen.m_as(mol["O2"] / L) is 0.005, while oxygen.m_as("mol/L") raises TypeError. Mismatches that survive physical cancellation are caught too: a gadoteridol relaxivity times a gadobutrol concentration cannot be read with .m_as(1 / s). .magnitude remains Pint's unchecked accessor; use as_undesignated() to drop designations deliberately.
  • with_designation(label) labels a value whose unit is a single factor, such as (5 * mmol).with_designation("O2"); as_undesignated() removes every label.
  • Unit reduction keeps designations and physical scale: 1 m["a"] / (1 cm["b"]) reduces to 100 m[a] / m[b].
  • Compound units do not guess which factor a label belongs to. mM["O2"] works because the registry declares that molar concentration designates its substance; otherwise write the factor form, mmol["O2"] / L.

Designations also work in unit strings (since 0.1.0b4):

ureg.Unit("mM[Fe]") == mM["Fe"]
iron = ureg.Quantity(5, "mM[Fe]")  # also ureg("5 mM[Fe]")
iron.m_as("mmol[Fe]/L")  # 5
iron.to("mM[Gd]")  # TypeError: different factor designations
ureg.Unit(str(mmol["O2"] / L)) == mmol["O2"] / L

Attach [label] immediately to one unit token: m[cap]**2 labels m before the power; (m**2)[cap] and m [cap] are invalid. Labels are opaque and case-sensitive, including spaces, hyphens, digits, +, *, and /: "mM[gadoteridol-Gd]" and "mL[blood plasma]" work. Inside a label, escape backslashes and brackets as \\, \[, and \] (use Python raw strings when writing those escapes). No other escapes are accepted. Malformed or ineligible designations raise ValueError naming the input and zero-based character position. Both str(unit) and format(unit, "~") round-trip through Unit for compounds. Powers parse with the registry's non_int_type (float by default), as in Pint, so on a default registry a unit built with a Fraction power such as Fraction(1, 3) parses back with the float power. Use operator syntax after a label (m[cap]**2); Pint's word forms such as squared are not supported there. Pint dimensionality strings such as "[length]" still work in get_dimensionality() and definitions.

Temperature roles

Thermodynamic temperatures have one of three roles:

Role Comes from Meaning
Absolute offset units such as degC, degF, and Réaumur; Rankine; or kind Temperature a point on a scale
Delta delta units (delta_degC, ...), or kind TemperatureDifference a difference
Generic plain Kelvin and other linear units, unclassified ordinary Pint arithmetic
Operands Addition Subtraction
Absolute, Absolute rejected Delta, in the left operand's scale
Absolute, Delta Absolute Absolute
Delta, Absolute Absolute rejected
Delta, Delta Delta Delta
Generic, Generic Pint's rules Pint's rules
Generic with Absolute or Delta rejected rejected
  • The role survives conversion: (20 * degC).to(K) is an absolute temperature of kind Temperature, so adding Generic 5 * K to it is rejected.
  • Absolute temperatures reject multiplication, division, powers, negation, and abs(). To use ratio-scale algebra, convert to Kelvin and remove the role: (20 * degC).to(K).as_unkinded() ** 4.
  • 20 * degC and ureg.Quantity(20, "degC") construct an absolute temperature. String parsing such as ureg("20 degC") multiplies by the unit and is rejected with a pointer to these spellings; "5 delta_degC" and "300 kelvin" parse normally.
  • Equality between offset scales converts first. Unlike ordinary Pint, 0 * degC == 0 * degF is false.
  • Custom kinds cannot be defined over temperature dimensions, because the Temperature and TemperatureDifference kinds carry the role.

The strict roles apply to thermodynamic temperature only, not to gauge pressure or other offset scales.

Optional angle dimensions

By default plane angle is dimensionless, as in SI and Pint. A registry can instead make it a base dimension, with solid angle derived as rad ** 2:

from xpint import SemanticRegistry

u = SemanticRegistry(angle_dimension=True)

u.Quantity(1, "radian") + u.Quantity(1, "steradian")  # DimensionalityError
u.get_dimensionality("radian / second") == u.get_dimensionality("hertz")  # False
((1 * u.mrad) * (1 * u.mrad)).to("microsteradian")  # 1.0 microsteradian
torque = 5 * u.J / u.rad  # not interchangeable with energy

Rotational quantities then take their conjugate forms (joule / radian for torque), and leaving the angle world is explicit: extract a magnitude with theta.m_as("radian"), or multiply by eta = 1 / u.radian. float() and conversion to a plain array (np.asarray(), assignment into an ndarray, and libraries that coerce first, such as Matplotlib's errorbar) refuse a value carrying an angle rather than strip it, and masked filled()/compressed() and Dask rechunk() keep it as a quantity. Pint's constants carry no angle dimension, so formulas such as hbar * omega need one eta. The mode is fixed when the registry is created, and values from angle and ordinary registries cannot be mixed. See examples/angle_dimensions.py.

In either mode, float(), int(), and functions that call them, such as math.sin, reject a kinded value even when it is dimensionless, because the conversion would drop the kind. Use math.sin(angle.m_as("radian")); NumPy trigonometric functions accept such angles directly and return unkinded results.

Extending a registry

The built-in catalogue is a starting point. A registry can declare its own kinds and designation slots:

from xpint import SemanticRegistry

u = SemanticRegistry()

u.define_kind("SpecificActivity", "becquerel / gram")
activity = u.Quantity(4.2, "becquerel / gram", kind="SpecificActivity")

u.Quantity(1.0, "becquerel").as_kind("SpecificActivity")
# ValueError: kind 'SpecificActivity' requires dimensions 1 / [time] / [mass], ...

u.define_designation_slot("katal", "[substance]")
3 * u.katal["trypsin"]  # 3 katal[trypsin]

define_kind() constrains a name to the dimensions of a reference unit. define_designation_slot() names the single factor that a label designates on a one-token derived unit; a slot on an unprefixed unit also covers its prefixed forms, so u.kilokatal["trypsin"] works too. Both affect only that registry. See examples/custom_semantics.py.

Working with Pint, NumPy, and other libraries

Registries

Every xpint value belongs to one SemanticRegistry. Combining values from two registries, or an xpint value with an ordinary Pint quantity, raises ValueError; equality between two xpint registries is simply false. One caveat comes from Pint: pint_quantity == xpint_quantity (ordinary Pint on the left) compares physically and can return true.

What behaves like ordinary Pint

Values without kinds, designations, or a temperature role go through Pint's own code paths, with two guards added: registry ownership checks, and rejection of Pint 0.25.3's unit-blind NumPy out= and quantity-valued initial= handling (including ndarray += quantity, which would strip the unit). A bare scalar initial= is a magnitude in the quantity's own unit.

Celsius, Fahrenheit, and Rankine values are classified by their unit, so they follow the semantic rules below rather than Pint's full NumPy surface. Functions outside the list, such as np.sort, np.median, np.percentile, np.isclose, and np.interp, raise for them. Convert explicitly with temperatures.to("kelvin").as_unkinded() to use them as ordinary linear values, or operate on .magnitude.

NumPy arrays

For classified arrays the supported surface is deliberately bounded, and anything outside it raises rather than silently dropping metadata:

  • Arithmetic and comparisons follow the kind, designation, and temperature rules above.
  • mean/nanmean, max/min/amax/amin, shape-only methods, zeros_like, concatenate, stack, and where preserve metadata, including absolute temperatures. Combined operands must have matching semantic state. nanmax/nanmin are not supported yet.
  • sum/nansum and cumsum/nancumsum preserve kinds and designations and reject absolute temperatures, which cannot be added together.
  • sqrt, square, cbrt, and reciprocal follow power rules: kinds are dropped and designation exponents scale.
  • std/nanstd preserve units, kinds, and designations; var/nanvar square the units and drop the kind. Both reject absolute temperatures, and these and the cumulative sums reject output targets.
  • diff preserves metadata, except that absolute temperatures produce temperature differences; diff(n=0) returns the input unchanged.
  • argmax, argmin, argsort, and nonzero return plain indices.
  • clip() keeps the input's classification and allows an unkinded bound.
  • Reductions validate out and initial whether passed by position or keyword. A quantity initial must match the input's semantic state.
  • Undefined operations, discrete arithmetic such as modulo, dot, prod, and implicit conversion to ndarray (including np.asarray) raise. Use .magnitude when you intend to leave xpint's checks.

In-place powers, unit conversions (ito()), temperature-role addition and subtraction, and classified in-place multiplication and division stage a temporary NumPy copy, so a failure in those operations leaves values and metadata unchanged. This costs one extra full-size buffer, sometimes two. Other in-place operations, including ordinary += on a kinded array, write through Pint directly, as in Pint, and a floating-point error raised midway can leave the array partly updated. The staging covers NumPy arrays only. Classified quantities expose array metadata such as shape, dtype, and mask, but forwarded magnitude methods such as view() raise.

Other array libraries

  • Masked arrays: filled() and compressed() preserve classified values and, in an angle-dimension registry, angles.
  • Dask: compute(), persist(), and rechunk() preserve metadata; classified map_blocks() raises because the callback could change values or dimensions.
  • xarray: reductions, concatenation, where, transpose and indexing, Dask-backed computation, and multiplication by xpint units are tested.
  • pint-xarray: ordinary units and designated DataArray unit strings round-trip through quantify/dequantify with SemanticRegistry(force_ndarray_like=True) (designated strings require 0.1.0b4 or later). Use default or "~" formatting; this does not serialize quantity kinds.
  • pandas and arbitrary Dask graph transformations are not supported.

Comparison and hashing

Scalar quantities can be dictionary keys. Hashes include the registry and the semantic state, so equal values hash equally; arrays are unhashable. is_compatible_with() has two meanings. With a unit or unit string it asks whether converting to that unit would be accepted, so (20 * degC).is_compatible_with(K) is true while K.is_compatible_with(degC) is false. With a quantity it also requires the same kind, designations, and temperature role, so an Energy value is not compatible with a Torque value even though it converts to its units. A quantity used as a conversion target supplies units and designations only; it never reclassifies the value.

Formatting

str() and format() include designations and accept numeric specs such as f"{oxygen:.2f}" and the abbreviated unit spec ~; other unit specs (P, L, H, C, D) are rejected for designated values. repr() shows the kind, kind='Energy', which str() omits. Pint's formatter and Babel APIs reject designated values rather than drop their labels.

Pickling

Quantities and units from any registry can be pickled. A pickle records its angle mode, and unpickling reconstructs against xpint.get_application_registry(angle_dimension=...) for that mode: by default the registry behind xpint.units, or a shared SemanticRegistry(angle_dimension=True) for angle-mode pickles. Registry definitions are not pickled: to unpickle values that use custom units or kinds, call xpint.set_application_registry(registry) first, also in worker processes such as multiprocessing or Dask distributed. It selects the registry for registry.angle_dimension's mode only, so pickles of both modes load side by side. A missing kind raises an error naming that function. Neither function changes Pint's application registry.

to_tuple() and to_timedelta() reject kinds and designations, which those representations cannot hold; remove them explicitly first.

Registry adapters

  • ureg.check("[length]") checks physical dimensions only and passes the original quantity through with its metadata.
  • ureg.wraps(...) converts arguments to magnitudes for a numerical function. Converted arguments must be unclassified, including the implicit roles of Celsius and delta units; return units may construct those roles but cannot carry designations. With a fixed parameter list, a None argument specification passes the value through unchanged, metadata included.
  • Use fixed parameter lists with both. Pint 0.25.3 binds variadic and keyword-only parameters incorrectly (pint#2254, pint#2426). For wraps, xpint rejects classified values in slots Pint might convert by mistake, which can also reject a None-specified variadic argument; check inherits Pint's behavior unchanged.
  • ureg.pi_theorem({...}) and ureg.setup_matplotlib() accept unclassified values. For temperature data, convert to Kelvin and call as_unkinded() before plotting.
  • Measurements (plus_minus(), ureg.Measurement(), which need the uncertainties package) accept only unclassified values. To combine uncertainty with semantic checks, construct ureg.Quantity(measurement.magnitude, measurement.units).

Examples

Runnable scripts that start from Pint's tutorial style and add xpint's checks:

Example Shows
pint_style_basics.py Construction, arithmetic, and conversion with imported symbols
designated_solutions.py Concentrations, analyte mismatches, and compartment ratios
affine_temperature.py Absolute, Delta, and Generic temperature roles
semantic_arrays.py Metadata through NumPy and where it stops
angle_dimensions.py The two angle modes side by side
custom_semantics.py Custom kinds and designation slots

Reporting issues

Use the issue tracker and include a minimal reproducer, your Python, Pint, and NumPy versions, and the expected and actual behavior.

Development

From a source checkout:

uv sync --locked
uv run ruff check .
uv run ruff format --check .
uv run mypy src tests examples benchmarks tools/check_minimal_install.py
uv run pytest

The full suite needs the development dependency group, which installs every optional dependency the tests use. CI tests the installed wheel on Python 3.12–3.14 and a separate install without optional dependencies. Maintainer documentation is in docs/.

License

BSD 3-Clause, matching Pint's license. Copyright (c) 2026 Matthias C. Schabel.

Metadata

Release files for xpint 0.1.0b5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for xpint 0.1.0b5
File Size Uploaded Provenance
xpint-0.1.0b5.tar.gz 57.9 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for xpint 0.1.0b5
File Interpreter ABI Platform
xpint-0.1.0b5-py3-none-any.whl Python 3 none any Details

Total release size: 109.7 kB

Release files / xpint-0.1.0b5.tar.gz

Download URL xpint-0.1.0b5.tar.gz
Size 57.9 kB
Tags Source
SHA-256 checksum
How to use checksums
f2206eb2bee8126a5f425b1e1d7cfa924f5e0d1f80e397effd62a00be0486e21
BLAKE2b-256 checksum
How to use checksums
e7eaa3a484ff208c42006936a0ea19eab83becc5e756ab907f1e73ac93d21f92
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.

Transparency log

Release files / xpint-0.1.0b5-py3-none-any.whl

Download URL xpint-0.1.0b5-py3-none-any.whl
Size 51.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
062cea4a3e64c3e9889dc3a6094148a1174ddd269d06d1bace19aa1c6fa531ba
BLAKE2b-256 checksum
How to use checksums
fe65fd7df0ae142ce95a62121b98105b5cd129340463f3d52ff3fb8e8880607b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.

Transparency log

Security for xpint 0.1.0b5

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for xpint-0.1.0b5.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page