Significant Digits

The Significant Digits transformation reduces the amount of significant digits to a practical upper value. This reduces flicker and meaningless state updates for sensor data which is usually less precise than the amount of digits suggests.

significant.js is a JavaScript transformation for openHAB that turns numeric values more readable by normalizing and rounding values, and optionally converting units.

It is mainly intended for sensor values such as temperature, speed, pressure, angles, power, energy, and similar measurements that change frequently, but where the transmitted value often contains more (pseudo-)precision than is useful in everyday openHAB UIs. But it also works with date/time values, angles and data sizes.

Examples

  • 6.34 °C → 6.5 °C [builtin default precision for °C]
  • 1013.26 hPa → 1013 hPa [precision=4,scale=0]
  • 52° → 90° (wind rose) [precision=1]
  • 1-2 → 1.5 [precision=1.5] (as from DWD Pollenflug - Bindings | openHAB)
  • 1200 Bytes → 1024 Bytes / 1 KByte [precision=1]
  • 2026-03-17T14:21:57.132 → 2026-03-17T14:15:00.000 [scale=1.3]

The goals are to keep values readable and compact and to reduce flicker while still preserving the meaningful information.

Features

  • Unit-specific defaults for rounding
  • Optional fixed decimal-place rounding via scale
  • Optional forced significant figures via precision
  • Supports fractional precision values (e.g. precision=1.3 for value steps …, 5.7, 6, 6.3, 6.5, …)
  • Optional pre-rounding adjustments with div, mult, and offset
  • Optional forced output unit via unit (e.g. unit=ā€œ%ā€)
  • Optional SI conversion via si
  • Handles rounding and scaling of date-time, angle and data size values specially
  • Converts textual intervals like ā€œ1-2ā€ to midpoint values
  • Includes verbose/debug/testing options

Parameters

Parameter Type Description
precision number Force a given number of significant figures
scale number Force a maximum number of decimal places
div string Divide the input before rounding; supports suffixes such as 1K, 1M, 1Mi
mult number Multiply the input before rounding (like div)
offset number Add a constant offset before rounding
unit string Force the output unit, or ā€œ.ā€ to remove the unit
si boolean Convert the output to SI units
flicker boolean Add a random tiny fraction (to encourage state updates for debugging)
verbose boolean Enable verbose logging
testing boolean Enable testing mode

Recognized true values include: true, t, 1, yes, y, on
Everything else is treated as false.

Examples

Round to 3 significant figures:

config:js:significantDigits?precision=3

Show only whole numbers:

config:js:significantDigits?scale=0

Force the output unit to be °C (and set the temperature-specific value of significant digits)

config:js:significantDigits?unit=°C

Make the angle fit to a wind rose’s angles for N,NE,E,SE,S,SW,W,NW:

config:js:significantDigits?precision=2

Pre-scale the input by dividing by 1000:

config:js:significantDigits?div=1k

Pre-scale the input by dividing by 1024*1024:

config:js:significantDigits?div=1Mi

Strip any unit (making the result assignable to a Number:Dimensionless item)

config:js:significantDigits?unit=.

Example text-based configuration

Number:Temperature MyTemp ā€œTemperature [%.1f %unit%]ā€ { channel=ā€œā€¦ā€ [profile=ā€œtransform:JSā€, toItemScript=ā€œconfig:js:significantDigitsā€] }

Example with parameters:

... [profile=ā€œtransform:JSā€, toItemScript=ā€œconfig:js:significantDigits?precision=2.5&unit=°C&si=trueā€]

Design notes

  • Works best with inputs that indicate the unit, such as ā€œ12.34 %ā€ or ā€œ12.34 °Cā€. Only then can a unit-specific default be derived.
  • The general default is 2 significant figures if no unit is in the input and no parameter is set.
  • Uses a higher default precision around important real-world values such as 100 °C, 100 °F, 30l, 220 V, and 50 Hz.
  • Fractional precision values allow midpoint-like rounding behavior.
  • Includes optional Node.js testing support via the commented CommonJS export block in the file.

Installation from marketplace

  1. Install Significant Digits from the Marketplace.
  2. Go to the Thing channel link for the Item you want to smooth/round.
  3. Set the link Profile to SCRIPT ECMAScript.
  4. Put the script call into Profile Configuration:
    config:js:significantDigits
  5. Save the link.
  6. Only if the defaults are not sufficient, extend the ProfileConfiguration, e.g. : config:js:significantDigits&precision=2

(N.B.: There is a glitch in openHAB 5.1.3: When saving the configuration it asks you ā€œDo you want to leave this page without saving?ā€, but seems to save it anyway.)

Feedback welcome

Feedback on Github or in the community forum is especially welcome for:

  • additional openHAB units
  • smarter defaults for specific sensor types
  • additional SI conversions
  • compatibility feedback from openHAB 4.x / 5.x users

Please include:

  • example input and output
  • openHAB version/runtime
  • expected behavior

Source code

GitHub: GitHub - sheilbronn/significant-digits Ā· GitHub

Changelog

0.84

  • refactoring

0.83

  • contains a workaround for #20477
  • renamed parameter skew to offset (skew will be obsoleted)
  • refactoring

0.82

  • updated to latest version from github (includes fractional scaling of date-time)

0.8

  • initial release to add-on marketplace

Resources

{
  "uid": "significant",
  "label": "Significant",
  "type": "js",
  "configuration": {
    "function": "// significant.js\n//\n// Copyright (C) 2024,2025,2026  Stephen Heilbronner\n//\n// This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or // (at your option) any later version.\n//\n// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\n// significant.js is a OpenHAB transformation script to reduce incoming values to a unit-dependant, home-automation typical number of\n// significant figures in the SI unit system, plus some other features. The general default number of significant figures is 2, but is adapted \n// depending on the concrete OpenHAB unit type (e.g. temperature, speed, frequency, power, pressure, etc.)\n// It also foresees a slightly higher number for significance around special values, e.g. around 0 °C, 100 °C, 100 °F, 220 V, 50 Hz etc.\n\n// These script parameters are supported (all optional):\n// \"precision\" : force a given number of significant figures to round to (=override the unit specific default), use like ...?precision=3\n// \"scale\"     : a number of decimal places to round to: ...?scale=0\n// \"div\"       : a divisor to apply to the input value before rounding: ...?div=10 oder 1M or 1000 (useful since OpenHAB only supports one transformation at a time)\n// \"mult\"      : a multiplier to apply to the input value before rounding: ...?mult=1K oder 1M oder 1000 (similar to div)\n// \"unit\"      : a unit to force the output to: ...?unit=°C (unit=. will remove any unit passed in the input)\n// \"verbose\"   : one of {t|true|1|yes|y||false|no} to enable or disable logging: ...?verbose=true\n// \"testing\"   : {t|true|1|yes|y||false|no} to enable or disable testing of new features: ...?testing=y\n// \"offset\"    : a number to add to the input value before rounding,: ...?offset=0.5 (formerly: skew)\n\n// Not implemented (yet):\n// \"mode\"    : specify the rounding mode (e.g. \"up\", \"down\", \"half-up\", \"half-down\", \"half-even\", etc.) for the significant figure rounding, half-up is the default for now\n\n// Some global variables:\nvar verboseAsked     = false; // if default set to true here, script will always log some details about the transformation\nvar testingAsked     = false; // if default set to true here, script will always support be set to \"testing of new features\"\nvar debugEnabled     = false; // if default set to true here, script will always log debug messages\nvar verboseIncreased = false; // if true and verbose is true, then log even more details\n\nvar ident         = \"\";                 // an optional ident string to identify the invocation in the log messages\nvar scriptname = \"significant.js: \"; // will hold the script name for logging\nvar genericUnitPrefixes = Object.freeze([\"n\", \"µ\", \"m\", \"\", \"k\", \"M\", \"G\", \"T\", \"P\", \"E\", \"Z\", \"Y\", \"R\", \"Q\"]); // generic prefixes for normalization\nvar SCALE_AMOUNT_MAP = Object.freeze({\n    \"\": 1, k: 1e3, K: 1024, ki: 1024, M: 1e6, Mi: 1024 ** 2, G: 1e9, Gi: 1024 ** 3, T: 1e12, Ti: 1024 ** 4, P: 1e15, Pi: 1024 ** 5,\n});\nvar DATE_TIME_SCALE_MAP = new Map([\n    [\"paddings\", [4, 2, 2, 2, 2, 2, 3]],\n    [\"steps\", new Map([               // for fractional of ...\n        [-3, [1, 1,  2,  3,  4,  6]], // years, e.g. to 5y, 4y, 3y, 2y steps\n        [-2, [1, 1,  5,  8, 10, 15]], // months, e.g. to 6m, 4m, 3m, 2m steps\n        [-1, [1, 1,  2,  2,  3,  3]], // days, e.g. to 2d, 3d, 4d, 5d, 6d, 7d, 8d, 9d steps\n        [0,  [1, 2,  4,  6,  8, 12]], // hours, e.g. to 12h, 8h, 6h, 4h, 2h steps\n        [1,  [1, 5, 10, 15, 20, 30]], // minutes, e.g. to 30min, 20min, 15min, 10min, 5min steps\n        [2,  [1, 5, 10, 15, 20, 30]], // seconds, e.g. to 30s, 20s, 15s, 10s, 5s steps\n        [3,  [1, 100, 125, 250, 333, 500]] // milliseconds, e.g. to 500ms, 333ms, 250ms, 125ms, 100ms steps\n    ])],\n]);\n\n// Lookup tables for \"nice\" borders and middle values for significant figure rounding with fractional precisions:\n// when frac=0.5/mult=2 would be 100, 500, 1000.                 OK: 100, 500, 1000           with borders at 300, 700\n// when frac=0.4/mult=3 would be 100, 333, 667, 1000.        Better: 100, 300, 600, 1000      with borders at 200, 450, 800\n// when frac=0.3/mult=4 would be 100, 250, 500, 750, 1000.   Better: 100, 200, 500, 700, 1000 with borders at 150, 350, 600, 850\n// when frac=0.2/mult=5 would be 100, 200, 400, 600, 800, 1000   OK: 100, 200, 400, 600, 800, 1000   with borders at 150, 300, 500, 700, 900\nvar BORDERS1 = Object.freeze({ // for different precision fractions, i.e. a main (=integer) value larger than 0\n  2: [2.5, 7.5],\n  3: [1.5, 4.5, 8],\n  4: [1, 3.5, 6, 8.5],\n  5: [1, 3,  5, 7, 9]\n });\nvar MIDDLES1 = Object.freeze({\n  2: [0, 5, 10],\n  3: [0, 3, 6, 10],\n  4: [0, 2, 5, 7, 10],\n  5: [0, 2, 4, 6, 8, 10]\n });\nvar BORDERS0 = Object.freeze({ // for precision fractions with a main (=integer) value of 0\n  2: [3, 7.5],\n  3: [2, 4.5, 8],\n  4: [1.5, 3.5, 6, 8.5],\n  5: [1.5, 3  , 5, 7, 9]\n });\nvar MIDDLES0 = Object.freeze({\n  2: [1, 5, 10],\n  3: [1, 3, 6, 10],\n  4: [1, 2, 5, 7, 10],\n  5: [1, 2, 4, 6, 8, 10]\n });\n\n/* All the following openHAB units should be understood: \nhttps://www.openhab.org/docs/concepts/units-of-measurement.html (uunits):\nAcceleration: m/s²\nAmount of substance: mol, °dH\nAngle: rad, °, ' (arc-min), '' (arc-sec)\nArea: m²\nAreal density: DU\nCatalytic activity: kat\nData amount: bit, B, o\nData rate: bit/s, Mbit/s\nDensity: g/m³, kg/m³\nDimensionless: one, %, ppm, dB\nElectric: V, A, mA, F, C, Ah, S, S/m, H, Ī©\nEnergy: J, Ws, Wh, VAh, varh, cal, kWh\nForce: N\nFrequency/Rotation: Hz, rpm\nIlluminance: lx\nIrradiance/Intensity: W/m², µW/cm²\nLength: m (plus cm, mm, etc.)\nLuminous: lm, cd\nMagnetic: Wb, T\nMass: g, kg, lb (see imperial below)\nPower: W, kW, VA, var, dBm\nPressure: Pa, hPa, mmHg, bar, psi, inHg\nRadioactivity / radiation: Bq, Gy, Sv, Ci\nSolid angle: sr\nSpeed: m/s, km/h, mph, kn\nTemperature: K, °C, °F, color-temp: mired / MK⁻¹ (aka mirek)\nTime: s, min, h, d, week, y\nVolume: l, m³, gal (US)\nVolumetric flow: l/min, m³/s, m³/min, m³/h, m³/d, gal/min.\n\nImperial base symbols (also understood):\nin, ft, yd, ch, fur, mi, lea, gr (mass), inHg, psi, mph, °F, gal (US), gal/min.\n\nPrefixes:\nAll metric prefixes (mA, cm, kW, …) and binary prefixes (kiB, MiB, …) are supported—just prepend the symbol.\n*/\n\n// Frequently used Math functions:\nvar { abs, min, max, floor, round } = Math;\n\n// Now the main function called by OpenHAB when the transformation is invoked:\nfunction significantTransformed(i, opts = {}) {\n\n    let input   = String(i ?? \"\").trim(); // store the incoming value (and optionally unit name) to be transformed\n    let unit_i  = \"\"   // will carry the unit name in the input i (if any)\n    let strVerb = \"\"   // will carry the message string for logging\n    let matches = null // will be used for regex matches\n \n    // reset on each invocation (prevents cross-call leakage) - FIXME: check whether this is still needed in openHAB 5?\n    verboseAsked = false;\n    testingAsked = false;\n    debugEnabled = false;\n    verboseIncreased = false;\n\n    // more vars to carry values of the injected parameters:\n    var precisionAsked  = undefined // will carry the requested number of significant figures\n    var offsetAsked  = undefined  // will carry a requested offset to be applied to the input value after div'iding and before rounding\n    var divAsked     = undefined  // will carry a divisor to be applied to the input value before offset adding and before rounding\n    var multAsked    = undefined  // will carry a multiplier to be applied to the input value before offset adding and before rounding\n    var unitAsked    = undefined  // will carry the requested unit name\n    var scaleAsked   = undefined  // will carry the requested number of decimal places\n    var siAsked      = true       // will carry true if units shall be transformed to SI units (default=true), e.g. °C instead of °F\n    var flickerEnabled = false; // if default set to true here, output will always have a tiny, small random value added to distinguish it from the previous value. This helps debugging\n    var dryRunAsked  = false;   // if set to true, the script will not return the transformed value but rather the input value and log the would-be transformation result for testing purposes\n\n    // Defaults:\n    var precisionSeeked = 2  // 2 is the default for significant figures to round to, if no or unknown unit given\n    var scaleSeeked    = undefined\n    var angledivider   = 1 // for rounding angles to 90°, 45°, 22.5° steps\n    let precisionFound = 0.5 // will hold the number of significant figures found in the input value, use 0.5 in case of no meaningful figures (also for \"0.0\")\n    let unitPrefixes   = [ ]; // will hold an array of units for normalization if needed, set to undefined if normalization is to be suppressed\n    var alwaysLogFinal = true; // if set to true, always log the final output of the transformation (set this to true for first timers!)\n\n    // helper functions:\n    const l = v => String(v)[0];  //return first character of the string passed in\n    const transpose = (v, factor, newUnit) => [v * factor, newUnit];\n    const fmt = (v, u) => String(v) + (u ? \" \" + u : \"\");\n    const parseAndTrace = (raw, parser, label, rawLabel, valueFormatter = v => v) => {\n        if (raw == null) return undefined;\n        const parsed = parser(raw, label);\n        const tracedValue = valueFormatter(parsed);\n        strVerb += rawLabel ? ` ${rawLabel}=${raw} ${label.toUpperCase()}=${tracedValue}` : ` ${label.toUpperCase()}=${tracedValue}`;\n        return parsed;\n    };\n    const parseBool = v => !!setDefault(v, isTrue);\n    const FIVEPERCENT = 1.5; // 1.5 significant digits for a precision of 5% (e.g. for speed, power, pressure, etc.)\n    const ONEPERCENT  = 2; // 2 significant digits for a precision of 1% \n    const HALFPERCENT = ONEPERCENT + 0.5; // 2.5 significant digits for a precision of 0.5% \n    const ONEPROMILLE = ONEPERCENT + 1  ; // 3 significant digits for a precision of 0.1%\n\n    // debugit(`input=${input}`);\n\n    // Now parse all invocation parameters from the script call:\n    ident          = opts.id ?? \"\"\n    // parseAndTrace(opts.id, v => v, \"ident\")\n    verboseAsked   = parseAndTrace(opts.verbose, parseBool, \"verb\",   undefined, l) ?? verboseAsked;\n    testingAsked   = parseAndTrace(opts.testing, parseBool, \"test\",   undefined, l) ?? testingAsked;\n    dryRunAsked    = parseAndTrace(opts.dryRun,  parseBool, \"dryrun\", undefined, l) ?? dryRunAsked;\n    siAsked        = parseAndTrace(opts.si,      parseBool, \"si\",     undefined, l) ?? siAsked;\n    flickerEnabled = parseAndTrace(opts.flicker, parseBool, \"flick\",  undefined, l) ?? flickerEnabled;\n\n    precisionAsked = parseAndTrace(opts.precision, numOrUndef, \"prec\")  ?? precisionAsked; // alias prec to precision for backward compatibility\n    scaleAsked  = parseAndTrace(opts.scale,     numOrUndef, \"scale\") ?? scaleAsked;\n    offsetAsked = parseAndTrace(opts.offset ?? opts.skew, numOrUndef, \"offset\"); // backward compatibility: skew renamed to offset, but still supported\n    divAsked    = parseAndTrace(opts.div,  parseScaledNumber, \"div\", \"div\");\n    multAsked   = parseAndTrace(opts.mult, parseScaledNumber, \"mult\");\n    unitAsked   = parseAndTrace(opts.unit, v => v, \"unit\"); // if a unit is explicitly given, then force it, even if div or mult are present\n\n    // if (testingAsked) { verboseAsked = true ; }// if testing is asked, then also enable verbose logging\n    \n    // consolelog(`PARAMS: ${strVerb}  ${verboseAsked ? \"(VERBOSE)\" : \"\"} ${dryRunAsked ? \"(DRYRUN)\" : \"\"}`);\n\n    // Special case: If the input matches a DATE-TIME string: scale the time part to a number of *significant time parts *\n    // (days, hours, minutes, seconds, ...), e.g. \"2025-09-27T14:16:00.000+0200\" or \"2025-09-27T14:16:12.20+0200\"\n    const dtregex = /^(\\d{4})-([01]\\d)-([0123]\\d)(T| )([01]\\d|2[0-3]):([0-5]\\d):([0-5]\\d)(\\.\\d{1,3})([+-]\\d{4})$/ // e.g. 2024-10-13T02:30:03.000+0200 or 2024-10-13T02:30:03.20+0200\n    matches = input.match(dtregex);\n    if (matches) { // input is a timestamp with a numeric offset\n        if (precisionAsked !== undefined) { // n.b. only scale= is considered, not prec=. Warn if prec is set!\n            logit(`WARNING: precision=${precisionAsked} is currently ignored for DATE-TIME input, use scale=0..4 to specify the number of time parts to keep (0=days, 1=hours, 2=minutes, 3=seconds, 4=milliseconds). ${strVerb}`);\n        }\n        // debugEnabled = true; // only for testing purposes\n\n        const [ , yy, mo, dd, , hh, mm, ss, dotms, tzoffset ] = matches // slice the matches into the time parts\n        const ms = parseInt(dotms.slice(1).padEnd(3, \"0\"), 10)  // \".2\" -> 200, \".20\" -> 200, \".123\" -> 123\n\n        // time-date scale levels are: 0=days, 1=hours, 2=minutes, 3=seconds, 4=milliseconds\n        scaleAsked = clamp(scaleAsked ?? 3, [0, 4])  // clamp scaleAsked to [0..4] with a default of 3\n        const [scaleFloor, frac] = splitScaleValue(scaleAsked)\n        debugit(`  DATE-TIME INPUT: scaleAsked=${scaleAsked} > scaleFloor=${scaleFloor}, frac=${frac}`);\n        const step = DATE_TIME_SCALE_MAP.get(\"steps\").get(scaleFloor)?.[round(frac * 10)] ?? 1\n\n        // Divide the (fake, UTC'ed) time by (roundingUnitMs*step size), round and multiply it back. \n        // Rounds the local time to the desired step size\n        // without having to deal with the complexities of calendar arithmetic for months and years.\n        const roundingUnitMs = [24 * 3600 * 1e3, 3600 * 1e3, 60 * 1e3, 1e3, 1][Math.ceil(scaleAsked)] // choose rounding unit: day, hour, minute, second, millisecond\n        const timeMs = Date.UTC(+yy, +mo - 1, +dd, +hh, +mm, +ss, +ms) // local wall time -> UTC epoch ms (treat tzoffset as a fixed-offset zone)\n        const roundedTime = round(timeMs / (step * roundingUnitMs)) * (step * roundingUnitMs)\n        const output = new Date(roundedTime).toISOString().replace(\"Z\", tzoffset)\n     \n        var logMsg = `${input} > ${output}  stringdiff=${suffixDiff(input, output).aSuffix}  ${strVerb}`;\n        if ( !logit(`${ident ?? \"FINAL\"}: ${logMsg}`) && alwaysLogFinal) {\n            consolelog(`${ident ?? \"SIGNF\"}: ${logMsg} ${dryRunAsked ? \"(DRYRUN)\" : \"\"}`);\n        }\n\n        return dryRunAsked ? input : output // early return for a (transformed) date-time string\n    }\n    debugit(`input=${input}, match date regex: ${(matches ? \"YES\" : \"NO\")}`);\n    \n    // Now, parse the value from the input value (and the unit if any):\n    var value = parseFloat(input);\n    var origValue = 0\n    var newValue  = 0\n    var origUnit  = \"\"\n    var finalUnit  = \"\"\n    if (isNaN(value)) { // check for special cases of NaN or non-numeric input, such as \"0-1\", \"1-2\", etc.\n        // treat special input cases \"0-1\", \"1-2\", \"2-3\" as midpoints, e.g. as from https://www.openhab.org/addons/bindings/dwdpollenflug\n        matches = input.match(/(-?\\d+(?:\\.\\d+)?)\\s*-\\s*(-?\\d+(?:\\.\\d+)?)/)\n        if (!matches) { // special case for ranges like \"0-1\", \"1-2\", \"2-3\"\n            warnit(`FINAL: \"${input}\" is NaN.`)\n            return input // take an early exit for NaN non-numeric values, and return the whole input as is.\n        }\n        value = (parseFloat(matches[1]) + parseFloat(matches[2])) / 2  // ... and no unit allowed in this case\n        logit(`input=\"${input}\" treated as midpoint value ${value}.`)\n        origValue = matches[1] + \"-\" + matches[2]\n    } else {\n        matches = input.match(/\\s+(.*)$/)\n        if (matches) { // consider the stuff behind a space to be the unit.\n            unit_i = matches[1]\n            origUnit = unit_i\n        }\n        origValue = value // preserve original value for later logging and comparison, FIXME: should be saved before parseFloat\n\n        // if (value>777 && value<778 && value===6.777) { // trigger debugging\n        if (isWithin(value, [6.776, 6.777], [328000, 0])) {\n            debugEnabled = true; // only for testing purposes\n            verboseAsked = true;\n            verboseIncreased = true;\n            logit(`  DEBUG: ${value} triggers debugging.  ${strVerb}`);\n        }\n    }\n\n    // Now determine the number of significant figures of the original INPUT value (i.e. those figures before AND after the decimal point):\n\n    // extract to matches the first numeric token: supports \"12.3 °C\", \"-.0450\", \"1.20e3\", etc.\n    matches = input.trim().match(/^[+-]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][+-]?\\d+)?/)\n    if (!matches) {\n        return input;  // should not happen, since we successfule parsed a float before\n    }\n\n    const mant = matches[0].split(/[eE]/)[0].replace(/^[+-]/, \"\"); // only the mantissa, no sign\n    let digits = mant.replace(\".\", \"\"); // for counting digits\n\n    if (/[1-9]/.test(digits)) {  // this should be the normal case:\n        precisionFound = digits.replace(/^0+/, \"\").length\n    }\n    debugit(`input: origValue=${origValue} ${origUnit} (${precisionFound}) ${strVerb}`);\n\n    // Now deal with the requested modifications of the input value before rounding:\n    if ( unitAsked != null) {\n        if (unitAsked === \"\" || unitAsked === \".\") {\n            value *= {KiB:1024, MiB:1024**2, GiB:1024**3, TiB:1024**4, PiB:1024**5}[unit_i] ?? 1 // convert common IEC units to bytes (no unit).\n        }\n        logit(` unit: \"${unit_i || \"(none)\"}\" > \"${unitAsked || \"(none)\"}\"  ${strVerb}`) ;\n        unit_i = (unitAsked === \".\") ? \"\" : unitAsked ;  // force unit\n    }\n\n    // Now the main part: Modify precision defaults depending on the unit coming in or asked for:\n    switch (unit_i) {\n    case \"°F\": // Temperature\n        if (!siAsked) {\n            precisionSeeked = (abs(value)<3) ? 1.3 : isWithin(value, [190, 215]) ? ONEPROMILLE : HALFPERCENT\n            break\n        }\n        [value, unit_i] = transpose(value-32, 5/9, \"°C\") ; // convert and fallthrough to °C ...\n    case \"°C\":\n        // precisionSeeked = (abs(value) < 1) ? 0.3 : (abs(value) < 10) ? 1.5 : isWithin(value, [100, 120], [36, 42]) ? 3 : HALFPERCENT\n        precisionSeeked = isWithin(value, [100, 120], [36, 42]) ? ONEPROMILLE : (abs(value) < 10) ? max(magniTude(value), 1.5) : HALFPERCENT\n        scaleSeeked = (abs(value) < 0.1) ? 2 : (abs(value) < 2) ? 1 : 0\n        break;\n    case \"K\":\n        precisionSeeked  = max(-1, magniTude(value)) // more significant figures for higher temperatures\n        precisionSeeked  = clamp(precisionSeeked, [1, 3]) // clamp it to 1..3\n        precisionSeeked += isWithin(value, [0, 10], [273, 2], [273+98, 3], 0.7)  // increase prec for special cases around water freezing and boiling point\n        logit(`Kelvin hit: value=${value} ${unit_i} precSeeked=${precisionSeeked} ${strVerb}`);\n        break;\n\n    // Speed\n    case \"kn\":\n        [ value, unit_i ] = transpose(value, 1.15078, \"mph\"); // convert kn to mph\n    case \"mph\":\n        // precisionSeeked = (abs(value) < 10) ? 1.5 : (abs(value) < 30) ? 1.3 : 1.5\n        precisionSeeked = 1.5 + isWithin(value, [10, 30], 0.2) \n        scaleSeeked = 0\n        if (siAsked) {\n            [ value, unit_i ] = transpose(value, 1.609344, \"km/h\") ; // convert mph to km/h (prefer km/h over m/s for typical weather station wind speed)\n        }\n        break\n    case \"m/s\":\n        [ value, unit_i ] = transpose(value, 3.6, \"km/h\") ; // fallthrough to km/h ...\n    case \"km/h\":\n        precisionSeeked = (abs(value) < 5) ? 1 : (abs(value) < 20) ? FIVEPERCENT : ONEPERCENT\n        break;\n    case \"in/h\":\n        if (siAsked) {\n            [ value, unit_i ] = transpose(value, 25.4, \"mm/h\") ; // convert in/h to mm/h\n        }\n        break;\n\n    // Length, distance, and precipitation\n    case \"yd\":\n        [ value, unit_i ] = transpose(value,  3, \"ft\") ; // convert yd to ft\n    case \"ft\":\n        [ value, unit_i ] = transpose(value, 12, \"in\") ; // fallthrough to in ...\n    case \"in\":\n        if (! siAsked) {\n            precisionSeeked = ONEPERCENT\n            break\n        }\n        [ value, unit_i ] = transpose(value, 2.54, \"cm\") ; // fallthrough to cm ...\n    case \"cm\": // typical for precipitation\n        [ value, unit_i ] = transpose(value, 10, \"mm\") ; // fallthrough to mm ...\n    case \"mm\": // typical for precipitation\n        precisionSeeked = ONEPERCENT - isWithin(value, [0, 80], 0.2); // a little less precision when <80mm, probably precipitation\n        break;\n    case \"m\": // typical for total precipitation\n        precisionSeeked = isWithin(value, [0, 0.08]) ? FIVEPERCENT : HALFPERCENT// decrease precision for less than 0.08m, probably precipitation\n        unitPrefixes = [ \"µ\", \"m\", \"\", \"k\" ]; // limit the normalization vector for m to µ, m, k (no need for larger units for length in home automation)\n        logit(`${unit_i} hit: value=${value} ${unit_i} (ORIG: ${origValue} ${origUnit})  ${strVerb}`);\n        break;\n\n    // Durations:\n    case \"d\":\n    case \"h\":\n        precisionSeeked = 99\n        break\n    case \"min\": // Time\n        precisionSeeked  = magniTude(value, +1)  // precision starts 1 for 1-digit values, 2 for 2-digit values, etc.\n        precisionSeeked -= (abs(value) > 12*60) ? 1 : 0 // reduce precision by 1 for values > 12 hours\n        break\n    case \"s\":\n        // if (verboseAsked) { debugEnabled = true; } // enable only for testing purposes\n        unitPrefixes = [ \"n\", \"µ\", \"m\", \"\" ]; // limit the normalization vector for s to µ, m, (no need for larger units for time in home automation)\n        if (isWithin(value, [1, 1000])) {\n            precisionSeeked = FIVEPERCENT // typical for song lengths running on Amazon Echo\n        } else {\n            precisionSeeked = min( ONEPERCENT, magniTude(value))// precision starts at 2 for <1 and 3 for >1000\n        }\n        break\n\n    // Weights: lb, kg, g\n    case \"lb\":\n    case \"lbs\": // support both \"lb\" and \"lbs\" for pounds, since both might be used\n        if (! siAsked) {\n            precisionSeeked = (abs(value) < 400) ? magniTude(value, +1.8) : ONEPROMILLE  // more significant figures for higher weights, but reduce precision for very high weights \n            break\n        }\n        [ value, unit_i ] = transpose(value, 0.4536, \"kg\") ; // convert lbs to kg and fallthrough to kg ...\n    case \"kg\":\n        // special consideartion for body weights\n        precisionSeeked = (abs(value) < 200) ? magniTude(value, +1.8) : ONEPROMILLE  // more significant figures for higher weights, but reduce precision for very high weights\n        scaleSeeked = 1\n        break\n    case \"g\":\n        unitPrefixes = genericUnitPrefixes\n        precisionSeeked = (abs(value) < 1000) ? magniTude(value, -0.2) : ONEPROMILLE  // more significant figures for higher weights, but reduce precision for very high weights\n        break\n\n    // Pressures: Pa, hPa, mmHg, mbar, psi, inHg, bar,\n    case \"psi\":\n    case \"inHg\":\n    case \"mmHg\":\n        if (! siAsked) {\n            precisionSeeked = ONEPROMILLE\n            switch (unit_i) {\n            case \"psi\":\n                precisionSeeked += isWithin(value, [14.7, 0.7], 0.5)  // more precision around typical atmospheric pressure at sea level (14.7 psi) for weather station pressure readings in imperial units, but not for higher pressures such as car tire pressure at around 30-35 psi\n                break\n            case \"inHg\":\n                precisionSeeked += isWithin(value, [30,  1.5], 0.5)  // more precision for athmospheric pressure\n                break\n            case \"mmHg\":\n                precisionSeeked += isWithin(value, [760, 10], 0.5) // more precision for typical atmospheric pressure around 760 mmHg\n                break\n            }\n            break\n        }\n        // convert to SI and fallthrough to hPa ...\n    case \"mbar\":\n    case \"hPa\": // Pressure\n        switch (unit_i) {\n        case \"psi\": // psi -> hPa\n            [ value, unit_i ] = transpose(value, 68.94757, \"hPa\") ; // exact factor: 1 psi = 68.94757293168 hPa\n            break\n        case \"inHg\": // inHg -> hPa\n            [ value, unit_i ] = transpose(value, 33.86386, \"hPa\") ; // exact factor: 1 inHg = 33.86388157895 hPa\n            break\n        case \"mmHg\": // mmHg -> hPa\n            [ value, unit_i ] = transpose(value, 1.33322,  \"hPa\") ; // exact factor: 1 mmHg = 1.3332236842105263 hPa\n            break\n        }\n        precisionSeeked = isWithin(value, [800, 1050]) ? magniTude(value, +1.5) : ONEPROMILLE // special case for typical pressure around 1000 hPa, but reduce precision for higher pressures such as car tire pressure at around 200-300 kPa (2000-3000 hPa)\n        break\n    case \"Pa\": // Pressure\n        precisionSeeked = isWithin(value, [80000, 105000]) ? magniTude(value, -0.5) : ONEPROMILLE // special case for typical pressure around 100000 Pa, but reduce precision for higher pressures such as car tire pressure at around 200-300 kPa (200000-300000 Pa)\n        break\n    case \"bar\":\n        precisionSeeked = isWithin(value, [0.8, 1.05]) ? magniTude(value, +3.5) : ONEPROMILLE // special case for typical pressure around 1 bar, but reduce precision for higher pressures such as car tire pressure at around 2-3 bar\n        break\n\n    // Power, Energy: Ws, Wh, VAh\n    case \"Wh\":\n    case \"VAh\":\n        unitPrefixes = genericUnitPrefixes\n        // normalizeVector = [ \"n\", \"µ\", \"m\", \"\", \"k\" ]; // limit the normalization vector for energy to Wh and kWh (no need for larger units for energy in home automation)\n        // examples from AVM DECT energy meter: 821312 Wh\n        // fallthrough to kWh to reduce precision for Wh\n    case \"kWh\":\n        precisionSeeked = magniTude(value, +1.3); // default precision is 1.3 for 1-digit values, 2.3 for 2-digit values, etc.\n        if (unit_i === \"Wh\") precisionSeeked -= 2; // reduce precision by 2 for Wh\n        precisionSeeked = max(FIVEPERCENT, precisionSeeked) // .. but at least 5%\n        scaleSeeked = 1\n        break\n\n    case \"J\":\n    case \"cal\": // Energy: J, varh, cal\n        unitPrefixes = genericUnitPrefixes\n        scaleSeeked = 0\n        break\n\n    case \"rpm\": // Rotation\n        precisionSeeked = 3\n        break\n    case \"Hz\": // Frequency/Rotation\n        unitPrefixes = genericUnitPrefixes;\n        precisionSeeked = ONEPERCENT + isWithin(value, [50, 0.3], [60, 0.2], [400, 10], 0.8) // higher precision for power line frequency\n        break;\n\n    // Electric: V, A, mA, F, C, Ah, S, S/m, H, Ī©\n    case \"A\":\n    case \"Ah\":\n    case \"Ī©\":\n        unitPrefixes = genericUnitPrefixes;\n    case \"kA\":\n    case \"mA\":\n    case \"µA\":\n    case \"nA\":\n    case \"mAh\":\n    case \"kAh\":\n    case \"mS\":\n    case \"µS\":\n    case \"S\": // Conductance\n    case \"S/m\": // Conductance density\n    case \"C\": // Electric charge\n    case \"F\": // Capacitance\n    case \"H\": // Inductance\n    case \"Wb\": // Magnetic flux\n    case \"kĪ©\":\n    case \"MĪ©\":\n    case \"Gy\":\n    case \"Sv\":\n        precisionSeeked = ONEPERCENT\n        break\n\n    case \"W\": // Power\n        unitPrefixes = genericUnitPrefixes;\n    case \"kW\":\n    case \"MW\":\n    case \"dBm\":\n        precisionSeeked = (abs(value) < 100) ? FIVEPERCENT : ONEPERCENT\n        break\n\n    case \"W/m²\": // Irradiance/Intensity\n        unitPrefixes = genericUnitPrefixes;\n    case \"µW/cm²\":\n        precisionSeeked = FIVEPERCENT + isWithin(value, [0, 10], +0.3) // higher precision for typical irradiance values around 10 µW/cm² (e.g. for solar radiation on a cloudy day) and around 100 µW/cm² (e.g. for solar radiation on a sunny day)\n        break\n\n    case \"V\": // Voltage\n        unitPrefixes = genericUnitPrefixes;\n        precisionSeeked = 2.7 + isWithin(value, [110, 5], [230, 20], [400, 40], +0.1)\n        // consolelog(`Voltage hit: value=${value} ${unit_i} isWithin=${isWithin(value, [110, 5], [230, 20], [400, 40], +0.1)} ${roundTo(2.7+0.1, 5)}`);\n        break;\n\n    // Volume: l, m³, gal (US)\n    // Volumetric flow: l/min, m³/s, m³/min, m³/h, m³/d, gal/min. :\n    case \"gal\":\n    case \"gal/min\":\n        if (! siAsked) {\n            precisionSeeked = 2.8\n            break\n        }\n        [ value, unit_i ] = transpose(value, 3.7854, unit_i.replace(\"gal\", \"l\")) ; // exact factor: 1 gal (US) = 3.785411784 liters\n    case \"l\":\n    case \"l/min\":\n    case \"m³\":\n    case \"m³/s\":\n    case \"m³/min\":\n    case \"m³/h\":\n    case \"m³/d\":\n        precisionSeeked = (unit_i===\"l\" && isWithin(value, [8, 500])) ? ONEPROMILLE : 2.8 // special case for typical volume around 8..300 liters (e.g. fuel tank)\n        break\n\n    case \"mi\": // Long distances\n        if (siAsked) {\n            [ value, unit_i ] = transpose(value, 1.609344, \"km\") ; // exact factor: 1 mi = 1.609344 km\n        }\n        // fallthrough to kilometers and use same default precision ...\n    case \"km\":\n        precisionSeeked = HALFPERCENT // use same default for mi and km\n        break;\n\n    case \"mg/m³\": // Typical for air quality\n    case \"µg/m³\":\n        precisionSeeked = HALFPERCENT\n        break;\n\n    case \"Mbit/s\": // Data rates\n    case \"kbit/s\":\n    case \"bit/s\":\n        precisionSeeked = ONEPERCENT\n        unitPrefixes = undefined; // don't normalize data rates\n        break;\n    case \"Mbit\": // Memory sizes\n    case \"kbit\":\n    case \"bit\":\n    case \"TiB\":\n    case \"GiB\":\n    case \"MiB\":\n    case \"KiB\":\n    case \"B\":\n        // debugEnabled = true; // FIXME\n        alwaysLogFinal = true; // FIXME: do not always log final (unless verboseAsked) if div with SCALING is used, to avoid log flooding with swap size logging\n        precisionSeeked = ONEPERCENT\n        unitPrefixes = undefined; // don't normalize memory sizes\n        break;\n\n    case \"ppm\":\n    case \"ppb\":\n    case \"ppt\":\n    case \"dB\":\n    case \"mol\":\n    case \"kat\":\n        precisionSeeked = HALFPERCENT\n        break;\n\n    case \"percent\":\n        unit_i = \"%\"; // treat option unit \"percent\" as \"%\" too - might avoid problems with the URL encoding of \"%\"\n    case \"%\": // Percent\n        precisionSeeked = FIVEPERCENT + isWithin(value, [0,5], [89,102], +0.3) // be more precise closer to 0% or to 100%\n        unitPrefixes = undefined; // don't normalize percentages\n        break;\n\n    case \"°\": // Angle\n        precisionSeeked = 2\n        unitPrefixes = undefined; // don't normalize angle values\n        break;\n\n    default: // Unknown unit -> use the default precision defined above\n        // precisionSeeked = 2 was set above as default\n        if (unit_i !== \"\" && (testingAsked || verboseAsked)) {\n            warnit(`Unknown input unit: \"${unit_i}\" ${strVerb}, value=${value} prec=${precisionSeeked}/${precisionAsked}, please contact author and/or set it with unit=${unit_i} parameter.`)\n        }\n        break\n    }\n\n    if (precisionAsked != null ) {\n        if (precisionAsked === 0) {\n            warnit(`Requested precisionAsked is 0, ignoring it.`);\n        } else {\n            precisionSeeked = precisionAsked; // use precisionAsked instead of any unit-specific defaults\n        }\n    }\n    if (scaleAsked != null) {\n        scaleSeeked = scaleAsked;\n    }\n\n    precisionSeeked = roundTo(precisionSeeked, 5) // needed to avoid 2.7 + 0.1 = 2.8000003. Rounding to 5 decimal places should prevent floating point issues.\n    var targetPrecisionSeeked = precisionSeeked;\n    finalUnit = unit_i\n    value += (offsetAsked ?? 0)  // ... also apply any offset, if given\n\n    if (unit_i === \"°\") {  // handle angle value more in terms of quadrants, not in the sense of significant figures, since angles are often more meaningful when rounded to 90°, 45°, 22.5° steps, etc. depending on the precisionSeeked (1, 2, 3, etc.) and not in terms of significant digits.\n        value = ((value % 360) + 360) % 360 // normalize into [0,360): adding 360 handles negative angles\n        // prec=1: 90°; 315    <-> 45    --> 0°, 45 <-> 135 --> 90°, 135 <-> 225 --> 180°, 225 <-> 315 --> 270°\n        // prec=2: 45°: 337.5  <-> 22.5  --> 0°, 22.5  <-> 67.5  --> 45°,   67.5  <-> 112.5 --> 90°,  112.5 <-> 157.5 --> 135°\n        // prec=3: 30°: 345    <-> 15    --> 0°, 15    <-> 45    --> 30°,   45    <->  75   --> 45°,   75   <-> 105   --> 90°, 105   <-> 135   --> 120°, 135   <-> 165   --> 135°, 165   <-> 195   --> 180°, 195   <-> 225   --> 210°, 225   <-> 255   --> 225°, 255   <-> 285   --> 270°, 285   <-> 315   --> 300°, 315   <-> 345   --> 315°\n        // prec=4:22.5: 348.75 <-> 11.25 --> 0°, 11.25 <-> 33.75 --> 22.5°, 33.75 <-> 56.25 --> 45°, 56.25 <-> 78.75 --> 67.5°\n        // prec=5: 15°: 352.5  <-> 7.5   --> 0°, 7.5   <-> 22.5  --> 15°, 22.5  <-> 37.5  --> 30°, 37.5  <-> 52.5  --> 45°, 52.5  <-> 67.5  --> 60°, 67.5  <-> 82.5  --> 75°, 82.5  <-> 97.5 --> 90°, ... and so on, with steps of (180/prec)° and borders at (360/(2*prec))° + n*(360/prec)°\n        // prec=6:11,25°: 354.375 <-> 5.625 --> 0°, 5.625 <-> 16.875 --> 11.25°, 16.875 <-> 28.125 --> 22.5°, 28.125 <-> 39.375 --> 33.75°, ... and so on, with steps of (180/prec)° and borders at (360/(2*prec))° + n*(360/prec)°\n        // therefore: prec=1 -> 90° steps, prec=2 -> 45° steps, prec=3 -> 22.5° steps\n        // for prec==1 need to divide by 90, round, and multiply by 90, but add\n        // angledivider = 90 / floor(precisionSeeked)\n        angledivider = [ 90, 45, 30, 22.5, 15, 11.25 ][floor(precisionSeeked) - 1] ?? 45 // fallback to 22.5 steps for precisionSeeked > 5\n        var v = roundTo(value / angledivider, 5) // round to 5 decimal places to avoid rounding errors\n        if (precisionSeeked < 5) { // wind rose/for larger sectors chosen differently....\n            newValue = floor(v+0.5) * angledivider  // good for odd precisionSeeked (1=90°), more compass-like (2=45°)\n        } else {\n            newValue = floor(  v  ) * angledivider  +  (angledivider/2)   // good for even precisionSeeked (2=45°, 4=22.5°)\n        }\n        newValue = newValue % 360 // normalize into range [0..360)\n        debugit(`Angle: v=${v}, value=${value}° (${compassAngleToDir(value,precisionSeeked)}), newValue=${newValue}° (${compassAngleToDir(newValue,precisionSeeked)}), anglediv=${angledivider} ${strVerb}`);\n    } else if (value === 0) {\n        alwaysLogFinal = false || verboseAsked; // avoid logging final zero values,  unless verboseAsked\n    } else { // all other units: round to significant figures with the given precisionSeeked, and apply any scaling if needed\n        if (divAsked != null) {\n            value /= divAsked // apply the divisor if given\n            logit(`DIV: divAsked=${divAsked} for new value=${value} unit=${unit_i} ${strVerb}`);\n        }\n        if (multAsked != null) {\n            value *= multAsked // apply the multiplier if given\n            logit(`MULT: multAsked=${multAsked} for new value=${value} unit=${unit_i} ${strVerb}`);\n        }\n\n        // Now take care of all the significant figure rounding...\n        var [precisionSeeked, frac] = splitScaleValue(precisionSeeked) // split any fractional part from the precisionSeeked (1 digit)\n        var magnit = magniTude(value)  // magnitude is 0 for 1-9, 1 for 10-99, 2 for 100-999 and so on....\n        var power = Math.pow(10, magnit - precisionSeeked + 1) // when prec=1: power is 100 for prec=2 and value=349 (magnit=2)\n        debugit(`=== value=${value} ${unit_i} Seeked=${precisionSeeked} AND Found=${precisionFound}, magnit=${magnit} power=${power} frac=${frac} ${strVerb}`);\n        // ... and do the magic for the fractional part in precisionSeeked:\n        if (frac > 0 && precisionFound > precisionSeeked) {\n            if (frac === 0.1) {\n                warnit(`Requested precision ${precisionSeeked} + 0.1, same as prec=${precisionSeeked + 1}, consider using integer precisions only.`);\n            }            \n            debugit(` == Rounding value=${value} with frac=${frac}, precisionSeeked=${precisionSeeked} ${strVerb}`);\n            let mult = clamp(Math.ceil(1/frac), [2, 5]) // mult is 2 for frac=0.5, 3 for frac=0.4, 4 for frac=0.3, 5 for frac=0.2\n            let sign = Math.sign(value)\n            newValue = sign * floor(abs(value) / power) * power // cut off to the integer part with the given precision\n            let normalizedvalue = sign * (value-newValue) / Math.pow(10, magnit - precisionSeeked)  // normalize the value to be between 1 and 10\n            debugit(` normalizedvalue=${normalizedvalue} (value=${value}, newValue=${newValue}, sign=${sign})`);\n\n            // for a certain mult: iterate the borders to find the right border for the normalized value, and then use the corresponding middle value as the rounded part to add to the newValue:\n            let borders = BORDERS0[mult]; // initialize borders for values for main figures equal to 0\n            let middles = MIDDLES0[mult];\n            if (abs(newValue) < 1e-10) { // treat any rounding errors as 0\n                // Distinguish the case where the main rounded part is effectively zero from non-zero values.\n                debugit(` newValue=${newValue}: Choose MIDDLES0`);\n            } else {\n                borders = BORDERS1[mult];  // for precision fractions with a main value different from 0\n                middles = MIDDLES1[mult];\n                debugit(` newValue=${newValue}: Choose MIDDLES1`);\n            }\n            let i = 0;\n            debugit(` Finding rounded value for normalizedvalue=${normalizedvalue}, mult=${mult} (frac=${frac}) in borders=${borders}`);\n            while (i < borders.length && normalizedvalue > borders[i]) \n                i++;\n            const rounded = middles[i]\n            newValue = newValue + sign * rounded * Math.pow(10, magnit - precisionSeeked) // add the rounded part to the newValue\n            newValue = Number(newValue.toPrecision(precisionSeeked+1))\n            debugit(` ROUNDED=${rounded} for newValue=${newValue} BECAUSE border[${i}]=${i === 0 ? 0 : borders[i-1]} for mult=${mult} (frac=${frac}) i=${i}`);\n        } else {\n            newValue = Number(value.toPrecision(precisionSeeked)) // use toPrecision to round to the given number of significant figures, but convert back to Number to avoid trailing zeros\n            // debugit(` No rounding, just toPrecision(${value},${precisionSeeked}) > newValue=${newValue} ${strVerb}`);\n        }\n\n        // might scale the unit by changing the dimension...\n        // FIXME: really scale 1276540 Wh to 1.2765 MWh?  Wouldn't 1276.5 kWh be nicer for readability?\n        let scale3 = Math.trunc(magniTude(newValue)/3)\n        if (scale3 !== 0 && unitPrefixes != null) { // magnitude could even be 1 larger...  // FIXME\n            // convert number to scientific notation and back to avoid signalling unneeded significant figures\n            // only normalize with multiples of 3 and use the normalizeVector if given:\n            // debugEnabled = true; // FIXME: enable only for testing purposes\n\n            let magnit = magniTude(newValue)\n            const baseUnitIndex = unitPrefixes.indexOf(\"\") // find the index of the (empty) base unit in the normalizeVector\n            if (unitPrefixes[baseUnitIndex + scale3]) {\n                // adapt value and unit dimension according to the amount of scale3\n                newValue = newValue / Math.pow(10, 3*scale3)\n                finalUnit = unitPrefixes[baseUnitIndex + scale3] + unit_i\n                if (scaleSeeked != null) { // covers the case where scaleSeeked is 0 or undefined\n                    scaleSeeked += 3*scale3 // adapt scaleSeeked according to the amount of scaling applied, since the number is now smaller and needs less decimals\n                }\n                debugit(` NORMALIZE: scale3=${scale3} * 3 applied to magnit=${magnit}: newValue=${newValue} finalUnit=${finalUnit}`);\n                scale3=0 // since we chose the fitting unit\n            } else {\n\t\t        // debugEnabled=true;\n                debugit(` NORMALIZE SKIPPED: scale3=${scale3}*3 NOT applied to magnit=${magnit}: no entry in normalizeVector=${unitPrefixes}`);\n                let movedecimals = min( precisionFound, Math.ceil(precisionSeeked+frac)) - 0\n                newValue = newValue.toExponential( movedecimals )  // newValue as string in scientific notation with precisionFound significant figures\n                \n                // NO MORE calculations possible here >> BE CAREFUL, since newValue now becomes a STRING!\n                // remove unnecessary stuff in the fractional part:\n                newValue = newValue.replace(/\\.0+([eE])/, \"$1\")              // any trailing .0+ before the 'e'\n                newValue = newValue.replace(/(\\.\\d*?[1-9])0+([eE])/, \"$1$2\") // any trailing 0's before the 'e'\n                newValue = newValue.replace(/[eE]\\+0$/, \"\")                  // remove an \"e+0\" at the end\n                newValue = newValue.replace(/([eE])\\+/, \"$1\") // FIXME: remove \"+\" from exponent, WORKAROUND for a bug in openHAB (#20477)\n                debugit(` CUT figures: magnitude=${magnit}, precisionSeeked=${precisionSeeked} > newValue=${newValue} finalUnit=${finalUnit}`);\n                scaleSeeked = null;  // Ignore any scaleSeeked, since we already cut the number to the right amount of significant figures\n                // do not apply any more scaling, since newValue is now a string with the right number of significant figures, and scaling would add unneeded zeros again\n            }\n        } else {\n            // debugit(` No cutting of extra significant figures: precisionFound=${precisionFound} >= precisionSeeked=${precisionSeeked}`);\n            newValue += testingAsked ? Number.EPSILON : 0 // add a very small value to avoid rounding errors in the next step\n            if (flickerEnabled) {\n                const denom = (Number.isFinite(power) && power !== 0) ? power : 1;\n                const flickerAmount = (round(Math.random() * 100) / 100) * 0.0001 / denom;\n                logit(`FLICKER: denom=${denom}, flickerAmount=${flickerAmount}: origValue=${fmt(origValue, origUnit)} -> newValue=${fmt(newValue, unit_i)} ${strVerb}`);\n                newValue += flickerAmount;\n            }\n        }\n        if (scaleSeeked != null) {\n            // logit(`SCALE: roundTo(newValue=${newValue}, scaleSeeked=${scaleSeeked})`);\n            newValue = roundTo(newValue, scaleSeeked)\n            // logit(`SCALE: scaleSeeked=${scaleSeeked} resulted in newValue=${newValue} unit=${unit_i} ${strVerb}`);\n        }\n        debugit(` newValue=${newValue}, precisionSeeked=${precisionSeeked}  ${strVerb}`);\n    }\n\n    var logMsg = `${input} (${precisionFound}) > ${parseFloat(value.toPrecision(8))} ${unit_i} > ${fmt(newValue, finalUnit)} (${targetPrecisionSeeked}${scaleSeeked===undefined ? \"\" : \" sc=\" + scaleSeeked})  ${strVerb}`;\n    if ( !logit(`${ident ?? \"FINAL\"}: ${logMsg}`) && alwaysLogFinal) {\n        consolelog(`${ident ?? \"SIGNF\"}: ${logMsg} ${dryRunAsked ? \"(DRYRUN)\" : \"\"}`);\n    }\n\n    if (dryRunAsked || (testingAsked && new Date().getSeconds() % 5 === 0)) { // at every full 5 seconds, return the original value for testing purposes\n        const out = fmt(origValue, origUnit);\n        logit(`RETURNing origValue: ${out}`);\n        return out;\n    }\n    return fmt(newValue, finalUnit);\n}\n\n// -------------------------\n// helper functions\n// -------------------------\n\n// suffixDiff(): return the differing suffixes of two strings a and b\nfunction suffixDiff(a, b) {\n  a = String(a ?? \"\");\n  b = String(b ?? \"\");\n\n  let pos = 0;\n  while (pos < min(a.length, b.length) && a[pos] === b[pos]) pos++; // find position of first differing character\n\n  return { aSuffix: a.slice(pos), bSuffix: b.slice(pos) };\n}\n\n// isWithin(): check if value is within any of the given ranges - ranges can be given as [min, max) or as [center, halfwidth]\n// if last element of the parameter list is not an array, it shall be the return value for true and 0 for false.\n// A range will be interpreted as [0, range) for positive values and (range, 0] for negative values\nfunction isWithin(value, ...ranges) {\n    if (!Number.isFinite(value)) return false;\n    let returnValue = null; // default return value for true if no numeric return value is given in the ranges\n\n    if (ranges.length > 0 && !Array.isArray(ranges[ranges.length - 1])) {\n        returnValue = ranges.pop(); // use last element as return value for true and 0 for false\n    }\n    const hasMatch = ranges.some(([a, b]) => {\n        if (a <= b) { // default return value is true for values in the range [min, max) and false otherwise\n            return (a <= value && value < b)\n        } else {\n            return (a-b <= value && value <= a+b); // range is [center - halfwidth, center + halfwidth]\n        }\n    });\n    return returnValue === null ? hasMatch : (hasMatch ? returnValue : 0);\n}\n\n// clamp(): clamp a number x to the range [min, max]\nfunction clamp(x, [minVal, maxVal]) {\n    return min(maxVal, max(minVal, x))\n}\n\n// roundTo(): round a number x to a given number of decimals (return a number)\nfunction roundTo(x, decimals) {\n    const factor = 10 ** decimals;\n    return round(x * factor) / factor; // if necessary, add NUMBER.EPSILON to x\n}\n\n// magniTude(): return the order magnitude of any number x (-1 for 0.1..0.9, 0 for 1..9, 1 for 10..99, 2 for 100..999, etc.)\n// second, optional parameter a to be added to result before returning\nfunction magniTude(x, a = 0) {\n    if (x === 0) return a;\n    return floor(Math.log10(abs(x))) + a;\n}\n\n// isTrue(): interpret a given string as boolean true/false, and return the boolean value\nfunction isTrue(s) {\n    if (s === null || s === undefined) return false\n    if (s === true || s === false) return s   // handle booleans directly\n    s = String(s ?? \"\").toLowerCase().trim()\n    return (s === \"t\" || s === \"true\" || s === \"yes\" || s === \"y\" || s === \"on\" || s === \"1\")\n}\n\n// consolelog(): log to console.log if available, otherwise use JS print()\nfunction consolelog(s) {\n    // try to find the contents of ident in string s:\n    const str = `${ident && ! s.includes(ident) ? ident + \": \" : \"\"}` + String(s ?? \"\").replace(/\\s+/g, ' '); // normalize spaces and add any id to the log message\n    if (typeof console !== \"undefined\" && console && typeof console.log === \"function\") {\n        console.log(str);\n    } else if (typeof print === \"function\") {\n        print(`${scriptname ?? \"significant.js: \"}${str}`);\n    }\n}\n\n// logit(): log only, if verbose or debug is enabled\nfunction logit(s) {\n    const now  = new Date()\n    const hour = now.getHours()\n    if (hour === 28 || verboseAsked || debugEnabled || testingAsked) { // ... or always at a certain hour to ease retrospective debugging\n        consolelog(`${s}`)\n        return true;\n    } else {\n        return false;\n    }\n }\n\nfunction logitmore(s) { // increased logging\n    if (verboseIncreased || testingAsked) {\n        logit(s)\n    }\n }\n\n// warnit(): log a warning message\nfunction warnit(s) {\n    consolelog(`WARNING: ` + s)\n }\n\nfunction debugit(s) {\n    // consolelog(`significant.js: ${s} (debugEnabled=${debugEnabled})`);\n    if (debugEnabled) {\n        consolelog(`${s}`)\n    }\n }\n\nfunction testit(s) {\n    if (testingAsked) {\n        consolelog(`${s}`)\n    }\n }\n\n// numOrUndef(): parse a number, then return it or undefined if it is not a valid finite number\nfunction numOrUndef(x) {\n    const n = parseFloat(x);\n    return Number.isFinite(n) ? n : undefined;\n}\n\n// parseScaledNumber(): parse \"<number><suffix>\" where suffix is a metric/binary shorthand like k, K, M, Mi, ...\nfunction parseScaledNumber(raw, label) {\n    const text = String(raw ?? \"\").trim();\n    const matches = text.match(/^([+-]?\\d+(?:\\.\\d+)?)([A-Za-z]*)$/);\n    let parsed;\n\n    if (matches) {\n        const amount = parseFloat(matches[1]);\n        const typea = matches[2];\n\n        if (!Object.prototype.hasOwnProperty.call(SCALE_AMOUNT_MAP, typea)) {\n            warnit(`UNKNOWN ${label} unit: \"${typea}\"; ignoring ${raw}.`);\n            parsed = undefined;\n        } else {\n            parsed = amount * SCALE_AMOUNT_MAP[typea];            \n            debugit(`Parsed: ${label} amount=${amount} typea=\"${typea}\" => ${label}Asked=${parsed}`);\n        }\n    } else {\n        parsed = numOrUndef(raw);\n        if (parsed == null) {\n            warnit(`Bad ${label} format: \"${raw}\"; ignoring it.`);\n            return undefined; // early return — avoids double-warning from the guard below\n        }\n    }\n\n    if (parsed === 0 && label === \"div\") {\n        warnit(`INVALID ${String(label).toUpperCase()} value 0; ignoring it.`);\n        return undefined;\n    }\n\n    return parsed;\n}\n\n// setDefault(): return defaultValue if value is undefined; if defaultValue is a function, call it with the value\nfunction setDefault(value, defaultValue) {\n    return typeof defaultValue === 'function' ?  defaultValue(value)  :  (value !== undefined) ? value : defaultValue;\n}\n\n// splitScaleValue(): split a scale value into its integer and fractional part, \n// and mirror the fractional part at 0.5 if it islarger than 0.5 to get nicer rounding steps (e.g. 1, 2, 5, 10) for the fractional part than for values close to 1 (e.g. 0.8 with steps 1, 4, 6, 8)\nfunction splitScaleValue(scaleval) {\n    var frac = roundTo(scaleval - floor(scaleval), 1)\n    frac = frac > 0.5 ? roundTo(1 - frac, 1) : frac; // mirror the fractional part at 0.5 if larger\n    scaleval = floor(scaleval)\n    return [ scaleval, frac  ];\n}\n\n// compassAngleToDir(): convert degrees to compass direction: N, NNE, NE, ENE, E, ESE, SE, SSE, S, SSW, SW, WSW, W, WNW, NW, NNW\nfunction compassAngleToDir(deg, scale = 2) {\n    // scale: 1=4 directions, 2=8 directions, 3=16 directions, 4=32 directions\n    const directions = [\n        [\"N\", \"E\", \"S\", \"W\"],  // scale=1\n        [\"N\", \"NE\", \"E\", \"SE\", \"S\", \"SW\", \"W\", \"NW\"], // scale=2\n        [\"N\", \"NNE\", \"NE\", \"ENE\", \"E\", \"ESE\", \"SE\", \"SSE\", \"S\", \"SSW\", \"SW\", \"WSW\", \"W\", \"WNW\", \"NW\", \"NNW\"], // scale=3\n        [\"N\", \"NbE\", \"NNE\", \"NEbN\", \"NE\", \"NEbE\", \"ENE\", \"EbN\", // scale=4\n         \"E\", \"EbS\", \"ESE\", \"SEbE\", \"SE\", \"SEbS\", \"SSE\", \"SbE\",\n         \"S\", \"SbW\", \"SSW\", \"SWbS\", \"SW\", \"SWbW\", \"WSW\", \"WbS\",\n         \"W\", \"WbN\", \"WNW\", \"NWbW\", \"NW\", \"NWbN\", \"NNW\", \"NbW\"]\n    ];\n\n    scale = clamp(scale ?? 1, [1, 4]);\n    const dirs = directions[scale - 1];\n    const step = 360 / dirs.length;\n\n    deg = ((deg % 360) + 360) % 360;\n    const index = floor((deg + step / 2) / step) % dirs.length;\n    return dirs[index];\n}\n\n// -----------------------------------------------------------------------------------------\n// openHAB wrapper: preserves transform usage; also lets us pass opts either via *query*, or also as *injected vars*\n// -----------------------------------------------------------------------------------------\n(function () {\n  // Try to parse as query options (e.g. significant.js?precision=1.5&scale=1)\n  var query = (typeof __scriptName === 'string' && __scriptName.split('?')[1]) || '';\n  var optsFromQuery = {};\n  if (query) {\n    scriptname = `${__scriptName.split('?')[1]}: `; // for logging\n    consolelog(scriptname + __scriptName.split('?')[0])\n    query.split('&').forEach(p => {\n      var [k, v] = p.split('=');\n      if (k) optsFromQuery[decodeURIComponent(k)] = decodeURIComponent(v || '');\n    });\n  }\n\n  // Pick up any injected globals (some transform profiles define them directly)\n  var injected = {};\n  var option_keys = ['precision', 'prec', 'scale', 'unit', 'div', 'mult', 'offset', 'skew', 'si', 'verbose', 'testing', 'flicker', 'dryRun', 'id', 'ident']\n  option_keys.forEach(k => {\n    if (this[k] != null) injected[k] = this[k];\n    // if (this[k] != undefined) consolelog(`PARAM: ${k} ===> ${this[k]}`);\n    this[k] = undefined; // reset the injected globals to undefined to avoid interference with next invocation\n  });\n\n  // `input` is injected by the openHAB transform runtime\n  var opts = Object.assign({}, optsFromQuery, injected);\n\n  // consolelog(`significant.js: input=${input}, opts=${JSON.stringify(opts)}`);\n  return significantTransformed(input, opts);\n})();\n\n// -----------------------------------------------------------------------------------------\n// Export for Node.js unit testing (sometimes ignored in openHAB)\n// -----------------------------------------------------------------------------------------\n// should be commented out during openHAB use (transformation script might return an object otherwise)\n/// if (typeof module !== \"undefined\" && module && typeof module.exports !== \"undefined\") {\n///  module.exports = { significantTransformed };\n/// }"
  }
}
5 Likes

Thanks for this contribution.These are pretty useful transformations.

For development I see @cweitkamp will provide a math transformation in the next update and perhaps it’s worth the discussion to see if there are any differences.

1 Like

Yes, the idea is to collect different math functions in one place.

AFAIK There are already some other Profiles which might fit into the new Transformation bundle.

Like the Round Profile - currently located in the Basic Profile Transformation:

And the Gain-Offset Profile - currently located in the Modbus Binding:

@cweitkamp, my problem with the Round Profile is its underlying idea that the amount of digits after (and before) the decimal point matters… My significant.js transformation instead focuses on the concept that for any real-world physical value the amount of significant digits is more relevant.

For instance, a weather service might send a precipitation rate of 5.43 mm/h and others as 0,00543 m/h. In both cases, we are probably only interested in the 5 and the 4, i.e. 5.4 mm/h. This is implicitly abstracted by significant.js which doesn’t care about the unit’s dimension, but assumes - as a default - that a precipitation rate is probably relevant only with at most two digits…

Similar rationale holds for incoming temperature values from cheap bluetooth or 433 MHz sensors (picked up by rtl_433 and rtl2mqtt) flickering between 7.0 °C and 7.1 °C which used to clutter my logs. Significant.js assumes - default may be overriden - that temperature values are only interesting in steps like 6.5, 7.0, 7.5 etc… There might still be flicker caused between 7.2 and 7.3 but that is not as often then…

I agree that my transformation were to become less relevant if the Round Profile were extended by a ā€œprecisionā€ concept. I can support in implementing that, however, I’m not a good Java / JavaScript programmer. Still, significant.js has several other features, too, that make it relevant beyond the concept of ā€œprecisionā€ā€¦

Thanks for your attention and the discussion!

1 Like

As I mentioned above I’m not a Java programmer. If somebody were willing to extend the Round Profile with a first start for the concept of significant digits, I sketched out the following prompt for Github Copilot:

How would I extend RoundStateProfile.java in openhab/openhab-addons to support an additional optional parameter prec for numeric precision / significant-digits rounding?

File: bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/RoundStateProfile.java

Source: https://github.com/openhab/openhab-addons/blob/main/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/RoundStateProfile.java

Requirements:

    Keep the existing scale parameter.
    Add a new optional parameter prec.
    prec means significant digits / numeric precision, not display formatting.
    If both prec and scale are present, apply prec first and then scale.
    Keep the existing mode parameter and use it for both operations.
    Continue supporting both DecimalType and QuantityType<?>.
    Preserve the current behavior for incompatible types and UnDefType.
    The solution should be backward-compatible for existing users of scale.

Please explain:

    what should change in the config class,
    what should change in RoundStateProfile,
    whether MathContext should be used for prec,
    any validation or edge cases I should consider,
    and show a concrete Java code sketch for the modified implementation.

I’d still like to ask for having significant.js on the add-on marketplace, but this above could be a simple step into implementation in the Round Profile without cluttering it too much with all the specific ideas I implemented in significant.js…

I created this PR for the Round Profile:
[basicprofiles] Add `precision` parameter to Round Profile by sheilbronn Ā· Pull Request #20423 Ā· openhab/openhab-addons Ā· GitHub (supercedes 20377)

What do you think?

@heilbron To clarify. My comment was not an argument against your solution. I just wanted to share my thoughts on a single point of truth for mathematical Profiles. I appreciate your input and effort.

If you see room for improvements in an existing solution you are welcome to contribute an enhancement. As you already did. Thanks for that.

1 Like

No clarification needed, I fully understood you (at least I think so :wink:) … I just wanted to make the point that IMHO the concepts of precision and of scale are equally important, and sometimes should be combinable:

  • When rounding a precipatation rate, probably 1 significant digit with any scale is adequate for most use cases at home.
  • When rounding a temperature given in °C or °K, one might want the steps to be +/- 1 °C , so a scale with 0 decimal digits is adaequate. Same might hold for athmosperic pressure, energy (kWh) etc.

Where it gets difficult with the concept of a ā€œbasic profileā€:

  • Voltages around 220V: steps of +/- 2V ?!
  • Temperatures: steps of +/- 0.5 °C !?
  • Angles (wind direction): A scale of -1 makes little sense, it should be more like octants or quadrants
  • Seconds: Negative scales should be for minutes or hours ?!
  • …

For all the more complicated cases, I’d propose the use of a special trasnformation profile such as significant.js mentioned above.

You put a lot more thoughts in this like I did when introducing the Round Profile.

I directly have an idea how I can use the significant.js. A while a go I tried to tinker around with rounding values of Number:Time or DateTime Items to 15 min steps. This maybe seems to be a useful tool when working with dynamic energy prices or related predictions like solar energy production or carbondioxide emissions.

Do you think that will be possible with your script Profile?

@cweitkamp
Yes, great extension idea! I have sample code running now, with the following intentions:

  • scale==-1, 0, 1, 2, 3, 4 are for weeks, days, hours, minutes, seconds, milliseconds, respectively.

  • E.g. for minutes and seconds, a fractional part in scale (e.g. scale=1.x) will scale the minutes to the next full value of: 30 (x=5), 20 (x==4), 15 (x==3), 10 (x==2), 5 (x==1)

  • So for your case of rounding to multiples of 15 minutes, scale should be 1.3

  • But: For the sake of simplicity and avoiding spillover, I consider to only go for rounding down, i.e. 23:58 should become 23:45 and not 0:00 on the the next day… I might reconsider this, once i figured out how to do the rounding based on milliseconds since the epoch (might make the whole code easier anyway)…

I now already have some sample code running in significant.js, but it’s ugly and needs to be heavily refactored.

I hope to work on it next weekend… I might commit the ugly early version to the repo, if you are interested in trying it sooner?

MyGitHub code should be now supporting all of this stuff. Drop me a note if it’s not!

@cweitkamp : set parameter scale==1.3 for your use case of rounding date-time values to full 15 minutes. It now also does proper rounding (beyong flooring!) for date-time values!

GitHub: GitHub - sheilbronn/significant-digits Ā· GitHub

Is there anything more that I can do to have this script published on the add-on marketplace? Thanks

Yes, you need to edit your top post and add the published tag.

Thanks! Just added the tag… Also the stability is beta :wink:

BTW: I updated to the PR [basicprofiles] Support DateTime in round profile by sheilbronn Ā· Pull Request #20559 Ā· openhab/openhab-addons Ā· GitHub for extending the Round Profile for DateTime items. This might cover your idea partly without having to resort this marketplace add-on.