/** * Copyright (C) 2018 Adrien Hopkins * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ package org.unitConverter; import java.io.BufferedReader; import java.io.File; import java.io.FileNotFoundException; import java.io.FileReader; import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import org.unitConverter.dimension.UnitDimension; import org.unitConverter.math.DecimalComparison; import org.unitConverter.math.ExpressionParser; import org.unitConverter.unit.BaseUnit; import org.unitConverter.unit.DefaultUnitPrefix; import org.unitConverter.unit.LinearUnit; import org.unitConverter.unit.SI; import org.unitConverter.unit.Unit; import org.unitConverter.unit.UnitPrefix; /** * A database of units and prefixes, and their names. * * @author Adrien Hopkins * @since 2019-01-07 * @since v0.1.0 */ public final class UnitsDatabase { /** * The exponent operator * * @param base * base of exponentiation * @param exponentUnit * exponent * @return result * @since 2019-04-10 */ private static final LinearUnit exponent(final LinearUnit base, final LinearUnit exponentUnit) { // exponent function - first check if o2 is a number, if (exponentUnit.getBase().equals(SI.SI.getBaseUnit(UnitDimension.EMPTY))) { // then check if it is an integer, final double exponent = exponentUnit.getConversionFactor(); if (DecimalComparison.equals(exponent % 1, 0)) // then exponentiate return base.toExponent((int) (exponent % 1 + 0.5)); else // not an integer throw new UnsupportedOperationException("Decimal exponents are currently not supported."); } else // not a number throw new IllegalArgumentException("Exponents must be numbers."); } /** * The units in this system. * * @since 2019-01-07 * @since v0.1.0 */ private final Map units; /** * The unit prefixes in this system. * * @since 2019-01-14 * @since v0.1.0 */ private final Map prefixes; /** * The dimensions in this system. * * @since 2019-03-14 */ private final Map dimensions; /** * A parser that can parse unit expressions. * * @since 2019-03-22 */ private final ExpressionParser unitExpressionParser = new ExpressionParser.Builder<>( this::getLinearUnit).addBinaryOperator("+", (o1, o2) -> o1.plus(o2), 0) .addBinaryOperator("-", (o1, o2) -> o1.minus(o2), 0) .addBinaryOperator("*", (o1, o2) -> o1.times(o2), 1).addSpaceFunction("*") .addBinaryOperator("/", (o1, o2) -> o1.dividedBy(o2), 1) .addBinaryOperator("^", UnitsDatabase::exponent, 2).build(); private final ExpressionParser unitDimensionParser = new ExpressionParser.Builder<>( this::getDimension).addBinaryOperator("*", (o1, o2) -> o1.times(o2), 0).addSpaceFunction("*") .addBinaryOperator("/", (o1, o2) -> o1.dividedBy(o2), 0).build(); /** * Creates the {@code UnitsDatabase}. * * @since 2019-01-10 * @since v0.1.0 */ public UnitsDatabase() { this.units = new HashMap<>(); this.prefixes = new HashMap<>(); this.dimensions = new HashMap<>(); } /** * Adds a unit dimension to the database. * * @param name * dimension's name * @param dimension * dimension to add * @throws NullPointerException * if name or dimension is null * @since 2019-03-14 */ public void addDimension(final String name, final UnitDimension dimension) { this.dimensions.put(Objects.requireNonNull(name, "name must not be null."), Objects.requireNonNull(dimension, "dimension must not be null.")); } /** * Adds to the list from a line in a unit dimension file. * * @param line * line to look at * @param lineCounter * number of line, for error messages * @since 2019-04-10 */ private void addDimensionFromLine(final String line, final long lineCounter) { // ignore lines that start with a # sign - they're comments if (line.isEmpty()) return; if (line.contains("#")) { this.addDimensionFromLine(line.substring(0, line.indexOf("#")), lineCounter); return; } // divide line into name and expression final String[] parts = line.split("\t"); if (parts.length < 2) throw new IllegalArgumentException(String.format( "Lines must consist of a dimension name and its definition, separated by tab(s) (line %d).", lineCounter)); final String name = parts[0]; final String expression = parts[parts.length - 1]; if (name.endsWith(" ")) { System.err.printf("Warning - line %d's dimension name ends in a space", lineCounter); } // if expression is "!", search for an existing dimension // if no unit found, throw an error if (expression.equals("!")) { if (!this.containsDimensionName(name)) throw new IllegalArgumentException( String.format("! used but no dimension found (line %d).", lineCounter)); } else { // it's a unit, get the unit final UnitDimension dimension; try { dimension = this.getDimensionFromExpression(expression); } catch (final IllegalArgumentException e) { System.err.printf("Parsing error on line %d:%n", lineCounter); throw e; } this.addDimension(name, dimension); } } /** * Adds a unit prefix to the database. * * @param name * prefix's name * @param prefix * prefix to add * @throws NullPointerException * if name or prefix is null * @since 2019-01-14 * @since v0.1.0 */ public void addPrefix(final String name, final UnitPrefix prefix) { this.prefixes.put(Objects.requireNonNull(name, "name must not be null."), Objects.requireNonNull(prefix, "prefix must not be null.")); } /** * Adds a unit to the database. * * @param name * unit's name * @param unit * unit to add * @throws NullPointerException * if unit is null * @since 2019-01-10 * @since v0.1.0 */ public void addUnit(final String name, final Unit unit) { this.units.put(Objects.requireNonNull(name, "name must not be null."), Objects.requireNonNull(unit, "unit must not be null.")); } /** * Adds to the list from a line in a unit file. * * @param line * line to look at * @param lineCounter * number of line, for error messages * @since 2019-04-10 */ private void addUnitOrPrefixFromLine(final String line, final long lineCounter) { // ignore lines that start with a # sign - they're comments if (line.isEmpty()) return; if (line.contains("#")) { this.addUnitOrPrefixFromLine(line.substring(0, line.indexOf("#")), lineCounter); return; } // divide line into name and expression final String[] parts = line.split("\t"); if (parts.length < 2) throw new IllegalArgumentException(String.format( "Lines must consist of a unit name and its definition, separated by tab(s) (line %d).", lineCounter)); final String name = parts[0]; final String expression = parts[parts.length - 1]; if (name.endsWith(" ")) { System.err.printf("Warning - line %d's unit name ends in a space", lineCounter); } // if expression is "!", search for an existing unit // if no unit found, throw an error if (expression.equals("!")) { if (!this.containsUnitName(name)) throw new IllegalArgumentException(String.format("! used but no unit found (line %d).", lineCounter)); } else { if (name.endsWith("-")) { final UnitPrefix prefix; try { prefix = this.getPrefixFromExpression(expression); } catch (final IllegalArgumentException e) { System.err.printf("Parsing error on line %d:%n", lineCounter); throw e; } this.addPrefix(name.substring(0, name.length() - 1), prefix); } else { // it's a unit, get the unit final Unit unit; try { unit = this.getUnitFromExpression(expression); } catch (final IllegalArgumentException e) { System.err.printf("Parsing error on line %d:%n", lineCounter); throw e; } this.addUnit(name, unit); } } } /** * Tests if the database has a unit dimension with this name. * * @param name * name to test * @return if database contains name * @since 2019-03-14 */ public boolean containsDimensionName(final String name) { return this.dimensions.containsKey(name); } /** * Tests if the database has a unit with this name, ignoring prefixes * * @param name * name to test * @return if database contains name * @since 2019-01-13 * @since v0.1.0 */ public boolean containsPrefixlessUnitName(final String name) { return this.units.containsKey(name); } /** * Tests if the database has a unit prefix with this name. * * @param name * name to test * @return if database contains name * @since 2019-01-13 * @since v0.1.0 */ public boolean containsPrefixName(final String name) { return this.prefixes.containsKey(name); } /** * Tests if the database has a unit with this name, taking prefixes into consideration * * @param name * name to test * @return if database contains name * @since 2019-01-13 * @since v0.1.0 */ public boolean containsUnitName(final String name) { // check for prefixes for (final String prefixName : this.prefixNameSet()) { if (name.startsWith(prefixName)) if (this.containsUnitName(name.substring(prefixName.length()))) return true; } return this.units.containsKey(name); } /** * @return an immutable set of all of the dimension names in this database. * @since 2019-03-14 */ public Set dimensionNameSet() { return Collections.unmodifiableSet(this.dimensions.keySet()); } /** * Gets a unit dimension from the database using its name. * *

* This method accepts exponents, like "L^3" *

* * @param name * dimension's name * @return dimension * @since 2019-03-14 */ public UnitDimension getDimension(final String name) { Objects.requireNonNull(name, "name must not be null."); if (name.contains("^")) { final String[] baseAndExponent = name.split("\\^"); final UnitDimension base = this.getDimension(baseAndExponent[0]); final int exponent; try { exponent = Integer.parseInt(baseAndExponent[baseAndExponent.length - 1]); } catch (final NumberFormatException e2) { throw new IllegalArgumentException("Exponent must be an integer."); } return base.toExponent(exponent); } return this.dimensions.get(name); } /** * Uses the database's data to parse an expression into a unit dimension *

* The expression is a series of any of the following: *

    *
  • The name of a unit dimension, which multiplies or divides the result based on preceding operators
  • *
  • The operators '*' and '/', which multiply and divide (note that just putting two unit dimensions next to each * other is equivalent to multiplication)
  • *
  • The operator '^' which exponentiates. Exponents must be integers.
  • *
* * @param expression * expression to parse * @throws IllegalArgumentException * if the expression cannot be parsed * @throws NullPointerException * if expression is null * @since 2019-04-13 */ public UnitDimension getDimensionFromExpression(final String expression) { Objects.requireNonNull(expression, "expression must not be null."); // attempt to get a dimension as an alias first if (this.containsDimensionName(expression)) return this.getDimension(expression); // force operators to have spaces String modifiedExpression = expression; modifiedExpression = modifiedExpression.replaceAll("\\*", " \\* "); modifiedExpression = modifiedExpression.replaceAll("/", " / "); modifiedExpression = modifiedExpression.replaceAll(" *\\^ *", "\\^"); // fix broken spaces modifiedExpression = modifiedExpression.replaceAll(" +", " "); return this.unitDimensionParser.parseExpression(modifiedExpression); } /** * Gets a unit. If it is linear, cast it to a LinearUnit and return it. Otherwise, throw an * {@code IllegalArgumentException}. * * @param name * unit's name * @return unit * @since 2019-03-22 */ private LinearUnit getLinearUnit(final String name) { // see if I am using a function-unit like tempC(100) if (name.contains("(") && name.contains(")")) { // break it into function name and value final List parts = Arrays.asList(name.split("\\(")); if (parts.size() != 2) throw new IllegalArgumentException("Format nonlinear units like: unit(value)."); // solve the function final Unit unit = this.getUnit(parts.get(0)); final double value = Double.parseDouble(parts.get(1).substring(0, parts.get(1).length() - 1)); return unit.getBase().times(unit.convertToBase(value)); } else { // get a linear unit final Unit unit = this.getUnit(name); if (unit instanceof LinearUnit) return (LinearUnit) unit; else throw new IllegalArgumentException(String.format("%s is not a linear unit.", name)); } } /** * Gets a unit prefix from the database from its name * * @param name * prefix's name * @return prefix * @since 2019-01-10 * @since v0.1.0 */ public UnitPrefix getPrefix(final String name) { return this.prefixes.get(name); } /** * Gets a unit prefix from a prefix expression *

* Currently, prefix expressions are much simpler than unit expressions: They are either a number or the name of * another prefix *

* * @param expression * expression to input * @return prefix * @throws IllegalArgumentException * if expression cannot be parsed * @throws NullPointerException * if any argument is null * @since 2019-01-14 * @since v0.1.0 */ public UnitPrefix getPrefixFromExpression(final String expression) { Objects.requireNonNull(expression, "expression must not be null."); try { return new DefaultUnitPrefix(Double.parseDouble(expression)); } catch (final NumberFormatException e) { if (expression.contains("^")) { final String[] baseAndExponent = expression.split("\\^"); final double base; try { base = Double.parseDouble(baseAndExponent[0]); } catch (final NumberFormatException e2) { throw new IllegalArgumentException("Base of exponientation must be a number."); } final int exponent; try { exponent = Integer.parseInt(baseAndExponent[baseAndExponent.length - 1]); } catch (final NumberFormatException e2) { throw new IllegalArgumentException("Exponent must be an integer."); } return new DefaultUnitPrefix(Math.pow(base, exponent)); } else { if (!this.containsPrefixName(expression)) throw new IllegalArgumentException("Unrecognized prefix name \"" + expression + "\"."); return this.getPrefix(expression); } } } /** * Gets a unit from the database from its name, ignoring prefixes. * * @param name * unit's name * @return unit * @since 2019-01-10 * @since v0.1.0 */ public Unit getPrefixlessUnit(final String name) { return this.units.get(name); } /** * Gets a unit from the database from its name, looking for prefixes. * * @param name * unit's name * @return unit * @since 2019-01-10 * @since v0.1.0 */ public Unit getUnit(final String name) { try { final double value = Double.parseDouble(name); return SI.SI.getBaseUnit(UnitDimension.EMPTY).times(value); } catch (final NumberFormatException e) { if (name.contains("^")) { final String[] baseAndExponent = name.split("\\^"); LinearUnit base; try { base = SI.SI.getBaseUnit(UnitDimension.EMPTY).times(Double.parseDouble(baseAndExponent[0])); } catch (final NumberFormatException e2) { final Unit unit = this.getUnit(baseAndExponent[0]); if (unit instanceof LinearUnit) { base = (LinearUnit) unit; } else throw new IllegalArgumentException("Base of exponientation must be a linear or base unit."); } final int exponent; try { exponent = Integer.parseInt(baseAndExponent[baseAndExponent.length - 1]); } catch (final NumberFormatException e2) { throw new IllegalArgumentException("Exponent must be an integer."); } final LinearUnit exponentiated = base.toExponent(exponent); if (exponentiated.getConversionFactor() == 1) return exponentiated.getSystem().getBaseUnit(exponentiated.getDimension()); else return exponentiated; } else { for (final String prefixName : this.prefixNameSet()) { // check for a prefix if (name.startsWith(prefixName)) { // prefix found! Make sure what comes after it is actually a unit! final String prefixless = name.substring(prefixName.length()); if (this.containsUnitName(prefixless)) { // yep, it's a proper prefix! Get the unit! final Unit unit = this.getUnit(prefixless); final UnitPrefix prefix = this.getPrefix(prefixName); // Prefixes only work with linear and base units, so make sure it's one of those if (unit instanceof LinearUnit) { final LinearUnit linearUnit = (LinearUnit) unit; return linearUnit.times(prefix.getMultiplier()); } else if (unit instanceof BaseUnit) { final BaseUnit baseUnit = (BaseUnit) unit; return baseUnit.times(prefix.getMultiplier()); } } } } return this.units.get(name); } } } /** * Uses the database's unit data to parse an expression into a unit *

* The expression is a series of any of the following: *

    *
  • The name of a unit, which multiplies or divides the result based on preceding operators
  • *
  • The operators '*' and '/', which multiply and divide (note that just putting two units or values next to each * other is equivalent to multiplication)
  • *
  • The operator '^' which exponentiates. Exponents must be integers.
  • *
  • A number which is multiplied or divided
  • *
* This method only works with linear units. * * @param expression * expression to parse * @throws IllegalArgumentException * if the expression cannot be parsed * @throws NullPointerException * if expression is null * @since 2019-01-07 * @since v0.1.0 */ public Unit getUnitFromExpression(final String expression) { Objects.requireNonNull(expression, "expression must not be null."); // attempt to get a unit as an alias first if (this.containsUnitName(expression)) return this.getUnit(expression); // force operators to have spaces String modifiedExpression = expression; modifiedExpression = modifiedExpression.replaceAll("\\+", " \\+ "); modifiedExpression = modifiedExpression.replaceAll("-", " - "); modifiedExpression = modifiedExpression.replaceAll("\\*", " \\* "); modifiedExpression = modifiedExpression.replaceAll("/", " / "); modifiedExpression = modifiedExpression.replaceAll("\\^", " \\^ "); // fix broken spaces modifiedExpression = modifiedExpression.replaceAll(" +", " "); // the previous operation breaks negative numbers, fix them! // (i.e. -2 becomes - 2) for (int i = 2; i < modifiedExpression.length(); i++) { if (modifiedExpression.charAt(i) == '-' && Arrays.asList('+', '-', '*', '/', '^').contains(modifiedExpression.charAt(i - 2))) { // found a broken negative number modifiedExpression = modifiedExpression.substring(0, i + 1) + modifiedExpression.substring(i + 2); } } return this.unitExpressionParser.parseExpression(modifiedExpression); } /** * Adds all dimensions from a file, using data from the database to parse them. *

* Each line in the file should consist of a name and an expression (parsed by getDimensionFromExpression) separated * by any number of tab characters. *

*

* Allowed exceptions: *

    *
  • Anything after a '#' character is considered a comment and ignored.
  • *
  • Blank lines are also ignored
  • *
  • If an expression consists of a single exclamation point, instead of parsing it, this method will search the * database for an existing unit. If no unit is found, an IllegalArgumentException is thrown. This is used to define * initial units and ensure that the database contains them.
  • *
* * @param file * file to read * @throws IllegalArgumentException * if the file cannot be parsed, found or read * @throws NullPointerException * if file is null * @since 2019-01-13 * @since v0.1.0 */ public void loadDimensionFile(final File file) { Objects.requireNonNull(file, "file must not be null."); try (FileReader fileReader = new FileReader(file); BufferedReader reader = new BufferedReader(fileReader)) { // while the reader has lines to read, read a line, then parse it, then add it long lineCounter = 0; while (reader.ready()) { this.addDimensionFromLine(reader.readLine(), ++lineCounter); } } catch (final FileNotFoundException e) { throw new IllegalArgumentException("Could not find file " + file, e); } catch (final IOException e) { throw new IllegalArgumentException("Could not read file " + file, e); } } /** * Adds all units from a file, using data from the database to parse them. *

* Each line in the file should consist of a name and an expression (parsed by getUnitFromExpression) separated by * any number of tab characters. *

*

* Allowed exceptions: *

    *
  • Anything after a '#' character is considered a comment and ignored.
  • *
  • Blank lines are also ignored
  • *
  • If an expression consists of a single exclamation point, instead of parsing it, this method will search the * database for an existing unit. If no unit is found, an IllegalArgumentException is thrown. This is used to define * initial units and ensure that the database contains them.
  • *
* * @param file * file to read * @throws IllegalArgumentException * if the file cannot be parsed, found or read * @throws NullPointerException * if file is null * @since 2019-01-13 * @since v0.1.0 */ public void loadUnitsFile(final File file) { Objects.requireNonNull(file, "file must not be null."); try (FileReader fileReader = new FileReader(file); BufferedReader reader = new BufferedReader(fileReader)) { // while the reader has lines to read, read a line, then parse it, then add it long lineCounter = 0; while (reader.ready()) { this.addUnitOrPrefixFromLine(reader.readLine(), ++lineCounter); } } catch (final FileNotFoundException e) { throw new IllegalArgumentException("Could not find file " + file, e); } catch (final IOException e) { throw new IllegalArgumentException("Could not read file " + file, e); } } /** * @return an immutable set of all of the unit names in this database, ignoring prefixes * @since 2019-01-14 * @since v0.1.0 */ public Set prefixlessUnitNameSet() { return Collections.unmodifiableSet(this.units.keySet()); } /** * @return an immutable set of all of the units in this database, ignoring prefixes. * @since 2019-04-10 */ public Set prefixlessUnitSet() { return Collections.unmodifiableSet(new HashSet<>(this.units.values())); } /** * @return an immutable set of all of the prefix names in this database * @since 2019-01-14 * @since v0.1.0 */ public Set prefixNameSet() { return Collections.unmodifiableSet(this.prefixes.keySet()); } }