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 }