diff --git a/CMakeLists.txt b/CMakeLists.txt index a8f3a3d..c2ff19a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,7 @@ if(ESP_PLATFORM) endif() project(electro - VERSION 0.1.0 + VERSION 0.2.0 DESCRIPTION "Type-safe electrical units library modeled after std::chrono" HOMEPAGE_URL "https://github.com/cleishm/electro-cpp" LANGUAGES CXX diff --git a/README.md b/README.md index 5246039..4ec3e68 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,12 @@ A type-safe, header-only C++20 library for electrical units, modeled after `std: - **Standard SI multiples**: microvolts through kilovolts, milliohms through gigaohms, picofarads through farads, and more - **Cross-quantity arithmetic** following circuit theory: Ohm's law (`I * R = V`), power (`V * I = P`), and friends - **`std::chrono` interop**: `mA x hours = mAh`, `W x hours = Wh`, `Ω x F = RC time constant` (as a `std::chrono::duration`) +- **Decibels**: `dB` gains and `dBm`/`dBW`/`dBµV` levels, related as `duration` is to `time_point`, so that `dBm + dBm` is a compile error - **Integer and floating-point representations** for exact or fractional precision - **Lossless implicit conversions** (lossy conversions require explicit casts) -- **User-defined literals** for concise notation (`3300_mV`, `10_kΩ`, `2000_mAh`) +- **User-defined literals** for concise notation (`3300_mV`, `10_kΩ`, `2000_mAh`, `-73_dBm`) - **`std::format` support** (when available) -- **Fully constexpr** - all operations can be evaluated at compile time +- **Fully constexpr** - all operations can be evaluated at compile time (except the dB ↔ linear conversions, which need `std::log10`/`std::pow`) ## Requirements @@ -30,7 +31,7 @@ include(FetchContent) FetchContent_Declare( electro GIT_REPOSITORY https://github.com/cleishm/electro-cpp.git - GIT_TAG v0.1.0 + GIT_TAG v0.2.0 ) FetchContent_MakeAvailable(electro) @@ -120,6 +121,93 @@ integer division, so choose operand precisions accordingly: `5_V / 3_Ohm` is `1 A`, while `5000_mV / 3_Ohm` is `1666 mA`. Floating-point representations (e.g. `voltage`) avoid truncation entirely. +## Decibels and Levels + +Decibels live in a separate, opt-in header: + +```cpp +#include // or +``` + +They come in two kinds, and conflating them is the classic source of bugs. This +library keeps them apart using the same distinction `std::chrono` draws between +a `duration` and a `time_point`: + +| | | Closed under | +|---|---|---| +| **gain** (`dB`) | a dimensionless *ratio* — the `duration` analog | `+`, `-`, `* scalar` | +| **level** (`dBm`, `dBW`, `dBµV`) | an *absolute* point on a log scale — the `time_point` analog | `level ± gain`, `level - level → gain` | + +```cpp +auto tx = 20_dBm; +auto eirp = tx + 6_dB - 2_dB; // level ± gain -> level (24 dBm) +auto rx = eirp - 90_dB; // path loss (-66 dBm) +auto margin = rx - -100_dBm; // level - level -> gain (34 dB) + +// tx + tx; // error: adding two absolute levels is meaningless +// tx * 2; // error: scaling an absolute level is meaningless +// tx + 10_dBW; // error: references do not mix +``` + +That last group matters: a plain `dBm` unit tag would accept all three and +silently compute nonsense (`10 dBm + 10 dBm` is `13.01 dBm`, not `20 dBm`). +Combining the powers of two uncorrelated signals is a named function, never +`operator+`: + +```cpp +add_powers(1000_cdBm, 1000_cdBm); // 13.01 dBm +``` + +### Crossing into the linear domain + +Conversions are explicit, named functions rather than constructors: they are +non-`constexpr`, nonlinear, and have domain errors a constructor cannot report. +A level converts at its own reference precision, so `dBm` yields milliwatts: + +```cpp +to_linear(30_dBm); // 1000.0 mW, as power +quantity_cast(to_linear(30_dBm)); // 1 W +to_level(milliwatts(1)); // 0 dBm +to_level(watts(1)); // 30 dBm +``` + +A zero quantity has no finite logarithm and maps to `dbm::min()`; a negative one +asserts. Values are rounded to nearest, not truncated. + +### Power and field references + +A reference records whether its linear quantity is a *power* (`10·log10`) or a +*field* such as voltage (`20·log10`), so callers never have to remember which +factor applies: + +```cpp +to_level(volts(1)); // 120 dBµV, not 60 +``` + +For the same reason, a bare `dB` value cannot be turned into a linear ratio +unambiguously — 3 dB is a factor of 2 in power but ~1.41 in amplitude — so the +two conversions are separately named: + +```cpp +power_ratio(3_dB); // ~1.995 +amplitude_ratio(6_dB); // ~1.995 +``` + +`add_powers` is available only for power references; for a field reference, +summing linear amplitudes would model coherent addition instead, which is a +different operation with a different answer. + +### Precision + +Integer decibels at 1 dB resolution are coarse. Use a finer precision or a +floating-point representation: + +```cpp +centi_dbm fine{-7350}; // -73.50 dBm +dbm_level exact{-73.5}; +to_string(fine); // "-73.50dBm" +``` + ## Quantity Types All standard aliases use `int64_t` representation. Each quantity also has an @@ -137,6 +225,20 @@ alias template for custom representations and precisions, e.g. | Capacitance (F) | `capacitance` | `picofarads`, `nanofarads`, `microfarads`, `millifarads`, `farads` | | Inductance (H) | `inductance` | `nanohenries`, `microhenries`, `millihenries`, `henries` | +### Decibel Types + +From ``. Levels are `level`; gains +are ordinary quantities of `decibel_unit`. + +| Kind | Alias template | Standard aliases | +|------|----------------|------------------| +| Gain (dB) | `gain` | `decibels`, `centidecibels`, `millidecibels` | +| Level vs 1 mW | `dbm_level` | `dbm`, `centi_dbm` | +| Level vs 1 W | `dbw_level` | `dbw` | +| Level vs 1 V | `dbv_level` | `dbv` | +| Level vs 1 mV | `dbmv_level` | `dbmv` | +| Level vs 1 µV | `dbuv_level` | `dbuv`, `centi_dbuv` | + ## Literals | Quantity | Literals | @@ -149,6 +251,13 @@ alias template for custom representations and precisions, e.g. | Energy | `_mJ`, `_J`, `_kJ`, `_Wh`, `_kWh` | | Capacitance | `_pF`, `_nF`, `_uF`, `_F` | | Inductance | `_nH`, `_uH`, `_mH`, `_H` | +| Gain | `_dB`, `_cdB` (0.01 dB steps) | +| Level | `_dBm`, `_cdBm`, `_dBW`, `_dBV`, `_dBmV`, `_dBuV` (also `_dBµV`) | + +Literals are integral, so fractional values use the `centi` variants +(`1301_cdBm` is 13.01 dBm) or an explicit floating-point type. Negative levels +such as `-73_dBm` work because `level` provides a unary `operator-`, which +reflects the level about its reference. ## Conversions @@ -167,6 +276,15 @@ auto r = round(millivolts(5500)); // 6 V (round half to even) auto c = quantity_cast(coulombs(7200)); // 2 Ah ``` +Levels follow the same rules, via `level_cast` and the same `floor`, `ceil` and +`round` overloads: + +```cpp +centi_dbm fine = 20_dBm; // implicit: 2000 (20.00 dBm) +dbm coarse = dbm(centi_dbm(1301)); // explicit: truncates to 13 dBm +auto n = round(centi_dbm(1350)); // 14 dBm +``` + ## ESP-IDF The library can be used as an ESP-IDF component. Add it to your project's @@ -174,7 +292,7 @@ The library can be used as an ESP-IDF component. Add it to your project's ```yaml dependencies: - cleishm/electro: "^0.1.0" + cleishm/electro: "^0.2.0" ``` `std::format` support is controlled via `CONFIG_ELECTRO_STD_FORMAT` in diff --git a/idf_component.yml b/idf_component.yml index 486f8bb..040ae52 100644 --- a/idf_component.yml +++ b/idf_component.yml @@ -1,4 +1,4 @@ -version: "0.1.0" +version: "0.2.0" description: "Type-safe electrical units library modeled after std::chrono" url: "https://github.com/cleishm/electro-cpp" repository: "https://github.com/cleishm/electro-cpp.git" diff --git a/include/electro/decibel b/include/electro/decibel new file mode 100644 index 0000000..6f808d9 --- /dev/null +++ b/include/electro/decibel @@ -0,0 +1,2 @@ +#pragma once +#include diff --git a/include/electro/decibel.hpp b/include/electro/decibel.hpp new file mode 100644 index 0000000..779d37e --- /dev/null +++ b/include/electro/decibel.hpp @@ -0,0 +1,767 @@ +#pragma once + +/** + * @file decibel + * @brief Logarithmic ratios (dB) and absolute levels (dBm, dBW, dBµV, ...). + * + * Decibels come in two flavours, and conflating them is the classic source of + * bugs. This header keeps them apart with the same distinction std::chrono + * draws between a duration and a time_point: + * + * - A **gain** (dB) is a dimensionless *ratio* - the duration analog. Gains + * add, subtract, and scale by a scalar. + * - A **level** (dBm, dBW, dBµV, ...) is an *absolute* point on a logarithmic + * scale, measured against a reference - the time_point analog. + * + * The resulting algebra is enforced at compile time: + * + * @code + * using namespace electro; + * using namespace electro_literals; + * + * auto tx = 20_dBm; + * auto eirp = tx + 6_dB - 2_dB; // level +/- gain -> level + * auto rx = eirp - 90_dB; // path loss -> -66 dBm + * auto margin = rx - (-100_dBm); // level - level -> 34 dB + * + * // tx + tx; // ill-formed: adding two levels is meaningless + * // tx * 2; // ill-formed: scaling an absolute level is meaningless + * @endcode + * + * ## Crossing into the linear domain + * + * Conversions to and from linear quantities are explicit, named functions + * rather than constructors, because they are non-constexpr (they need + * std::log10 and std::pow), they are nonlinear (so the implicit/explicit + * constructor convention used elsewhere in electro - which signals *precision* + * loss - would be misleading), and they have domain errors that a constructor + * cannot report. + * + * @code + * to_linear(30_dBm); // 1000.0 mW, as power + * to_level(milliwatts(1)); // 0 dBm + * @endcode + * + * ## Power references and field references + * + * A reference tag records both the reference quantity and whether the + * underlying linear quantity is a *power* (10*log10, e.g. dBm) or a *field* + * such as voltage (20*log10, e.g. dBµV). Callers never have to remember which + * factor applies; it travels with the type. + * + * For the same reason a bare dB value cannot be turned into a linear ratio + * unambiguously - 3 dB is a factor of 2 in power but ~1.41 in amplitude - so + * power_ratio() and amplitude_ratio() are separate, explicitly named functions. + */ + +#include +#include +#include + +namespace electro { + +// ============================================================================ +// Gain: a dimensionless logarithmic ratio +// ============================================================================ + +/** + * @brief A dimensionless logarithmic ratio, measured in decibels. + * + * This is an ordinary electro::quantity unit: gains add, subtract, negate and + * scale by a scalar, exactly as a std::chrono::duration does. Cascading two + * amplifiers adds their gains; doubling a gain squares the underlying ratio. + */ +struct decibel_unit { + static constexpr const char* symbol = "dB"; +}; + +/** @brief A logarithmic ratio with configurable representation and precision. */ +template> +using gain = quantity; + +/** @brief Gain with 1 dB precision. */ +using decibels = gain; +/** @brief Gain with 0.01 dB precision. */ +using centidecibels = gain; +/** @brief Gain with 0.001 dB precision. */ +using millidecibels = gain; + +// ============================================================================ +// References +// ============================================================================ + +/** + * @defgroup References Decibel Reference Points + * @{ + * + * Tag types identifying what a level is measured against. Each names the + * linear unit, the precision of the reference quantity (so that 1 count of + * that precision is the 0 dB point), the logarithmic factor (10 for power + * quantities, 20 for field quantities), and the display symbol. + */ + +/** @brief Reference of 1 mW, for dBm. */ +struct milliwatt_reference { + using unit = watt_unit; + using precision = std::milli; + static constexpr int factor = 10; + static constexpr const char* symbol = "dBm"; +}; + +/** @brief Reference of 1 W, for dBW. */ +struct watt_reference { + using unit = watt_unit; + using precision = std::ratio<1>; + static constexpr int factor = 10; + static constexpr const char* symbol = "dBW"; +}; + +/** @brief Reference of 1 V, for dBV. */ +struct volt_reference { + using unit = volt_unit; + using precision = std::ratio<1>; + static constexpr int factor = 20; + static constexpr const char* symbol = "dBV"; +}; + +/** @brief Reference of 1 mV, for dBmV. */ +struct millivolt_reference { + using unit = volt_unit; + using precision = std::milli; + static constexpr int factor = 20; + static constexpr const char* symbol = "dBmV"; +}; + +/** @brief Reference of 1 µV, for dBµV. */ +struct microvolt_reference { + using unit = volt_unit; + using precision = std::micro; + static constexpr int factor = 20; + static constexpr const char* symbol = "dBµV"; +}; + +/** @} */ // end of References group + +/** + * @brief Concept for decibel reference tags. + * + * A reference names a linear unit, the precision at which one count is the + * 0 dB point, a factor of 10 (power quantity) or 20 (field quantity), and a + * display symbol. + */ +template +concept decibel_reference = requires { + typename Reference::unit; + typename Reference::precision; + { Reference::factor } -> std::convertible_to; + { Reference::symbol } -> std::convertible_to; +} && (Reference::factor == 10 || Reference::factor == 20); + +/** @brief True if the reference measures a power quantity (10*log10). */ +template +inline constexpr bool is_power_reference_v = false; + +template +inline constexpr bool is_power_reference_v = Reference::factor == 10; + +/** @brief True if the reference measures a field quantity (20*log10). */ +template +inline constexpr bool is_field_reference_v = false; + +template +inline constexpr bool is_field_reference_v = Reference::factor == 20; + +// ============================================================================ +// level +// ============================================================================ + +template> +class level; + +/** + * @brief Trait to detect level specializations. + * @tparam T Type to check. + */ +template +struct is_level : std::false_type {}; + +template +struct is_level> : std::true_type {}; + +template +inline constexpr bool is_level_v = is_level::value; + +/** + * @brief An absolute point on a logarithmic scale, relative to a reference. + * + * The time_point analog: a level plus or minus a gain is a level, and the + * difference of two levels is a gain. Adding two levels, or scaling a level by + * a scalar, is deliberately ill-formed. + * + * @code + * dbm signal{-73}; // -73 dBm + * auto amplified = signal + 20_dB; // -53 dBm + * auto delta = amplified - signal; // 20 dB + * @endcode + * + * @tparam Reference The reference tag (milliwatt_reference, ...). + * @tparam Rep Arithmetic type for the offset count. + * @tparam Precision A std::ratio giving the offset precision, in dB. + */ +template +class level { + static_assert(decibel_reference, "Reference must satisfy the decibel_reference concept"); + +public: + /** @brief The reference tag type. */ + using reference = Reference; + /** @brief The representation type. */ + using rep = Rep; + /** @brief The offset precision, in dB, as a std::ratio. */ + using precision = typename Precision::type; + /** @brief The gain type measuring the offset from the reference. */ + using offset = gain; + + /** @brief Constructs a level at the reference point (0 dB offset). */ + constexpr level() = default; + level(const level&) = default; + + /** + * @brief Constructs from an offset count in dB. + * + * Always explicit: a bare number is not a level. + * + * @tparam Rep2 The source representation type. + * @param r The offset from the reference, in units of Precision. + */ + template + requires std::convertible_to && (treat_as_inexact_v || !treat_as_inexact_v) + constexpr explicit level(const Rep2& r) + : _o(static_cast(r)) {} + + /** @brief Constructs from an offset relative to the reference. */ + constexpr explicit level(const offset& o) + : _o(o) {} + + /** + * @brief Converts from a level with the same reference. + * + * Implicit when the conversion is lossless, mirroring quantity. + */ + template + requires std::convertible_to, offset> + constexpr level(const level& l) + : _o(l.offset_from_reference()) {} + + /** @brief Explicit constructor for lossy precision conversions. */ + template + requires(!std::is_same_v>) && + (!std::convertible_to, offset>) && + std::constructible_from> + constexpr explicit level(const level& l) + : _o(offset(l.offset_from_reference())) {} + + ~level() = default; + level& operator=(const level&) = default; + + /** @brief Returns the offset from the reference point. */ + constexpr offset offset_from_reference() const { return _o; } + + /** @brief Returns the raw offset count, in units of Precision. */ + constexpr rep count() const { return _o.count(); } + + /** + * @brief Reflects the level about its reference point. + * + * Provided so that negative literals such as `-73_dBm` parse. Note that + * std::chrono::time_point offers no such operator; a level is an absolute + * point, and negating one is only meaningful as "the same magnitude of + * offset, on the other side of the reference". + */ + constexpr level operator-() const { return level(-_o); } + constexpr level operator+() const { return *this; } + + constexpr level& operator+=(const offset& o) { + _o += o; + return *this; + } + + constexpr level& operator-=(const offset& o) { + _o -= o; + return *this; + } + + /** @brief Returns the reference point itself (a 0 dB offset). */ + static constexpr level reference_level() noexcept { return level(offset::zero()); } + + /** @brief Returns the minimum representable level. */ + static constexpr level min() noexcept { return level(offset::min()); } + + /** @brief Returns the maximum representable level. */ + static constexpr level max() noexcept { return level(offset::max()); } + +private: + offset _o{}; +}; + +/** + * @brief Converts a level to a different precision or representation. + * + * @tparam ToLevel The target level type (must have the same reference). + */ +template +constexpr ToLevel level_cast(const level& l) { + static_assert( + std::is_same_v, "level_cast cannot convert between different references" + ); + return ToLevel(quantity_cast(l.offset_from_reference())); +} + +/** @brief Converts a level to the target type, rounding toward negative infinity. */ +template +constexpr ToLevel floor(const level& l) { + static_assert(std::is_same_v, "floor cannot convert between references"); + return ToLevel(floor(l.offset_from_reference())); +} + +/** @brief Converts a level to the target type, rounding toward positive infinity. */ +template +constexpr ToLevel ceil(const level& l) { + static_assert(std::is_same_v, "ceil cannot convert between references"); + return ToLevel(ceil(l.offset_from_reference())); +} + +/** @brief Converts a level to the target type, rounding to nearest (ties to even). */ +template +constexpr ToLevel round(const level& l) { + static_assert(std::is_same_v, "round cannot convert between references"); + return ToLevel(round(l.offset_from_reference())); +} + +// ============================================================================ +// Affine arithmetic: level +/- gain -> level, level - level -> gain +// ============================================================================ + +/** @cond INTERNAL */ +template +using _common_gain = std::common_type_t, gain>; +/** @endcond */ + +/** @brief Applies a gain to a level. */ +template +constexpr auto +operator+(const level& l, const quantity& g) { + using cg = _common_gain; + using cl = level; + return cl(cg(l.offset_from_reference()) + cg(g)); +} + +/** @brief Applies a gain to a level. */ +template +constexpr auto +operator+(const quantity& g, const level& l) { + return l + g; +} + +/** @brief Applies a loss to a level. */ +template +constexpr auto +operator-(const level& l, const quantity& g) { + using cg = _common_gain; + using cl = level; + return cl(cg(l.offset_from_reference()) - cg(g)); +} + +/** + * @brief The difference between two levels is a gain. + * + * @code + * auto margin = -66_dBm - -100_dBm; // 34 dB + * @endcode + */ +template +constexpr auto operator-(const level& a, const level& b) { + return a.offset_from_reference() - b.offset_from_reference(); +} + +template +constexpr bool operator==(const level& a, const level& b) { + return a.offset_from_reference() == b.offset_from_reference(); +} + +template + requires std::three_way_comparable> +constexpr auto operator<=>(const level& a, const level& b) { + return a.offset_from_reference() <=> b.offset_from_reference(); +} + +// ============================================================================ +// Linear domain conversions (not constexpr before C++26) +// ============================================================================ + +/** @cond INTERNAL */ +template +constexpr double _db_value(const Offset& o) { + return quantity_cast>(o).count(); +} + +// The inverse of _db_value, rounding to nearest for integral reps. This exists +// because quantity_cast (and floor/ceil/round) truncate when converting from +// an inexact rep, and truncation would round e.g. -73.5 dB the wrong way. +template +inline Offset _db_offset(double db) { + if constexpr (treat_as_inexact_v) { + return quantity_cast(gain(db)); + } else { + using p = typename Offset::precision; + const double scaled = db * static_cast(p::den) / static_cast(p::num); + return Offset(static_cast(std::llround(scaled))); + } +} +/** @endcond */ + +/** + * @brief Converts a level to the equivalent linear quantity. + * + * The result is expressed at the reference's own precision, so to_linear() of + * a dBm level yields milliwatts and to_linear() of a dBµV level yields + * microvolts. Use quantity_cast to retarget: + * + * @code + * to_linear(30_dBm); // 1000.0 mW + * quantity_cast(to_linear(30_dBm)); // 1 W + * @endcode + * + * @note Not constexpr: requires std::pow. + */ +template +inline auto to_linear(const level& l) { + using linear = quantity; + return linear(std::pow(10.0, _db_value(l.offset_from_reference()) / Reference::factor)); +} + +/** + * @brief Converts a linear quantity to a level. + * + * The quantity's unit must match the target level's reference unit. Values are + * rounded to nearest at the target precision, rather than truncated. + * + * A zero quantity has no finite logarithm and maps to ToLevel::min(). A + * negative quantity has no logarithm at all; in a debug build this asserts, + * and otherwise returns ToLevel::min(). + * + * @code + * to_level(milliwatts(1)); // 0 dBm + * to_level(watts(1)); // 30 dBm + * to_level(volts(1)); // 120 dBµV (field reference: 20*log10) + * @endcode + * + * @note Not constexpr: requires std::log10. + */ +template +inline ToLevel to_level(const quantity& q) { + using ref = typename ToLevel::reference; + static_assert( + std::is_same_v, "to_level: quantity unit does not match the level reference" + ); + + using linear = quantity; + const double ratio = quantity_cast(q).count(); + if (!(ratio > 0.0)) { + assert(ratio >= 0.0 && "to_level: a negative quantity has no logarithmic level"); + return ToLevel::min(); + } + return ToLevel(_db_offset(ref::factor * std::log10(ratio))); +} + +/** + * @brief The linear power ratio a gain represents (10^(dB/10)). + * + * @code + * power_ratio(3_dB); // ~1.995 (a doubling of power) + * @endcode + * + * @note Not constexpr: requires std::pow. + */ +template +inline double power_ratio(const quantity& g) { + return std::pow(10.0, _db_value(g) / 10.0); +} + +/** + * @brief The linear amplitude ratio a gain represents (10^(dB/20)). + * + * @code + * amplitude_ratio(6_dB); // ~1.995 (a doubling of voltage) + * @endcode + * + * @note Not constexpr: requires std::pow. + */ +template +inline double amplitude_ratio(const quantity& g) { + return std::pow(10.0, _db_value(g) / 20.0); +} + +/** @brief The gain corresponding to a linear power ratio (10*log10(ratio)). */ +inline gain power_gain(double ratio) { + assert(ratio > 0.0 && "power_gain: ratio must be positive"); + return gain(10.0 * std::log10(ratio)); +} + +/** @brief The gain corresponding to a linear amplitude ratio (20*log10(ratio)). */ +inline gain amplitude_gain(double ratio) { + assert(ratio > 0.0 && "amplitude_gain: ratio must be positive"); + return gain(20.0 * std::log10(ratio)); +} + +/** + * @brief Combines the powers of two uncorrelated signals. + * + * This is what "adding" two levels physically means, and it is deliberately + * not operator+: 10 dBm combined with 10 dBm is 13.01 dBm, not 20 dBm. + * + * Only available for power references. For a field reference such as dBµV, + * summing the linear amplitudes would model coherent, in-phase addition + * instead, which is a different operation with a different answer. + * + * @code + * add_powers(centi_dbm(1000), centi_dbm(1000)); // 13.01 dBm + * @endcode + * + * @note Not constexpr: requires std::pow and std::log10. + */ +template + requires is_power_reference_v +inline auto add_powers(const level& a, const level& b) { + using cg = _common_gain; + using result = level; + return to_level(to_linear(a) + to_linear(b)); +} + +// ============================================================================ +// Type aliases +// ============================================================================ + +/** + * @defgroup LevelTypes Standard Level Type Aliases + * @{ + */ + +/** @brief A level referenced to 1 mW. */ +template> +using dbm_level = level; + +/** @brief A level referenced to 1 W. */ +template> +using dbw_level = level; + +/** @brief A level referenced to 1 V. */ +template> +using dbv_level = level; + +/** @brief A level referenced to 1 mV. */ +template> +using dbmv_level = level; + +/** @brief A level referenced to 1 µV. */ +template> +using dbuv_level = level; + +/** @brief Power level in dBm, with 1 dB precision. */ +using dbm = dbm_level; +/** @brief Power level in dBm, with 0.01 dB precision. */ +using centi_dbm = dbm_level; +/** @brief Power level in dBW, with 1 dB precision. */ +using dbw = dbw_level; +/** @brief Voltage level in dBV, with 1 dB precision. */ +using dbv = dbv_level; +/** @brief Voltage level in dBmV, with 1 dB precision. */ +using dbmv = dbmv_level; +/** @brief Voltage level in dBµV, with 1 dB precision. */ +using dbuv = dbuv_level; +/** @brief Voltage level in dBµV, with 0.01 dB precision. */ +using centi_dbuv = dbuv_level; + +/** @} */ // end of LevelTypes group + +// ============================================================================ +// String conversion and formatting +// ============================================================================ + +/** + * @brief Decibels opt out of the generic SI-prefix suffix machinery. + * + * A gain's precision is rendered as decimal places, not as an SI prefix: + * "10.50dB", never "1050cdB". Leaving this specialization empty makes the + * generic quantity to_string and formatter unusable for decibel_unit, so the + * decibel-specific overloads below are the only candidates rather than merely + * the better-matching ones. + */ +template +struct quantity_suffix {}; + +/** @cond INTERNAL */ +// The number of decimal places needed to render a count at the given precision +// as a fixed-point decimal, or -1 when the precision is not 1, 1/10, 1/100, ... +consteval int _decimal_places(intmax_t num, intmax_t den) { + if (num != 1) { + return -1; + } + int places = 0; + while (den % 10 == 0) { + den /= 10; + ++places; + } + return den == 1 ? places : -1; +} + +template +inline constexpr bool _is_decimal_precision = _decimal_places(Precision::num, Precision::den) >= 0; + +template +inline std::string _format_db(const Rep& count) { + static_assert(_is_decimal_precision, "precision cannot be rendered as a fixed-point decimal"); + if constexpr (treat_as_inexact_v) { + return std::to_string(static_cast(count) * Precision::num / Precision::den); + } else { + constexpr int places = _decimal_places(Precision::num, Precision::den); + if constexpr (places == 0) { + return std::to_string(count); + } else { + const bool negative = count < Rep(0); + // Compute the magnitude in unsigned arithmetic so that the most + // negative representable count does not overflow on negation. + const unsigned long long magnitude = + negative ? 0ULL - static_cast(count) : static_cast(count); + + const unsigned long long whole = magnitude / Precision::den; + const unsigned long long frac = magnitude % Precision::den; + + std::string frac_digits = std::to_string(frac); + frac_digits.insert(0, static_cast(places) - frac_digits.size(), '0'); + + return (negative ? "-" : "") + std::to_string(whole) + "." + frac_digits; + } + } +} +/** @endcond */ + +/** + * @brief Converts a gain to a string, e.g. "10dB" or "10.50dB". + * + * Unlike the generic quantity to_string, a gain's precision is rendered as + * decimal places rather than as an SI prefix: nobody writes "1050cdB". + */ +template + requires _is_decimal_precision +inline std::string to_string(const quantity& g) { + return _format_db(g.count()) + decibel_unit::symbol; +} + +/** + * @brief Converts a level to a string, e.g. "-73dBm" or "13.01dBm". + */ +template + requires _is_decimal_precision +inline std::string to_string(const level& l) { + return _format_db(l.count()) + Reference::symbol; +} + +} // namespace electro + +namespace std { + +template +struct common_type, electro::level> { +private: + using common_offset = std::common_type_t, electro::gain>; + +public: + using type = electro::level; +}; + +#if CONFIG_ELECTRO_STD_FORMAT +template + requires electro::_is_decimal_precision +struct formatter, char> { + constexpr auto parse(format_parse_context& ctx) { return ctx.begin(); } + + auto format(const electro::quantity& g, format_context& ctx) const { + return std::format_to( + ctx.out(), "{}{}", electro::_format_db(g.count()), electro::decibel_unit::symbol + ); + } +}; + +template + requires electro::_is_decimal_precision +struct formatter, char> { + constexpr auto parse(format_parse_context& ctx) { return ctx.begin(); } + + auto format(const electro::level& l, format_context& ctx) const { + return std::format_to( + ctx.out(), "{}{}", electro::_format_db(l.count()), Reference::symbol + ); + } +}; +#endif + +} // namespace std + +namespace electro_literals { + +/** @brief Literal for decibels (e.g., 20_dB). */ +template +constexpr electro::decibels operator""_dB() { + return detail::check_overflow(); +} + +/** @brief Literal for centidecibels, i.e. 0.01 dB steps (e.g., 1050_cdB is 10.50 dB). */ +template +constexpr electro::centidecibels operator""_cdB() { + return detail::check_overflow(); +} + +/** @brief Literal for dBm (e.g., 20_dBm, or -73_dBm). */ +template +constexpr electro::dbm operator""_dBm() { + return electro::dbm(detail::check_overflow()); +} + +/** @brief Literal for dBm in 0.01 dB steps (e.g., 1301_cdBm is 13.01 dBm). */ +template +constexpr electro::centi_dbm operator""_cdBm() { + return electro::centi_dbm(detail::check_overflow()); +} + +/** @brief Literal for dBW (e.g., 10_dBW). */ +template +constexpr electro::dbw operator""_dBW() { + return electro::dbw(detail::check_overflow()); +} + +/** @brief Literal for dBV (e.g., 6_dBV). */ +template +constexpr electro::dbv operator""_dBV() { + return electro::dbv(detail::check_overflow()); +} + +/** @brief Literal for dBmV (e.g., 40_dBmV). */ +template +constexpr electro::dbmv operator""_dBmV() { + return electro::dbmv(detail::check_overflow()); +} + +/** @brief Literal for dBµV (e.g., 120_dBuV). */ +template +constexpr electro::dbuv operator""_dBuV() { + return electro::dbuv(detail::check_overflow()); +} + +/** @brief Literal for dBµV (e.g., 120_dBµV). */ +template +constexpr electro::dbuv operator""_dBµV() { + return electro::dbuv(detail::check_overflow()); +} + +} // namespace electro_literals diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 30b5c0d..6923e9f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -9,6 +9,7 @@ FetchContent_MakeAvailable(Catch2) add_executable(electro_tests electro_test.cpp + decibel_test.cpp ) target_link_libraries(electro_tests PRIVATE diff --git a/tests/decibel_test.cpp b/tests/decibel_test.cpp new file mode 100644 index 0000000..598caee --- /dev/null +++ b/tests/decibel_test.cpp @@ -0,0 +1,341 @@ +// Unit tests for the electro decibel types +// Uses Catch2 test framework with compile-time static_asserts + +#include +#include +#include + +using namespace electro; +using namespace electro_literals; + +// ============================================================================= +// Compile-time tests (static_assert) +// ============================================================================= + +// Construction and count +static_assert(decibels(10).count() == 10); +static_assert(dbm(-73).count() == -73); +static_assert(centi_dbm(1301).count() == 1301); +static_assert(dbm(decibels(20)) == dbm(20)); +static_assert(dbm().count() == 0); +static_assert(dbm::reference_level() == dbm(0)); + +// Literals +static_assert(20_dB == decibels(20)); +static_assert(1050_cdB == centidecibels(1050)); +static_assert(20_dBm == dbm(20)); +static_assert(-73_dBm == dbm(-73)); +static_assert(1301_cdBm == centi_dbm(1301)); +static_assert(10_dBW == dbw(10)); +static_assert(6_dBV == dbv(6)); +static_assert(40_dBmV == dbmv(40)); +static_assert(120_dBuV == dbuv(120)); +static_assert(120_dBµV == dbuv(120)); + +// Gain is an ordinary quantity: it adds, subtracts, negates and scales +static_assert(10_dB + 10_dB == 20_dB); +static_assert(20_dB - 5_dB == 15_dB); +static_assert(10_dB * 2 == 20_dB); +static_assert(2 * 10_dB == 20_dB); +static_assert(20_dB / 2 == 10_dB); +static_assert(20_dB / 10_dB == 2); +static_assert(-10_dB == decibels(-10)); + +// Affine algebra: level +/- gain -> level, level - level -> gain +static_assert(20_dBm + 3_dB == dbm(23)); +static_assert(3_dB + 20_dBm == dbm(23)); +static_assert(20_dBm - 3_dB == dbm(17)); +static_assert(20_dBm - 5_dBm == 15_dB); +static_assert(-66_dBm - -100_dBm == 34_dB); +static_assert(-(-73_dBm) == dbm(73)); +static_assert(+20_dBm == dbm(20)); + +// A complete link budget, evaluated at compile time +static_assert(20_dBm + 6_dB - 2_dB - 90_dB == dbm(-66)); + +// Comparisons +static_assert(20_dBm > 5_dBm); +static_assert(-73_dBm < -50_dBm); +static_assert(dbm(10) == centi_dbm(1000)); +static_assert(dbm(10) != centi_dbm(1001)); + +// Mixed precision resolves through std::common_type +static_assert(20_dBm + 50_cdB == centi_dbm(2050)); +static_assert(std::is_same_v, centi_dbm>); +static_assert(std::is_same_v); +static_assert(std::is_same_v); + +// Lossless conversions are implicit; lossy ones are explicit +static_assert(std::is_convertible_v); +static_assert(!std::is_convertible_v); +static_assert(std::is_constructible_v); +static_assert(dbm(centi_dbm(1301)) == dbm(13)); // truncates +static_assert(level_cast(centi_dbm(1399)) == dbm(13)); + +// Rounding +static_assert(floor(centi_dbm(1350)) == dbm(13)); +static_assert(ceil(centi_dbm(1301)) == dbm(14)); +static_assert(round(centi_dbm(1350)) == dbm(14)); // tie: 13 is odd, round to even +static_assert(round(centi_dbm(1250)) == dbm(12)); // tie: 12 is even +static_assert(floor(centi_dbm(-50)) == dbm(-1)); +static_assert(ceil(centi_dbm(-50)) == dbm(0)); + +// Traits +static_assert(is_quantity_v); +static_assert(!is_quantity_v); +static_assert(is_level_v); +static_assert(is_level_v); +static_assert(!is_level_v); +static_assert(!is_level_v); +static_assert(decibel_reference); +static_assert(!decibel_reference); +static_assert(is_power_reference_v); +static_assert(is_power_reference_v); +static_assert(!is_power_reference_v); +static_assert(is_field_reference_v); +static_assert(is_field_reference_v); +// The reference traits are total: a non-reference type is simply false +static_assert(!is_power_reference_v); +static_assert(!is_field_reference_v); +static_assert(!is_power_reference_v); +static_assert(!is_field_reference_v); + +// ============================================================================= +// Ill-formed operations +// +// These are the whole point of modelling a level as a time_point rather than a +// duration. A positive control (level + gain) guards against the detection +// concepts being vacuously true. +// ============================================================================= + +template +concept can_add = requires(A a, B b) { a + b; }; +template +concept can_subtract = requires(A a, B b) { a - b; }; +template +concept can_multiply = requires(A a, B b) { a * b; }; +template +concept can_divide = requires(A a, B b) { a / b; }; +template +concept can_modulo = requires(A a) { a % a; }; +template +concept can_add_powers = requires(A a) { add_powers(a, a); }; + +// Positive controls +static_assert(can_add); +static_assert(can_add); +static_assert(can_subtract); +static_assert(can_subtract); +static_assert(can_add); +static_assert(can_multiply); + +// Adding two absolute levels is meaningless; use add_powers() +static_assert(!can_add); +// Scaling an absolute level is meaningless +static_assert(!can_multiply); +static_assert(!can_divide); +static_assert(!can_divide); +static_assert(!can_modulo); +// A level is not a gain, and references do not mix +static_assert(!can_add); +static_assert(!can_subtract); +static_assert(!can_add); +static_assert(!can_subtract); +// Levels do not mix with linear quantities +static_assert(!can_add); +static_assert(!can_add); +static_assert(!can_add); +// A bare number is not a level +static_assert(!std::is_convertible_v); +static_assert(std::is_constructible_v); +// add_powers is only defined for power references +static_assert(can_add_powers); +static_assert(can_add_powers); +static_assert(!can_add_powers); +static_assert(!can_add_powers); + +// ============================================================================= +// Runtime tests +// ============================================================================= + +TEST_CASE("compound assignment applies gains", "[level]") { + dbm signal{-73}; + signal += 20_dB; + REQUIRE(signal.count() == -53); + signal -= 3_dB; + REQUIRE(signal.count() == -56); +} + +TEST_CASE("to_linear expresses a level at its reference precision", "[level][linear]") { + // dBm references 1 mW, so to_linear yields milliwatts + REQUIRE(to_linear(0_dBm).count() == Catch::Approx(1.0)); + REQUIRE(to_linear(30_dBm).count() == Catch::Approx(1000.0)); + REQUIRE(to_linear(-30_dBm).count() == Catch::Approx(0.001)); + REQUIRE(to_linear(dbm::reference_level()).count() == Catch::Approx(1.0)); + static_assert(std::is_same_v>); + + // dBW references 1 W + REQUIRE(to_linear(0_dBW).count() == Catch::Approx(1.0)); + REQUIRE(to_linear(10_dBW).count() == Catch::Approx(10.0)); + static_assert(std::is_same_v>); + + // Field references use 20*log10, and yield their own unit + REQUIRE(to_linear(120_dBuV).count() == Catch::Approx(1000000.0)); + REQUIRE(to_linear(0_dBV).count() == Catch::Approx(1.0)); + REQUIRE(to_linear(6_dBV).count() == Catch::Approx(1.9952623)); + static_assert(std::is_same_v>); + + // quantity_cast retargets the result + REQUIRE(quantity_cast(to_linear(30_dBm)) == watts(1)); +} + +TEST_CASE("to_level converts a linear quantity to a level", "[level][linear]") { + REQUIRE(to_level(milliwatts(1)) == 0_dBm); + REQUIRE(to_level(watts(1)) == 30_dBm); + REQUIRE(to_level(microwatts(1)) == -30_dBm); + REQUIRE(to_level(watts(10)) == 10_dBW); + + // Field references: 1 V is 120 dBµV, not 60 + REQUIRE(to_level(volts(1)) == 120_dBuV); + REQUIRE(to_level(volts(1)) == 60_dBmV); + REQUIRE(to_level(volts(1)) == 0_dBV); + + // Rounds to nearest rather than truncating + REQUIRE(to_level(milliwatts(2)) == 3_dBm); // 3.0103 dB + REQUIRE(to_level(milliwatts(2)) == 301_cdBm); // 3.0103 dB + REQUIRE(to_level(microwatts(500)) == -3_dBm); // -3.0103 dB +} + +TEST_CASE("to_level and to_linear round-trip", "[level][linear]") { + for (int64_t db : {-100, -73, -30, -1, 0, 1, 20, 30}) { + REQUIRE(to_level(to_linear(centi_dbm(db * 100))) == centi_dbm(db * 100)); + } + REQUIRE(to_level(to_linear(centi_dbuv(12000))) == centi_dbuv(12000)); +} + +TEST_CASE("a zero quantity has no finite level", "[level][linear]") { + REQUIRE(to_level(milliwatts(0)) == dbm::min()); + REQUIRE(to_level(watts(0)) == centi_dbm::min()); +} + +TEST_CASE("add_powers combines uncorrelated signals", "[level]") { + // Two equal powers sum to +3.0103 dB, not +10 dB and not double the dBm value + REQUIRE(add_powers(1000_cdBm, 1000_cdBm) == 1301_cdBm); + REQUIRE(add_powers(0_dBm, 0_dBm) == 3_dBm); + + // A much weaker signal barely moves the total + REQUIRE(add_powers(centi_dbm(3000), centi_dbm(-3000)) == centi_dbm(3000)); + + // Order does not matter, and the result carries the finer precision + static_assert(std::is_same_v); + REQUIRE(add_powers(0_dBm, 1000_cdBm) == add_powers(1000_cdBm, 0_dBm)); +} + +TEST_CASE("power and amplitude ratios are distinct", "[gain]") { + // 3 dB doubles power; 6 dB doubles amplitude + REQUIRE(power_ratio(3_dB) == Catch::Approx(1.9952623)); + REQUIRE(power_ratio(10_dB) == Catch::Approx(10.0)); + REQUIRE(power_ratio(0_dB) == Catch::Approx(1.0)); + REQUIRE(amplitude_ratio(6_dB) == Catch::Approx(1.9952623)); + REQUIRE(amplitude_ratio(20_dB) == Catch::Approx(10.0)); + + // And the inverses + REQUIRE(power_gain(10.0).count() == Catch::Approx(10.0)); + REQUIRE(amplitude_gain(10.0).count() == Catch::Approx(20.0)); + REQUIRE(power_ratio(power_gain(7.5)) == Catch::Approx(7.5)); + REQUIRE(amplitude_ratio(amplitude_gain(7.5)) == Catch::Approx(7.5)); + + // Applying a gain to a linear quantity, via the appropriate ratio + REQUIRE(watts(2) * power_ratio(10_dB) == power(20.0)); +} + +TEST_CASE("floating-point levels and gains", "[level][gain]") { + dbm_level signal{-73.5}; + REQUIRE(signal.count() == Catch::Approx(-73.5)); + + auto amplified = signal + gain(20.25); + REQUIRE(amplified.count() == Catch::Approx(-53.25)); + + auto delta = amplified - signal; + REQUIRE(delta.count() == Catch::Approx(20.25)); + + // An integer level converts implicitly into a floating-point one + dbm_level from_int = 20_dBm; + REQUIRE(from_int.count() == Catch::Approx(20.0)); +} + +TEST_CASE("to_string renders decibels as decimals", "[format]") { + REQUIRE(to_string(10_dB) == "10dB"); + REQUIRE(to_string(-10_dB) == "-10dB"); + REQUIRE(to_string(1050_cdB) == "10.50dB"); + REQUIRE(to_string(millidecibels(1234)) == "1.234dB"); + + REQUIRE(to_string(20_dBm) == "20dBm"); + REQUIRE(to_string(-73_dBm) == "-73dBm"); + REQUIRE(to_string(1301_cdBm) == "13.01dBm"); + REQUIRE(to_string(centi_dbm(-50)) == "-0.50dBm"); + REQUIRE(to_string(centi_dbm(-1)) == "-0.01dBm"); + REQUIRE(to_string(centi_dbm(0)) == "0.00dBm"); + + REQUIRE(to_string(10_dBW) == "10dBW"); + REQUIRE(to_string(0_dBV) == "0dBV"); + REQUIRE(to_string(40_dBmV) == "40dBmV"); + REQUIRE(to_string(120_dBuV) == "120dBµV"); +} + +#if CONFIG_ELECTRO_STD_FORMAT +TEST_CASE("std::format support for levels and gains", "[format]") { + REQUIRE(std::format("{}", -73_dBm) == "-73dBm"); + REQUIRE(std::format("{}", 1301_cdBm) == "13.01dBm"); + REQUIRE(std::format("{}", 20_dB) == "20dB"); + REQUIRE(std::format("{}", 1050_cdB) == "10.50dB"); + REQUIRE(std::format("rx {} with {} margin", -66_dBm, 34_dB) == "rx -66dBm with 34dB margin"); +} +#endif + +TEST_CASE("link budget example", "[integration]") { + auto tx_power = 20_dBm; + auto antenna_gain = 6_dB; + auto cable_loss = 2_dB; + auto path_loss = 90_dB; + + auto eirp = tx_power + antenna_gain - cable_loss; + REQUIRE(eirp == 24_dBm); + + auto rx_power = eirp - path_loss; + REQUIRE(rx_power == -66_dBm); + + auto sensitivity = -100_dBm; + auto margin = rx_power - sensitivity; + REQUIRE(margin == 34_dB); + REQUIRE(rx_power > sensitivity); + + // And the received power really is 251 pW + REQUIRE(to_linear(rx_power).count() == Catch::Approx(2.5118864e-7)); +} + +TEST_CASE("amplifier cascade example", "[integration]") { + // Two identical 12 dB stages, then a 3 dB attenuator + auto stage = 12_dB; + auto total = stage * 2 - 3_dB; + REQUIRE(total == 21_dB); + + auto out = -40_dBm + total; + REQUIRE(out == -19_dBm); + + // A 1 mW input through 30 dB of gain is 1 W out + REQUIRE(quantity_cast(to_linear(to_level(milliwatts(1)) + 30_dB)) == watts(1)); +} + +TEST_CASE("receiver noise floor example", "[integration]") { + // Thermal noise in a 1 MHz bandwidth is about -114 dBm; a signal 10 dB + // above it, combined with the noise, is only ~0.4 dB stronger than the + // signal alone. + auto noise = -11400_cdBm; + auto signal = noise + 1000_cdB; + REQUIRE(signal == -10400_cdBm); + + auto combined = add_powers(signal, noise); + REQUIRE(combined - signal == 41_cdB); // 0.41 dB + REQUIRE(combined > signal); +} diff --git a/vcpkg-port/portfile.cmake b/vcpkg-port/portfile.cmake index 76f059f..e17e2f2 100644 --- a/vcpkg-port/portfile.cmake +++ b/vcpkg-port/portfile.cmake @@ -2,7 +2,7 @@ vcpkg_from_github( OUT_SOURCE_PATH SOURCE_PATH REPO cleishm/electro-cpp REF "v${VERSION}" - SHA512 d5c11e80af17d126e55a85785c2cb461c34404af7efafda8450b0093e280cd88e4c88733f395ca94bb997ac76edd7a8fdd6a4261e56f061cac6659717e41c3b3 + SHA512 0 # Set after the v0.2.0 release tarball is published HEAD_REF main ) diff --git a/vcpkg-port/vcpkg.json b/vcpkg-port/vcpkg.json index 338a70b..dd8e215 100644 --- a/vcpkg-port/vcpkg.json +++ b/vcpkg-port/vcpkg.json @@ -1,6 +1,6 @@ { "name": "cleishm-electro-cpp", - "version": "0.1.0", + "version": "0.2.0", "description": "Type-safe electrical units library modeled after std::chrono", "homepage": "https://github.com/cleishm/electro-cpp", "license": "MIT",