Saturday, 8 November 2014

Unit converter data structures

This post will use Java's Map API extensively and assumes you are familiar with them. Read the Java tutorial on Maps if you think I'm talking about a street map or if you want to refresh your memory.

In this post, some code showing how TLCalc's unit converter works. If you read the preceding post to this, you might have suspected I would be using a map interface where the keys are the unit's name and the entry is the scaling factor. The final product is a bit more generic than that and encapsulates the scaling factor in an object called UnitInfo. This will allow us to handle non-scalar units in one go. Due to the long length of the post, only the data structure utilised by the unit converter and how the app creates those data structures is covered in this post.

UnitInfo


So first, here is the interface for UnitInfo:
public interface UnitInfo {
    public double forwardConvert(double a);
    public double reverseConvert(double b);
    public String getDimension();
    public boolean allowSIPrefix();
    public boolean isScalar();
}
The methods forwardConvert and reverseConvert performs the conversion of the quantity from the UnitInfo's represented quantity to the chosen common quantity. getDimension is the dimension of the unit as a string and I will explain this further later. allowSIPrefix indicates whether the unit can be prefixed with an SI prefix like "kilo" and isScalar is whether the unit is scalar. The scalar property is used to display a warning when compounding as non-scalar units will result in a conversion function that depends on the order the units are converted. In fact, non-scalar units should not be compounded as the result is most likely rubbish.

You may have noticed I have not included the unit's name in the data. That is because multiple names can be mapped to the same unit (most arising from an abbreviated and non-abbreviated version) and the name does not affect the conversion in any way. It would also be wasteful specifying the name in the map and the unit info at the same time (although with increasing RAM of phones nowadays, this is becoming less significant).

The properties dimension and allowsSIPrefix are simply properties that can be stored in variables to an abstract version of UnitInfo was created:
    public static abstract class AbstractUnitInfo implements UnitInfo {

        private final String dimension;
        private final boolean allowSIPrefix;

        protected AbstractUnitInfo(String dimension, boolean allowSIUnitPrefix) {
            this.dimension = dimension;
            this.allowSIPrefix = allowSIUnitPrefix;
        }

        @Override
        public String getDimension() {
            return dimension;
        }

        @Override
        public boolean allowSIPrefix() {
            return allowSIPrefix;
        }

        @Override
        public boolean isScalar() {
            return false;
        }
    }
The implementation of a scalar unit follows:
    public static class ScalarUnitInfo extends AbstractUnitInfo {

        private final double m;

        public ScalarUnitInfo(String dim, double m, boolean allowSIUnitPrefix) {
            super(dim, allowSIUnitPrefix);
            this.m = m;
        }

        @Override
        public double forwardConvert(double a) {
            return a*m;
        }

        @Override
        public double reverseConvert(double b) {
            return b/m;
        }

        @Override
        public boolean isScalar() {
            return true;
        }
    }
Attached at the end is the source code for all types of UnitInfo including linear units (LinearUnitInfo) such as temperature and even non-linear units (ExprUnitInfo) such as decibels. Non-linear units are specified as expressions where the input is variable x. You can view the source file but they are pretty easy to grasp. The ExprUnitInfo uses the expression parser of TLCalc but you could use your own expression parser.

The most complicated of the implementation of UnitInfo is the CompoundUnitInfo which allows one to build a UnitInfo from other UnitInfo. The compound version simply chains units together by chaining calls to convert so you can think if each unit as a function such as kg(x), lb(x), metre(x), etc and to compound these units together you apply them like so: metre(lb(kg(x))). CompoundUnitInfo also accepts ratio conversions implemented as compound of numerator units divided by compound of denominator units. CompoundUnitInfo is really only useful for scalar units.
    public static class CompoundUnitInfo implements UnitInfo {

        private final ArrayList<UnitInfo> uis = new ArrayList<UnitInfo>(10);
        private String dimCache;
        private int splitPoint;

        public void add(UnitInfo ui, boolean isNum) {
            dimCache = null;
            if (isNum) {
                uis.add(splitPoint, ui);
                splitPoint++;
            } else {
                uis.add(ui);
            }

        }

        public int getCount() {
            return uis.size();
        }

        public void clear() {
            dimCache = null;
            uis.clear();
            splitPoint = 0;
        }

        public boolean isEmpty() {
            return uis.isEmpty();
        }

        public void swap(CompoundUnitInfo cui) {
            final ArrayList<UnitInfo> tmp = new ArrayList<UnitInfo>(uis);
            String tmpDimCache = dimCache;
            int sp = splitPoint;
            uis.clear();
            uis.addAll(cui.uis);
            dimCache = cui.dimCache;
            splitPoint = cui.splitPoint;
            cui.uis.clear();
            cui.uis.addAll(tmp);
            cui.dimCache = tmpDimCache;
            cui.splitPoint = sp;
        }

        @Override
        public double forwardConvert(double a) {
            for (int i=0; i<splitPoint; i++) {
                a = uis.get(i).forwardConvert(a);
            }
            double b = 1;
            for (int i=splitPoint; i<uis.size(); i++) {
                b = uis.get(i).forwardConvert(b);
            }
            return a/b;
        }

        @Override
        public double reverseConvert(double b) {
            for (int i=0; i<splitPoint; i++) {
                b = uis.get(i).reverseConvert(b);
            }
            double a = 1;
            for (int i=splitPoint; i<uis.size(); i++) {
                a = uis.get(i).reverseConvert(a);
            }
            return b/a;
        }

        @Override
        public String getDimension() {
            if (dimCache == null) {
                int[] dims = new int[5];
                for (int i=0; i<uis.size(); i++) {
                    Utils.addDim(dims, uis.get(i).getDimension(), 
                            i < splitPoint ? 1 : -1);
                }
                dimCache = Utils.dimToString(dims);
            }
            return dimCache;
        }

        @Override
        public boolean allowSIPrefix() {
            return false;
        }

        @Override
        public boolean isScalar() {
            for (int i=0; i<uis.size(); i++) {
                if (!uis.get(i).isScalar()) return false;
            }
            return true;
        }
        
    }

Parsing CSV data to UnitInfo Map


The data for unit is stored in a map to allow fast and efficient lookup of unit names. Only the base units are stored in a map and prefixes are handled by building up upon the base unit. The same is done for powers. Two maps are used, one stores the base units and one stores the SI prefix.
    public static Map<String, UnitInfo> unitsMap, siMap;

The SI map is hard coded:
    private static void loadSIMap() {
        siMap = new ArrayMap<String, UnitInfo>(28 * 2);
        dualPut("yotta", "Y", 1E+24, siMap);
        dualPut("zetta", "Z", 1E+21, siMap);
        dualPut("exa", "E", 1E+18, siMap);
        dualPut("peta", "P", 1E+15, siMap);
        dualPut("tera", "T", 1E+12, siMap);
        dualPut("giga", "G", 1E+09, siMap);
        dualPut("mega", "M", 1E+06, siMap);
        dualPut("kilo", "k", 1E+03, siMap);
        dualPut("hecto", "h", 1E+02, siMap);
        dualPut("deca", "da", 1E+01, siMap);
        dualPut("deci", "d", 1E-01, siMap);
        dualPut("centi", "c", 1E-02, siMap);
        dualPut("milli", "m", 1E-03, siMap);
        dualPut("micro", "u", 1E-06, siMap);
        dualPut("nano", "n", 1E-09, siMap);
        dualPut("pico", "p", 1E-12, siMap);
        dualPut("fempto", "f", 1E-15, siMap);
        dualPut("atto", "a", 1E-18, siMap);
        dualPut("zepto", "z", 1E-21, siMap);
        dualPut("yocto", "y", 1E-24, siMap);

        dualPut("yobi", "Yi", Math.pow(2, 80), siMap);
        dualPut("zebi", "Zi", Math.pow(2, 70), siMap);
        dualPut("exbi", "Ei", Math.pow(2, 60), siMap);
        dualPut("pebi", "Pi", Math.pow(2, 50), siMap);
        dualPut("tebi", "Ti", Math.pow(2, 40), siMap);
        dualPut("gibi", "Gi", Math.pow(2, 30), siMap);
        dualPut("mebi", "Mi", Math.pow(2, 20), siMap);
        dualPut("kibi", "ki", Math.pow(2, 10), siMap);
    }

    /**
     * Helper method to add same unit under two names to the unit map.
     * This method is designed for ScalarUnitInfo only.
     * @param s1 name 1
     * @param s2 name 2
     * @param val value of ScalarUnitInfo
     * @param map map to add unit to
     */
    private static void dualPut(String s1, String s2, double val, 
            Map<String, UnitInfo> map) {
        UnitInfo ui = new UnitInfo.ScalarUnitInfo("", val, false);
        map.put(s1, ui);
        map.put(s2, ui);
    }
To parse the CSV file described from the previous post, we parse the text file line by line and break up the line into tokens separated by commas. This was developed in Android so the file was retrieved using getAssets. The implementation of UnitInfo used is determine by whether the unit is scalar, linear or none of the above.
    /**
     * Loads units data from CSV file located in the assets folder. Will not
     * throw an error although if an error occurs from reading the file, the
     * routine will stop loading any more data and return what is has
     * successfully parsed.
     * @param c context to retrieve CSV asset.
     */
    private void loadUnits(Context c) {
        unitsMap = new ArrayMap<String, UnitInfo>(50);

        // Load CSV data file from Assets
        InputStream is = null;
        try {
            is = c.getResources().getAssets().open("unit_conversion_dat.csv");
        } catch (IOException e) {
            Log.e(UnitConverterActivity.class.getName(),
                    "Error opening unit conversion data file", e);
        }
        if (is == null) return;
        BufferedReader reader = new BufferedReader(new InputStreamReader(is));

        String line;
        String[] st, names;
        try {
            reader.readLine(); // Discard header line
            while ((line = reader.readLine()) != null) {
                st = line.split(",");
                // Name  | type  |  linear  |  conversion
                //                             Forward | Backward
                if (st.length < 6) {
                    Log.e(UnitConverterActivity.class.getName(),
                            "Skipped invalid line: " + line);
                    continue;
                }
                if (st[0].isEmpty()) continue; // Invalid name
                UnitInfo tmp;
                final boolean isScalar = st[5].charAt(0) == '1';
                if ("1".equals(st[2])) {
                    // Linear equation
                    if (st[4].isEmpty()) {
                        tmp = new UnitInfo.ScalarUnitInfo(st[1], 
                                Double.parseDouble(st[3]),
                                isScalar);
                    } else {
                        tmp = new UnitInfo.LinearUnitInfo(st[1], 
                                Double.parseDouble(st[3]),
                                Double.parseDouble(st[4]), isScalar);
                    }
                } else {
                    // Non-linear equation
                    tmp = new UnitInfo.ExprUnitInfo(st[1], st[3], st[4], isScalar);
                }

                // Split name up
                names = st[0].split(";");
                for (String name : names) {
                    unitsMap.put(name, tmp);
                }
            }
        } catch (Exception e) {
            Log.e(UnitConverterActivity.class.getName(),
                    "Error loading unit conversion data file");
        }
    }
That's it for now, the next post will cover parsing unit description strings using the data structures we have created above.

Resources: UnitInfo.java