Skip to main content

Units and quantity kinds defined by ISO/IEC 80000 and other subfields.

Project description

isqx

image image image

Docs | Visualiser

Documenting physical units in Python often relies on ambiguous docstrings or performance-heavy wrapper libraries that break interoperability.

isqx provides a comprehensive set of metadata objects based on the International System of Quantities (ISQ). These objects represent physical units (kg, knots), quantity kinds (mass, velocity) and make crucial distinctions between them (internal energy vs. work done vs. heat).

isqx objects are metadata-only: they do not wrap numerical types at runtime. Annotated[np.ndarray, isqx.M] should be treated as np.ndarray, ensuring zero performance overhead and immediate interoperability with external libraries.

It also comes with optional utilities like unit conversion, simplification and extensible formatting.

Installation

# with pip
pip install isqx
# with uv
uv add isqx

isqx has zero dependencies and is designed to be documentation-first. It can be used without introducing a hard dependency on your project. You can find more examples, search the list of units/quantity kinds and find the API reference in the documentation.

An interactive visualisation of all quantity kinds can also be viewed here.

Visualisation of quantity kinds and equations related to the Reynolds number.

Tutorial: Documenting code with type annotations

Most libraries use docstrings for simplicity. isqx recommends incrementally adopting PEP 593.

First, define generic types that you will use throughout the codebase:

# isqx_types.py
from typing import Annotated, TypeVar

import isqx

_T = TypeVar("_T")
M = Annotated[_T, isqx.M]
K = Annotated[_T, isqx.K]
Pa = Annotated[_T, isqx.PA]

M[float]  # is the same as a plain float.
M         # a bare type is inferred by static type checkers as `Unknown`

Annotate function arguments or data containers like dataclasses:

# in another file
from .isqx_types import M, K, Pa

def pressure_isa(altitude: M, isa_dev: K) -> Pa:
    # `altitude` has `Unknown` type
    ...

# or, if you prefer stricter typing:
from numpy.typing import ArrayLike

def pressure_isa(altitude: M[ArrayLike], isa_dev: K[ArrayLike]) -> Pa[ArrayLike]:
    # `altitude` now expects the type `ArrayLike` (not a wrapper over it!) 
    ...

from dataclasses import dataclass

@dataclass
class GasState:
    temperature: K
    pressure: Pa

Since annotations are ignored by the Python interpreter at runtime, and Annotated[T, x] is equivalent to T, there is no interoperability cost. Internally, unit objects are defined by composing with each other: J = (N * M).alias("joule") and N = (KG * M * S**-2).alias("newton").

You can also retrieve the annotations at runtime:

from typing import get_type_hints

def print_metadata(obj):
    for param, hint in get_type_hints(obj, include_extras=True).items():
        print(f"`{param}`: {hint.__metadata__[0]}")

print_metadata(pressure_isa)
# `altitude`: meter
# `isa_dev`: kelvin
# `return`: pascal
# - pascal = newton · meter⁻²
#   - newton = kilogram · meter · second⁻²
print_metadata(GasState)
# `temperature`: kelvin
# `pressure`: pascal
# - pascal = newton · meter⁻²
#   - newton = kilogram · meter · second⁻²

But there is a flaw in using units alone:

isqx encourages you to use a more abstract quantity kind, which can contain arbitrary tags that store important metadata.

A quantity kind can take any unit system (MKS, imperial...): calling it with a particular unit returns a isqx.Tagged expression:

from typing import Annotated, TypeVar

import isqx

_T = TypeVar("_T")
GeopAltM = Annotated[_T, isqx.aerospace.GEOPOTENTIAL_ALTITUDE(isqx.M)]
TempDevIsaK = Annotated[_T, isqx.aerospace.TEMPERATURE_DEVIATION_ISA(isqx.K)]
StaticPressurePa = Annotated[_T, isqx.STATIC_PRESSURE(isqx.PA)]

def pressure_isa(altitude: GeopAltM, isa_dev: TempDevIsaK) -> StaticPressurePa:
    ...

# altitude: meter['altitude', relative to `'mean_sea_level'`, 'geopotential']
# isa_dev: kelvin['static', Δ, relative to `288.15 · kelvin`]
# return: pascal['static']
# - pascal = newton · meter⁻²
#   - newton = kilogram · meter · second⁻²

To create your own quantity kinds, see below.

Quick note on hard dependencies

If you intend to use isqx for documenting code only (without runtime features like conversions or simplification , which we will explore below), it is recommended to make isqx an optional dependency of your project instead:

uv add isqx --optional typing

Put isqx imports within the typing.TYPE_CHECKING block:

from __future__ import annotations  # see PEP 563, PEP 649

from typing import Annotated, TYPE_CHECKING

if TYPE_CHECKING:
    import isqx

    FloatM = Annotated[float, isqx.M]  # or put them in a separate module

def foo(x: FloatM): ...

# to inspect annotations at runtime in another module:
from typing import get_type_hints
import isqx

for param, hint in get_type_hints(
    foo,
    include_extras=True,
    localns={"isqx": isqx}  # add the location of your custom definitions (if any)
).items():
    print(f"`{param}`: {hint.__metadata__[0]}")
# `x`: meter

This makes sure that your code doesn't fail with ImportError if downstream users decide not to install your_project[typing].

Tutorial: Utilities

So far, we have covered usecases for code documentation.

Units are immutable expression trees and isqx provides some runtime utilities to transform the expression tree.

Simplification

The isqx.simplify function canonicalises it into a flat form:

>>> from isqx.usc import PSI
>>> print(PSI)
psi
- psi = lbf · inch⁻²
  - lbf = pound · 9.80665 · (meter · second⁻²)
    - pound = 0.45359237 · kilogram
  - inch = 1/12 · foot
    - foot = 0.3048 · meter
>>> from isqx import simplify, dimension
>>> print(simplify(PSI))
0.45359237 · 9.80665 · (1/12)⁻² · 0.3048⁻² · (kilogram · meter⁻¹ · second⁻²)
>>> print(dimension(simplify(PSI)))
L⁻¹ · M · T⁻²

Note that the final scaling factor is not eagerly evaluated. This enables you to choose between approximate and exact arithmetic (useful for financial applications).

Unit conversion

The convert function creates a callable that allow you to convert between compatible units. Under the hood, it uses simplify to check dimensions and computes the conversion factors once:

>>> from isqx import M, S, MIN, convert
>>> from isqx.usc import FT
>>> fpm_to_mps = convert(FT * MIN**-1, M * S**-1)
>>> fpm_to_mps
Converter(scale=0.00508)
>>> fpm_to_mps(7200.0)
36.576
>>> convert(M, FT, exact=True)(11000)
Fraction(13750000, 381)
>>> convert(FT * MIN**-1, M * S**-2)  # velocity -> acceleration fails
isqx._core.DimensionMismatchError: cannot convert from `foot · minute⁻¹
- foot = 0.3048 · meter
- minute = 60 · second` to `meter · second⁻²`.
= help: expected compatible dimensions, but found:
dimension of origin: `L · T⁻¹`
dimension of target: `L · T⁻²`

It is compatible many libraries, including using it for functional transformations like jax.jit:

>>> import numpy as np
>>> fpm_to_mps(np.linspace(-1300, 1300, 10))
array([-6.604     , -5.13644444, -3.66888889, -2.20133333, -0.73377778,
        0.73377778,  2.20133333,  3.66888889,  5.13644444,  6.604     ])
>>> import jax
>>> jax.grad(fpm_to_mps)(0.0)
Array(0.00508, dtype=float32, weak_type=True)

Converting between logarithmic units is also supported:

>>> from isqx import DBM, DBW, convert
>>> print(DBM)
dBm
- dBm = 10 · log₁₀(ratio[`watt` to `1 · milliwatt`])
  - watt = joule · second⁻¹
    - joule = newton · meter
      - newton = kilogram · meter · second⁻²
>>> convert(DBW, DBM)
NonAffineConverter(scale=1.0, offset=29.999999999999996)
>>> convert(DBW, DBM)(10)
40.0

Note that converting between linear and logarithmic quantities are not supported. Representing quantities like attenuation ($\text{dB}\text{ m}^{-1}$) is permitted, but conversion of them is not yet implemented.

Formatting

The isqx.fmt function (called by isqx.Expr.__format__) by default uses a isqx.BasicFormatter(verbose=True), but also supports customisation. To use shorter symbols for example:

>>> from isqx import N, fmt, BasicFormatter
>>> f"{N}" == fmt(N, BasicFormatter(verbose=True))
True
>>> print(fmt(N, BasicFormatter(
...     verbose=True,
...     overrides={  # alias names
...         "newton": "N",
...         "kilogram": "kg",
...         "meter": "m",
...         "second": "s"
...     },
... )))
N
- N = kg · m · s⁻²

Internally, the basic formatter uses isqx.Visitor to traverse each node in post-order. You can pass in your own formatter as long as it adheres to the isqx.Formatter protocol.

A $\LaTeX$ formatter is WIP.

Tutorial: Creating your own units and quantity kinds

We follow the code-as-data principle: creating units and quantity kinds can be done effortlessly with pure Python:

>>> from fractions import Fraction
>>> import isqx
>>> SMOOT = ((5 + Fraction(7, 12)) * isqx.usc.FT).alias("smoot")
>>> print(SMOOT)
smoot
- smoot = 67/12 · foot
  - foot = 0.3048 · meter
>>> print((SMOOT**-1 * isqx.M * SMOOT * isqx.M**-1)**2)
(smoot⁻¹ · meter · smoot · meter⁻¹)²
- smoot = 67/12 · foot
  - foot = 0.3048 · meter

Note that expressions are represented exactly in the order you define it: no attempt is made to distribute exponents or combine terms unless you instruct it to.


As explored earlier, units alone are insufficient to describe a quantity kind. Consider we might want to represent:

Common Units Possible Quantity Kinds
$\text{m}$, $\text{ft}$, $\text{in}$... geopotential altitude / geometric altitude / wingspan / chord length / radius / thickness / wavelength
$\text{m}\text{ s}^{-1}$, $\text{kt}$... indicated / true / ground speed
$\text{J}$, $\text{Btu}$ internal / kinetic / potential / enthalpy / Gibbs free energy / work done / heat / moment of force
$\text{W}$, $\text{kWh}$ instantaneous / RMS / peak-to-peak / time-averaged power
$\text{mol}$ amount of hydrogen / oxygen / (some arbitrary compound)
$\text{USD}$, $\text{EUR}$... nominal / real, capex / opex
- radians / aspect ratio / Reynolds number <of some characteristic length> / coefficient of drag (zero-lift / lift-induced)

And that particular quantity kind may also refer to a specific:

  • inertial / body / stability reference frame
  • x / y / z direction
  • temperature / pressure at location A / B / C...

Tags

isqx allows you to constrain an existing unit with arbitrary tags using the [] operator:

>>> import isqx
>>> MOL_H2_L = isqx.MOL["H_2", "liquid"]
>>> MOL_O2_G = isqx.MOL["O_2", "gas"]
>>> print(MOL_H2_L * MOL_O2_G**-1)  # does not reduce to dimensionless!
mole['H_2', 'liquid'] · (mole['O_2', 'gas'])⁻¹

You can use any hashable object including strings, frozen dataclasses, or even isqx units itself! This is helpful when you want to represent something awkward like Reynolds number with characteristic length = chord length.

isqx provides two important tags, Delta and OriginAt:

>>> import isqx
>>> print(isqx.K[isqx.DELTA])  # finite interval/difference in temperature
kelvin[Δ]
>>> print(isqx.J[isqx.INEXACT_DIFFERENTIAL, "heat"])  # "small change"
joule['inexact differential', 'heat']
- joule = newton · meter
  - newton = kilogram · meter · second⁻²
>>> print(isqx.M[isqx.OriginAt("ground level")])  # elevation varies
meter[relative to `'ground level'`]
>>> print(isqx.K[isqx.DELTA, isqx.OriginAt(isqx.Quantity(130, K))])
kelvin[Δ, relative to `130 · kelvin`]
>>> print(isqx.DBU)
dBu
- dBu = 20 · log₁₀(ratio[`volt` relative to `0.6¹⸍² · volt`])
  - volt = watt · ampere⁻¹
    - watt = joule · second⁻¹
      - joule = newton · meter
        - newton = kilogram · meter · second⁻²

Quantity Kinds

The Tagged class is useful, but it forces downstream users to use a specific unit system.

Instead, use the more generic isqx.QtyKind, a factory that produces isqx.Tagged:

>>> import isqx
>>> DIAMETER_PIPE = isqx.QtyKind(unit_si_coherent=isqx.M, tags=("diameter", "pipe"))
>>> print(DIAMETER_PIPE(isqx.M))
meter['diameter', 'pipe']
>>> print(DIAMETER_PIPE(isqx.usc.IN))
inch['diameter', 'pipe']
- inch = 1/12 · foot
  - foot = 0.3048 · meter
>>> print(DIAMETER_PIPE(isqx.usc.LB))
isqx._core.UnitKindMismatchError: cannot create tagged unit for kind `('diameter', 'pipe')` with unit `pound
- pound = 0.45359237 · kilogram`.
expected dimension of kind: `L` (`meter`)
   found dimension of unit: `M` (`pound
- pound = 0.45359237 · kilogram`)

Users can now select the unit system they prefer, while ensuring the dimensions are correct.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

isqx-0.1.2.tar.gz (482.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

isqx-0.1.2-py3-none-any.whl (124.0 kB view details)

Uploaded Python 3

File details

Details for the file isqx-0.1.2.tar.gz.

File metadata

  • Download URL: isqx-0.1.2.tar.gz
  • Upload date:
  • Size: 482.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for isqx-0.1.2.tar.gz
Algorithm Hash digest
SHA256 341552103f34e66a6a17848170f14e8fc60406963f13b8705f32b8379a6f75ff
MD5 c7cf3c5fcafb9dc69370f2e0eb97a91e
BLAKE2b-256 05247bf8af5502b5f6548778b72f888d41d95f6946a9bb718a35d0a74271eba8

See more details on using hashes here.

Provenance

The following attestation bundles were made for isqx-0.1.2.tar.gz:

Publisher: publish-pypi.yml on abc8747/isqx

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file isqx-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: isqx-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 124.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for isqx-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 5a52a52610d42346fc19de5d00d35fe54cae223ddb09a90c4e75fc2af155f91f
MD5 eadc710c24991526b81995d0bfeb38d1
BLAKE2b-256 30310e5f1fce0346cace1769caf384b8e97b9ec4032a0f2c62a8fb93dc15ceb3

See more details on using hashes here.

Provenance

The following attestation bundles were made for isqx-0.1.2-py3-none-any.whl:

Publisher: publish-pypi.yml on abc8747/isqx

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page