UnitsRouter.java

// © 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.IllegalIcuArgumentException;
import com.ibm.icu.impl.number.MicroProps;
import com.ibm.icu.number.Precision;
import com.ibm.icu.util.MeasureUnit;
import com.ibm.icu.util.ULocale;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

/**
 * {@code UnitsRouter} responsible for converting from a single unit (such as {@code meter} or
 * {@code meter-per-second}) to one of the complex units based on the limits. For example: if the
 * input is {@code meter} and the output as following {{@code foot+inch}, limit: 3.0} {{@code inch}
 * , limit: no value (-inf)} Thus means if the input in {@code meter} is greater than or equal to
 * {@code 3.0 feet}, the output will be in {@code foot+inch}, otherwise, the output will be in
 * {@code inch}.
 *
 * <p>NOTE: the output units and their limits MUST BE in order, for example, if the output units,
 * from the previous example, are the following: {{@code inch} , limit: no value (-inf)} {{@code
 * foot+inch}, limit: 3.0} IN THIS CASE THE OUTPUT WILL BE ALWAYS IN {@code 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: {@code UnitRouter} uses internally {@code 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(String inputUnitIdentifier, ULocale locale, String usage) {
        this(MeasureUnitImpl.forIdentifier(inputUnitIdentifier), locale, usage);
    }

    public UnitsRouter(MeasureUnitImpl inputUnit, ULocale locale, 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();

        String category = data.getCategory(inputUnit);
        UnitPreferences.UnitPreference[] unitPreferences =
                data.getPreferencesFor(category, usage, locale);

        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(
                            inputUnit,
                            complexTargetUnitImpl,
                            preference.getGeq(),
                            precision,
                            data.getConversionRates()));
        }
    }

    /** If micros.rounder is a BogusRounder, this function replaces it with a valid one. */
    public RouteResult route(BigDecimal quantity, MicroProps micros) {
        Precision rounder = micros == null ? null : micros.rounder;
        ConverterPreference converterPreference = null;
        for (ConverterPreference itr : converterPreferences_) {
            converterPreference = itr;
            if (converterPreference.converter.greaterThanOrEqual(
                    quantity.abs(), converterPreference.limit)) {
                break;
            }
        }
        assert converterPreference != null;
        assert converterPreference.precision != null;

        // Set up the rounder for this preference's precision
        if (rounder != null && rounder instanceof Precision.BogusRounder) {
            Precision.BogusRounder bogus = (Precision.BogusRounder) rounder;
            if (converterPreference.precision.length() > 0) {
                rounder = bogus.into(parseSkeletonToPrecision(converterPreference.precision));
            } else {
                // We use the same rounding mode as COMPACT notation: known to be a
                // human-friendly rounding mode: integers, but add a decimal digit
                // as needed to ensure we have at least 2 significant digits.
                rounder = bogus.into(Precision.integer().withMinDigits(2));
            }
        }

        if (micros != null) {
            micros.rounder = rounder;
        }
        return new RouteResult(
                converterPreference.converter.convert(quantity, rounder),
                converterPreference.targetUnit);
    }

    private static Precision parseSkeletonToPrecision(String precisionSkeleton) {
        final String kSkeletonPrefix = "precision-increment/";
        if (!precisionSkeleton.startsWith(kSkeletonPrefix)) {
            throw new IllegalIcuArgumentException("precisionSkeleton is only precision-increment");
        }

        // TODO(icu-units#104): the C++ code uses a more sophisticated
        // parseIncrementOption which supports "withMinFraction" - e.g.
        // "precision-increment/0.5". Test with a unit preference that uses
        // this, and fix Java.
        String incrementValue = precisionSkeleton.substring(kSkeletonPrefix.length());
        return Precision.increment(new BigDecimal(incrementValue));
    }

    /**
     * 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 List<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 {@code foot+inch}
     * and the limit equals 3.0, thus means the converter should not convert to a value less than
     * {@code 3.0 feet}.
     *
     * <p>NOTE: if the limit doest not has a value (i.e. {@code
     * std::numeric_limits<double>::lowest()}), this mean there is no limit for the converter.
     */
    public static class ConverterPreference {
        // The output unit for this ConverterPreference. This may be a MIXED unit -
        // for example: "yard-and-foot-and-inch".
        final MeasureUnitImpl targetUnit;
        final ComplexUnitsConverter converter;
        final BigDecimal limit;
        final String precision;

        // In case there is no limit, the limit will be -inf.
        public ConverterPreference(
                MeasureUnitImpl source,
                MeasureUnitImpl targetUnit,
                String precision,
                ConversionRates conversionRates) {
            this(
                    source,
                    targetUnit,
                    BigDecimal.valueOf(Double.MIN_VALUE),
                    precision,
                    conversionRates);
        }

        public ConverterPreference(
                MeasureUnitImpl source,
                MeasureUnitImpl targetUnit,
                BigDecimal limit,
                String precision,
                ConversionRates conversionRates) {
            this.converter = new ComplexUnitsConverter(source, targetUnit, conversionRates);
            this.limit = limit;
            this.precision = precision;
            this.targetUnit = targetUnit;
        }
    }

    public class RouteResult {
        public final ComplexUnitsConverter.ComplexConverterResult complexConverterResult;

        // The output unit for this RouteResult. This may be a MIXED unit - for
        // example: "yard-and-foot-and-inch", for which `measures` will have three
        // elements.
        public final MeasureUnitImpl outputUnit;

        RouteResult(
                ComplexUnitsConverter.ComplexConverterResult complexConverterResult,
                MeasureUnitImpl outputUnit) {
            this.complexConverterResult = complexConverterResult;
            this.outputUnit = outputUnit;
        }
    }
}