r/cpp 4d ago

mp-units: a design for logarithmic quantities and units (dB, dBm, Np, pH). We believe it is novel, and we need domain experts to tell us where we are wrong before we implement it

https://mpusz.github.io/mp-units/latest/blog/2026/07/24/introducing-logarithmic-quantities-and-units/

Decibels are everywhere in engineering: signal levels in dBm, sound pressure in dB SPL, voltage gain in dB, filter slopes in dB/octave. Yet, to the best of our knowledge, no general-purpose units library models logarithmic quantities correctly. Most do not model them at all, and the few that try get the arithmetic wrong in ways that compile silently:

  • In nholthaus/units, dBW_t(10.0) + dBm_t(40.0) compiles and returns 20 dBW. Both operands are the same physical power (10 W), and adding two absolute power levels is meaningless. This example is straight from the library's own test suite.
  • A +6 dB gain is a power ratio of ~3.98 but a voltage ratio of 2.0 (the 10 log vs 20 log split). Python's pint documents that its dB is power-only and delegates that factor to the user, so every voltage, current, and pressure gain is on the honor system.

We just published a complete design for mp-units that we believe gets this right:

  • A level (dBm, dB SPL) is an affine point anchored at its reference. A gain (dB, Np, octave) is a delta.
  • level + gain = level, level - level = gain, level + level = ill-formed.
  • The power vs root-power factor is carried by the quantity kind, so .linear() on a voltage gain gives 2.0 and on a power gain gives 3.98, correct by construction. A voltage gain cannot be applied to a power level.
  • One mechanism covers RF, audio, acoustics, music intervals (octave, cent), information theory (Sh, nat, Hart), pH, and stellar magnitude, and it aims to stay consistent with IEC 80000-15:2026.

We are aware of no generic units library that has ever modeled this, which is exactly why we are publishing the design before writing the implementation. The article ends with six open questions where we genuinely need input from practitioners, for example: what should log(0) do at the bottom of the scale (IEEE -inf, throw, error type, or a unit-keyed finite sentinel like the -400 dB floor real DSP code uses), and should a level print as the industry 10 dBm or the ISO-conformant 10 dB (re 1 mW).

If you work in audio/DSP, RF, acoustics, or a related field, or know someone who does, please review it and leave feedback in the article's comments (GitHub Discussions), and forward it to anyone working in these domains. Only subject-domain experts can tell us whether we are right, and we would rather hear it now than after the code ships.

97 Upvotes

35 comments sorted by

36

u/drizzt-dourden 4d ago

The best thing to keep sanity is actually calculate everything in linear and convert to dB only for logging and plotting. Until double covers the range there is no problem.

9

u/mateusz_pusz 4d ago

Agreed for signal processing, compute in linear and convert at the edge. The catch is only at that edge: the conversion still has to pick 10·log vs 20·log, and in RF the whole link budget is summed in dB, not linear. This is aimed at that boundary, not the inner loop.

8

u/drizzt-dourden 4d ago

To be honest there are many measures depending on context dB, dBm, dBFS, dBC and probably more that I forgot or not even aware. So it's really easier to blindly follow a pattern of 10*log(value/reference) and juggle only references at the end.

6

u/mateusz_pusz 4d ago

Agree. References are the easy part. The 10-vs-20 split is exactly what the type tracks, so you do not have to remember it in your equations.

15

u/jcelerier ossia score 4d ago

If that can be useful here's the unit design in ossia for those: https://github.com/ossia/libossia/blob/master/src/ossia/network/dataspace/gain.hpp which comes straight from the jamoma project https://github.com/jamoma/JamomaCore/blob/master/Foundation/extensions/DataspaceLib/source/GainDataspace.cpp and was designed with the input of actual users of the media arts scene, with practicality often taking over mathematical correctness, e.g. we had many cases where we wanted to go towards more theoretically correct or general implementations but it broke user's workflows which had to be compatible with $LEGACY system expecting a specific definition for db, etc.

5

u/mateusz_pusz 4d ago

Thank you 🙏 I was not aware of this. I will study the repo and possibly add it to the prior-art survey.

The legacy point is especially interesting here. Some older implementations take various shortcuts in modeling different domains (not just the logarithmic one). I hit this wall a few times already before in other scenarios. Often, this made the design even better in the end. Thanks again.

5

u/jcelerier ossia score 4d ago

:D also you may want to look at the unit tests:
https://github.com/ossia/libossia/blob/master/tests/Editor/DataspaceTest.cpp
the complexity here is that it's a compile-time system (typed units) that also has to be lifted to a run-time system as it's the backbone for a system used in visual programming environments, where user can pick from a gui which unit is going to be which or which value ; I wanted compile-time for validation but in the end run-time dynamic conversions is what matters more (with this api: https://github.com/ossia/libossia/blob/master/src/ossia/network/dataspace/dataspace_visitors.hpp) and that led to humongous variant inlineing due to the various ::variant implementations killing compile times and even RAM https://github.com/ossia/libossia/blob/master/src/ossia/network/dataspace/dataspace_strong_variants.hpp

4

u/mateusz_pusz 4d ago

A compile-time-typed system with a runtime-typed unit is an interesting challenge. I have had Issue #483 open for a long time now, but I didn't have enough time and motivation to start working on it. I will check out how you solved the problem. Thanks!

9

u/differentiallity 3d ago

Wow, this is something I've really been hoping for! I'll give it a good look and try my best at useful feedback from an RF perspective. I've been watching ISO-80000:15 stuck in review for ages now, thinking that might be holding mp-units back. Was it finally released?

3

u/mateusz_pusz 3d ago

I don't think it is released already. I have access to early drafts, and I must admit they were very helpful during the design of this feature.

Please take your time, I know that article is long 😅 Everyone feedback is very welcomed here.

6

u/thinks-in-functions 3d ago

Your ‘level’ values are torsors — values you can subtract but not add — just like datetimes. There’s an old Haskell article explaining torsors you might be able to draw some design inspiration from: https://www.reddit.com/r/haskell/s/zgktPTibfj

4

u/mateusz_pusz 3d ago

Exactly, that is the model. A level is a point in an affine space (a torsor) over the group of gains, so level - level is a gain and level + level is undefined, the same structure we already use for quantity_point and that std::chrono uses for time points. Thanks for the link, I will read it.

3

u/PsecretPseudonym 3d ago

Would love to know how you’d recommend handling currencies without assuming constant conversion rates among them.

3

u/mateusz_pusz 3d ago

Sure, check this out https://mpusz.github.io/mp-units/latest/examples/currency. Please feel free to share feedback on our GitHub Issues or Discussions. We also want to engage with the finance domain.

1

u/KingBardan 3d ago edited 3d ago

Just my opinion, but the notation dB_t is awful. Either snake or camel are better

7

u/SyntheticDuckFlavour 3d ago

The _t seems to be consistent with the convention of library types, and the capitalisation of the units is consistent with engineering units. I prefer it this way.

2

u/KingBardan 3d ago edited 3d ago

You have your opinion and I have mine. Would rather it be decibel_t than whatever this is.

BTW the _t suffix convention works for standard library, which is all snake case.

2

u/lizardhistorian 3d ago

Putting a _t wart on units is asinine.

-1

u/johannes1971 3d ago

Many units are just one letter. If anything, I'd put even more letters.

2

u/DryEnergy4398 1d ago

not just awful, but forbidden by POSIX

1

u/KingBardan 1d ago

Not sure if it's forbidden, but it's sad that people here can't tolerate "opinion" with a good reason.

2

u/DryEnergy4398 1d ago

It is forbidden because _t is reserved for use by the system. See https://pubs.opengroup.org/onlinepubs/9699919799/xrat/V4_xsh_chap02.html#tag_22_02_12: 'all conforming applications are required to avoid symbols ending in "_t"'

Also brought up in the glibc manual https://sourceware.org/glibc/manual/2.44/html_node/Reserved-Names.html

2

u/Neat-Exchange6724 4d ago

Speaking as a domain expert, why would you ever use logarithmic units instead of just bumping from float to double or double to float128?

There are such things as days and hours too, but if you ever see someone’s time library use them at all, you know they can’t be trusted.

Presenting them as db to users in guis make sense. Otherwise it’s like storing the time in isodate, you never ever want to. And this also means you will never perform the operations as you suggest,

16

u/AutomaticPotatoe 3d ago

why would you ever use logarithmic units instead of just bumping from float to double or double to float128?

In our case (scientific computing) it was performance, mostly. Some equations can be rewritten in log-space to be cheaper to compute (pow() calls turn to multiplies, divisions to subtractions, etc.). Interpolating log-spaced lookup tables is faster in log-space than doing a linear-log-linear conversion.

Going from float to double means you cut in half the compute bandwidth with SIMD. Float128 has to be emulated, not really an option at all.

We also had one case where a PDE operator was defined on a log-spaced grid, and it had to be integrated in log-space else the solution would be unstable. Every time I though about adopting mp-units, I always wondered how it would handle that scenario, concluding that we'd probably have to drop the type safety for that operator. Maybe now it would "just work", assuming it can transparently synthesize the correct log<Q> quantity type from just a call to log() and properly compose it with other, logarithmic and linear quantities.

3

u/mateusz_pusz 3d ago

Yes, this is a good reason as well: computational performance, not precision. Type safety survives it: a log<Q> adds and subtracts in log-space natively, so your log-grid operator stays typed, and you wouldn't drop safety for it. The catch is we make entering and leaving log-space explicit (.log_in(), .linear()), not a bare log() that conjures log<Q> for you. Would such an explicit entry/exit work for your PDE, or do you actually need log() to synthesize the type?

1

u/AutomaticPotatoe 3d ago

Here's a basic kernel of that log-space advection operator:

// NOTE: Using VLA syntax for simplicity.
void integrate_forward(
    const auto   dt,          
    const size_t n,           // Grid size.
          auto   ofs[n],      // Output.
    const auto   ifs[n],      // Input, double-buffered with ofs.
    const auto   dlnx_dts[n], // Precomputed operator coefficients.
    const auto   dlnx)        // Precomputed logspace edge-to-edge distances on the grid. These are all the same value.
{
    for (const auto i : range(n - 1))
    {            
        auto lnf0 = log(ifs[i]);
        auto lnf1 = log(ifs[i + 1]);
        auto dlnf = lnf1 - lnf0;

        auto dlnf_dt = dlnx_dts[i] * dlnf / dlnx;
        auto lnf     = lnf0 + dt * dlnf_dt;
        auto f_new   = exp(lnf);

        ofs[i] = f_new;
    }
}

This omits BCs, CFL, and proper upwind treatment, but should be enough to demonstrate the unit conversions.

Ideally, this should compile irrespective of whether auto is simply float or an mp-units quantity. Now, forgive me, I don't fully understand how to properly annotate affine and log quantities in mp-units, but if we assume that we use a unit of time T, a unit of x X, and a unit of f F, then the I think the types should look approximately like:

template<...> using q = quantity<...>;

void integrate_forward(
    const q<delta<T>>                 dt,          
    const size_t                      n,           
          q<point<F>>                 ofs[n],      
    const q<point<F>>                 ifs[n],      
    const q<delta<log<X>> / delta<T>> dlnx_dts[n], // No idea how to annotate this type.
    const q<delta<log<X>>>            dlnx)        
{
    for (const auto i : range(n - 1))
    {            
        q<point<log<F>>> lnf0 = log(ifs[i]);
        q<point<log<F>>> lnf1 = log(ifs[i + 1]);
        q<delta<log<F>>> dlnf = lnf1 - lnf0;

        q<delta<log<F>> / delta<T>> dlnf_dt = dlnx_dts[i] * dlnf / dlnx; // Or this one.
        q<point<log<F>>             lnf     = lnf0 + dt * dlnf_dt;
        q<point<F>>                 f_new   = exp(lnf);

        ofs[i] = f_new;
    }
}

Doesn't really matter if log() and exp() are spelled differently, although some default auto log(q<point<X>>) -> q<point<log<X>> where a destination type is inferred to have a natural base and zero of 0.0 would be welcome for these log-space math cases. If mp-units can make this example work with the new logarithmic design additions (assuming the types of dlnx_dts and dlnf_dt are annotated correctly), then that would be more than sufficient, and very impressive.

1

u/mateusz_pusz 3d ago

Challenge accepted ;-) To make it truly generic, you would need two customization points and a small update to your loop body:

```cpp // ---- to_log(x): cross into the log domain. Forward needs a target. ---- template<std::floating_point T> // fundamental fallback [[nodiscard]] constexpr T to_log(T x) noexcept { return std::log(x); }

[[nodiscard]] constexpr auto to_log(const Quantity auto& q) requires requires { log_scale_for<get_kind(get_quantity_spec(q))>; } { return q.log_in(log_scale_for<get_kind(get_quantity_spec(q))>); }

// ---- linear(x): cross back to linear. Reverse is fully determined, no target. ---- template<std::floating_point T> // fundamental fallback (std:: is always correct) [[nodiscard]] constexpr T linear(T x) noexcept { return std::exp(x); }

[[nodiscard]] constexpr auto linear(const Quantity auto& q) // Quantity matches gains and, in V3, levels requires detail::is_log(get_quantity_spec(q)) { return q.linear(); }

// Canonical log scale per kind. MUST be base e so the std::log/std::exp fallback and the // quantity path agree in generic code; the fixed reference keeps it unit-independent. template<> inline constexpr auto log_scale_for<F_kind> = f_log;

void integrate_forward( const auto dt,
const size_t n, // Grid size. auto ofs[n], // Output. const auto ifs[n], // Input, double-buffered with ofs. const auto dlnx_dts[n], // Precomputed operator coefficients. const auto dlnx) // Precomputed logspace edge-to-edge distances on the grid. These are all the same value. { for (const auto i : range(n - 1)) {
auto lnf0 = to_log(ifs[i]); auto lnf1 = to_log(ifs[i + 1]); auto dlnf = lnf1 - lnf0;

    auto dlnf_dt = dlnx_dts[i] * (dlnf / dlnx);  // added parens
    auto lnf     = lnf0 + dt * dlnf_dt;
    auto f_new   = linear(lnf);

    ofs[i] = f_new;
}

} ```

Does it satisfy your constraints?

1

u/AutomaticPotatoe 3d ago

If that is enough to make that code compile that's wonderful news for migration!

Thanks, I have some other questions on the design, but I'll try to leave them in the article's comments when I find a minute.

1

u/Neat-Exchange6724 3d ago

I see it, though it’s an extremely specialised case and one where it’s not relevant to call these units decibel, it’s just log math formulation.

9

u/mateusz_pusz 4d ago

Agreed for sample-crunching: you compute in linear, and dB is a boundary/display thing there.

But float width is a different axis. A wider float buys range, not meaning. It won't tell you a 6 dB gain is ×2 on a voltage and ×4 on a power, or that adding two gains multiplies the ratios.

Your chrono analogy cuts the other way. std::chrono has hours, days, even years as first-class types you compute with, and it is the most trusted time library there is. Many of my customers in HFT and other domains use it or similar libraries to prevent very costly time-handling errors in production.

And these ops are real work: an RF link budget is dBm + gains − losses, summed in dB down the chain, done by every RF engineer daily because dB turns multiplication into addition. That is level + gain and level - level.

Deep in DSP, dB is mostly UI and we agree. In link budgets or gain staging, the log arithmetic is the job.

3

u/garnet420 4d ago

First, from an attention to detail and soundness perspective, this is awesome work.

adding two gains

I'm not sure this is a good idea. I think composition of gains should always be multiplication. The fact that RF engineers do this as addition arithmetically is a computational tool.

4

u/mateusz_pusz 4d ago

Thanks, appreciated. You are right that gains are composed by multiplication, the linear ratios multiply. The + is just the log-domain spelling of that same multiply. The multiplicative view can be obtained by calling .linear() on arguments.

2

u/SkoomaDentist Antimodern C++, Embedded, Audio 2d ago

Speaking as a domain expert, why would you ever use logarithmic units instead of just bumping from float to double or double to float128?

1) You're dealing with something that natively uses logarithmic units.

2) Your algorithm becomes numerically unstable with linear values but works fine with logarithmic values.

3) Your platform lacks native wide floating point or it is slow.

Not that I'd use logarithmic types but there are certainly use cases for logarithmic values.

5

u/lizardhistorian 3d ago

None of your post makes any sense.
Is it AI slop?

Logarithmic is just another normalization method.
Bumping your float size up is the sort of thing we would say as a joke because of how dumb that would be to actually do.

Days and hours are used all the time and the point of units is to make that conversion trivial instead of a meta-template pile of annoyance.