kuchka
A small pile of physically-typed engineering units for Python.
from kuchka import Volt, Milliampere
voltage = Volt(4.2)
current = Milliampere(150)
power = voltage * current # -> Power(0.63 W), typed and computed automatically.
Installation
uv add kuchka
or, with pip:
pip install kuchka
That's the only install step needed to use kuchka in application code.
The Development section below covers the separate, larger set of tools (ruff, mypy, ty, pytest, hypothesis, ...) needed to work on kuchka.
Stability
kuchka is 0.1.0: pre-1.0, and versioned accordingly.
Breaking changes are expected between 0.1.x releases while the API settles.
From 0.2.0 onward, kuchka follows ordinary semantic versioning:
a minor version bump won't break existing code, and a breaking change always comes with a major version bump.
Why
A raw float doesn't remember what it means.
Passing floats through an engineering pipeline makes unit mistakes easy to write and hard to catch:
the code stays syntactically valid even when it's semantically wrong.
kuchka makes the physically-typed version of the code no harder to write than the untyped one.
There's no reason left to reach for a naked float.
def analyse_voltage(voltage: Voltage) -> Report: ...
analyse_voltage(4.2) # rejected by mypy/ty, and again at runtime.
analyse_voltage(Ampere(2)) # rejected by mypy/ty, and again at runtime.
analyse_voltage(Volt(4.2)) # one example of a valid type check.
analyse_voltage(Millivolt(4200)) # another example of a valid type check.
What's in v0.1
| Quantity | Constructors |
|---|---|
Voltage |
Volt, Millivolt, Microvolt, Kilovolt |
Current |
Ampere, Milliampere, Microampere, Kiloampere |
Power |
Watt, Milliwatt, Kilowatt |
Resistance |
Ohm, Milliohm, Kiloohm, Megaohm |
Temperature |
Celsius, Kelvin |
TemperatureDelta |
DeltaCelsius, DeltaKelvin |
Time |
Second, Millisecond, Microsecond |
Frequency |
Hertz, Kilohertz, Megahertz |
SamplingPeriod |
SecondSamplingPeriod, MillisecondSamplingPeriod, MicrosecondSamplingPeriod |
SampleRate |
SamplesPerSecond, KilosamplesPerSecond, MegasamplesPerSecond |
BaudRate |
Baud, Kilobaud |
Length |
Nanometer, Micrometer, Millimeter, Meter, Kilometer, Inch |
Mass |
Nanogram, Microgram, Milligram, Gram, Kilogram, Megagram, Tonne |
AmountOfSubstance |
Micromole, Millimole, Mole |
LuminousIntensity |
Candela |
Proportion |
Ratio, Percent |
Deliberately nothing else yet. See "What this is not (yet)" below.
Design choices, and why
Decimal, notfloat. SI prefixes are powers of ten, and those are exact inDecimalbut usually not in binaryfloat.Volt(1) + Millivolt(500) == Volt(1.5)is true by construction, and not by lucky rounding.- Unit constructors are plain functions, not classes.
Volt(4.2)returns aVoltage. There's noVolttype to accidentallyisinstance()-check against, and no subclass/__new__surprise whereisinstance(Volt(5), Volt)isFalse. .to(Unit)uses Python names, never unit strings. There's novoltage.to("mV")anywhere in this library.- Boundary code that must resolve a unit from a string has a maintained table to use.
kuchka.unitsexports a*_UNITSmapping per dimension (VOLTAGE_UNITS,CURRENT_UNITS,FREQUENCY_UNITS, and so on), closed to that dimension's own constructors and immutable. This is not a second construction path:Volt(5)is still how application code builds aVoltage. It exists for the one place a unit is expected to arrive as a string -- a config file, a CSV header, a hardware descriptor. The intention is so that place doesn't have to hand-roll the same table every other application resolvingkuchkaunits already needed. Seedocs/examples/config_migrationfor it in use, anddocs/review_notes.mdfor why this doesn't reintroduce the registry the "Comparison to existing tools" section below argues against. - Cross-type arithmetic is rejected statically, not just at runtime.
Volt(5) + Ampere(2)andVolt(5) + 4.2are both mypy errors, not only runtime ones. This falls out of typing the base arithmetic methods againstSelfinstead ofobject, so every quantity type only ever type-checks arithmetic against its own kind. See "Known limitations" for wheretydoesn't (yet) enforce this the same way. - A hand-written overload table.
The runtime arithmetic derives any product or quotient from general exponent-vector algebra.
Type checkers can't infer "Voltage times Current is Power" from that algebra on their own, so each pairing is spelled out as an explicit
@overload. Temperatureis a separate, smaller type from everything else. Absolute temperature is affine, not linear:20 degC + 5 degCisn't meaningful the way5V + 3Vis. It supportsTemperature +/- TemperatureDeltaandTemperature - Temperature, but notTemperature - Temperature, and no scalar multiplication at all. See the module docstring next toTemperaturefor the full reasoning.- Scope gaps are curated, not generic, where the gap has a name.
Resistance * ResistanceraisesUnrepresentableDimensionError: dimensionally valid, no registered type, and no plan to add one. Planned to be implemented dimensions raise aPlannedDimensionErrorinstead, a subclass of the same error, because some calculations are planned to be implemented soon. The error message is explicitly different, and not leaving the reader to reverse-engineer the physics from an exponent vector.
Same dimension, different concepts
Frequency, SampleRate, and BaudRate all share the same physical dimension (T^-1), but they are not the same thing, and kuchka keeps them as separate types on purpose:
# IncompatibleDimensionError, even though both are dimensionally T^-1.
Hertz(50) + SamplesPerSecond(50)
Only one type per dimension is ever the canonical one -- the type generic arithmetic produces automatically.
That's always the plain SI name (Frequency for T^-1, Time for T^1), never a domain-specific type like SampleRate or BaudRate.
Those stay reachable only by explicit construction, the same way Temperature already works.
Genuine reciprocal pairs convert into each other directly:
rate = 1 / SecondSamplingPeriod(2) # -> SampleRate, not Frequency.
period = 1 / rate # -> SamplingPeriod again.
BaudRate gets no reciprocal at all: baud counts symbols per second, not bits or cycles, so a default equivalence with Frequency would be wrong for any encoding other than exactly one bit per symbol.
Silently guessing here is worse than refusing.
For code that wants to accept "a Frequency or a SampleRate, interchangeably", there's a ConvertibleToFrequency protocol:
from kuchka import ConvertibleToFrequency, Frequency
def nyquist_ok(signal: ConvertibleToFrequency, sample_rate: Frequency) -> bool:
return signal.as_frequency() * 2 <= sample_rate
BaudRate deliberately does not satisfy this protocol, since it has no as_frequency().
Percent and Ratio are the same quantity, not different concepts
Proportion is the dimensionless quantity: Ratio is its coherent, unscaled unit (a bare fraction), and Percent is a hundredth of that.
Unlike Frequency/SampleRate/BaudRate, these are not different concepts sharing a dimension -- 5% and 0.05 are the same quantity at different scale, the same relationship Volt/Millivolt has:
Percent(5) == Ratio(Decimal("0.05")) # True
Percent(5) + Ratio(Decimal("0.05")) == Percent(10) # True
Proportion is never registered for DIMENSIONLESS, and could not usefully be even if it wanted to: Ohm(5) / Ohm(2) already returns a bare Decimal for any dimensionless result, before the registry is even consulted, and that does not change.
Percent/Ratio stay reachable only by explicit construction, the same way SampleRate/BaudRate do.
What does change: multiplying or dividing any existing quantity by a Proportion already produces the correctly-typed result, with no new arithmetic added anywhere.
Volt(5) * Ratio(Decimal("0.1")) == Volt(Decimal("0.5")) # True, a tolerance band.
This falls out of the same general dimension algebra that already derives Power from Voltage * Current: multiplying by the dimensionless identity leaves the other operand's dimension, and therefore its type, unchanged.
Physical constants
kuchka.constants provides a small set of physical constants as ordinary kuchka quantities.
from kuchka import Hertz
from kuchka.constants import SPEED_OF_LIGHT
wifi_wavelength = SPEED_OF_LIGHT / Hertz(2_400_000_000) # -> Length, ~0.1249 m
SPEED_OF_LIGHT and ELEMENTARY_CHARGE are exact by SI definition.
ELECTRON_MASS, PROTON_MASS, and NEUTRON_MASS are CODATA 2022 recommended values, carrying exactly as many significant digits as CODATA publishes for each.
The module docstring states which category each constant belongs to.
The set grows as concrete needs come up, the same way the unit vocabulary itself does.
Errors
Every error kuchka raises for a mistake in application code is a MeasurementError; catch that to handle the whole family at once.
| Error | Raised when |
|---|---|
InvalidMeasurementError |
a unit constructor got a non-numeric value, a bool, or an incompatible measurement |
IncompatibleDimensionError |
an operation was attempted between two incompatible dimensions or types |
InvalidConversionError |
.to(...) was given something that isn't a valid unit for that dimension |
UnrepresentableDimensionError |
arithmetic produced a dimension with no registered engineering type |
PlannedDimensionError |
same as above, but for a dimension already named and intended for a future release (subclass of UnrepresentableDimensionError) |
Formatting
Every quantity supports Python's format mini-language through __format__, applied to the magnitude in its current display unit.
from kuchka import Volt
voltage = Volt(1234.5)
f"{voltage}" # "1234.5 V" -- matches repr()
f"{voltage:.2f}" # "1234.50 V"
f"{voltage:.2e}" # "1.23e+3 V"
f"{voltage:,.2f}" # "1,234.50 V"
An empty format spec matches repr().
A non-empty spec applies to the underlying Decimal, and the unit symbol is appended after.
Examples
- Migrating a config from unit-suffixed keys to kuchka --
a worked example: an old
.inifile where units live in the key name (accepted_voltage_min_mv), migrated to a.tomlfile where units are data, parsed through a boundary layer into typed dataclasses. Covers closed per-dimension unit resolution,Decimal-exact parsing, and three concrete failure modes the boundary layer catches at load time.
Package layout
subject to change as the design and architecture bounces around and settles down.
src/kuchka/
constants.py a handful of physical constants
dimensions.py exponent-vector dimensional algebra
numeric.py strict coercion of int/float/Decimal/Fraction into Decimal
errors.py the exception hierarchy
quantities/
base.py the quantity engine: LinearQuantity, UnitDef, the dimension registry
electrical.py Voltage, Current, Power, Resistance and their overload tables
temporal.py Time, Frequency, SamplingPeriod, SampleRate, BaudRate
thermal.py TemperatureDelta, and the affine Temperature type
length.py Length
mass.py Mass
amount.py AmountOfSubstance
luminous_intensity.py LuminousIntensity
proportion.py Proportion
units/
voltage.py, current.py, resistance.py, power.py, temporal.py, length.py,
mass.py, amount.py, luminous_intensity.py, proportion.py, ...
one module per dimension's prefix constructors
tests/ All tests in the module.
Application code should only ever need the top-level kuchka package.
Everything under quantities/, units/, and the internals of temperature.py is implementation detail, re-exported for convenience but not meant to be imported piecemeal.
Development
uv sync installs the dev dependency group (ruff, mypy, ty, pytest, hypothesis, coverage, and editor tooling) alongside kuchka itself.
uv sync
uv run pytest
uv run mypy --strict src/kuchka
uv run ty check src/kuchka tests
uv run coverage run -m pytest && uv run coverage report
Alternatively, one can leverage the justfile's just ct or just covtest to execute the whole testbench and additional statistics.
tests/test_typing.py runs mypy against tests/typing_fixtures/should_fail.py, a file that mixes lines that must type-check with lines marked # type: ignore[...] that must not.
Under --strict, an unused type: ignore is itself an error.
A clean run of that test is only possible if mypy's static verdict matches the fixture's claim on every single line -- that's the thing that actually proves the type story works, not just that it looks plausible.
Ruff runs with select = ["ALL"].
Every disabled rule in pyproject.toml carries an inline comment explaining why it doesn't fit this codebase.
This mostly includes dunder-per-dunder docstrings, the deliberately capitalized unit-constructor names, and the exception hierarchy's inline-message style.
Worth reading before adding a new blanket ignore.
Performance
kuchka deliberately uses Decimal under the hood for correctness.
The design philosophy is to utilize kuchka for unit correctness as values come from configuration files, through permutations and checks flow into reports.
Running kuchka in a hot loop transforming values in a vector of mega-samples-per-second live is not really the intended use case of it.
For more see benchmarks.
Comparison to existing tools
kuchka isn't the first attempt at physical quantities in Python.
A non-exhaustive list of prior art: Pint, astropy.units, physipy, unyt, forallpeople, numericalunits, Unum, quantities, units, unitpy, and impunity.
The uncomfortable starting point. Pint has existed since roughly 2012.
Despite that, more than a decade of engineering work across healthcare, finance, and secure embedded devices has produced (for me) essentially zero sightings of any of these libraries in Python code.
What actually gets written instead is voltage_mv, temperature_c, time_ms, or a small home-grown newtype.
I assume that's not because engineers don't know these libraries exist.
None of them seem to have made the safe path more comfortable than the naming-convention path.
That's the problem kuchka is trying to solve -- and it's worth being honest that kuchka hasn't proven it solves it either.
It has no adoption history of its own to point to.
The claim here is about mechanism, not a guarantee.
Where the existing libraries put the friction:
# Pint / physipy: a registry, and a unit looked up on or from it.
ureg = pint.UnitRegistry()
voltage = 3 * ureg.volt + 40 * ureg.millivolt
# kuchka: the unit is the constructor.
voltage = Volt(3) + Millivolt(40)
| Library | Unit is... | Result type |
|---|---|---|
| Pint | looked up on a registry (ureg.meter, "3 m") |
one generic Quantity for everything |
| physipy | looked up in a dict (units["nm"]) |
one generic Quantity for everything |
| units / unitpy | a string (unit("m"), U("km")) |
-- |
| impunity | a string in a function annotation, checked at import time | none at runtime -- rewritten away |
| kuchka | an ordinary Python name (Meter) |
a distinct Python type per quantity |
Pint is the mature, general-purpose default: a huge unit vocabulary, NumPy/Pandas integration, unit parsing from strings.
That generality is the whole point of Pint, and kuchka isn't trying to replicate it.
impunity is the closest in spirit to kuchka's static-first goal, and worth knowing about specifically for that reason.
It's a genuinely different, and in some ways more efficient, trade-off:
zero runtime cost once the check has run, at the cost of the "unit" being a string inside an annotation rather than an ordinary object you can construct, return, and pass around.
kuchka's bet: isinstance(Volt(5), Voltage) should work with no registry, no plugin, and no decorator rewriting your function body.
mypy/ty should understand the arithmetic because it's ordinary overloads on an ordinary class, not a new annotation vocabulary.
The trade-off: a deliberately small, hand-maintained unit vocabulary, and no array/NumPy support yet.
The gap this is aimed at. Not "Python has no unit libraries" -- it obviously does.
The narrower claim: physical quantities should be ordinary, boring Python types that a type checker already understands, without an engineer ever carrying a registry or spelling a unit as a string.
Whether that's also the missing ingredient for adoption, versus adoption needing something else entirely (habit, deadlines, nobody
asking), is genuinely open.
kuchka is a bet on the first explanation, not proof of it.
What this is not (yet)
Lengthwas added,Foot/Yard/Milewere not.- No NumPy/Pandas integration.
Decimaldoesn't vectorize intondarraycleanly, and per-sample scalar wrapping is the wrong shape for high-rate acquisition. Intended pattern for now: buffer raw floats at the acquisition boundary, convert once there is a value worth reasoning about. - No JSON/DB persistence layer. When it's added:
- the storage format may use a unit string (
{"value": 4.2, "unit": "V"}), since that's not the application-facing API. Loading it back must reconstruct a typed measurement, never hand back a bare number.
- the storage format may use a unit string (
- No point-in-time type.
Timeis a span (a duration, a timeout, a sample period), not a timestamp. A timestamp would need the same affine treatmentTemperaturegets, and isn't modelled yet.
Known limitations
tydoesn't yet enforceSelf-narrowed arithmetic across sibling quantity types the waymypy --strictdoes.Volt(5) + Ampere(2)is amypy --stricterror but is currently accepted byty. Both tools agree on naked-number arithmetic (Volt(5) + 4.2) and on theTemperaturecases. Search the test suite for "not (yet) flagged by ty" for the exact cases.- The dimension vocabulary is small on purpose.
Resistance * Resistanceand similar mathematically-valid-but-unregistered products raiseUnrepresentableDimensionErrorat runtime rather than returning a typed result. Neither mypy nor ty catch this ahead of time, since the fallback overload on each quantity type has to acceptobjectto stay compatible with the base class. - No special array, NumPy/Pandas support.
Open questions for the upcoming iterations / next versions
- Whether Kelvin should get a distinct, scalar-multipliable type (see the design note next to
Temperature). - Whether a point-in-time type (
Instant, most likely) is ever worth adding, and what its "you can't add two timestamps" rule should look like. - NumPy/Pandas integration strategy, once vectorized acquisition is actually needed, not just anticipated.
- Better
AffineQuantityrepresentation. ModularorCyclic- for angles, compass values, etc.dBfamily.
History
The original pre-implementation design proposal lives at docs/introduction/design_proposal.md, kept as a record of the initial thinking.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file kuchka-0.1.0.tar.gz.
File metadata
- Download URL: kuchka-0.1.0.tar.gz
- Upload date:
- Size: 37.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4bbbd66959bfdbe3aefa4faea9e00d1fd0455cd2ce3dd599bb53145198c0a8f5
|
|
| MD5 |
c85e07a7050e87f072392c45a87a5d68
|
|
| BLAKE2b-256 |
9247ca417b099bd428927068f0c29d75382b17ea5a9de579d7ad84f5be1b1dac
|
File details
Details for the file kuchka-0.1.0-py3-none-any.whl.
File metadata
- Download URL: kuchka-0.1.0-py3-none-any.whl
- Upload date:
- Size: 52.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eb4f144da94f06d7353325402e7ee364ec3f4d96301abf57409a0e76f9124faf
|
|
| MD5 |
f5c77e609cfeb93b55bc8fc82eb9be48
|
|
| BLAKE2b-256 |
34a041263e2253472dac50594cae8f307a6b1a940c878bc2e153301f753089ca
|