ICU-20568 Implementation of UnitConverter, ComplexUnitConverter and UnitsRouter
See #1279
diff --git a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/ComplexUnitsConverter.java b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/ComplexUnitsConverter.java
new file mode 100644
index 0000000..aec0e5f
--- /dev/null
+++ b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/ComplexUnitsConverter.java
@@ -0,0 +1,128 @@
+// © 2020 and later: Unicode, Inc. and others.
+// License & terms of use: http://www.unicode.org/copyright.html
+
+
+package com.ibm.icu.impl.units;
+
+
+import com.ibm.icu.util.Measure;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Converts from single or compound unit to single, compound or mixed units.
+ * For example, from `meter` to `foot+inch`.
+ * <p>
+ * DESIGN:
+ * This class uses `UnitConverter` in order to perform the single converter (i.e. converters from a
+ * single unit to another single unit). Therefore, `ComplexUnitsConverter` class contains multiple
+ * instances of the `UnitConverter` to perform the conversion.
+ */
+public class ComplexUnitsConverter {
+ public static final BigDecimal EPSILON = BigDecimal.valueOf(Math.ulp(1.0));
+ public static final BigDecimal EPSILON_MULTIPLIER = BigDecimal.valueOf(1).add(EPSILON);
+ private ArrayList<UnitConverter> unitConverters_;
+ private ArrayList<MeasureUnitImpl> units_;
+
+ /**
+ * Constructor of `ComplexUnitsConverter`.
+ * NOTE:
+ * - inputUnit and outputUnits must be under the same category
+ * - e.g. meter to feet and inches --> all of them are length units.
+ *
+ * @param inputUnit represents the source unit. (should be single or compound unit).
+ * @param outputUnits represents the output unit. could be any type. (single, compound or mixed).
+ */
+ public ComplexUnitsConverter(MeasureUnitImpl inputUnit, MeasureUnitImpl outputUnits,
+ ConversionRates conversionRates) {
+ units_ = outputUnits.extractIndividualUnits();
+ assert (!units_.isEmpty());
+
+ // Sort the units in a descending order.
+ Collections.sort(
+ this.units_,
+ Collections.reverseOrder(new MeasureUnitImpl.MeasureUnitImplComparator(conversionRates)));
+
+
+ // If the `outputUnits` is `UMEASURE_UNIT_MIXED` such as `foot+inch`. Thus means there is more than one unit
+ // and In this case we need more converters to convert from the `inputUnit` to the first unit in the
+ // `outputUnits`. Then, a converter from the first unit in the `outputUnits` to the second unit and so on.
+ // For Example:
+ // - inputUnit is `meter`
+ // - outputUnits is `foot+inch`
+ // - Therefore, we need to have two converters:
+ // 1. a converter from `meter` to `foot`
+ // 2. a converter from `foot` to `inch`
+ // - Therefore, if the input is `2 meter`:
+ // 1. convert `meter` to `foot` --> 2 meter to 6.56168 feet
+ // 2. convert the residual of 6.56168 feet (0.56168) to inches, which will be (6.74016
+ // inches)
+ // 3. then, the final result will be (6 feet and 6.74016 inches)
+ unitConverters_ = new ArrayList<>();
+ for (int i = 0, n = units_.size(); i < n; i++) {
+ if (i == 0) { // first element
+ unitConverters_.add(new UnitConverter(inputUnit, units_.get(i), conversionRates));
+ } else {
+ unitConverters_.add(new UnitConverter(units_.get(i - 1), units_.get(i), conversionRates));
+ }
+ }
+ }
+
+ /**
+ * Returns true if the specified `quantity` of the `inputUnit`, expressed in terms of the biggest
+ * unit in the MeasureUnit `outputUnit`, is greater than or equal to `limit`.
+ * <p>
+ * For example, if the input unit is `meter` and the target unit is `foot+inch`. Therefore, this
+ * function will convert the `quantity` from `meter` to `foot`, then, it will compare the value in
+ * `foot` with the `limit`.
+ */
+ public boolean greaterThanOrEqual(BigDecimal quantity, BigDecimal limit) {
+ assert !units_.isEmpty();
+
+ // NOTE: First converter converts to the biggest quantity.
+ return unitConverters_.get(0).convert(quantity).multiply(EPSILON_MULTIPLIER).compareTo(limit) >= 0;
+ }
+
+ /**
+ * Returns outputMeasures which is an array with the corresponding values.
+ * - E.g. converting meters to feet and inches.
+ * 1 meter --> 3 feet, 3.3701 inches
+ * NOTE:
+ * the smallest element is the only element that could have fractional values. And all
+ * other elements are floored to the nearest integer
+ */
+ public List<Measure> convert(BigDecimal quantity) {
+ List<Measure> result = new ArrayList<>();
+
+ for (int i = 0, n = unitConverters_.size(); i < n; ++i) {
+ quantity = (unitConverters_.get(i)).convert(quantity);
+
+ if (i < n - 1) {
+ // The double type has 15 decimal digits of precision. For choosing
+ // whether to use the current unit or the next smaller unit, we
+ // therefore nudge up the number with which the thresholding
+ // decision is made. However after the thresholding, we use the
+ // original values to ensure unbiased accuracy (to the extent of
+ // double's capabilities).
+ BigDecimal newQuantity = quantity.multiply(EPSILON_MULTIPLIER).setScale(0, RoundingMode.FLOOR);
+
+ result.add(new Measure(newQuantity, units_.get(i).build()));
+
+ // Keep the residual of the quantity.
+ // For example: `3.6 feet`, keep only `0.6 feet`
+ quantity = quantity.subtract(newQuantity);
+ if (quantity.compareTo(BigDecimal.ZERO) == -1) {
+ quantity = BigDecimal.ZERO;
+ }
+ } else { // LAST ELEMENT
+ result.add(new Measure(quantity, units_.get(i).build()));
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/ConversionRates.java b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/ConversionRates.java
new file mode 100644
index 0000000..eec294c
--- /dev/null
+++ b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/ConversionRates.java
@@ -0,0 +1,234 @@
+// © 2020 and later: Unicode, Inc. and others.
+// License & terms of use: http://www.unicode.org/copyright.html
+
+
+package com.ibm.icu.impl.units;
+
+import com.ibm.icu.impl.ICUData;
+import com.ibm.icu.impl.ICUResourceBundle;
+import com.ibm.icu.impl.UResource;
+import com.ibm.icu.util.MeasureUnit;
+import com.ibm.icu.util.UResourceBundle;
+
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.util.ArrayList;
+import java.util.HashMap;
+
+public class ConversionRates {
+
+ /**
+ * Map from any simple unit (i.e. "meter", "foot", "inch") to its basic/root conversion rate info.
+ */
+ private HashMap<String, ConversionRateInfo> mapToConversionRate;
+
+ public ConversionRates() {
+ // Read the conversion rates from the data (units.txt).
+ ICUResourceBundle resource;
+ resource = (ICUResourceBundle) UResourceBundle.getBundleInstance(ICUData.ICU_BASE_NAME, "units");
+ ConversionRatesSink sink = new ConversionRatesSink();
+ resource.getAllItemsWithFallback(UnitsData.Constants.CONVERSION_UNIT_TABLE_NAME, sink);
+ this.mapToConversionRate = sink.getMapToConversionRate();
+ }
+
+ /**
+ * Extracts the factor from a `SingleUnitImpl` to its Basic Unit.
+ *
+ * @param singleUnit
+ * @return
+ */
+ private UnitConverter.Factor getFactorToBase(SingleUnitImpl singleUnit) {
+ int power = singleUnit.getDimensionality();
+ MeasureUnit.SIPrefix siPrefix = singleUnit.getSiPrefix();
+ UnitConverter.Factor result = UnitConverter.Factor.processFactor(mapToConversionRate.get(singleUnit.getSimpleUnit()).getConversionRate());
+
+ return result.applySiPrefix(siPrefix).power(power); // NOTE: you must apply the SI prefixes before the power.
+ }
+
+ public UnitConverter.Factor getFactorToBase(MeasureUnitImpl measureUnit) {
+ UnitConverter.Factor result = new UnitConverter.Factor();
+ for (SingleUnitImpl singleUnit :
+ measureUnit.getSingleUnits()) {
+ result = result.multiply(getFactorToBase(singleUnit));
+ }
+
+ return result;
+ }
+
+ protected BigDecimal getOffset(MeasureUnitImpl source, MeasureUnitImpl target, UnitConverter.Factor
+ sourceToBase, UnitConverter.Factor targetToBase, UnitConverter.Convertibility convertibility) {
+ if (convertibility != UnitConverter.Convertibility.CONVERTIBLE) return BigDecimal.valueOf(0);
+ if (!(checkSimpleUnit(source) && checkSimpleUnit(target))) return BigDecimal.valueOf(0);
+
+ String sourceSimpleIdentifier = source.getSingleUnits().get(0).getSimpleUnit();
+ String targetSimpleIdentifier = target.getSingleUnits().get(0).getSimpleUnit();
+
+ BigDecimal sourceOffset = this.mapToConversionRate.get(sourceSimpleIdentifier).getOffset();
+ BigDecimal targetOffset = this.mapToConversionRate.get(targetSimpleIdentifier).getOffset();
+ return sourceOffset
+ .subtract(targetOffset)
+ .divide(targetToBase.getConversionRate(), MathContext.DECIMAL128);
+
+
+ }
+
+ public MeasureUnitImpl extractCompoundBaseUnit(MeasureUnitImpl measureUnit) {
+ ArrayList<SingleUnitImpl> baseUnits = this.extractBaseUnits(measureUnit);
+
+ MeasureUnitImpl result = new MeasureUnitImpl();
+ for (SingleUnitImpl baseUnit :
+ baseUnits) {
+ result.appendSingleUnit(baseUnit);
+ }
+
+ return result;
+ }
+
+ public ArrayList<SingleUnitImpl> extractBaseUnits(MeasureUnitImpl measureUnitImpl) {
+ ArrayList<SingleUnitImpl> result = new ArrayList<>();
+ ArrayList<SingleUnitImpl> singleUnits = measureUnitImpl.getSingleUnits();
+ for (SingleUnitImpl singleUnit :
+ singleUnits) {
+ result.addAll(extractBaseUnits(singleUnit));
+ }
+
+ return result;
+ }
+
+ /**
+ * @param singleUnit
+ * @return The bese units in the `SingleUnitImpl` with applying the dimensionality only and not the SI prefix.
+ * <p>
+ * NOTE:
+ * This method is helpful when checking the convertibility because no need to check convertibility.
+ */
+ public ArrayList<SingleUnitImpl> extractBaseUnits(SingleUnitImpl singleUnit) {
+ String target = mapToConversionRate.get(singleUnit.getSimpleUnit()).getTarget();
+ MeasureUnitImpl targetImpl = MeasureUnitImpl.UnitsParser.parseForIdentifier(target);
+
+ // Each unit must be powered by the same dimension
+ targetImpl.applyDimensionality(singleUnit.getDimensionality());
+
+ // NOTE: we do not apply SI prefixes.
+
+ return targetImpl.getSingleUnits();
+ }
+
+ /**
+ * Checks if the `MeasureUnitImpl` is simple or not.
+ *
+ * @param measureUnitImpl
+ * @return true if the `MeasureUnitImpl` is simple, false otherwise.
+ */
+ private boolean checkSimpleUnit(MeasureUnitImpl measureUnitImpl) {
+ if (measureUnitImpl.getComplexity() != MeasureUnit.Complexity.SINGLE) return false;
+ SingleUnitImpl singleUnit = measureUnitImpl.getSingleUnits().get(0);
+
+ if (singleUnit.getSiPrefix() != MeasureUnit.SIPrefix.ONE) return false;
+ if (singleUnit.getDimensionality() != 1) return false;
+
+ return true;
+ }
+
+ public static class ConversionRatesSink extends UResource.Sink {
+ /**
+ * Map from any simple unit (i.e. "meter", "foot", "inch") to its basic/root conversion rate info.
+ */
+ private HashMap<String, ConversionRateInfo> mapToConversionRate = new HashMap<>();
+
+ @Override
+ public void put(UResource.Key key, UResource.Value value, boolean noFallback) {
+ assert (UnitsData.Constants.CONVERSION_UNIT_TABLE_NAME.equals(key.toString()));
+
+ UResource.Table conversionRateTable = value.getTable();
+ for (int i = 0; conversionRateTable.getKeyAndValue(i, key, value); i++) {
+ assert (value.getType() == UResourceBundle.TABLE);
+
+ String simpleUnit = key.toString();
+
+ UResource.Table simpleUnitConversionInfo = value.getTable();
+ String target = null;
+ String factor = null;
+ String offset = "0";
+ for (int j = 0; simpleUnitConversionInfo.getKeyAndValue(j, key, value); j++) {
+ assert (value.getType() == UResourceBundle.STRING);
+
+
+ String keyString = key.toString();
+ String valueString = value.toString().replaceAll(" ", "");
+ if ("target".equals(keyString)) {
+ target = valueString;
+ } else if ("factor".equals(keyString)) {
+ factor = valueString;
+ } else if ("offset".equals(keyString)) {
+ offset = valueString;
+ } else {
+ assert false : "The key must be target, factor or offset";
+ }
+ }
+
+ // HERE a single conversion rate data should be loaded
+ assert (target != null);
+ assert (factor != null);
+
+ mapToConversionRate.put(simpleUnit, new ConversionRateInfo(simpleUnit, target, factor, offset));
+ }
+
+
+ }
+
+ public HashMap<String, ConversionRateInfo> getMapToConversionRate() {
+ return mapToConversionRate;
+ }
+ }
+
+ public static class ConversionRateInfo {
+
+ private final String simpleUnit;
+ private final String target;
+ private final String conversionRate;
+ private final BigDecimal offset;
+
+ public ConversionRateInfo(String simpleUnit, String target, String conversionRate, String offset) {
+ this.simpleUnit = simpleUnit;
+ this.target = target;
+ this.conversionRate = conversionRate;
+ this.offset = forNumberWithDivision(offset);
+ }
+
+ private static BigDecimal forNumberWithDivision(String numberWithDivision) {
+ String[] numbers = numberWithDivision.split("/");
+ assert (numbers.length <= 2);
+
+ if (numbers.length == 1) {
+ return new BigDecimal(numbers[0]);
+ }
+
+ return new BigDecimal(numbers[0]).divide(new BigDecimal(numbers[1]), MathContext.DECIMAL128);
+ }
+
+ /**
+ * @return the base unit.
+ * <p>
+ * For example:
+ * ("meter", "foot", "inch", "mile" ... etc.) have "meter" as a base/root unit.
+ */
+ public String getTarget() {
+ return this.target;
+ }
+
+ /**
+ * @return The offset from this unit to the base unit.
+ */
+ public BigDecimal getOffset() {
+ return this.offset;
+ }
+
+ /**
+ * @return The conversion rate from this unit to the base unit.
+ */
+ public String getConversionRate() {
+ return conversionRate;
+ }
+ }
+}
diff --git a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/MeasureUnitImpl.java b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/MeasureUnitImpl.java
index db98db0..ac8b1ed 100644
--- a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/MeasureUnitImpl.java
+++ b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/MeasureUnitImpl.java
@@ -4,8 +4,14 @@
package com.ibm.icu.impl.units;
-import com.ibm.icu.util.*;
+import com.ibm.icu.util.BytesTrie;
+import com.ibm.icu.util.CharsTrie;
+import com.ibm.icu.util.CharsTrieBuilder;
+import com.ibm.icu.util.ICUCloneNotSupportedException;
+import com.ibm.icu.util.MeasureUnit;
+import com.ibm.icu.util.StringTrieBuilder;
+import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
@@ -115,6 +121,17 @@
}
/**
+ * Applies dimensionality to all the internal single units.
+ * For example: <b>square-meter-per-second</b>, when we apply dimensionality -2, it will be <b>square-second-per-p4-meter</b>
+ */
+ public void applyDimensionality(int dimensionality) {
+ for (SingleUnitImpl singleUnit :
+ singleUnits) {
+ singleUnit.setDimensionality(singleUnit.getDimensionality() * dimensionality);
+ }
+ }
+
+ /**
* Mutates this MeasureUnitImpl to append a single unit.
*
* @return true if a new item was added. If unit is the dimensionless unit,
@@ -728,10 +745,24 @@
}
}
- class SingleUnitComparator implements Comparator<SingleUnitImpl> {
+ static class MeasureUnitImplComparator implements Comparator<MeasureUnitImpl> {
+ private ConversionRates conversionRates;
+
+ public MeasureUnitImplComparator(ConversionRates conversionRates) {
+ this.conversionRates = conversionRates;
+ }
+
+ @Override
+ public int compare(MeasureUnitImpl o1, MeasureUnitImpl o2) {
+ UnitConverter fromO1toO2 = new UnitConverter(o1, o2, conversionRates);
+ return fromO1toO2.convert(BigDecimal.valueOf(1)).compareTo(BigDecimal.valueOf(1));
+ }
+ }
+
+ static class SingleUnitComparator implements Comparator<SingleUnitImpl> {
@Override
public int compare(SingleUnitImpl o1, SingleUnitImpl o2) {
return o1.compareTo(o2);
}
}
-}
+}
\ No newline at end of file
diff --git a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/SingleUnitImpl.java b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/SingleUnitImpl.java
index 52595c0..1bec1c7 100644
--- a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/SingleUnitImpl.java
+++ b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/SingleUnitImpl.java
@@ -1,7 +1,6 @@
// © 2020 and later: Unicode, Inc. and others.
// License & terms of use: http://www.unicode.org/copyright.html
-
package com.ibm.icu.impl.units;
import com.ibm.icu.util.MeasureUnit;
diff --git a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitConverter.java b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitConverter.java
new file mode 100644
index 0000000..9ac7e1c
--- /dev/null
+++ b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitConverter.java
@@ -0,0 +1,311 @@
+// © 2020 and later: Unicode, Inc. and others.
+// License & terms of use: http://www.unicode.org/copyright.html
+
+
+package com.ibm.icu.impl.units;
+
+import com.ibm.icu.util.MeasureUnit;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.regex.Pattern;
+
+import static java.math.MathContext.DECIMAL128;
+
+public class UnitConverter {
+ private BigDecimal conversionRate;
+ private BigDecimal offset;
+
+ /**
+ * Constructor of `UnitConverter`.
+ * NOTE:
+ * - source and target must be under the same category
+ * - e.g. meter to mile --> both of them are length units.
+ *
+ * @param source represents the source unit.
+ * @param target represents the target unit.
+ * @param conversionRates contains all the needed conversion rates.
+ */
+ public UnitConverter(MeasureUnitImpl source, MeasureUnitImpl target, ConversionRates conversionRates) {
+ Convertibility convertibility = extractConvertibility(source, target, conversionRates);
+ assert (convertibility == Convertibility.CONVERTIBLE || convertibility == Convertibility.RECIPROCAL);
+
+ Factor sourceToBase = conversionRates.getFactorToBase(source);
+ Factor targetToBase = conversionRates.getFactorToBase(target);
+
+ if (convertibility == Convertibility.CONVERTIBLE) {
+ this.conversionRate = sourceToBase.divide(targetToBase).getConversionRate();
+ } else {
+ this.conversionRate = sourceToBase.multiply(targetToBase).getConversionRate();
+ }
+
+ // calculate the offset
+ this.offset = conversionRates.getOffset(source, target, sourceToBase, targetToBase, convertibility);
+ }
+
+ static public Convertibility extractConvertibility(MeasureUnitImpl source, MeasureUnitImpl target, ConversionRates conversionRates) {
+ ArrayList<SingleUnitImpl> sourceSingleUnits = conversionRates.extractBaseUnits(source);
+ ArrayList<SingleUnitImpl> targetSingleUnits = conversionRates.extractBaseUnits(target);
+
+ HashMap<String, Integer> dimensionMap = new HashMap<>();
+
+ insertInMap(dimensionMap, sourceSingleUnits, 1);
+ insertInMap(dimensionMap, targetSingleUnits, -1);
+
+ if (areDimensionsZeroes(dimensionMap)) return Convertibility.CONVERTIBLE;
+
+ insertInMap(dimensionMap, targetSingleUnits, 2);
+ if (areDimensionsZeroes(dimensionMap)) return Convertibility.RECIPROCAL;
+
+ return Convertibility.UNCONVERTIBLE;
+ }
+
+ /**
+ * Helpers
+ */
+ private static void insertInMap(HashMap<String, Integer> dimensionMap, ArrayList<SingleUnitImpl> singleUnits, int multiplier) {
+ for (SingleUnitImpl singleUnit :
+ singleUnits) {
+ if (dimensionMap.containsKey(singleUnit.getSimpleUnit())) {
+ dimensionMap.put(singleUnit.getSimpleUnit(), dimensionMap.get(singleUnit.getSimpleUnit()) + singleUnit.getDimensionality() * multiplier);
+ } else {
+ dimensionMap.put(singleUnit.getSimpleUnit(), singleUnit.getDimensionality() * multiplier);
+ }
+ }
+ }
+
+ private static boolean areDimensionsZeroes(HashMap<String, Integer> dimensionMap) {
+ for (Integer value :
+ dimensionMap.values()) {
+ if (!value.equals(0)) return false;
+ }
+
+ return true;
+ }
+
+ public BigDecimal convert(BigDecimal inputValue) {
+ return inputValue.multiply(this.conversionRate).add(offset);
+ }
+
+ public enum Convertibility {
+ CONVERTIBLE,
+ RECIPROCAL,
+ UNCONVERTIBLE,
+ }
+
+ // TODO: improve documentation and Constant implementation
+
+ /**
+ * Responsible for all the Factor operation
+ * NOTE:
+ * This class is immutable
+ */
+ static class Factor {
+ private BigDecimal factorNum;
+ private BigDecimal factorDen;
+ /* FACTOR CONSTANTS */
+ private int
+ CONSTANT_FT2M = 0, // ft2m stands for foot to meter.
+ CONSTANT_PI = 0, // PI
+ CONSTANT_GRAVITY = 0, // Gravity
+ CONSTANT_G = 0,
+ CONSTANT_GAL_IMP2M3 = 0, // Gallon imp to m3
+ CONSTANT_LB2KG = 0; // Pound to Kilogram
+
+
+ /**
+ * Creates Empty Factor
+ */
+ public Factor() {
+ this.factorNum = BigDecimal.valueOf(1);
+ this.factorDen = BigDecimal.valueOf(1);
+ }
+
+ public static Factor processFactor(String factor) {
+ assert (!factor.isEmpty());
+
+ // Remove all spaces in the factor
+ factor.replaceAll("\\s+", "");
+
+ String[] fractions = factor.split("/");
+ assert (fractions.length == 1 || fractions.length == 2);
+
+ if (fractions.length == 1) {
+ return processFactorWithoutDivision(fractions[0]);
+ }
+
+ Factor num = processFactorWithoutDivision(fractions[0]);
+ Factor den = processFactorWithoutDivision(fractions[1]);
+ return num.divide(den);
+ }
+
+ private static Factor processFactorWithoutDivision(String factorWithoutDivision) {
+ Factor result = new Factor();
+ for (String poweredEntity :
+ factorWithoutDivision.split(Pattern.quote("*"))) {
+ result.addPoweredEntity(poweredEntity);
+ }
+
+ return result;
+ }
+
+ /**
+ * Clone this <code>Factor</code>.
+ */
+ protected Factor clone() {
+ Factor result = new Factor();
+ result.factorNum = this.factorNum;
+ result.factorDen = this.factorDen;
+
+ result.CONSTANT_FT2M = this.CONSTANT_FT2M;
+ result.CONSTANT_PI = this.CONSTANT_PI;
+ result.CONSTANT_GRAVITY = this.CONSTANT_GRAVITY;
+ result.CONSTANT_G = this.CONSTANT_G;
+ result.CONSTANT_GAL_IMP2M3 = this.CONSTANT_GAL_IMP2M3;
+ result.CONSTANT_LB2KG = this.CONSTANT_LB2KG;
+
+ return result;
+ }
+
+ /**
+ * Returns a single `BigDecimal` that represent the conversion rate after substituting all the constants.
+ *
+ * @return
+ */
+ public BigDecimal getConversionRate() {
+ Factor resultCollector = this.clone();
+
+ resultCollector.substitute(new BigDecimal("0.3048"), this.CONSTANT_FT2M);
+ resultCollector.substitute(new BigDecimal("411557987.0").divide(new BigDecimal("131002976.0"), DECIMAL128), this.CONSTANT_PI);
+ resultCollector.substitute(new BigDecimal("9.80665"), this.CONSTANT_GRAVITY);
+ resultCollector.substitute(new BigDecimal("6.67408E-11"), this.CONSTANT_G);
+ resultCollector.substitute(new BigDecimal("0.00454609"), this.CONSTANT_GAL_IMP2M3);
+ resultCollector.substitute(new BigDecimal("0.45359237"), this.CONSTANT_LB2KG);
+
+ return resultCollector.factorNum.divide(resultCollector.factorDen, DECIMAL128);
+ }
+
+ private void substitute(BigDecimal value, int power) {
+ if (power == 0) return;
+
+ BigDecimal absPoweredValue = value.pow(Math.abs(power), DECIMAL128);
+ if (power > 0) {
+ this.factorNum = this.factorNum.multiply(absPoweredValue);
+ } else {
+ this.factorDen = this.factorDen.multiply(absPoweredValue);
+ }
+ }
+
+ public Factor applySiPrefix(MeasureUnit.SIPrefix siPrefix) {
+ Factor result = this.clone();
+ if (siPrefix == MeasureUnit.SIPrefix.ONE) {
+ return result;
+ }
+
+ BigDecimal siApplied = BigDecimal.valueOf(Math.pow(10.0, Math.abs(siPrefix.getSiPrefixPower())));
+
+ if (siPrefix.getSiPrefixPower() < 0) {
+ result.factorDen = this.factorDen.multiply(siApplied);
+ return result;
+ }
+
+ result.factorNum = this.factorNum.multiply(siApplied);
+ return result;
+ }
+
+ public Factor power(int power) {
+ Factor result = new Factor();
+ if (power == 0) return result;
+ if (power > 0) {
+ result.factorNum = this.factorNum.pow(power);
+ result.factorDen = this.factorDen.pow(power);
+ } else {
+ result.factorNum = this.factorDen.pow(power * -1);
+ result.factorDen = this.factorNum.pow(power * -1);
+ }
+
+ result.CONSTANT_FT2M = this.CONSTANT_FT2M * power;
+ result.CONSTANT_PI = this.CONSTANT_PI * power;
+ result.CONSTANT_GRAVITY = this.CONSTANT_GRAVITY * power;
+ result.CONSTANT_G = this.CONSTANT_G * power;
+ result.CONSTANT_GAL_IMP2M3 = this.CONSTANT_GAL_IMP2M3 * power;
+ result.CONSTANT_LB2KG = this.CONSTANT_LB2KG * power;
+
+ return result;
+ }
+
+ public Factor divide(Factor other) {
+ Factor result = new Factor();
+ result.factorNum = this.factorNum.multiply(other.factorDen);
+ result.factorDen = this.factorDen.multiply(other.factorNum);
+
+ result.CONSTANT_FT2M = this.CONSTANT_FT2M - other.CONSTANT_FT2M;
+ result.CONSTANT_PI = this.CONSTANT_PI - other.CONSTANT_PI;
+ result.CONSTANT_GRAVITY = this.CONSTANT_GRAVITY - other.CONSTANT_GRAVITY;
+ result.CONSTANT_G = this.CONSTANT_G - other.CONSTANT_G;
+ result.CONSTANT_GAL_IMP2M3 = this.CONSTANT_GAL_IMP2M3 - other.CONSTANT_GAL_IMP2M3;
+ result.CONSTANT_LB2KG = this.CONSTANT_LB2KG - other.CONSTANT_LB2KG;
+
+ return result;
+ }
+
+ public Factor multiply(Factor other) {
+ Factor result = new Factor();
+ result.factorNum = this.factorNum.multiply(other.factorNum);
+ result.factorDen = this.factorDen.multiply(other.factorDen);
+
+ result.CONSTANT_FT2M = this.CONSTANT_FT2M + other.CONSTANT_FT2M;
+ result.CONSTANT_PI = this.CONSTANT_PI + other.CONSTANT_PI;
+ result.CONSTANT_GRAVITY = this.CONSTANT_GRAVITY + other.CONSTANT_GRAVITY;
+ result.CONSTANT_G = this.CONSTANT_G + other.CONSTANT_G;
+ result.CONSTANT_GAL_IMP2M3 = this.CONSTANT_GAL_IMP2M3 + other.CONSTANT_GAL_IMP2M3;
+ result.CONSTANT_LB2KG = this.CONSTANT_LB2KG + other.CONSTANT_LB2KG;
+
+ return result;
+ }
+
+ /**
+ * Adds Entity with power or not. For example, `12 ^ 3` or `12`.
+ *
+ * @param poweredEntity
+ */
+ private void addPoweredEntity(String poweredEntity) {
+ String[] entities = poweredEntity.split(Pattern.quote("^"));
+ assert (entities.length == 1 || entities.length == 2);
+
+ int power = entities.length == 2 ? Integer.parseInt(entities[1]) : 1;
+ this.addEntity(entities[0], power);
+ }
+
+ private void addEntity(String entity, int power) {
+ if ("ft_to_m".equals(entity)) {
+ this.CONSTANT_FT2M += power;
+ } else if ("ft2_to_m2".equals(entity)) {
+ this.CONSTANT_FT2M += 2 * power;
+ } else if ("ft3_to_m3".equals(entity)) {
+ this.CONSTANT_FT2M += 3 * power;
+ } else if ("in3_to_m3".equals(entity)) {
+ this.CONSTANT_FT2M += 3 * power;
+ this.factorDen = this.factorDen.multiply(BigDecimal.valueOf(Math.pow(12, 3)));
+ } else if ("gal_to_m3".equals(entity)) {
+ this.factorNum = this.factorNum.multiply(BigDecimal.valueOf(231));
+ this.CONSTANT_FT2M += 3 * power;
+ this.factorDen = this.factorDen.multiply(BigDecimal.valueOf(12 * 12 * 12));
+ } else if ("gal_imp_to_m3".equals(entity)) {
+ this.CONSTANT_GAL_IMP2M3 += power;
+ } else if ("G".equals(entity)) {
+ this.CONSTANT_G += power;
+ } else if ("gravity".equals(entity)) {
+ this.CONSTANT_GRAVITY += power;
+ } else if ("lb_to_kg".equals(entity)) {
+ this.CONSTANT_LB2KG += power;
+ } else if ("PI".equals(entity)) {
+ this.CONSTANT_PI += power;
+ } else {
+ BigDecimal decimalEntity = new BigDecimal(entity).pow(power, DECIMAL128);
+ this.factorNum = this.factorNum.multiply(decimalEntity);
+ }
+ }
+ }
+}
diff --git a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitPreferences.java b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitPreferences.java
new file mode 100644
index 0000000..ac5d9a7
--- /dev/null
+++ b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitPreferences.java
@@ -0,0 +1,210 @@
+// © 2020 and later: Unicode, Inc. and others.
+// License & terms of use: http://www.unicode.org/copyright.html
+
+
+package com.ibm.icu.impl.units;
+
+import com.ibm.icu.impl.ICUData;
+import com.ibm.icu.impl.ICUResourceBundle;
+import com.ibm.icu.impl.UResource;
+import com.ibm.icu.util.UResourceBundle;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.HashMap;
+
+public class UnitPreferences {
+
+ private HashMap<String, HashMap<String, UnitPreference[]>> mapToUnitPreferences = new HashMap<>();
+
+ public UnitPreferences() {
+ // Read unit preferences
+ ICUResourceBundle resource;
+ resource = (ICUResourceBundle) UResourceBundle.getBundleInstance(ICUData.ICU_BASE_NAME, "units");
+ UnitPreferencesSink sink = new UnitPreferencesSink();
+ resource.getAllItemsWithFallback(UnitsData.Constants.UNIT_PREFERENCE_TABLE_NAME, sink);
+ this.mapToUnitPreferences = sink.getMapToUnitPreferences();
+ }
+
+ public static String formMapKey(String category, String usage) {
+ return category + "++" + usage;
+ }
+
+ /**
+ * Extracts all the sub-usages from a usage including the default one in the end.
+ * The usages will be in order starting with the longest matching one.
+ * For example:
+ * if usage : "person-height-child"
+ * the function will return: "person-height-child"
+ * "person-height"
+ * "person"
+ * "default"
+ *
+ * @param usage
+ * @return
+ */
+ private static String[] getAllUsages(String usage) {
+ ArrayList<String> result = new ArrayList<>();
+ result.add(usage);
+ for (int i = usage.length() - 1; i >= 0; --i) {
+ if (usage.charAt(i) == '-') {
+ result.add(usage.substring(0, i));
+ }
+ }
+
+ if (!usage.equals(UnitsData.Constants.DEFAULT_USAGE)) { // Do not add default usage twice.
+ result.add(UnitsData.Constants.DEFAULT_USAGE);
+ }
+ return result.toArray(new String[0]);
+ }
+
+ public UnitPreference[] getPreferencesFor(String category, String usage, String region) {
+ String[] subUsages = getAllUsages(usage);
+ UnitPreference[] result = null;
+ for (String subUsage :
+ subUsages) {
+ result = getUnitPreferences(category, subUsage, region);
+ if (result != null) break;
+ }
+
+ assert (result != null) : "At least the category must be exist";
+ return result;
+ }
+
+ /**
+ * @param category
+ * @param usage
+ * @param region
+ * @return null if there is no entry associated to the category and usage. O.W. returns the corresponding UnitPreference[]
+ */
+ private UnitPreference[] getUnitPreferences(String category, String usage, String region) {
+ String key = formMapKey(category, usage);
+ if (this.mapToUnitPreferences.containsKey(key)) {
+ HashMap<String, UnitPreference[]> unitPreferencesMap = this.mapToUnitPreferences.get(key);
+ UnitPreference[] result =
+ unitPreferencesMap.containsKey(region) ?
+ unitPreferencesMap.get(region) :
+ unitPreferencesMap.get(UnitsData.Constants.DEFAULT_REGION);
+
+ assert (result != null);
+ return result;
+ }
+
+ return null;
+ }
+
+ public static class UnitPreference {
+ private final String unit;
+ private final BigDecimal geq;
+ private final String skeleton;
+
+
+ public UnitPreference(String unit, String geq, String skeleton) {
+ this.unit = unit;
+ this.geq = new BigDecimal(geq);
+ this.skeleton = skeleton;
+ }
+
+ public String getUnit() {
+ return this.unit;
+ }
+
+ public BigDecimal getGeq() {
+ return geq;
+ }
+
+ public String getSkeleton() {
+ return skeleton;
+ }
+ }
+
+ public static class UnitPreferencesSink extends UResource.Sink {
+
+ private HashMap<String, HashMap<String, UnitPreference[]>> mapToUnitPreferences;
+
+ public UnitPreferencesSink() {
+ this.mapToUnitPreferences = new HashMap<>();
+ }
+
+ public HashMap<String, HashMap<String, UnitPreference[]>> getMapToUnitPreferences() {
+ return mapToUnitPreferences;
+ }
+
+ /**
+ * The unitPreferenceData structure (see icu4c/source/data/misc/units.txt) contains a
+ * hierarchy of category/usage/region, within which are a set of
+ * preferences. Hence three for-loops and another loop for the
+ * preferences themselves.
+ */
+ @Override
+ public void put(UResource.Key key, UResource.Value value, boolean noFallback) {
+ assert (UnitsData.Constants.UNIT_PREFERENCE_TABLE_NAME.equals(key.toString()));
+
+ UResource.Table categoryTable = value.getTable();
+ for (int i = 0; categoryTable.getKeyAndValue(i, key, value); i++) {
+ assert (value.getType() == UResourceBundle.TABLE);
+
+ String category = key.toString();
+ UResource.Table usageTable = value.getTable();
+ for (int j = 0; usageTable.getKeyAndValue(j, key, value); j++) {
+ assert (value.getType() == UResourceBundle.TABLE);
+
+ String usage = key.toString();
+ UResource.Table regionTable = value.getTable();
+ for (int k = 0; regionTable.getKeyAndValue(k, key, value); k++) {
+ assert (value.getType() == UResourceBundle.ARRAY);
+
+ String region = key.toString();
+ UResource.Array preferencesTable = value.getArray();
+ ArrayList<UnitPreference> unitPreferences = new ArrayList<>();
+ for (int l = 0; preferencesTable.getValue(l, value); l++) {
+ assert (value.getType() == UResourceBundle.TABLE);
+
+ UResource.Table singlePrefTable = value.getTable();
+ // TODO collect the data
+ String unit = null;
+ String geq = "1";
+ String skeleton = "";
+ for (int m = 0; singlePrefTable.getKeyAndValue(m, key, value); m++) {
+ assert (value.getType() == UResourceBundle.STRING);
+ String keyString = key.toString();
+ if ("unit".equals(keyString)) {
+ unit = value.getString();
+ } else if ("geq".equals(keyString)) {
+ geq = value.getString();
+ } else if ("skeleton".equals(keyString)) {
+ skeleton = value.getString();
+ } else {
+ assert false : "key must be unit, geq or skeleton";
+ }
+ }
+ assert (unit != null);
+ unitPreferences.add(new UnitPreference(unit, geq, skeleton));
+ }
+
+ assert (!unitPreferences.isEmpty());
+ this.insertUnitPreferences(
+ category,
+ usage,
+ region,
+ unitPreferences.toArray(new UnitPreference[0])
+ );
+ }
+ }
+ }
+ }
+
+ private void insertUnitPreferences(String category, String usage, String region, UnitPreference[] unitPreferences) {
+ String key = formMapKey(category, usage);
+ HashMap<String, UnitPreference[]> shouldInsert;
+ if (this.mapToUnitPreferences.containsKey(key)) {
+ shouldInsert = this.mapToUnitPreferences.get(key);
+ } else {
+ shouldInsert = new HashMap<>();
+ this.mapToUnitPreferences.put(key, shouldInsert);
+ }
+
+ shouldInsert.put(region, unitPreferences);
+ }
+ }
+}
diff --git a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitsData.java b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitsData.java
index 87b4e20..7df22e8 100644
--- a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitsData.java
+++ b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitsData.java
@@ -7,15 +7,29 @@
import com.ibm.icu.impl.ICUData;
import com.ibm.icu.impl.ICUResourceBundle;
import com.ibm.icu.impl.UResource;
+import com.ibm.icu.util.MeasureUnit;
import com.ibm.icu.util.UResourceBundle;
import java.util.ArrayList;
+import java.util.HashMap;
/**
* Responsible for all units data operations (retriever, analysis, extraction certain data ... etc.).
*/
-class UnitsData {
+public class UnitsData {
private volatile static String[] simpleUnits = null;
+ private ConversionRates conversionRates;
+ private UnitPreferences unitPreferences;
+ /**
+ * Pairs of categories and the corresponding base units.
+ */
+ private Categories categories;
+
+ public UnitsData() {
+ this.conversionRates = new ConversionRates();
+ this.unitPreferences = new UnitPreferences();
+ this.categories = new Categories();
+ }
public static String[] getSimpleUnits() {
if (simpleUnits != null) {
@@ -32,6 +46,38 @@
return simpleUnits;
}
+ public ConversionRates getConversionRates() {
+ return conversionRates;
+ }
+
+ public UnitPreferences getUnitPreferences() {
+ return unitPreferences;
+ }
+
+ /**
+ * @param measureUnit
+ * @return the corresponding category.
+ */
+ public String getCategory(MeasureUnitImpl measureUnit) {
+ MeasureUnitImpl baseMeasureUnit
+ = this.getConversionRates().extractCompoundBaseUnit(measureUnit);
+ String baseUnitIdentifier = MeasureUnit.fromMeasureUnitImpl(baseMeasureUnit).getIdentifier();
+
+ if (baseUnitIdentifier.equals("meter-per-cubic-meter")) {
+ // TODO(CLDR-13787,hugovdm): special-casing the consumption-inverse
+ // case. Once CLDR-13787 is clarified, this should be generalised (or
+ // possibly removed):
+
+ return "consumption-inverse";
+ }
+
+ return this.categories.mapFromUnitToCategory.get(baseUnitIdentifier);
+ }
+
+ public UnitPreferences.UnitPreference[] getPreferencesFor(String category, String usage, String region) {
+ return this.unitPreferences.getPreferencesFor(category, usage, region);
+ }
+
public static class SimpleUnitIdentifiersSink extends UResource.Sink {
String[] simpleUnits = null;
@@ -89,4 +135,51 @@
public static final String DEFAULT_REGION = "001";
public static final String DEFAULT_USAGE = "default";
}
+
+ public static class Categories {
+
+ /**
+ * Contains the map between units in their base units into their category.
+ * For example: meter-per-second --> "speed"
+ */
+ HashMap<String, String> mapFromUnitToCategory;
+
+
+ public Categories() {
+ // Read unit Categories
+ ICUResourceBundle resource;
+ resource = (ICUResourceBundle) UResourceBundle.getBundleInstance(ICUData.ICU_BASE_NAME, "units");
+ CategoriesSink sink = new CategoriesSink();
+ resource.getAllItemsWithFallback(Constants.CATEGORY_TABLE_NAME, sink);
+ this.mapFromUnitToCategory = sink.getMapFromUnitToCategory();
+ }
+ }
+
+ public static class CategoriesSink extends UResource.Sink {
+ /**
+ * Contains the map between units in their base units into their category.
+ * For example: meter-per-second --> "speed"
+ */
+ HashMap<String, String> mapFromUnitToCategory;
+
+ public CategoriesSink() {
+ mapFromUnitToCategory = new HashMap<>();
+ }
+
+ @Override
+ public void put(UResource.Key key, UResource.Value value, boolean noFallback) {
+ assert (key.toString() == Constants.CATEGORY_TABLE_NAME);
+ assert (value.getType() == UResourceBundle.TABLE);
+
+ UResource.Table categoryTable = value.getTable();
+ for (int i = 0; categoryTable.getKeyAndValue(i, key, value); i++) {
+ assert (value.getType() == UResourceBundle.STRING);
+ mapFromUnitToCategory.put(key.toString(), value.toString());
+ }
+ }
+
+ public HashMap<String, String> getMapFromUnitToCategory() {
+ return mapFromUnitToCategory;
+ }
+ }
}
diff --git a/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitsRouter.java b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitsRouter.java
new file mode 100644
index 0000000..19acafc
--- /dev/null
+++ b/icu4j/main/classes/core/src/com/ibm/icu/impl/units/UnitsRouter.java
@@ -0,0 +1,144 @@
+// © 2020 and later: Unicode, Inc. and others.
+// License & terms of use: http://www.unicode.org/copyright.html
+
+
+package com.ibm.icu.impl.units;
+
+import com.ibm.icu.util.Measure;
+import com.ibm.icu.util.MeasureUnit;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * `UnitsRouter` responsible for converting from a single unit (such as `meter` or `meter-per-second`) to
+ * one of the complex units based on the limits.
+ * For example:
+ * if the input is `meter` and the output as following
+ * {`foot+inch`, limit: 3.0}
+ * {`inch` , limit: no value (-inf)}
+ * Thus means if the input in `meter` is greater than or equal to `3.0 feet`, the output will be in
+ * `foot+inch`, otherwise, the output will be in `inch`.
+ * <p>
+ * NOTE:
+ * the output units and the their limits MUST BE in order, for example, if the output units, from the
+ * previous example, are the following:
+ * {`inch` , limit: no value (-inf)}
+ * {`foot+inch`, limit: 3.0}
+ * IN THIS CASE THE OUTPUT WILL BE ALWAYS IN `inch`.
+ * <p>
+ * NOTE:
+ * the output units and their limits will be extracted from the units preferences database by knowing
+ * the followings:
+ * - input unit
+ * - locale
+ * - usage
+ * <p>
+ * DESIGN:
+ * `UnitRouter` uses internally `ComplexUnitConverter` in order to convert the input units to the
+ * desired complex units and to check the limit too.
+ */
+public class UnitsRouter {
+ // List of possible output units. TODO: converterPreferences_ now also has
+ // this data available. Maybe drop outputUnits_ and have getOutputUnits
+ // construct a the list from data in converterPreferences_ instead?
+ private ArrayList<MeasureUnit> outputUnits_ = new ArrayList<>();
+ private ArrayList<ConverterPreference> converterPreferences_ = new ArrayList<>();
+
+ public UnitsRouter(MeasureUnitImpl inputUnitImpl, String region, String usage) {
+ // TODO: do we want to pass in ConversionRates and UnitPreferences instead?
+ // of loading in each UnitsRouter instance? (Or make global?)
+ UnitsData data = new UnitsData();
+
+ //MeasureUnitImpl inputUnitImpl = MeasureUnitImpl.forMeasureUnitMaybeCopy(inputUnit);
+ String category = data.getCategory(inputUnitImpl);
+ UnitPreferences.UnitPreference[] unitPreferences = data.getPreferencesFor(category, usage, region);
+
+ for (int i = 0; i < unitPreferences.length; ++i) {
+ UnitPreferences.UnitPreference preference = unitPreferences[i];
+
+ MeasureUnitImpl complexTargetUnitImpl =
+ MeasureUnitImpl.UnitsParser.parseForIdentifier(preference.getUnit());
+
+ String precision = preference.getSkeleton();
+
+ // For now, we only have "precision-increment" in Units Preferences skeleton.
+ // Therefore, we check if the skeleton starts with "precision-increment" and force the program to
+ // fail otherwise.
+ // NOTE:
+ // It is allowed to have an empty precision.
+ if (!precision.isEmpty() && !precision.startsWith("precision-increment")) {
+ throw new AssertionError("Only `precision-increment` is allowed");
+ }
+
+ outputUnits_.add(complexTargetUnitImpl.build());
+ converterPreferences_.add(new ConverterPreference(inputUnitImpl, complexTargetUnitImpl,
+ preference.getGeq(), precision,
+ data.getConversionRates()));
+ }
+ }
+
+ public RouteResult route(BigDecimal quantity) {
+ for (ConverterPreference converterPreference :
+ converterPreferences_) {
+ if (converterPreference.converter.greaterThanOrEqual(quantity, converterPreference.limit)) {
+ return new RouteResult(converterPreference.converter.convert(quantity), converterPreference.precision);
+ }
+ }
+
+ // In case of the `quantity` does not fit in any converter limit, use the last converter.
+ ConverterPreference lastConverterPreference = converterPreferences_.get(converterPreferences_.size() - 1);
+ return new RouteResult(lastConverterPreference.converter.convert(quantity), lastConverterPreference.precision);
+ }
+
+ /**
+ * Returns the list of possible output units, i.e. the full set of
+ * preferences, for the localized, usage-specific unit preferences.
+ * <p>
+ * The returned pointer should be valid for the lifetime of the
+ * UnitsRouter instance.
+ */
+ public ArrayList<MeasureUnit> getOutputUnits() {
+ return this.outputUnits_;
+ }
+
+ /**
+ * Contains the complex unit converter and the limit which representing the smallest value that the
+ * converter should accept. For example, if the converter is converting to `foot+inch` and the limit
+ * equals 3.0, thus means the converter should not convert to a value less than `3.0 feet`.
+ * <p>
+ * NOTE:
+ * if the limit doest not has a value `i.e. (std::numeric_limits<double>::lowest())`, this mean there
+ * is no limit for the converter.
+ */
+ public static class ConverterPreference {
+ ComplexUnitsConverter converter;
+ BigDecimal limit;
+ String precision;
+
+ // In case there is no limit, the limit will be -inf.
+ public ConverterPreference(MeasureUnitImpl source, MeasureUnitImpl outputUnits,
+ String precision, ConversionRates conversionRates) {
+ this(source, outputUnits, BigDecimal.valueOf(Double.MIN_VALUE), precision,
+ conversionRates);
+ }
+
+ public ConverterPreference(MeasureUnitImpl source, MeasureUnitImpl outputUnits,
+ BigDecimal limit, String precision, ConversionRates conversionRates) {
+ this.converter = new ComplexUnitsConverter(source, outputUnits, conversionRates);
+ this.limit = limit;
+ this.precision = precision;
+ }
+ }
+
+ public class RouteResult {
+ public List<Measure> measures;
+ public String precision;
+
+ RouteResult(List<Measure> measures, String precision) {
+ this.measures = measures;
+ this.precision = precision;
+ }
+ }
+}
diff --git a/icu4j/main/classes/core/src/com/ibm/icu/util/MeasureUnit.java b/icu4j/main/classes/core/src/com/ibm/icu/util/MeasureUnit.java
index 32c05c4..3e81417 100644
--- a/icu4j/main/classes/core/src/com/ibm/icu/util/MeasureUnit.java
+++ b/icu4j/main/classes/core/src/com/ibm/icu/util/MeasureUnit.java
@@ -32,7 +32,6 @@
import com.ibm.icu.text.UnicodeSet;
-
/**
* A unit such as length, mass, volume, currency, etc. A unit is
* coupled with a numeric amount to produce a Measure. MeasureUnit objects are immutable.
diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/impl/UnitsTest.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/impl/UnitsTest.java
new file mode 100644
index 0000000..3ace927
--- /dev/null
+++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/impl/UnitsTest.java
@@ -0,0 +1,366 @@
+// © 2020 and later: Unicode, Inc. and others.
+// License & terms of use: http://www.unicode.org/copyright.html
+
+
+package com.ibm.icu.dev.test.impl;
+
+import com.ibm.icu.dev.test.TestUtil;
+import com.ibm.icu.impl.Pair;
+import com.ibm.icu.impl.units.ComplexUnitsConverter;
+import com.ibm.icu.impl.units.ConversionRates;
+import com.ibm.icu.impl.units.MeasureUnitImpl;
+import com.ibm.icu.impl.units.UnitConverter;
+import com.ibm.icu.impl.units.UnitsRouter;
+import com.ibm.icu.util.Measure;
+import com.ibm.icu.util.MeasureUnit;
+import org.junit.Test;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.MathContext;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.*;
+
+
+public class UnitsTest {
+
+ public static boolean compareTwoBigDecimal(BigDecimal expected, BigDecimal actual, BigDecimal delta) {
+ BigDecimal diff =
+ expected.abs().compareTo(BigDecimal.ZERO) < 1 ?
+ expected.subtract(actual).abs() :
+ (expected.subtract(actual).divide(expected, MathContext.DECIMAL128)).abs();
+
+ if (diff.compareTo(delta) == -1) return true;
+ return false;
+ }
+
+ @Test
+ public void testComplexUnitsConverter() {
+ ConversionRates rates = new ConversionRates();
+ MeasureUnit input = MeasureUnit.FOOT;
+ MeasureUnit output = MeasureUnit.forIdentifier("foot-and-inch");
+ final MeasureUnitImpl inputImpl = MeasureUnitImpl.forIdentifier(input.getIdentifier());
+ final MeasureUnitImpl outputImpl = MeasureUnitImpl.forIdentifier(output.getIdentifier());
+ ComplexUnitsConverter converter = new ComplexUnitsConverter(inputImpl, outputImpl, rates);
+
+ // Significantly less than 2.0.
+ List<Measure> measures = converter.convert(BigDecimal.valueOf(1.9999));
+ assertEquals("measures length", 2, measures.size());
+ assertEquals("1.9999: measures[0] value", BigDecimal.valueOf(1), measures.get(0).getNumber());
+ assertEquals("1.9999: measures[0] unit", MeasureUnit.FOOT.getIdentifier(),
+ measures.get(0).getUnit().getIdentifier());
+
+ assertTrue("1.9999: measures[1] value", compareTwoBigDecimal(BigDecimal.valueOf(11.9988),
+ BigDecimal.valueOf(measures.get(1).getNumber().doubleValue()), BigDecimal.valueOf(0.0001)));
+ assertEquals("1.9999: measures[1] unit", MeasureUnit.INCH.getIdentifier(),
+ measures.get(1).getUnit().getIdentifier());
+
+ // TODO: consider factoring out the set of tests to make this function more
+ // data-driven, *after* dealing appropriately with the memory leaks that can
+ // be demonstrated by this code.
+
+ // TODO: reusing measures results in a leak.
+ // A minimal nudge under 2.0.
+ List<Measure> measures2 = converter.convert(BigDecimal.valueOf(2.0).subtract(ComplexUnitsConverter.EPSILON));
+ assertEquals("measures length", 2, measures2.size());
+ assertEquals("1 - eps: measures[0] value", BigDecimal.valueOf(2), measures2.get(0).getNumber());
+ assertEquals("1 - eps: measures[0] unit", MeasureUnit.FOOT.getIdentifier(),
+ measures2.get(0).getUnit().getIdentifier());
+ assertEquals("1 - eps: measures[1] value", BigDecimal.ZERO, measures2.get(1).getNumber());
+ assertEquals("1 - eps: measures[1] unit", MeasureUnit.INCH.getIdentifier(),
+ measures2.get(1).getUnit().getIdentifier());
+
+ // Testing precision with meter and light-year. 1e-16 light years is
+ // 0.946073 meters, and double precision can provide only ~15 decimal
+ // digits, so we don't expect to get anything less than 1 meter.
+
+ // An epsilon's nudge under one light-year: should give 1 ly, 0 m.
+ input = MeasureUnit.LIGHT_YEAR;
+ output = MeasureUnit.forIdentifier("light-year-and-meter");
+ final MeasureUnitImpl inputImpl3 = MeasureUnitImpl.forIdentifier(input.getIdentifier());
+ final MeasureUnitImpl outputImpl3 = MeasureUnitImpl.forIdentifier(output.getIdentifier());
+
+ // TODO: reusing converter results in a leak.
+ ComplexUnitsConverter converter3 = new ComplexUnitsConverter(inputImpl3, outputImpl3, rates);
+
+ // TODO: reusing measures results in a leak.
+ List<Measure> measures3 = converter3.convert(BigDecimal.valueOf(2.0).subtract(ComplexUnitsConverter.EPSILON));
+ assertEquals("measures length", 2, measures3.size());
+ assertEquals("light-year test: measures[0] value", BigDecimal.valueOf(2), measures3.get(0).getNumber());
+ assertEquals("light-year test: measures[0] unit", MeasureUnit.LIGHT_YEAR.getIdentifier(),
+ measures3.get(0).getUnit().getIdentifier());
+ assertEquals("light-year test: measures[1] value", BigDecimal.ZERO, measures3.get(1).getNumber());
+ assertEquals("light-year test: measures[1] unit", MeasureUnit.METER.getIdentifier(),
+ measures3.get(1).getUnit().getIdentifier());
+
+ // 1e-15 light years is 9.46073 meters (calculated using "bc" and the CLDR
+ // conversion factor). With double-precision maths, we get 10.5. In this
+ // case, we're off by almost 1 meter.
+ List<Measure> measures4 = converter3.convert(BigDecimal.valueOf(1.0 + 1e-15));
+ assertEquals("measures length", 2, measures4.size());
+ assertEquals("light-year test: measures[0] value", BigDecimal.ONE, measures4.get(0).getNumber());
+ assertEquals("light-year test: measures[0] unit", MeasureUnit.LIGHT_YEAR.getIdentifier(),
+ measures4.get(0).getUnit().getIdentifier());
+ assertTrue("light-year test: measures[1] value", compareTwoBigDecimal(BigDecimal.valueOf(10),
+ BigDecimal.valueOf(measures4.get(1).getNumber().doubleValue()),
+ BigDecimal.valueOf(1)));
+ assertEquals("light-year test: measures[1] unit", MeasureUnit.METER.getIdentifier(),
+ measures4.get(1).getUnit().getIdentifier());
+
+ // 2e-16 light years is 1.892146 meters. We consider this in the noise, and
+ // thus expect a 0. (This test fails when 2e-16 is increased to 4e-16.)
+ List<Measure> measures5 = converter3.convert(BigDecimal.valueOf(1.0 + 2e-17));
+ assertEquals("measures length", 2, measures5.size());
+ assertEquals("light-year test: measures[0] value", BigDecimal.ONE, measures5.get(0).getNumber());
+ assertEquals("light-year test: measures[0] unit", MeasureUnit.LIGHT_YEAR.getIdentifier(),
+ measures5.get(0).getUnit().getIdentifier());
+ assertEquals("light-year test: measures[1] value", BigDecimal.valueOf(0.0),
+ measures5.get(1).getNumber());
+ assertEquals("light-year test: measures[1] unit", MeasureUnit.METER.getIdentifier(),
+ measures5.get(1).getUnit().getIdentifier());
+
+ // TODO(icu-units#63): test negative numbers!
+ }
+
+
+ @Test
+ public void testComplexUnitConverterSorting() {
+
+ MeasureUnitImpl source = MeasureUnitImpl.forIdentifier("meter");
+ MeasureUnitImpl target = MeasureUnitImpl.forIdentifier("inch-and-foot");
+ ConversionRates conversionRates = new ConversionRates();
+
+ ComplexUnitsConverter complexConverter = new ComplexUnitsConverter(source, target, conversionRates);
+ List<Measure> measures = complexConverter.convert(BigDecimal.valueOf(10.0));
+
+ assertEquals(measures.size(), 2);
+ assertEquals("inch-and-foot unit 0", "foot", measures.get(0).getUnit().getIdentifier());
+ assertEquals("inch-and-foot unit 1", "inch", measures.get(1).getUnit().getIdentifier());
+
+ assertTrue("inch-and-foot value 0", compareTwoBigDecimal(BigDecimal.valueOf(32), BigDecimal.valueOf(measures.get(0).getNumber().doubleValue()), BigDecimal.valueOf(0.0001)));
+ assertTrue("inch-and-foot value 1", compareTwoBigDecimal(BigDecimal.valueOf(9.7008), BigDecimal.valueOf(measures.get(1).getNumber().doubleValue()), BigDecimal.valueOf(0.0001)));
+ }
+
+
+ @Test
+ public void testExtractConvertibility() {
+ class TestData {
+ MeasureUnitImpl source;
+ MeasureUnitImpl target;
+ UnitConverter.Convertibility expected;
+
+ TestData(String source, String target, UnitConverter.Convertibility convertibility) {
+ this.source = MeasureUnitImpl.UnitsParser.parseForIdentifier(source);
+ this.target = MeasureUnitImpl.UnitsParser.parseForIdentifier(target);
+ this.expected = convertibility;
+ }
+ }
+
+ TestData[] tests = {
+ new TestData("meter", "foot", UnitConverter.Convertibility.CONVERTIBLE),
+ new TestData("square-meter-per-square-hour", "hectare-per-square-second", UnitConverter.Convertibility.CONVERTIBLE),
+ new TestData("hertz", "revolution-per-second", UnitConverter.Convertibility.CONVERTIBLE),
+ new TestData("millimeter", "meter", UnitConverter.Convertibility.CONVERTIBLE),
+ new TestData("yard", "meter", UnitConverter.Convertibility.CONVERTIBLE),
+ new TestData("ounce-troy", "kilogram", UnitConverter.Convertibility.CONVERTIBLE),
+ new TestData("percent", "portion", UnitConverter.Convertibility.CONVERTIBLE),
+ new TestData("ofhg", "kilogram-per-square-meter-square-second", UnitConverter.Convertibility.CONVERTIBLE),
+
+ new TestData("second-per-meter", "meter-per-second", UnitConverter.Convertibility.RECIPROCAL),
+ };
+ ConversionRates conversionRates = new ConversionRates();
+
+ for (TestData test :
+ tests) {
+ assertEquals(test.expected, UnitConverter.extractConvertibility(test.source, test.target, conversionRates));
+ }
+ }
+
+ @Test
+ public void testConverterForTemperature() {
+ class TestData {
+ MeasureUnitImpl source;
+ MeasureUnitImpl target;
+ BigDecimal input;
+ BigDecimal expected;
+
+ TestData(String source, String target, double input, double expected) {
+ this.source = MeasureUnitImpl.UnitsParser.parseForIdentifier(source);
+ this.target = MeasureUnitImpl.UnitsParser.parseForIdentifier(target);
+ this.input = BigDecimal.valueOf(input);
+ this.expected = BigDecimal.valueOf(expected);
+ }
+
+ }
+
+ TestData[] tests = {
+ new TestData("celsius", "fahrenheit", 1000, 1832),
+ new TestData("fahrenheit", "fahrenheit", 1000, 1000),
+ };
+
+ ConversionRates conversionRates = new ConversionRates();
+
+ for (TestData test :
+ tests) {
+ UnitConverter converter = new UnitConverter(test.source, test.target, conversionRates);
+ assertEquals(test.expected.doubleValue(), converter.convert(test.input).doubleValue(), (0.001));
+ }
+
+ }
+
+ @Test
+ public void testConverterFromUnitTests() throws IOException {
+ class TestCase {
+ String category;
+ String sourceString;
+ String targetString;
+ MeasureUnitImpl source;
+ MeasureUnitImpl target;
+ BigDecimal input;
+ BigDecimal expected;
+
+ TestCase(String line) {
+ String[] fields = line
+ .replaceAll(" ", "") // Remove all the spaces.
+ .replaceAll(",", "") // Remove all the commas.
+ .replaceAll("\t", "")
+ .split(";");
+
+ this.category = fields[0].replaceAll(" ", "");
+ this.sourceString = fields[1];
+ this.targetString = fields[2];
+ this.source = MeasureUnitImpl.UnitsParser.parseForIdentifier(fields[1]);
+ this.target = MeasureUnitImpl.UnitsParser.parseForIdentifier(fields[2]);
+ this.input = BigDecimal.valueOf(1000);
+ this.expected = new BigDecimal(fields[4]);
+ }
+ }
+
+ String codePage = "UTF-8";
+ BufferedReader f = TestUtil.getDataReader("cldr/units/unitsTest.txt", codePage);
+ ArrayList<TestCase> tests = new ArrayList<>();
+ while (true) {
+ String line = f.readLine();
+ if (line == null) break;
+ if (line.isEmpty() || line.startsWith("#")) continue;
+ tests.add(new TestCase(line));
+ }
+
+ ConversionRates conversionRates = new ConversionRates();
+
+ for (TestCase testCase :
+ tests) {
+ UnitConverter converter = new UnitConverter(testCase.source, testCase.target, conversionRates);
+ if (compareTwoBigDecimal(testCase.expected, converter.convert(testCase.input), BigDecimal.valueOf(0.000001))) {
+ continue;
+ } else {
+ fail(new StringBuilder()
+ .append(testCase.category)
+ .append(" ")
+ .append(testCase.sourceString)
+ .append(" ")
+ .append(testCase.targetString)
+ .append(" ")
+ .append(converter.convert(testCase.input).toString())
+ .append(" expected ")
+ .append(testCase.expected.toString())
+ .toString());
+ }
+ }
+ }
+
+ @Test
+ public void testUnitPreferencesFromUnitTests() throws IOException {
+ class TestCase {
+
+ final ArrayList<Pair<String, MeasureUnitImpl>> outputUnitInOrder = new ArrayList<>();
+ final ArrayList<BigDecimal> expectedInOrder = new ArrayList<>();
+ /**
+ * Test Case Data
+ */
+ String category;
+ String usage;
+ String region;
+ Pair<String, MeasureUnitImpl> inputUnit;
+ BigDecimal input;
+
+ TestCase(String line) {
+ String[] fields = line
+ .replaceAll(" ", "") // Remove all the spaces.
+ .replaceAll(",", "") // Remove all the commas.
+ .replaceAll("\t", "")
+ .split(";");
+
+ String category = fields[0];
+ String usage = fields[1];
+ String region = fields[2];
+ String inputValue = fields[4];
+ String inputUnit = fields[5];
+ ArrayList<Pair<String, String>> outputs = new ArrayList<>();
+
+ for (int i = 6; i < fields.length - 2; i += 2) {
+ if (i == fields.length - 3) { // last field
+ outputs.add(Pair.of(fields[i + 2], fields[i + 1]));
+ } else {
+ outputs.add(Pair.of(fields[i + 1], fields[i]));
+ }
+ }
+
+ this.insertData(category, usage, region, inputUnit, inputValue, outputs);
+ }
+
+ private void insertData(String category,
+ String usage,
+ String region,
+ String inputUnitString,
+ String inputValue,
+ ArrayList<Pair<String, String>> outputs /* Unit Identifier, expected value */) {
+ this.category = category;
+ this.usage = usage;
+ this.region = region;
+ this.inputUnit = Pair.of(inputUnitString, MeasureUnitImpl.UnitsParser.parseForIdentifier(inputUnitString));
+ this.input = new BigDecimal(inputValue);
+ for (Pair<String, String> output :
+ outputs) {
+ outputUnitInOrder.add(Pair.of(output.first, MeasureUnitImpl.UnitsParser.parseForIdentifier(output.first)));
+ expectedInOrder.add(new BigDecimal(output.second));
+ }
+ }
+ }
+
+ // Read Test data from the unitPreferencesTest
+ String codePage = "UTF-8";
+ BufferedReader f = TestUtil.getDataReader("cldr/units/unitPreferencesTest.txt", codePage);
+ ArrayList<TestCase> tests = new ArrayList<>();
+ while (true) {
+ String line = f.readLine();
+ if (line == null) break;
+ if (line.isEmpty() || line.startsWith("#")) continue;
+ tests.add(new TestCase(line));
+ }
+
+ for (TestCase testCase :
+ tests) {
+ UnitsRouter router = new UnitsRouter(testCase.inputUnit.second, testCase.region, testCase.usage);
+ List<Measure> measures = router.route(testCase.input).measures;
+
+ assertEquals("Measures size must be the same as expected units",
+ measures.size(), testCase.expectedInOrder.size());
+ assertEquals("Measures size must be the same as output units",
+ measures.size(), testCase.outputUnitInOrder.size());
+
+
+ for (int i = 0; i < measures.size(); i++) {
+ if (!UnitsTest
+ .compareTwoBigDecimal(testCase.expectedInOrder.get(i),
+ BigDecimal.valueOf(measures.get(i).getNumber().doubleValue()),
+ BigDecimal.valueOf(0.00001))) {
+ fail(testCase.toString() + measures.toString());
+ }
+ }
+ }
+ }
+}