Units.java
package org.djunits.unit;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;
import org.djutils.exceptions.Throw;
import org.djutils.logger.CategoryLogger;
/**
* Units is a static class that can register and resolve string representations of units, possibly using a locale. The Units
* class is responsible for maintaining a registry of all units based on their textual abbreviations. It allows for a unit to
* register itself, and for code to retrieve a unit based on a textual abbreviation. The Units class also takes care of
* localization of the unit representations. If the Locale is not US, it will look for a resource bundle of the active Locale to
* see if localized textual abbreviations are registered, and it will use these when resolving the unit. When no localized
* matches can be found, it will test the (default) US Locale abbreviations as well.
* <p>
* Copyright (c) 2025-2026 Delft University of Technology, Jaffalaan 5, 2628 BX Delft, the Netherlands. All rights reserved. See
* for project information <a href="https://djunits.org" target="_blank">https://djunits.org</a>. The DJUNITS project is
* distributed under a <a href="https://djunits.org/docs/license.html" target="_blank">three-clause BSD-style license</a>.
* @author Alexander Verbraeck
*/
public final class Units
{
/** Map with all units per quantity type. */
private static final Map<String, Map<String, UnitInterface<?, ?>>> UNIT_MAP = new LinkedHashMap<>();
/** Current map locale. */
private static Locale currentLocale = Locale.US;
/** Current resource bundle. */
private static ResourceBundle resourceBundle = null;
/** Localized map to translate textual unit abbreviation to US id. */
private static Map<String, Map<String, String>> localizedUnitTranslateMap = new LinkedHashMap<>();
/** Prefix for quantity keys in the resource bundle. */
private static final String QUANTITY_PREFIX = "quantity.";
/** Prefix for unit keys in the resource bundle. */
private static final String UNIT_PREFIX = "unit.";
/** Suffix used for textual abbreviations (pipe separated). */
private static final String ABBR_SUFFIX = ".abbr";
/** Suffix used for name. */
private static final String NAME_SUFFIX = ".name";
/** Suffix used for display abbreviations or symbols. */
private static final String DISPLAY_SUFFIX = ".display";
/** */
private Units()
{
// static class.
}
/**
* Register a unit so it can be found based on its textual abbreviations.
* @param unit the unit to register
* @throws NullPointerException when unit is null
*/
public static void register(final UnitInterface<?, ?> unit)
{
Throw.whenNull(unit, "unit");
var subMap =
UNIT_MAP.computeIfAbsent(quantityName(unit.getClass()), k -> new LinkedHashMap<String, UnitInterface<?, ?>>());
subMap.putIfAbsent(unit.getStoredTextualAbbreviation(), unit);
}
/**
* Unregister a unit, e.g. in a unit test. No exception will be thrown when the unit was not in the registration map.
* @param unit the unit to unregister
* @throws NullPointerException when unit is null
*/
public static void unregister(final UnitInterface<?, ?> unit)
{
Throw.whenNull(unit, "unit");
var subMap = UNIT_MAP.get(quantityName(unit.getClass()));
if (subMap != null)
{
subMap.remove(unit.getStoredTextualAbbreviation());
}
}
/**
* Look up a unit in the registry, based on its textual abbreviation.
* @param unitClass the unit class for which the abbreviation has to be looked up
* @param abbreviation the abbreviation to look up in the unit registry
* @return the unit belonging to the abbreviation (if it exists)
* @throws NullPointerException when unitClass or abbreviation is null
* @throws UnitRuntimeException when the unit did not exist, or the abbreviation was not registered
* @param <U> the unit type
*/
public static <U extends UnitInterface<U, ?>> U resolve(final Class<U> unitClass, final String abbreviation)
throws UnitRuntimeException
{
Throw.whenNull(unitClass, "unitClass");
Throw.whenNull(abbreviation, "abbreviation");
Throw.when(!(UnitInterface.class.isAssignableFrom(unitClass)), IllegalArgumentException.class,
"The provided unit class %s does not implement a unit", unitClass.getName());
String quantityName = quantityName(unitClass);
if (!UNIT_MAP.containsKey(quantityName))
{
// force the class to load and initialize its units
try
{
Class.forName(unitClass.getName(), true, // <-- initialize
unitClass.getClassLoader());
}
catch (ClassNotFoundException e)
{
throw new UnitRuntimeException("Could not load unit class " + unitClass.getName(), e);
}
}
Throw.when(!UNIT_MAP.containsKey(quantityName), UnitRuntimeException.class,
"Error resolving unit class %s (abbreviation '%s')", unitClass.getSimpleName(), abbreviation);
readTranslateMap();
String unitKey = abbreviation;
if (localizedUnitTranslateMap.containsKey(quantityName)
&& localizedUnitTranslateMap.get(quantityName).containsKey(abbreviation))
{
unitKey = localizedUnitTranslateMap.get(quantityName).get(abbreviation);
}
@SuppressWarnings("unchecked")
U result = (U) UNIT_MAP.get(quantityName).get(unitKey);
Throw.when(result == null, UnitRuntimeException.class, "Error resolving abbreviation '%s' for unit class %s",
abbreviation, unitClass.getSimpleName());
return result;
}
/**
* Return a safe copy of the registered units, e.g. to build pick lists in a user interface.
* @return a safe copy of the registered units
*/
public static Map<String, Map<String, UnitInterface<?, ?>>> registeredUnits()
{
return new LinkedHashMap<String, Map<String, UnitInterface<?, ?>>>(UNIT_MAP);
}
/**
* Return the quantity name based on a unit class.
* @param unitClass the unit class
* @return the quantity name based on the unit class
*/
private static String quantityName(final Class<?> unitClass)
{
String name = unitClassName(unitClass);
if (name.endsWith(".Unit"))
{
name = name.substring(0, name.length() - 5);
}
return name;
}
/**
* Return the proper class name for a unit class, also when it is a nested class. This method returns 'Length.Unit' rather
* than 'Unit' for the inner 'Unit' class of the 'Length' class.
* @param cls the unit class to return the name for, including the outer class name(s)
* @return the class name, including the outer class name(s)
*/
public static String unitClassName(final Class<?> cls)
{
return cls.getCanonicalName().substring(cls.getPackageName().isEmpty() ? 0 : cls.getPackageName().length() + 1);
}
/** The base of the resource bundle name, will expand to unit.properties, unit_nl.properties, etc. */
private static final String BUNDLE_BASE = "unit";
/** UTF-8 loader for .properties ResourceBundles. */
public static final class Utf8Control extends ResourceBundle.Control
{
@Override
public ResourceBundle newBundle(final String baseName, final Locale locale, final String format,
final ClassLoader loader, final boolean reload)
throws IllegalAccessException, InstantiationException, IOException
{
String bundleName = toBundleName(baseName, locale);
String resourceName = toResourceName("locale/" + bundleName, "properties");
URL url = loader.getResource(resourceName);
if (url == null)
{
resourceName = toResourceName("resources/locale/" + bundleName, "properties");
url = loader.getResource(resourceName);
}
if (url == null)
{
return null;
}
try (var is = loader.getResourceAsStream(resourceName))
{
if (is == null)
{
return null;
}
try (var reader = new InputStreamReader(is, StandardCharsets.UTF_8))
{
return new PropertyResourceBundle(reader);
}
}
}
}
/**
* Return a resource bundle for the Locale.
* @param locale the locale to search for
* @return The resource budle belonging to the given locale
*/
public static ResourceBundle bundle(final Locale locale)
{
return ResourceBundle.getBundle(BUNDLE_BASE, locale, new Utf8Control());
}
/**
* Reads a string value from the bundle returning {@code null} if the key is missing.
* @param bundle the resource bundle.
* @param key the key to read.
* @return the string value or {@code null}.
*/
private static String getStringSafe(final ResourceBundle bundle, final String key)
{
try
{
return bundle.getString(key);
}
catch (MissingResourceException e)
{
return null;
}
}
/**
* Read the data from the resource bundle. Load ALL classes, even the ones that are not (yet) part of the UNIT_MAP. Since
* the UNIT_MAP is filled by lazy loading (only register units for unit classes when they are used in the code or requested
* in the <code>resolve()</code> function, unit classes or units might not yet be present when the resource bundle is read.
*/
private static synchronized void readTranslateMap()
{
if (Locale.getDefault().equals(currentLocale))
{
return;
}
localizedUnitTranslateMap.clear();
if (Locale.getDefault().equals(Locale.US))
{
currentLocale = Locale.US;
return;
}
currentLocale = Locale.getDefault();
resourceBundle = bundle(currentLocale);
if (resourceBundle != null)
{
for (String key : resourceBundle.keySet())
{
if (!key.startsWith(UNIT_PREFIX) || !key.endsWith(ABBR_SUFFIX))
{
continue;
}
String quantity = key.substring(UNIT_PREFIX.length(), key.indexOf('.', UNIT_PREFIX.length()));
var quantityMap = localizedUnitTranslateMap.computeIfAbsent(quantity, k -> new LinkedHashMap<String, String>());
String unitId = key.substring(key.indexOf('.', UNIT_PREFIX.length()));
unitId = unitId.substring(1, unitId.length() - ABBR_SUFFIX.length());
String token = getStringSafe(resourceBundle, key).strip();
if (token == null || token.isBlank())
{
continue;
}
quantityMap.put(token, unitId);
}
}
}
/**
* Lookup a quantity name for a given locale.
* @param locale the locale
* @param quantityName the simple class name of the quantity
* @return the localized name of the quantity
*/
public static String localizedQuantityName(final Locale locale, final String quantityName)
{
return getLocalizedOrFallback(locale, QUANTITY_PREFIX + quantityName + NAME_SUFFIX, quantityName);
}
/**
* Get the localized unit name for a unit class.
* @param unitClass the class of the unit to lookup
* @return the localized name of the quantity belonging to that unit
*/
public static String localizedQuantityName(final Class<? extends UnitInterface<?, ?>> unitClass)
{
return localizedQuantityName(Locale.getDefault(), quantityName(unitClass));
}
/**
* Return the unit based on a quantity name and unit key. Give a warning when either of them cannot be found. In that case,
* return null.
* @param quantityName the simple class name of the quantity
* @param unitKey the key of the unit
* @return the unit identified by unitKey for the quantity, or null when either cannot be found
*/
private static UnitInterface<?, ?> getUnit(final String quantityName, final String unitKey)
{
var subMap = UNIT_MAP.get(quantityName);
if (subMap == null)
{
CategoryLogger.always().info("djunits localization. Quantity {} unknown", quantityName);
return null;
}
UnitInterface<?, ?> unit = UNIT_MAP.get(quantityName).get(unitKey);
if (unit == null)
{
CategoryLogger.always().info("djunits localization. Unit {} for quantity {} could not be found", unitKey,
quantityName);
return null;
}
return unit;
}
/**
* Lookup a display abbreviation for a given unit key. If it cannot be found, use the stored US unit as a fallback. If the
* US-based unit cannot be found on the basis of the unit key, return the unit key itself as the display abbreviation.
* @param locale the locale
* @param quantityName the simple class name of the quantity
* @param unitKey the key of the unit
* @return the localized display abbreviation of the unit
*/
public static String localizedUnitDisplayAbbr(final Locale locale, final String quantityName, final String unitKey)
{
String abbr = getLocalized(locale, UNIT_PREFIX + quantityName + "." + unitKey + DISPLAY_SUFFIX, false);
if (abbr == null)
{
abbr = getLocalized(locale, UNIT_PREFIX + quantityName + "." + unitKey + ABBR_SUFFIX, false);
}
if (abbr != null)
{
return abbr;
}
var unit = getUnit(quantityName, unitKey);
return unit == null ? unitKey : unit.getStoredDisplayAbbreviation();
}
/**
* Lookup a display abbreviation for a given unit key. If it cannot be found, use the stored US unit as a fallback. If the
* US-based unit cannot be found on the basis of the unit key, return the unit key itself as the display abbreviation.
* @param unitClass the class of the unit to lookup
* @param unitKey the key of the unit
* @return the localized display abbreviation of the unit
*/
public static String localizedUnitDisplayAbbr(final Class<?> unitClass, final String unitKey)
{
return localizedUnitDisplayAbbr(Locale.getDefault(), quantityName(unitClass), unitKey);
}
/**
* Lookup a display Name for a given unit key. If it cannot be found, use the stored US unit as a fallback. If the US-based
* unit cannot be found on the basis of the unit key, return the unit key itself as the display name.
* @param locale the locale
* @param quantityName the simple class name of the quantity
* @param unitKey the key of the unit
* @return the localized display name of the unit
*/
public static String localizedUnitName(final Locale locale, final String quantityName, final String unitKey)
{
String name = getLocalized(locale, UNIT_PREFIX + quantityName + "." + unitKey + NAME_SUFFIX, false);
if (name != null)
{
return name;
}
var unit = getUnit(quantityName, unitKey);
return unit == null ? unitKey : unit.getStoredName();
}
/**
* Lookup a display name for a given unit key. If it cannot be found, use the stored US unit as a fallback. If the US-based
* unit cannot be found on the basis of the unit key, return the unit key itself as the display name.
* @param unitClass the class of the unit to lookup
* @param unitKey the key of the unit
* @return the localized display name of the unit
*/
public static String localizedUnitName(final Class<?> unitClass, final String unitKey)
{
return localizedUnitName(Locale.getDefault(), quantityName(unitClass), unitKey);
}
/**
* Lookup a textual abbreviation for a given unit key. If it cannot be found, use the stored US unit as a fallback. If the
* US-based unit cannot be found on the basis of the unit key, return the unit key itself as the textual abbreviation.
* @param locale the locale
* @param quantityName the simple class name of the quantity
* @param unitKey the key of the unit
* @return the localized textual abbreviation of the unit
*/
public static String localizedUnitTextualAbbr(final Locale locale, final String quantityName, final String unitKey)
{
String abbr = getLocalized(locale, UNIT_PREFIX + quantityName + "." + unitKey + ABBR_SUFFIX, false);
if (abbr != null)
{
return abbr;
}
var unit = getUnit(quantityName, unitKey);
return unit == null ? unitKey : unit.getStoredTextualAbbreviation();
}
/**
* Lookup a textual abbreviation (key) for a given unit key. If it cannot be found, use the stored US unit as a fallback. If
* the US-based unit cannot be found on the basis of the unit key, return the unit key itself as the textual abbreviation.
* @param unitClass the class of the unit to lookup
* @param unitKey the key of the unit
* @return the localized textual abbreviation of the unit
*/
public static String localizedUnitTextualAbbr(final Class<?> unitClass, final String unitKey)
{
return localizedUnitTextualAbbr(Locale.getDefault(), quantityName(unitClass), unitKey);
}
/**
* Return the value of a key for the given locale.
* @param locale the locale to use
* @param key the key to search for
* @param fallback a fallback string
* @return the value of the key for the given locale
*/
private static String getLocalizedOrFallback(final Locale locale, final String key, final String fallback)
{
String v = getLocalized(locale, key, false);
return (v != null) ? v : fallback;
}
/**
* Return the value of a key for the given locale.
* @param locale the locale to use
* @param key the key to search for
* @param required whether the key should be present in the bundle
* @return the value of the key for the given locale
*/
private static String getLocalized(final Locale locale, final String key, final boolean required)
{
try
{
ResourceBundle b = bundle(locale);
return b.getString(key);
}
catch (MissingResourceException e)
{
if (required)
{
throw e;
}
return null;
}
}
}