Skip to content

Commit a1e1e7c

Browse files
committed
feat: non-negative quantities support added
Resolves #468
1 parent 4ab84fc commit a1e1e7c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+2417
-656
lines changed

docs/blog/posts/quantity-point-bounds.md renamed to docs/blog/posts/range-validated-quantity-points.md

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,6 @@ polymorphism.
2929
This article describes the motivation in depth, the design we arrived at, and
3030
the open questions we would love the community's help to answer.
3131

32-
!!! note "Work in progress"
33-
34-
This feature is shipping in the current development version of **mp-units**.
35-
We are planning a further extension for **non-negative quantity annotations**
36-
(quantities that live on a ratio scale and are always ≥ 0, such as _mass_,
37-
_duration_, or _electric charge_). That extension will be covered in a
38-
follow-up article once the implementation is complete.
39-
4032
<!-- more -->
4133

4234
## The Problem
@@ -230,7 +222,7 @@ parent's bounds:
230222
// static_assert fires at compile time if relative bounds exceed parent bounds
231223
template<>
232224
inline constexpr auto mp_units::quantity_bounds<ac_setpoint> =
233-
mp_units::clamp_to_range{delta<deg_C>(-3), delta<deg_C>(+3)};
225+
clamp_to_range{delta<deg_C>(-3), delta<deg_C>(+3)};
234226
// ❌ compile error if this would violate the parent origin's physical bounds
235227
```
236228
@@ -245,7 +237,7 @@ instantiated by enforcing the following, in order:
245237
by the cumulative offset) must nest strictly inside the parent's range.
246238
247239
Both checks are **compile-time** `static_assert`s. They fire exactly once per
248-
specialisation regardless of how many `quantity_point` variables are constructed.
240+
specialization regardless of how many `quantity_point` variables are constructed.
249241
250242
### Full example: geodetic coordinate types
251243
@@ -264,7 +256,7 @@ inline constexpr struct equator final : absolute_point_origin<geo_latitude> {}
264256
inline constexpr struct prime_meridian final : absolute_point_origin<geo_longitude> {} prime_meridian;
265257
```
266258

267-
Outside the anonymous namespace (so the specialisations have external linkage):
259+
Outside the anonymous namespace (so the specializations have external linkage):
268260

269261
```cpp
270262
template<>
@@ -419,6 +411,47 @@ guarantees described above apply here too: you could use `constrained<float>` or
419411
`constrained<int>` as the representation without changing the
420412
`quantity_bounds` specialization.
421413

414+
### Non-negative quantity annotations
415+
416+
[Absolute quantities](introducing-absolute-quantities.md) are quantities that
417+
live on a ratio scale — always measured from a natural zero — such as _mass_,
418+
_duration_, or _electric charge magnitude_. Non-negativity is the canonical
419+
constraint for all of them, and **mp-units** now implements it at the
420+
quantity-specification level.
421+
422+
The `non_negative` flag can be applied to any real-scalar base or named child
423+
quantity spec, and the library propagates the flag transitively: a quantity
424+
derived from two non-negative specs is itself non-negative.
425+
426+
```cpp
427+
static_assert(is_non_negative(isq::length)); // ✅ tagged in ISQ system definition
428+
static_assert(is_non_negative(isq::mass)); // ✅ tagged in ISQ system definition
429+
static_assert(is_non_negative(isq::speed)); // ✅ derived: length / duration
430+
static_assert(!is_non_negative(isq::velocity)); // ❌ vector character — excluded
431+
```
432+
433+
!!! note
434+
435+
`kind_of<QS>` is **never** non-negative, even when `QS` itself is tagged `non_negative`,
436+
because `kind_of<QS>` represents the entire quantity tree including vector quantities
437+
and signed coordinates. This matters when using CTAD with bare SI units:
438+
439+
```cpp
440+
quantity_point generic{5.0 * m}; // origin uses kind_of<isq::length> — NOT auto-bounded
441+
quantity_point dist{distance_traveled(5.0 * m)}; // uses isq::distance — auto-bounded
442+
```
443+
444+
When a `quantity_point` uses a `natural_point_origin` whose quantity spec is
445+
non-negative, the library **automatically attaches `check_non_negative`** as the
446+
bounds policy — no explicit `quantity_bounds` specialization is needed. The
447+
default can always be overridden:
448+
449+
```cpp
450+
// Override the auto-applied check_non_negative with a clamping policy instead:
451+
template<>
452+
inline constexpr auto mp_units::quantity_bounds<natural_point_origin<isq::length>> = clamp_non_negative{};
453+
```
454+
422455
---
423456

424457
## Design Trade-offs and Open Questions
@@ -432,18 +465,12 @@ misleading (there is no "smallest" longitude on a wrapped circle; they're all
432465
equivalent modulo 360°). The current implementation does return `min` and `max`
433466
for all policy types that expose these members.
434467

435-
### Should `quantity_bounds` be supported on absolute quantities?
436-
437-
[Absolute quantities](introducing-absolute-quantities.md) are quantities that
438-
live on a ratio scale — always measured from a natural zero — such as _mass_,
439-
_duration_, or _electric current magnitude_. Non-negativity is the canonical
440-
constraint for them and will be addressed by a dedicated language-level
441-
annotation in a future **mp-units** release.
468+
### Should `quantity_bounds` be applied to application-level absolute quantity ranges?
442469

443-
Beyond non-negativity, do you see real use cases for attaching
444-
_application-specific_ bounds to absolute quantities directly — for example,
445-
clamping a sensor's mass reading to its physical measurement range, or bounding
446-
a duration to a maximum scheduling window?
470+
Beyond the automatic non-negativity enforcement described above, do you see real
471+
use cases for attaching _application-specific_ range bounds to absolute quantities
472+
directly — for example, clamping a sensor's _mass_ reading to its physical
473+
measurement range, or bounding a _duration_ to a maximum scheduling window?
447474

448475
---
449476

@@ -460,7 +487,7 @@ domain, we would love to hear from you:
460487
- Did the design make your use case straightforward to express?
461488
- Were there cases where you reached for bounds but the current design would not
462489
cover them?
463-
- Do you have strong opinions on the open questions above?
490+
- Do you have a view on either of the two open questions above?
464491

465492
Please join the conversation in the
466493
[GitHub Discussions](https://github.com/mpusz/mp-units/discussions) or open a
@@ -470,14 +497,15 @@ Please join the conversation in the
470497

471498
## Summary
472499

473-
| | Before | After |
474-
|-------------------------------------------------|-----------------------|-------------------------------------|
475-
| Latitude type enforces pole constraint | ❌ user responsibility | ✅ compile-time, zero-overhead |
476-
| Longitude wraps cyclically | ❌ manual modulo |`wrap_to_range` on origin |
477-
| Sensor range clamped at API boundary | ❌ runtime if-else | ✅ policy on origin |
478-
| Relative origin inherits parent bounds | ❌ impossible | ✅ automatic, static-checked nesting |
479-
| `min()`/`max()`/`numeric_limits` reflect bounds | ❌ reports type limits | ✅ reports domain limits |
480-
| Half-line (non-negative) bounds | ❌ impossible | ✅ custom policy with `.min` only |
500+
| | Before | After |
501+
|-------------------------------------------------|-----------------------|--------------------------------------------------------|
502+
| Latitude type enforces pole constraint | ❌ user responsibility | ✅ compile-time, zero-overhead |
503+
| Longitude wraps cyclically | ❌ manual modulo |`wrap_to_range` on origin |
504+
| Sensor range clamped at API boundary | ❌ runtime if-else | ✅ policy on origin |
505+
| Relative origin inherits parent bounds | ❌ impossible | ✅ automatic, static-checked nesting |
506+
| `min()`/`max()`/`numeric_limits` reflect bounds | ❌ reports type limits | ✅ reports domain limits |
507+
| Half-line (non-negative) bounds | ❌ impossible |`check_non_negative` / `clamp_non_negative` policies |
508+
| Non-negative QS auto-guards natural origins | ❌ impossible | ✅ automatic, no specialization needed |
481509

482510
The implementation is already merged and covered by a comprehensive compile-time
483511
test suite. Documentation lives in the

docs/blog/posts/understanding-safety-levels.md

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,59 @@ delays.emplace_back(42, us); // ✅ OK
460460

461461
There is no construction path that silently discards unit information.
462462

463+
### Non-Negative Quantity Tracking
464+
465+
Beyond numeric representation, **mp-units** also encodes domain constraints at the quantity
466+
specification level. Many physical quantities are inherently non-negative: _length_, _mass_,
467+
_duration_, _thermodynamic temperature_, _amount of substance_, and _luminous intensity_ can
468+
never be negative. The ISQ base quantity definitions in **mp-units** carry this fact as a
469+
compile-time property:
470+
471+
```cpp
472+
static_assert(is_non_negative(isq::length));
473+
static_assert(is_non_negative(isq::mass));
474+
static_assert(is_non_negative(isq::duration));
475+
static_assert(!is_non_negative(isq::electric_current)); // can be negative
476+
```
477+
478+
This property **propagates through derived equations** and is automatically inherited by
479+
named real-scalar children — if all factors in a multiplication or division are
480+
non-negative, the result is too, and any named specialization that does not change the
481+
quantity character carries the constraint forward:
482+
483+
```cpp
484+
static_assert(is_non_negative(isq::speed)); // length / duration — both non-negative
485+
static_assert(is_non_negative(isq::area)); // length * length — both non-negative
486+
static_assert(is_non_negative(isq::distance)); // named real-scalar child of length
487+
static_assert(!is_non_negative(isq::velocity)); // vector character — excluded from inheritance
488+
```
489+
490+
!!! note
491+
492+
`kind_of<QS>` is **never** non-negative, even when `QS` itself is tagged `non_negative`,
493+
because `kind_of<QS>` represents the entire quantity tree including vector quantities
494+
and signed coordinates. This matters when using CTAD with bare SI units:
495+
496+
```cpp
497+
quantity_point generic{5.0 * m}; // origin uses kind_of<isq::length> — NOT auto-bounded
498+
quantity_point dist{distance_traveled(5.0 * m)}; // uses isq::distance — auto-bounded
499+
```
500+
501+
This metadata is automatically enforced at runtime for `quantity_point` types: when a
502+
`quantity_point` uses a natural origin and the associated quantity spec is non-negative,
503+
the library automatically attaches a `check_non_negative` policy — no explicit
504+
`quantity_bounds` specialization is needed. You can still override the default by
505+
providing a full specialization before the type is first instantiated:
506+
507+
```cpp
508+
// Replace error-on-negative with silent clamp-to-zero (e.g., for FP rounding noise):
509+
template<>
510+
inline constexpr auto mp_units::quantity_bounds<natural_point_origin<isq::length>> = clamp_non_negative{};
511+
```
512+
513+
The metadata is also available for tooling, documentation, and static analysis — for
514+
example, to automatically select signed vs. unsigned representations.
515+
463516
### Bounded Quantity Points
464517

465518
Representation safety also extends to **quantity points** (Level 6). The library's
@@ -479,13 +532,18 @@ using latitude = quantity_point<geo_latitude[deg], equator>;
479532
latitude lat{95.0 * deg}; // reflects to 85° (boundary mirror)
480533
```
481534
482-
The library ships four overflow policies (`check_in_range`, `clamp_to_range`,
483-
`wrap_to_range`, `reflect_in_range`), and the interface is extensible — you can write
484-
your own policy (e.g. a `clamp_bottom` for halflines that only enforce a lower bound)
535+
The library ships six overflow policies (`check_in_range`, `clamp_to_range`,
536+
`wrap_to_range`, `reflect_in_range`, `check_non_negative`, `clamp_non_negative`),
537+
and the interface is extensible — you can write your own policy (e.g. a one-sided policy
538+
for custom bounds that are neither zero-based nor symmetric)
485539
as long as it provides `V operator()(V)`. Combined with the `constrained<T, ErrorPolicy>`
486540
wrapper described above, `check_in_range` provides **guaranteed enforcement** in every
487541
build mode — not just debug builds.
488542
543+
`check_non_negative` and `clamp_non_negative` are specifically designed for halflines
544+
`[0, +∞)`: the former reports a violation when the value is negative, while the latter
545+
silently clamps negative values to zero.
546+
489547
For the complete walkthrough, see
490548
[Range-Validated Quantity Points](../../users_guide/framework_basics/the_affine_space.md#range-validated-quantity-points)
491549
(including [Custom Policies](../../users_guide/framework_basics/the_affine_space.md#custom-policies-one-sided-bounds))

docs/getting_started/safety_features.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,58 @@ latitude lat{95.0 * deg, equator}; // throws std::domain_error (out of [-90, 90
147147
[Representation Types: `constraint_violation_handler`](../users_guide/framework_basics/representation_types.md#constraint-violation-handler).
148148
149149
150+
### Non-Negative Quantities
151+
152+
The library also tracks which physical quantities are inherently non-negative (e.g.,
153+
_length_, _mass_, _duration_) through the type system. This property propagates through
154+
derived equations and is automatically inherited by named real-scalar children:
155+
156+
```cpp
157+
static_assert(is_non_negative(isq::length)); // ✅ Tagged in ISQ definitions
158+
static_assert(is_non_negative(isq::mass)); // ✅ Tagged in ISQ definitions
159+
static_assert(is_non_negative(isq::distance)); // ✅ Named real-scalar child of length
160+
static_assert(is_non_negative(isq::speed)); // ✅ Derived: length / duration
161+
static_assert(!is_non_negative(isq::velocity)); // ❌ Vector character — excluded from inheritance
162+
```
163+
164+
!!! question "How is non-negativity enforced?"
165+
166+
Non-negativity is enforced at **two levels**:
167+
168+
**Compile time (always)**
169+
: The `non_negative` flag is tracked in the type system and propagated through
170+
dimensional equations, so the compiler knows whether any derived quantity is
171+
non-negative.
172+
173+
**Runtime (automatic for `quantity_point` with a natural origin)**
174+
: Every `quantity_point` whose reference is rooted at the natural point origin of a
175+
non-negative quantity spec automatically has `check_non_negative` bounds attached.
176+
This means construction of, or arithmetic on, such a point checks the value:
177+
178+
- If the representation type has a
179+
[`constraint_violation_handler`](../users_guide/framework_basics/representation_types.md#constraint-violation-handler)
180+
specialization (e.g., a `constrained<T, Policy>` rep), the handler is invoked on
181+
violation — behaviour is guaranteed regardless of build mode.
182+
- For plain types (like `double`) the library falls back to
183+
[`MP_UNITS_EXPECTS`](../how_to_guides/integration/wide_compatibility.md#contract-checking-macros),
184+
which may be disabled in release builds.
185+
186+
You can override the default policy for a specific quantity spec by providing a
187+
full specialization of `quantity_bounds` before the point type is first used:
188+
189+
```cpp
190+
// Replace error-on-negative with silent clamp-to-zero (e.g., for FP noise):
191+
template<>
192+
inline constexpr auto mp_units::quantity_bounds<natural_point_origin<isq::length>> = clamp_non_negative{};
193+
```
194+
195+
See [Tutorial: Custom Contract Handlers](../tutorials/affine_space/custom_contract_handlers.md)
196+
for a step-by-step guide to implementing custom error policies with `constrained<T, ErrorPolicy>`.
197+
198+
For quantities modeled only as a `quantity` displacement (not as a `quantity_point`),
199+
non-negativity remains a compile-time property only.
200+
201+
150202
## Level 4: Quantity Kind Safety
151203

152204
**Quantity kind safety** distinguishes between quantities that share the same dimension but

docs/how_to_guides/advanced_usage/ultimate_safety.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ or compiler optimization level.
77
!!! tip "Background reading"
88

99
For in-depth background on how `quantity_bounds` and overflow policies work, see
10-
[Range-Validated Quantity Points](../../blog/posts/quantity-point-bounds.md).
10+
[Range-Validated Quantity Points](../../blog/posts/range-validated-quantity-points.md).
1111

1212
## The Problem
1313

@@ -119,14 +119,12 @@ release-with-debug-info.
119119
!!! tip "Extensible policy interface"
120120

121121
The `quantity_bounds` customization point accepts any callable policy with the
122-
signature `V operator()(V)`. The library ships four built-in policies, but you
123-
can write your own — for example a `clamp_bottom` for halflines that only enforce
124-
a lower bound, or a domain-specific policy that logs and corrects invalid readings.
125-
As long as your policy's `operator()` returns the (possibly adjusted) value,
126-
it will integrate seamlessly with the `quantity_point` enforcement machinery.
127-
128-
See [Custom Policies](../../users_guide/framework_basics/the_affine_space.md#custom-policies-one-sided-bounds)
129-
for a complete example of implementing one-sided bound constraints.
122+
signature `V operator()(V)`. The library ships six built-in policies — `check_in_range`,
123+
`clamp_to_range`, `wrap_to_range`, `reflect_in_range`, `check_non_negative`, and
124+
`clamp_non_negative` — and the interface is fully extensible. `check_non_negative` and
125+
`clamp_non_negative` cover the common `[0, +∞)` halfline case; they are also applied
126+
automatically to natural origins of non-negative ISQ quantity specs. For a fully custom
127+
one-sided or asymmetric policy, see [Custom Policies](../../users_guide/framework_basics/the_affine_space.md#custom-policies-one-sided-bounds).
130128

131129
## How It Works?
132130

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"source_hash": "d2564890eceb1ea7a6c23336abea40ab1620cdbeec04c5053dca9269ab3c2db0"
2+
"source_hash": "9c87a9964988ae7923d976586ab277ceb5c328a96621b6baab79786fe6becbdc"
33
}

docs/reference/systems_reference/hierarchies/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ This section contains all quantity hierarchy trees across all systems, grouped b
3030
## Dimension: L
3131

3232
- [`hep::length`](length_hep.md) (16 quantities)
33-
- [`isq::length`](length_isq.md) (13 quantities)
33+
- [`isq::length`](length_isq.md) (14 quantities)
3434

3535
## Dimension: M
3636

@@ -53,7 +53,7 @@ This section contains all quantity hierarchy trees across all systems, grouped b
5353
## Dimension: Θ
5454

5555
- [`hep::temperature`](temperature.md) (1 quantity)
56-
- [`isq::thermodynamic_temperature`](thermodynamic_temperature.md) (3 quantities)
56+
- [`isq::thermodynamic_temperature`](thermodynamic_temperature.md) (2 quantities)
5757

5858
## Dimension: α
5959

0 commit comments

Comments
 (0)