Add Decimal64.scaleTo() 87/101287/2
authorRobert Varga <robert.varga@pantheon.tech>
Wed, 25 May 2022 08:27:47 +0000 (10:27 +0200)
committerRobert Varga <robert.varga@pantheon.tech>
Wed, 25 May 2022 11:52:56 +0000 (13:52 +0200)
Decimal64.valueOf(String) results in variable-scale results, based on
the input string. This is sub-optimal when we want to compare values
of particular type -- those should be governed by fraction-digits and
hence, for example "2.00" and "2.0" should be normalized to the same
scale.

Introduce Decimal64.scaleTo(), which can be used to adjust the scale
of an existing Decimal64.

JIRA: YANGTOOLS-1440
Change-Id: Icbc215dff6d8146996c5be1040751fd1e14b6cfe
Signed-off-by: Robert Varga <robert.varga@pantheon.tech>
(cherry picked from commit c6624c7716ea59e4df3cb7bf90d172e81a544be5)

common/yang-common/src/main/java/org/opendaylight/yangtools/yang/common/Decimal64.java
common/yang-common/src/test/java/org/opendaylight/yangtools/yang/common/YT1440Test.java [new file with mode: 0644]

index 5a762a5bcd1ff93b77898db9d7d2c49384b79ca6..a2254371c65de710ae11eef55a1b15dea2169baf 100644 (file)
@@ -9,6 +9,7 @@ package org.opendaylight.yangtools.yang.common;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Verify.verify;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.Beta;
 import com.google.common.annotations.VisibleForTesting;
@@ -136,6 +137,37 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
         }
     }
 
+    /**
+     * Tri-state indicator of how a non-zero remainder is significant to rounding.
+     */
+    private enum RemainderSignificance {
+        /**
+         * The remainder is less than the half of the interval.
+         */
+        LT_HALF,
+        /**
+         * The remainder is exactly half of the interval.
+         */
+        HALF,
+        /**
+         * The remainder is greater than the half of the interval.
+         */
+        GT_HALF;
+
+        static RemainderSignificance of(final long remainder, final long interval) {
+            final long absRemainder = Math.abs(remainder);
+            final long half = interval / 2;
+
+            if (absRemainder > half) {
+                return GT_HALF;
+            } else if (absRemainder < half) {
+                return LT_HALF;
+            } else {
+                return HALF;
+            }
+        }
+    }
+
     private static final CanonicalValueSupport<Decimal64> SUPPORT = new Support();
     private static final long serialVersionUID = 1L;
 
@@ -336,6 +368,126 @@ public class Decimal64 extends Number implements CanonicalValue<Decimal64> {
         return value;
     }
 
+    /**
+     * Return this decimal in the specified scale.
+     *
+     * @param scale target scale
+     * @return Scaled number
+     * @throws ArithmeticException if the conversion would overflow or require rounding
+     */
+    public Decimal64 scaleTo(final int scale) {
+        return scaleTo(scale, RoundingMode.UNNECESSARY);
+    }
+
+    /**
+     * Return this decimal in the specified scale.
+     *
+     * @param scale scale
+     * @param roundingMode rounding mode
+     * @return Scaled number
+     * @throws ArithmeticException if the conversion would overflow or require rounding and {@code roundingMode} is
+     *                             {@link RoundingMode#UNNECESSARY}.
+     * @throws IllegalArgumentException if {@code scale} is not valid
+     * @throws NullPointerException if {@code roundingMode} is {@code null}
+     */
+    public Decimal64 scaleTo(final int scale, final RoundingMode roundingMode) {
+        final var mode = requireNonNull(roundingMode);
+        final byte scaleOffset = offsetOf(scale);
+        final int diff = scaleOffset - offset;
+        if (diff == 0) {
+            // Same scale, no-op
+            return this;
+        } else if (value == 0) {
+            // Zero is special, as it has the same unscaled value in all scales
+            return new Decimal64(scaleOffset, 0);
+        }
+
+        if (diff > 0) {
+            // Increasing scale is simple, as we have pre-calculated min/max boundaries and then it's just
+            // factor multiplication
+            final int diffOffset = diff - 1;
+            final var conv = CONVERSION[diffOffset];
+            if (value < conv.minLong || value > conv.maxLong) {
+                throw new ArithmeticException("Increasing scale of " + this + " to " + scale + " would overflow");
+            }
+            return new Decimal64(scaleOffset, value * FACTOR[diffOffset]);
+        }
+
+        // Decreasing scale is hard, as we need to deal with rounding
+        final int diffOffset = -diff - 1;
+        final long factor = FACTOR[diffOffset];
+        final long trunc = value / factor;
+        final long remainder = value - trunc * factor;
+
+        // No remainder, we do not need to involve rounding
+        if (remainder == 0) {
+            return new Decimal64(scaleOffset, trunc);
+        }
+
+        final long increment;
+        switch (mode) {
+            case UP:
+                increment = Long.signum(trunc);
+                break;
+            case DOWN:
+                increment = 0;
+                break;
+            case CEILING:
+                increment = Long.signum(trunc) > 0 ? 1 : 0;
+                break;
+            case FLOOR:
+                increment = Long.signum(trunc) < 0 ? -1 : 0;
+                break;
+            case HALF_UP:
+                switch (RemainderSignificance.of(remainder, factor)) {
+                    case LT_HALF:
+                        increment = 0;
+                        break;
+                    case HALF:
+                    case GT_HALF:
+                        increment = Long.signum(trunc);
+                        break;
+                    default:
+                        throw new IllegalStateException();
+                }
+                break;
+            case HALF_DOWN:
+                switch (RemainderSignificance.of(remainder, factor)) {
+                    case LT_HALF:
+                    case HALF:
+                        increment = 0;
+                        break;
+                    case GT_HALF:
+                        increment = Long.signum(trunc);
+                        break;
+                    default:
+                        throw new IllegalStateException();
+                }
+                break;
+            case HALF_EVEN:
+                switch (RemainderSignificance.of(remainder, factor)) {
+                    case LT_HALF:
+                        increment = 0;
+                        break;
+                    case HALF:
+                        increment = (trunc & 0x1) != 0 ? Long.signum(trunc) : 0;
+                        break;
+                    case GT_HALF:
+                        increment = Long.signum(trunc);
+                        break;
+                    default:
+                        throw new IllegalStateException();
+                }
+                break;
+            case UNNECESSARY:
+                throw new ArithmeticException("Decreasing scale of " + this + " to " + scale + " requires rounding");
+            default:
+                throw new IllegalStateException();
+        }
+
+        return new Decimal64(scaleOffset, trunc + increment);
+    }
+
     public final BigDecimal decimalValue() {
         return BigDecimal.valueOf(value, scale());
     }
diff --git a/common/yang-common/src/test/java/org/opendaylight/yangtools/yang/common/YT1440Test.java b/common/yang-common/src/test/java/org/opendaylight/yangtools/yang/common/YT1440Test.java
new file mode 100644 (file)
index 0000000..68faebe
--- /dev/null
@@ -0,0 +1,207 @@
+/*
+ * Copyright (c) 2022 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.yangtools.yang.common;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.math.RoundingMode;
+import org.junit.jupiter.api.Test;
+
+public class YT1440Test {
+    @Test
+    public void testScaleSame() {
+        final var twenty = Decimal64.valueOf(2, 20);
+        assertSame(twenty, twenty.scaleTo(2));
+
+        // Do not tolerate null rounding even for no-op
+        assertThrows(NullPointerException.class, () -> twenty.scaleTo(2, null));
+    }
+
+    @Test
+    public void testScaleZero() {
+        final var two = Decimal64.valueOf(2, 0);
+        final var one = two.scaleTo(1);
+        assertEquals(1, one.scale());
+        assertEquals(0, one.unscaledValue());
+        final var three = two.scaleTo(3);
+        assertEquals(3, three.scale());
+        assertEquals(0, three.unscaledValue());
+    }
+
+    @Test
+    public void testScaleUpNoRemain() {
+        // Template, scale=5
+        final var two = Decimal64.valueOf(2, 20);
+
+        // scale = 5
+        final var five = two.scaleTo(5);
+        assertEquals(5, five.scale());
+        assertEquals(two, five);
+        assertEquals("20.0", five.toString());
+
+        // scale = 18 fails
+        final var ex = assertThrows(ArithmeticException.class, () -> two.scaleTo(18));
+        assertEquals("Increasing scale of 20.0 to 18 would overflow", ex.getMessage());
+    }
+
+    @Test
+    public void testScaleDownNoRemain() {
+        // Template, scale=5
+        final var five = Decimal64.valueOf(5, 20);
+
+        // scale = 2
+        final var two = five.scaleTo(2);
+        assertEquals(2, two.scale());
+        assertEquals(five, two);
+        assertEquals("20.0", two.toString());
+    }
+
+    @Test
+    public void testScaleDownPositive() {
+        final var two = Decimal64.valueOf("0.63");
+        assertEquals(2, two.scale());
+        assertEquals(63, two.unscaledValue());
+
+        // Trim '3'
+        assertScaleDown(7, two, 1, RoundingMode.UP);
+        assertScaleDown(6, two, 1, RoundingMode.DOWN);
+        assertScaleDown(7, two, 1, RoundingMode.CEILING);
+        assertScaleDown(6, two, 1, RoundingMode.FLOOR);
+        assertScaleDown(6, two, 1, RoundingMode.HALF_UP);
+        assertScaleDown(6, two, 1, RoundingMode.HALF_DOWN);
+        assertScaleDown(6, two, 1, RoundingMode.HALF_EVEN);
+
+        final var three = Decimal64.valueOf("0.635");
+        assertEquals(3, three.scale());
+        assertEquals(635, three.unscaledValue());
+
+        // Trim '5'
+        assertScaleDown(64, three, 2, RoundingMode.UP);
+        assertScaleDown(63, three, 2, RoundingMode.DOWN);
+        assertScaleDown(64, three, 2, RoundingMode.CEILING);
+        assertScaleDown(63, three, 2, RoundingMode.FLOOR);
+        assertScaleDown(64, three, 2, RoundingMode.HALF_UP);
+        assertScaleDown(63, three, 2, RoundingMode.HALF_DOWN);
+        assertScaleDown(64, three, 2, RoundingMode.HALF_EVEN);
+
+        // Trim '35'
+        assertScaleDown(7, three, 1, RoundingMode.UP);
+        assertScaleDown(6, three, 1, RoundingMode.DOWN);
+        assertScaleDown(7, three, 1, RoundingMode.CEILING);
+        assertScaleDown(6, three, 1, RoundingMode.FLOOR);
+        assertScaleDown(6, three, 1, RoundingMode.HALF_UP);
+        assertScaleDown(6, three, 1, RoundingMode.HALF_DOWN);
+        assertScaleDown(6, three, 1, RoundingMode.HALF_EVEN);
+
+        final var four = Decimal64.valueOf("0.6355");
+        assertEquals(4, four.scale());
+        assertEquals(6355, four.unscaledValue());
+
+        // Trim 55
+        assertScaleDown(64, four, 2, RoundingMode.UP);
+        assertScaleDown(63, four, 2, RoundingMode.DOWN);
+        assertScaleDown(64, four, 2, RoundingMode.CEILING);
+        assertScaleDown(63, four, 2, RoundingMode.FLOOR);
+        assertScaleDown(64, four, 2, RoundingMode.HALF_UP);
+        assertScaleDown(64, four, 2, RoundingMode.HALF_DOWN);
+        assertScaleDown(64, four, 2, RoundingMode.HALF_EVEN);
+
+        final var five = Decimal64.valueOf("0.635").scaleTo(5);
+        assertEquals(5, five.scale());
+        assertEquals(63500, five.unscaledValue());
+
+        // Trim 500
+        assertScaleDown(64, five, 2, RoundingMode.UP);
+        assertScaleDown(63, five, 2, RoundingMode.DOWN);
+        assertScaleDown(64, five, 2, RoundingMode.CEILING);
+        assertScaleDown(63, five, 2, RoundingMode.FLOOR);
+        assertScaleDown(64, five, 2, RoundingMode.HALF_UP);
+        assertScaleDown(63, five, 2, RoundingMode.HALF_DOWN);
+        assertScaleDown(64, five, 2, RoundingMode.HALF_EVEN);
+    }
+
+    @Test
+    public void testScaleDownNegative() {
+        final var two = Decimal64.valueOf("-0.63");
+        assertEquals(2, two.scale());
+        assertEquals(-63, two.unscaledValue());
+
+        // Trim '3'
+        assertScaleDown(-7, two, 1, RoundingMode.UP);
+        assertScaleDown(-6, two, 1, RoundingMode.DOWN);
+        assertScaleDown(-6, two, 1, RoundingMode.CEILING);
+        assertScaleDown(-7, two, 1, RoundingMode.FLOOR);
+        assertScaleDown(-6, two, 1, RoundingMode.HALF_UP);
+        assertScaleDown(-6, two, 1, RoundingMode.HALF_DOWN);
+        assertScaleDown(-6, two, 1, RoundingMode.HALF_EVEN);
+
+        final var three = Decimal64.valueOf("-0.635");
+        assertEquals(3, three.scale());
+        assertEquals(-635, three.unscaledValue());
+
+        // Trim '5'
+        assertScaleDown(-64, three, 2, RoundingMode.UP);
+        assertScaleDown(-63, three, 2, RoundingMode.DOWN);
+        assertScaleDown(-63, three, 2, RoundingMode.CEILING);
+        assertScaleDown(-64, three, 2, RoundingMode.FLOOR);
+        assertScaleDown(-64, three, 2, RoundingMode.HALF_UP);
+        assertScaleDown(-63, three, 2, RoundingMode.HALF_DOWN);
+        assertScaleDown(-64, three, 2, RoundingMode.HALF_EVEN);
+
+        // Trim '35'
+        assertScaleDown(-7, three, 1, RoundingMode.UP);
+        assertScaleDown(-6, three, 1, RoundingMode.DOWN);
+        assertScaleDown(-6, three, 1, RoundingMode.CEILING);
+        assertScaleDown(-7, three, 1, RoundingMode.FLOOR);
+        assertScaleDown(-6, three, 1, RoundingMode.HALF_UP);
+        assertScaleDown(-6, three, 1, RoundingMode.HALF_DOWN);
+        assertScaleDown(-6, three, 1, RoundingMode.HALF_EVEN);
+
+        final var four = Decimal64.valueOf("-0.6355");
+        assertEquals(4, four.scale());
+        assertEquals(-6355, four.unscaledValue());
+
+        // Trim 55
+        assertScaleDown(-64, four, 2, RoundingMode.UP);
+        assertScaleDown(-63, four, 2, RoundingMode.DOWN);
+        assertScaleDown(-63, four, 2, RoundingMode.CEILING);
+        assertScaleDown(-64, four, 2, RoundingMode.FLOOR);
+        assertScaleDown(-64, four, 2, RoundingMode.HALF_UP);
+        assertScaleDown(-64, four, 2, RoundingMode.HALF_DOWN);
+        assertScaleDown(-64, four, 2, RoundingMode.HALF_EVEN);
+
+        final var five = Decimal64.valueOf("-0.635").scaleTo(5);
+        assertEquals(5, five.scale());
+        assertEquals(-63500, five.unscaledValue());
+
+        // Trim 500
+        assertScaleDown(-64, five, 2, RoundingMode.UP);
+        assertScaleDown(-63, five, 2, RoundingMode.DOWN);
+        assertScaleDown(-63, five, 2, RoundingMode.CEILING);
+        assertScaleDown(-64, five, 2, RoundingMode.FLOOR);
+        assertScaleDown(-64, five, 2, RoundingMode.HALF_UP);
+        assertScaleDown(-63, five, 2, RoundingMode.HALF_DOWN);
+        assertScaleDown(-64, five, 2, RoundingMode.HALF_EVEN);
+    }
+
+    @Test
+    public void testScaleDownTrim() {
+        final var two = Decimal64.valueOf("0.63");
+        final var ex = assertThrows(ArithmeticException.class, () -> two.scaleTo(1));
+        assertEquals("Decreasing scale of 0.63 to 1 requires rounding", ex.getMessage());
+    }
+
+    private static void assertScaleDown(final long expectedUnscaled, final Decimal64 value, final int scale,
+            final RoundingMode mode) {
+        final var scaled = value.scaleTo(scale, mode);
+        assertEquals(scale, scaled.scale());
+        assertEquals(expectedUnscaled, scaled.unscaledValue());
+    }
+}