View Javadoc
1   package org.djunits.unit;
2   
3   import java.io.IOException;
4   import java.io.InputStreamReader;
5   import java.net.URL;
6   import java.nio.charset.StandardCharsets;
7   import java.util.LinkedHashMap;
8   import java.util.Locale;
9   import java.util.Map;
10  import java.util.MissingResourceException;
11  import java.util.PropertyResourceBundle;
12  import java.util.ResourceBundle;
13  
14  import org.djutils.exceptions.Throw;
15  import org.djutils.logger.CategoryLogger;
16  
17  /**
18   * Units is a static class that can register and resolve string representations of units, possibly using a locale. The Units
19   * class is responsible for maintaining a registry of all units based on their textual abbreviations. It allows for a unit to
20   * register itself, and for code to retrieve a unit based on a textual abbreviation. The Units class also takes care of
21   * localization of the unit representations. If the Locale is not US, it will look for a resource bundle of the active Locale to
22   * see if localized textual abbreviations are registered, and it will use these when resolving the unit. When no localized
23   * matches can be found, it will test the (default) US Locale abbreviations as well.
24   * <p>
25   * Copyright (c) 2025-2026 Delft University of Technology, Jaffalaan 5, 2628 BX Delft, the Netherlands. All rights reserved. See
26   * for project information <a href="https://djunits.org" target="_blank">https://djunits.org</a>. The DJUNITS project is
27   * distributed under a <a href="https://djunits.org/docs/license.html" target="_blank">three-clause BSD-style license</a>.
28   * @author Alexander Verbraeck
29   */
30  public final class Units
31  {
32      /** Map with all units per quantity type. */
33      private static final Map<String, Map<String, UnitInterface<?, ?>>> UNIT_MAP = new LinkedHashMap<>();
34  
35      /** Current map locale. */
36      private static Locale currentLocale = Locale.US;
37  
38      /** Current resource bundle. */
39      private static ResourceBundle resourceBundle = null;
40  
41      /** Localized map to translate textual unit abbreviation to US id. */
42      private static Map<String, Map<String, String>> localizedUnitTranslateMap = new LinkedHashMap<>();
43  
44      /** Prefix for quantity keys in the resource bundle. */
45      private static final String QUANTITY_PREFIX = "quantity.";
46  
47      /** Prefix for unit keys in the resource bundle. */
48      private static final String UNIT_PREFIX = "unit.";
49  
50      /** Suffix used for textual abbreviations (pipe separated). */
51      private static final String ABBR_SUFFIX = ".abbr";
52  
53      /** Suffix used for name. */
54      private static final String NAME_SUFFIX = ".name";
55  
56      /** Suffix used for display abbreviations or symbols. */
57      private static final String DISPLAY_SUFFIX = ".display";
58  
59      /** */
60      private Units()
61      {
62          // static class.
63      }
64  
65      /**
66       * Register a unit so it can be found based on its textual abbreviations.
67       * @param unit the unit to register
68       * @throws NullPointerException when unit is null
69       */
70      public static void register(final UnitInterface<?, ?> unit)
71      {
72          Throw.whenNull(unit, "unit");
73          var subMap =
74                  UNIT_MAP.computeIfAbsent(quantityName(unit.getClass()), k -> new LinkedHashMap<String, UnitInterface<?, ?>>());
75          subMap.putIfAbsent(unit.getStoredTextualAbbreviation(), unit);
76      }
77  
78      /**
79       * Unregister a unit, e.g. in a unit test. No exception will be thrown when the unit was not in the registration map.
80       * @param unit the unit to unregister
81       * @throws NullPointerException when unit is null
82       */
83      public static void unregister(final UnitInterface<?, ?> unit)
84      {
85          Throw.whenNull(unit, "unit");
86          var subMap = UNIT_MAP.get(quantityName(unit.getClass()));
87          if (subMap != null)
88          {
89              subMap.remove(unit.getStoredTextualAbbreviation());
90          }
91      }
92  
93      /**
94       * Look up a unit in the registry, based on its textual abbreviation.
95       * @param unitClass the unit class for which the abbreviation has to be looked up
96       * @param abbreviation the abbreviation to look up in the unit registry
97       * @return the unit belonging to the abbreviation (if it exists)
98       * @throws NullPointerException when unitClass or abbreviation is null
99       * @throws UnitRuntimeException when the unit did not exist, or the abbreviation was not registered
100      * @param <U> the unit type
101      */
102     public static <U extends UnitInterface<U, ?>> U resolve(final Class<U> unitClass, final String abbreviation)
103             throws UnitRuntimeException
104     {
105         Throw.whenNull(unitClass, "unitClass");
106         Throw.whenNull(abbreviation, "abbreviation");
107         Throw.when(!(UnitInterface.class.isAssignableFrom(unitClass)), IllegalArgumentException.class,
108                 "The provided unit class %s does not implement a unit", unitClass.getName());
109 
110         String quantityName = quantityName(unitClass);
111         if (!UNIT_MAP.containsKey(quantityName))
112         {
113             // force the class to load and initialize its units
114             try
115             {
116                 Class.forName(unitClass.getName(), true, // <-- initialize
117                         unitClass.getClassLoader());
118             }
119             catch (ClassNotFoundException e)
120             {
121                 throw new UnitRuntimeException("Could not load unit class " + unitClass.getName(), e);
122             }
123         }
124 
125         Throw.when(!UNIT_MAP.containsKey(quantityName), UnitRuntimeException.class,
126                 "Error resolving unit class %s (abbreviation '%s')", unitClass.getSimpleName(), abbreviation);
127         readTranslateMap();
128         String unitKey = abbreviation;
129         if (localizedUnitTranslateMap.containsKey(quantityName)
130                 && localizedUnitTranslateMap.get(quantityName).containsKey(abbreviation))
131         {
132             unitKey = localizedUnitTranslateMap.get(quantityName).get(abbreviation);
133         }
134         @SuppressWarnings("unchecked")
135         U result = (U) UNIT_MAP.get(quantityName).get(unitKey);
136         Throw.when(result == null, UnitRuntimeException.class, "Error resolving abbreviation '%s' for unit class %s",
137                 abbreviation, unitClass.getSimpleName());
138         return result;
139     }
140 
141     /**
142      * Return a safe copy of the registered units, e.g. to build pick lists in a user interface.
143      * @return a safe copy of the registered units
144      */
145     public static Map<String, Map<String, UnitInterface<?, ?>>> registeredUnits()
146     {
147         return new LinkedHashMap<String, Map<String, UnitInterface<?, ?>>>(UNIT_MAP);
148     }
149 
150     /**
151      * Return the quantity name based on a unit class.
152      * @param unitClass the unit class
153      * @return the quantity name based on the unit class
154      */
155     private static String quantityName(final Class<?> unitClass)
156     {
157         String name = unitClassName(unitClass);
158         if (name.endsWith(".Unit"))
159         {
160             name = name.substring(0, name.length() - 5);
161         }
162         return name;
163     }
164 
165     /**
166      * Return the proper class name for a unit class, also when it is a nested class. This method returns 'Length.Unit' rather
167      * than 'Unit' for the inner 'Unit' class of the 'Length' class.
168      * @param cls the unit class to return the name for, including the outer class name(s)
169      * @return the class name, including the outer class name(s)
170      */
171     public static String unitClassName(final Class<?> cls)
172     {
173         return cls.getCanonicalName().substring(cls.getPackageName().isEmpty() ? 0 : cls.getPackageName().length() + 1);
174     }
175 
176     /** The base of the resource bundle name, will expand to unit.properties, unit_nl.properties, etc. */
177     private static final String BUNDLE_BASE = "unit";
178 
179     /** UTF-8 loader for .properties ResourceBundles. */
180     public static final class Utf8Control extends ResourceBundle.Control
181     {
182         @Override
183         public ResourceBundle newBundle(final String baseName, final Locale locale, final String format,
184                 final ClassLoader loader, final boolean reload)
185                 throws IllegalAccessException, InstantiationException, IOException
186         {
187             String bundleName = toBundleName(baseName, locale);
188             String resourceName = toResourceName("locale/" + bundleName, "properties");
189             URL url = loader.getResource(resourceName);
190             if (url == null)
191             {
192                 resourceName = toResourceName("resources/locale/" + bundleName, "properties");
193                 url = loader.getResource(resourceName);
194             }
195             if (url == null)
196             {
197                 return null;
198             }
199             try (var is = loader.getResourceAsStream(resourceName))
200             {
201                 if (is == null)
202                 {
203                     return null;
204                 }
205                 try (var reader = new InputStreamReader(is, StandardCharsets.UTF_8))
206                 {
207                     return new PropertyResourceBundle(reader);
208                 }
209             }
210         }
211     }
212 
213     /**
214      * Return a resource bundle for the Locale.
215      * @param locale the locale to search for
216      * @return The resource budle belonging to the given locale
217      */
218     public static ResourceBundle bundle(final Locale locale)
219     {
220         return ResourceBundle.getBundle(BUNDLE_BASE, locale, new Utf8Control());
221     }
222 
223     /**
224      * Reads a string value from the bundle returning {@code null} if the key is missing.
225      * @param bundle the resource bundle.
226      * @param key the key to read.
227      * @return the string value or {@code null}.
228      */
229     private static String getStringSafe(final ResourceBundle bundle, final String key)
230     {
231         try
232         {
233             return bundle.getString(key);
234         }
235         catch (MissingResourceException e)
236         {
237             return null;
238         }
239     }
240 
241     /**
242      * Read the data from the resource bundle. Load ALL classes, even the ones that are not (yet) part of the UNIT_MAP. Since
243      * the UNIT_MAP is filled by lazy loading (only register units for unit classes when they are used in the code or requested
244      * in the <code>resolve()</code> function, unit classes or units might not yet be present when the resource bundle is read.
245      */
246     private static synchronized void readTranslateMap()
247     {
248         if (Locale.getDefault().equals(currentLocale))
249         {
250             return;
251         }
252         localizedUnitTranslateMap.clear();
253         if (Locale.getDefault().equals(Locale.US))
254         {
255             currentLocale = Locale.US;
256             return;
257         }
258         currentLocale = Locale.getDefault();
259         resourceBundle = bundle(currentLocale);
260         if (resourceBundle != null)
261         {
262             for (String key : resourceBundle.keySet())
263             {
264                 if (!key.startsWith(UNIT_PREFIX) || !key.endsWith(ABBR_SUFFIX))
265                 {
266                     continue;
267                 }
268                 String quantity = key.substring(UNIT_PREFIX.length(), key.indexOf('.', UNIT_PREFIX.length()));
269                 var quantityMap = localizedUnitTranslateMap.computeIfAbsent(quantity, k -> new LinkedHashMap<String, String>());
270                 String unitId = key.substring(key.indexOf('.', UNIT_PREFIX.length()));
271                 unitId = unitId.substring(1, unitId.length() - ABBR_SUFFIX.length());
272                 String token = getStringSafe(resourceBundle, key).strip();
273                 if (token == null || token.isBlank())
274                 {
275                     continue;
276                 }
277                 quantityMap.put(token, unitId);
278             }
279         }
280     }
281 
282     /**
283      * Lookup a quantity name for a given locale.
284      * @param locale the locale
285      * @param quantityName the simple class name of the quantity
286      * @return the localized name of the quantity
287      */
288     public static String localizedQuantityName(final Locale locale, final String quantityName)
289     {
290         return getLocalizedOrFallback(locale, QUANTITY_PREFIX + quantityName + NAME_SUFFIX, quantityName);
291     }
292 
293     /**
294      * Get the localized unit name for a unit class.
295      * @param unitClass the class of the unit to lookup
296      * @return the localized name of the quantity belonging to that unit
297      */
298     public static String localizedQuantityName(final Class<? extends UnitInterface<?, ?>> unitClass)
299     {
300         return localizedQuantityName(Locale.getDefault(), quantityName(unitClass));
301     }
302 
303     /**
304      * Return the unit based on a quantity name and unit key. Give a warning when either of them cannot be found. In that case,
305      * return null.
306      * @param quantityName the simple class name of the quantity
307      * @param unitKey the key of the unit
308      * @return the unit identified by unitKey for the quantity, or null when either cannot be found
309      */
310     private static UnitInterface<?, ?> getUnit(final String quantityName, final String unitKey)
311     {
312         var subMap = UNIT_MAP.get(quantityName);
313         if (subMap == null)
314         {
315             CategoryLogger.always().info("djunits localization. Quantity {} unknown", quantityName);
316             return null;
317         }
318         UnitInterface<?, ?> unit = UNIT_MAP.get(quantityName).get(unitKey);
319         if (unit == null)
320         {
321             CategoryLogger.always().info("djunits localization. Unit {} for quantity {} could not be found", unitKey,
322                     quantityName);
323             return null;
324         }
325         return unit;
326     }
327 
328     /**
329      * Lookup a display abbreviation for a given unit key. If it cannot be found, use the stored US unit as a fallback. If the
330      * US-based unit cannot be found on the basis of the unit key, return the unit key itself as the display abbreviation.
331      * @param locale the locale
332      * @param quantityName the simple class name of the quantity
333      * @param unitKey the key of the unit
334      * @return the localized display abbreviation of the unit
335      */
336     public static String localizedUnitDisplayAbbr(final Locale locale, final String quantityName, final String unitKey)
337     {
338         String abbr = getLocalized(locale, UNIT_PREFIX + quantityName + "." + unitKey + DISPLAY_SUFFIX, false);
339         if (abbr == null)
340         {
341             abbr = getLocalized(locale, UNIT_PREFIX + quantityName + "." + unitKey + ABBR_SUFFIX, false);
342         }
343         if (abbr != null)
344         {
345             return abbr;
346         }
347         var unit = getUnit(quantityName, unitKey);
348         return unit == null ? unitKey : unit.getStoredDisplayAbbreviation();
349     }
350 
351     /**
352      * Lookup a display abbreviation for a given unit key. If it cannot be found, use the stored US unit as a fallback. If the
353      * US-based unit cannot be found on the basis of the unit key, return the unit key itself as the display abbreviation.
354      * @param unitClass the class of the unit to lookup
355      * @param unitKey the key of the unit
356      * @return the localized display abbreviation of the unit
357      */
358     public static String localizedUnitDisplayAbbr(final Class<?> unitClass, final String unitKey)
359     {
360         return localizedUnitDisplayAbbr(Locale.getDefault(), quantityName(unitClass), unitKey);
361     }
362 
363     /**
364      * 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
365      * unit cannot be found on the basis of the unit key, return the unit key itself as the display name.
366      * @param locale the locale
367      * @param quantityName the simple class name of the quantity
368      * @param unitKey the key of the unit
369      * @return the localized display name of the unit
370      */
371     public static String localizedUnitName(final Locale locale, final String quantityName, final String unitKey)
372     {
373         String name = getLocalized(locale, UNIT_PREFIX + quantityName + "." + unitKey + NAME_SUFFIX, false);
374         if (name != null)
375         {
376             return name;
377         }
378         var unit = getUnit(quantityName, unitKey);
379         return unit == null ? unitKey : unit.getStoredName();
380     }
381 
382     /**
383      * 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
384      * unit cannot be found on the basis of the unit key, return the unit key itself as the display name.
385      * @param unitClass the class of the unit to lookup
386      * @param unitKey the key of the unit
387      * @return the localized display name of the unit
388      */
389     public static String localizedUnitName(final Class<?> unitClass, final String unitKey)
390     {
391         return localizedUnitName(Locale.getDefault(), quantityName(unitClass), unitKey);
392     }
393 
394     /**
395      * Lookup a textual abbreviation for a given unit key. If it cannot be found, use the stored US unit as a fallback. If the
396      * US-based unit cannot be found on the basis of the unit key, return the unit key itself as the textual abbreviation.
397      * @param locale the locale
398      * @param quantityName the simple class name of the quantity
399      * @param unitKey the key of the unit
400      * @return the localized textual abbreviation of the unit
401      */
402     public static String localizedUnitTextualAbbr(final Locale locale, final String quantityName, final String unitKey)
403     {
404         String abbr = getLocalized(locale, UNIT_PREFIX + quantityName + "." + unitKey + ABBR_SUFFIX, false);
405         if (abbr != null)
406         {
407             return abbr;
408         }
409         var unit = getUnit(quantityName, unitKey);
410         return unit == null ? unitKey : unit.getStoredTextualAbbreviation();
411     }
412 
413     /**
414      * Lookup a textual abbreviation (key) for a given unit key. If it cannot be found, use the stored US unit as a fallback. If
415      * the US-based unit cannot be found on the basis of the unit key, return the unit key itself as the textual abbreviation.
416      * @param unitClass the class of the unit to lookup
417      * @param unitKey the key of the unit
418      * @return the localized textual abbreviation of the unit
419      */
420     public static String localizedUnitTextualAbbr(final Class<?> unitClass, final String unitKey)
421     {
422         return localizedUnitTextualAbbr(Locale.getDefault(), quantityName(unitClass), unitKey);
423     }
424 
425     /**
426      * Return the value of a key for the given locale.
427      * @param locale the locale to use
428      * @param key the key to search for
429      * @param fallback a fallback string
430      * @return the value of the key for the given locale
431      */
432     private static String getLocalizedOrFallback(final Locale locale, final String key, final String fallback)
433     {
434         String v = getLocalized(locale, key, false);
435         return (v != null) ? v : fallback;
436     }
437 
438     /**
439      * Return the value of a key for the given locale.
440      * @param locale the locale to use
441      * @param key the key to search for
442      * @param required whether the key should be present in the bundle
443      * @return the value of the key for the given locale
444      */
445     private static String getLocalized(final Locale locale, final String key, final boolean required)
446     {
447         try
448         {
449             ResourceBundle b = bundle(locale);
450             return b.getString(key);
451         }
452         catch (MissingResourceException e)
453         {
454             if (required)
455             {
456                 throw e;
457             }
458             return null;
459         }
460     }
461 
462 }