Skip to content

Commit ee96b4f

Browse files
authored
✨ Add Quantity.From/TryFromUnitAbbreviation (#1265)
Fixes #1252 Adds a naive implementation for creating a quantity given a value and a unit abbreviation. However, there is a significant risk of failing due to multiple units from different quantities having the same unit abbreviation. Matching is case-sensitive. ### Changes - Add `Quantity.FromUnitAbbreviation` - Add `Quantity.TryFromUnitAbbreviation` - Add tests - Add example to README
1 parent 425a947 commit ee96b4f

File tree

6 files changed

+233
-8
lines changed

6 files changed

+233
-8
lines changed

CodeGen/Generators/UnitsNetGen/StaticQuantityGenerator.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public string Generate()
2121
using UnitsNet.Units;
2222
using System.Collections.Generic;
2323
using System.Diagnostics.CodeAnalysis;
24+
using System.Linq;
2425
2526
#nullable enable
2627
@@ -71,7 +72,7 @@ public static IQuantity FromQuantityInfo(QuantityInfo quantityInfo, QuantityValu
7172
/// <param name=""unit"">Unit enum value.</param>
7273
/// <param name=""quantity"">The resulting quantity if successful, otherwise <c>default</c>.</param>
7374
/// <returns><c>True</c> if successful with <paramref name=""quantity""/> assigned the value, otherwise <c>false</c>.</returns>
74-
public static bool TryFrom(QuantityValue value, Enum unit, [NotNullWhen(true)] out IQuantity? quantity)
75+
public static bool TryFrom(QuantityValue value, Enum? unit, [NotNullWhen(true)] out IQuantity? quantity)
7576
{
7677
switch (unit)
7778
{");

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,16 @@ if (Quantity.TryFrom(value: 3, quantityName: "Length", unitName: "Centimeter", o
178178
}
179179
```
180180

181+
Or create by just the unit abbreviation, as long as there is exactly one unit with this abbreviation.
182+
```c#
183+
// Length with unit LengthUnit.Centimeter
184+
IQuantity quantity = Quantity.FromUnitAbbreviation(3, "cm");
185+
186+
if (Quantity.TryFromUnitAbbreviation(3, "cm", out IQuantity? quantity2))
187+
{
188+
}
189+
```
190+
181191
#### Parse quantity
182192
Parse any string to a quantity instance of the given the quantity type.
183193

@@ -261,6 +271,25 @@ Console.WriteLine(Convert(HowMuchUnit.Lots)); // 100 lts
261271
Console.WriteLine(Convert(HowMuchUnit.Tons)); // 10 tns
262272
```
263273

274+
#### Parse custom quantity
275+
[QuantityParser](UnitsNet/CustomCode/QuantityParser.cs) parses quantity strings to `IQuantity` by providing a `UnitAbbreviationsCache` with custom units and unit abbreviations.
276+
277+
```c#
278+
// Alternatively, manipulate the global UnitAbbreviationsCache.Default.
279+
var unitAbbreviationsCache = new UnitAbbreviationsCache();
280+
unitAbbreviationsCache.MapUnitToAbbreviation(HowMuchUnit.Some, "sm");
281+
unitAbbreviationsCache.MapUnitToAbbreviation(HowMuchUnit.ATon, "tn");
282+
283+
var quantityParser = new QuantityParser(unitAbbreviationsCache);
284+
285+
// 1 Some
286+
HowMuch q = quantityParser.Parse<HowMuch, HowMuchUnit>(
287+
str: "1 sm",
288+
formatProvider: null,
289+
fromDelegate: (value, unit) => new HowMuch((double) value, unit));
290+
```
291+
292+
264293
### Example: Unit converter app
265294
[Source code](https://github.com/angularsen/UnitsNet/tree/master/Samples/UnitConverter.Wpf) for `Samples/UnitConverter.Wpf`<br/>
266295
[Download](https://github.com/angularsen/UnitsNet/releases/tag/UnitConverterWpf%2F2018-11-09) (release 2018-11-09 for Windows)

UnitsNet.Tests/QuantityTests.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ namespace UnitsNet.Tests
1010
{
1111
public partial class QuantityTests
1212
{
13+
private static readonly CultureInfo Russian = CultureInfo.GetCultureInfo("ru-RU");
14+
1315
[Fact]
1416
public void GetHashCodeForDifferentQuantitiesWithSameValuesAreNotEqual()
1517
{
@@ -143,6 +145,79 @@ public void From_InvalidQuantityNameOrUnitName_ThrowsUnitNotFoundException()
143145
Assert.Throws<UnitNotFoundException>(() => Quantity.From(5, "InvalidQuantity", "Kilogram"));
144146
}
145147

148+
[Fact]
149+
public void FromUnitAbbreviation_ReturnsQuantity()
150+
{
151+
IQuantity q = Quantity.FromUnitAbbreviation(5, "cm");
152+
Assert.Equal(5, q.Value);
153+
Assert.Equal(LengthUnit.Centimeter, q.Unit);
154+
}
155+
156+
[Fact]
157+
public void TryFromUnitAbbreviation_ReturnsQuantity()
158+
{
159+
Assert.True(Quantity.TryFromUnitAbbreviation(5, "cm", out IQuantity? q));
160+
Assert.Equal(LengthUnit.Centimeter, q!.Unit);
161+
}
162+
163+
[Fact]
164+
public void FromUnitAbbreviation_MatchingCulture_ReturnsQuantity()
165+
{
166+
IQuantity q = Quantity.FromUnitAbbreviation(Russian, 5, "см");
167+
Assert.Equal(5, q.Value);
168+
Assert.Equal(LengthUnit.Centimeter, q.Unit);
169+
}
170+
171+
[Fact]
172+
public void TryFromUnitAbbreviation_MatchingCulture_ReturnsQuantity()
173+
{
174+
Assert.False(Quantity.TryFromUnitAbbreviation(Russian, 5, "cm", out IQuantity? q));
175+
}
176+
177+
[Fact]
178+
public void FromUnitAbbreviation_MismatchingCulture_ThrowsUnitNotFoundException()
179+
{
180+
Assert.Throws<UnitNotFoundException>(() => Quantity.FromUnitAbbreviation(Russian, 5, "cm")); // Expected "см"
181+
}
182+
183+
[Fact]
184+
public void TryFromUnitAbbreviation_MismatchingCulture_ThrowsUnitNotFoundException()
185+
{
186+
Assert.Throws<UnitNotFoundException>(() => Quantity.FromUnitAbbreviation(Russian, 5, "cm")); // Expected "см"
187+
}
188+
189+
[Fact]
190+
public void FromUnitAbbreviation_InvalidAbbreviation_ThrowsUnitNotFoundException()
191+
{
192+
Assert.Throws<UnitNotFoundException>(() => Quantity.FromUnitAbbreviation(5, "nonexisting-unit"));
193+
}
194+
195+
[Fact]
196+
public void TryFromUnitAbbreviation_InvalidAbbreviation_ThrowsUnitNotFoundException()
197+
{
198+
Assert.False(Quantity.TryFromUnitAbbreviation(5, "nonexisting-unit", out IQuantity? q));
199+
Assert.Null(q);
200+
}
201+
202+
[Fact]
203+
public void FromUnitAbbreviation_AmbiguousAbbreviation_ThrowsAmbiguousUnitParseException()
204+
{
205+
// MassFraction.Percent
206+
// Ratio.Percent
207+
// VolumeConcentration.Percent
208+
Assert.Throws<AmbiguousUnitParseException>(() => Quantity.FromUnitAbbreviation(5, "%"));
209+
}
210+
211+
[Fact]
212+
public void TryFromUnitAbbreviation_AmbiguousAbbreviation_ReturnsFalse()
213+
{
214+
// MassFraction.Percent
215+
// Ratio.Percent
216+
// VolumeConcentration.Percent
217+
Assert.False(Quantity.TryFromUnitAbbreviation(5, "%", out IQuantity? q));
218+
Assert.Null(q);
219+
}
220+
146221
private static Length ParseLength(string str)
147222
{
148223
return Length.Parse(str, CultureInfo.InvariantCulture);

UnitsNet.Tests/QuantityTypeConverterTest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ public void ConvertFrom_GivenWrongQuantity_ThrowsArgumentException()
137137
var converter = new QuantityTypeConverter<Length>();
138138
ITypeDescriptorContext context = new TypeDescriptorContext("SomeMemberName", new Attribute[] { });
139139

140-
Assert.Throws<ArgumentException>(() => converter.ConvertFrom(context, Culture, "1m^2"));
140+
Assert.Throws<UnitNotFoundException>(() => converter.ConvertFrom(context, Culture, "1m^2"));
141141
}
142142

143143
[Theory]

UnitsNet/CustomCode/Quantity.cs

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Diagnostics.CodeAnalysis;
44
using System.Globalization;
55
using System.Linq;
6+
using UnitsNet.Units;
67

78
namespace UnitsNet
89
{
@@ -53,17 +54,17 @@ public static bool TryGetUnitInfo(Enum unitEnum, [NotNullWhen(true)] out UnitInf
5354
UnitTypeAndNameToUnitInfoLazy.Value.TryGetValue((unitEnum.GetType(), unitEnum.ToString()), out unitInfo);
5455

5556
/// <summary>
56-
/// Dynamically construct a quantity.
57+
/// Dynamically constructs a quantity from a numeric value and a unit enum value.
5758
/// </summary>
5859
/// <param name="value">Numeric value.</param>
5960
/// <param name="unit">Unit enum value.</param>
6061
/// <returns>An <see cref="IQuantity"/> object.</returns>
61-
/// <exception cref="ArgumentException">Unit value is not a know unit enum type.</exception>
62+
/// <exception cref="UnitNotFoundException">Unit value is not a known unit enum type.</exception>
6263
public static IQuantity From(QuantityValue value, Enum unit)
6364
{
6465
return TryFrom(value, unit, out IQuantity? quantity)
6566
? quantity
66-
: throw new ArgumentException($"Unit value {unit} of type {unit.GetType()} is not a known unit enum type. Expected types like UnitsNet.Units.LengthUnit. Did you pass in a third-party enum type defined outside UnitsNet library?");
67+
: throw new UnitNotFoundException($"Unit value {unit} of type {unit.GetType()} is not a known unit enum type. Expected types like UnitsNet.Units.LengthUnit. Did you pass in a custom enum type defined outside the UnitsNet library?");
6768
}
6869

6970
/// <summary>
@@ -73,7 +74,7 @@ public static IQuantity From(QuantityValue value, Enum unit)
7374
/// <param name="quantityName">The invariant quantity name, such as "Length". Does not support localization.</param>
7475
/// <param name="unitName">The invariant unit enum name, such as "Meter". Does not support localization.</param>
7576
/// <returns>An <see cref="IQuantity"/> object.</returns>
76-
/// <exception cref="ArgumentException">Unit value is not a know unit enum type.</exception>
77+
/// <exception cref="ArgumentException">Unit value is not a known unit enum type.</exception>
7778
public static IQuantity From(QuantityValue value, string quantityName, string unitName)
7879
{
7980
// Get enum value for this unit, f.ex. LengthUnit.Meter for unit name "Meter".
@@ -82,6 +83,57 @@ public static IQuantity From(QuantityValue value, string quantityName, string un
8283
: throw new UnitNotFoundException($"Unit [{unitName}] not found for quantity [{quantityName}].");
8384
}
8485

86+
/// <summary>
87+
/// Dynamically construct a quantity from a numeric value and a unit abbreviation using <see cref="CultureInfo.CurrentCulture"/>.
88+
/// </summary>
89+
/// <remarks>
90+
/// This method is currently not optimized for performance and will enumerate all units and their unit abbreviations each time.<br/>
91+
/// Unit abbreviation matching is case-insensitive.<br/>
92+
/// <br/>
93+
/// This will fail if more than one unit across all quantities share the same unit abbreviation.<br/>
94+
/// Prefer <see cref="From(UnitsNet.QuantityValue,System.Enum)"/> or <see cref="From(UnitsNet.QuantityValue,string,string)"/> instead.
95+
/// </remarks>
96+
/// <param name="value">Numeric value.</param>
97+
/// <param name="unitAbbreviation">Unit abbreviation, such as "kg" for <see cref="MassUnit.Kilogram"/>.</param>
98+
/// <returns>An <see cref="IQuantity"/> object.</returns>
99+
/// <exception cref="UnitNotFoundException">Unit abbreviation is not known.</exception>
100+
/// <exception cref="AmbiguousUnitParseException">Multiple units found matching the given unit abbreviation.</exception>
101+
public static IQuantity FromUnitAbbreviation(QuantityValue value, string unitAbbreviation) => FromUnitAbbreviation(null, value, unitAbbreviation);
102+
103+
/// <summary>
104+
/// Dynamically construct a quantity from a numeric value and a unit abbreviation.
105+
/// </summary>
106+
/// <remarks>
107+
/// This method is currently not optimized for performance and will enumerate all units and their unit abbreviations each time.<br/>
108+
/// Unit abbreviation matching is case-insensitive.<br/>
109+
/// <br/>
110+
/// This will fail if more than one unit across all quantities share the same unit abbreviation.<br/>
111+
/// Prefer <see cref="From(UnitsNet.QuantityValue,System.Enum)"/> or <see cref="From(UnitsNet.QuantityValue,string,string)"/> instead.
112+
/// </remarks>
113+
/// <param name="formatProvider">The format provider to use for lookup. Defaults to <see cref="CultureInfo.CurrentCulture" /> if null.</param>
114+
/// <param name="value">Numeric value.</param>
115+
/// <param name="unitAbbreviation">Unit abbreviation, such as "kg" for <see cref="MassUnit.Kilogram"/>.</param>
116+
/// <returns>An <see cref="IQuantity"/> object.</returns>
117+
/// <exception cref="UnitNotFoundException">Unit abbreviation is not known.</exception>
118+
/// <exception cref="AmbiguousUnitParseException">Multiple units found matching the given unit abbreviation.</exception>
119+
public static IQuantity FromUnitAbbreviation(IFormatProvider? formatProvider, QuantityValue value, string unitAbbreviation)
120+
{
121+
// TODO Optimize this with UnitValueAbbreviationLookup via UnitAbbreviationsCache.TryGetUnitValueAbbreviationLookup.
122+
List<Enum> units = GetUnitsForAbbreviation(formatProvider, unitAbbreviation);
123+
if (units.Count > 1)
124+
{
125+
throw new AmbiguousUnitParseException($"Multiple units found matching the given unit abbreviation: {unitAbbreviation}");
126+
}
127+
128+
if (units.Count == 0)
129+
{
130+
throw new UnitNotFoundException($"Unit abbreviation {unitAbbreviation} is not known. Did you pass in a custom unit abbreviation defined outside the UnitsNet library? This is currently not supported.");
131+
}
132+
133+
Enum unit = units.Single();
134+
return From(value, unit);
135+
}
136+
85137
/// <inheritdoc cref="TryFrom(QuantityValue,System.Enum,out UnitsNet.IQuantity)"/>
86138
public static bool TryFrom(double value, Enum unit, [NotNullWhen(true)] out IQuantity? quantity)
87139
{
@@ -110,6 +162,54 @@ public static bool TryFrom(double value, string quantityName, string unitName, [
110162
TryFrom(value, unitValue, out quantity);
111163
}
112164

165+
/// <summary>
166+
/// Dynamically construct a quantity from a numeric value and a unit abbreviation using <see cref="CultureInfo.CurrentCulture"/>.
167+
/// </summary>
168+
/// <remarks>
169+
/// This method is currently not optimized for performance and will enumerate all units and their unit abbreviations each time.<br/>
170+
/// Unit abbreviation matching is case-insensitive.<br/>
171+
/// <br/>
172+
/// This will fail if more than one unit across all quantities share the same unit abbreviation.<br/>
173+
/// Prefer <see cref="From(UnitsNet.QuantityValue,System.Enum)"/> or <see cref="From(UnitsNet.QuantityValue,string,string)"/> instead.
174+
/// </remarks>
175+
/// <param name="value">Numeric value.</param>
176+
/// <param name="unitAbbreviation">Unit abbreviation, such as "kg" for <see cref="MassUnit.Kilogram"/>.</param>
177+
/// <param name="quantity">The quantity if successful, otherwise null.</param>
178+
/// <returns>True if successful.</returns>
179+
/// <exception cref="ArgumentException">Unit value is not a known unit enum type.</exception>
180+
public static bool TryFromUnitAbbreviation(QuantityValue value, string unitAbbreviation, [NotNullWhen(true)] out IQuantity? quantity) =>
181+
TryFromUnitAbbreviation(null, value, unitAbbreviation, out quantity);
182+
183+
/// <summary>
184+
/// Dynamically construct a quantity from a numeric value and a unit abbreviation.
185+
/// </summary>
186+
/// <remarks>
187+
/// This method is currently not optimized for performance and will enumerate all units and their unit abbreviations each time.<br/>
188+
/// Unit abbreviation matching is case-insensitive.<br/>
189+
/// <br/>
190+
/// This will fail if more than one unit across all quantities share the same unit abbreviation.<br/>
191+
/// Prefer <see cref="From(UnitsNet.QuantityValue,System.Enum)"/> or <see cref="From(UnitsNet.QuantityValue,string,string)"/> instead.
192+
/// </remarks>
193+
/// <param name="formatProvider">The format provider to use for lookup. Defaults to <see cref="CultureInfo.CurrentCulture" /> if null.</param>
194+
/// <param name="value">Numeric value.</param>
195+
/// <param name="unitAbbreviation">Unit abbreviation, such as "kg" for <see cref="MassUnit.Kilogram"/>.</param>
196+
/// <param name="quantity">The quantity if successful, otherwise null.</param>
197+
/// <returns>True if successful.</returns>
198+
/// <exception cref="ArgumentException">Unit value is not a known unit enum type.</exception>
199+
public static bool TryFromUnitAbbreviation(IFormatProvider? formatProvider, QuantityValue value, string unitAbbreviation, [NotNullWhen(true)] out IQuantity? quantity)
200+
{
201+
// TODO Optimize this with UnitValueAbbreviationLookup via UnitAbbreviationsCache.TryGetUnitValueAbbreviationLookup.
202+
List<Enum> units = GetUnitsForAbbreviation(formatProvider, unitAbbreviation);
203+
if (units.Count == 1)
204+
{
205+
Enum? unit = units.SingleOrDefault();
206+
return TryFrom(value, unit, out quantity);
207+
}
208+
209+
quantity = default;
210+
return false;
211+
}
212+
113213
/// <inheritdoc cref="Parse(IFormatProvider, System.Type,string)"/>
114214
public static IQuantity Parse(Type quantityType, string quantityString) => Parse(null, quantityType, quantityString);
115215

@@ -121,6 +221,7 @@ public static bool TryFrom(double value, string quantityName, string unitName, [
121221
/// <param name="quantityString">Quantity string representation, such as "1.5 kg". Must be compatible with given quantity type.</param>
122222
/// <returns>The parsed quantity.</returns>
123223
/// <exception cref="ArgumentException">Type must be of type UnitsNet.IQuantity -or- Type is not a known quantity type.</exception>
224+
/// <exception cref="UnitNotFoundException">Type must be of type UnitsNet.IQuantity -or- Type is not a known quantity type.</exception>
124225
public static IQuantity Parse(IFormatProvider? formatProvider, Type quantityType, string quantityString)
125226
{
126227
if (!typeof(IQuantity).IsAssignableFrom(quantityType))
@@ -129,7 +230,7 @@ public static IQuantity Parse(IFormatProvider? formatProvider, Type quantityType
129230
if (TryParse(formatProvider, quantityType, quantityString, out IQuantity? quantity))
130231
return quantity;
131232

132-
throw new ArgumentException($"Quantity string could not be parsed to quantity {quantityType}.");
233+
throw new UnitNotFoundException($"Quantity string could not be parsed to quantity {quantityType}.");
133234
}
134235

135236
/// <inheritdoc cref="TryParse(IFormatProvider,System.Type,string,out UnitsNet.IQuantity)"/>
@@ -144,5 +245,23 @@ public static IEnumerable<QuantityInfo> GetQuantitiesWithBaseDimensions(BaseDime
144245
{
145246
return InfosLazy.Value.Where(info => info.BaseDimensions.Equals(baseDimensions));
146247
}
248+
249+
private static List<Enum> GetUnitsForAbbreviation(IFormatProvider? formatProvider, string unitAbbreviation)
250+
{
251+
// Use case-sensitive match to reduce ambiguity.
252+
// Don't use UnitParser.TryParse() here, since it allows case-insensitive match per quantity as long as there are no ambiguous abbreviations for
253+
// units of that quantity, but here we try all quantities and this results in too high of a chance for ambiguous matches,
254+
// such as "cm" matching both LengthUnit.Centimeter (cm) and MolarityUnit.CentimolePerLiter (cM).
255+
return Infos
256+
.SelectMany(i => i.UnitInfos)
257+
.Select(ui => UnitAbbreviationsCache.Default
258+
.GetUnitAbbreviations(ui.Value.GetType(), Convert.ToInt32(ui.Value), formatProvider)
259+
.Contains(unitAbbreviation, StringComparer.Ordinal)
260+
? ui.Value
261+
: null)
262+
.Where(unitValue => unitValue != null)
263+
.Select(unitValue => unitValue!)
264+
.ToList();
265+
}
147266
}
148267
}

UnitsNet/GeneratedCode/Quantity.g.cs

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)