Skip to content

Commit

Permalink
✨ Add Quantity.From/TryFromUnitAbbreviation (#1265)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
angularsen authored Jun 17, 2023
1 parent 425a947 commit ee96b4f
Show file tree
Hide file tree
Showing 6 changed files with 233 additions and 8 deletions.
3 changes: 2 additions & 1 deletion CodeGen/Generators/UnitsNetGen/StaticQuantityGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public string Generate()
using UnitsNet.Units;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
#nullable enable
Expand Down Expand Up @@ -71,7 +72,7 @@ public static IQuantity FromQuantityInfo(QuantityInfo quantityInfo, QuantityValu
/// <param name=""unit"">Unit enum value.</param>
/// <param name=""quantity"">The resulting quantity if successful, otherwise <c>default</c>.</param>
/// <returns><c>True</c> if successful with <paramref name=""quantity""/> assigned the value, otherwise <c>false</c>.</returns>
public static bool TryFrom(QuantityValue value, Enum unit, [NotNullWhen(true)] out IQuantity? quantity)
public static bool TryFrom(QuantityValue value, Enum? unit, [NotNullWhen(true)] out IQuantity? quantity)
{
switch (unit)
{");
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,16 @@ if (Quantity.TryFrom(value: 3, quantityName: "Length", unitName: "Centimeter", o
}
```

Or create by just the unit abbreviation, as long as there is exactly one unit with this abbreviation.
```c#
// Length with unit LengthUnit.Centimeter
IQuantity quantity = Quantity.FromUnitAbbreviation(3, "cm");

if (Quantity.TryFromUnitAbbreviation(3, "cm", out IQuantity? quantity2))
{
}
```

#### Parse quantity
Parse any string to a quantity instance of the given the quantity type.

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

#### Parse custom quantity
[QuantityParser](UnitsNet/CustomCode/QuantityParser.cs) parses quantity strings to `IQuantity` by providing a `UnitAbbreviationsCache` with custom units and unit abbreviations.

```c#
// Alternatively, manipulate the global UnitAbbreviationsCache.Default.
var unitAbbreviationsCache = new UnitAbbreviationsCache();
unitAbbreviationsCache.MapUnitToAbbreviation(HowMuchUnit.Some, "sm");
unitAbbreviationsCache.MapUnitToAbbreviation(HowMuchUnit.ATon, "tn");

var quantityParser = new QuantityParser(unitAbbreviationsCache);

// 1 Some
HowMuch q = quantityParser.Parse<HowMuch, HowMuchUnit>(
str: "1 sm",
formatProvider: null,
fromDelegate: (value, unit) => new HowMuch((double) value, unit));
```


### Example: Unit converter app
[Source code](https://github.com/angularsen/UnitsNet/tree/master/Samples/UnitConverter.Wpf) for `Samples/UnitConverter.Wpf`<br/>
[Download](https://github.com/angularsen/UnitsNet/releases/tag/UnitConverterWpf%2F2018-11-09) (release 2018-11-09 for Windows)
Expand Down
75 changes: 75 additions & 0 deletions UnitsNet.Tests/QuantityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ namespace UnitsNet.Tests
{
public partial class QuantityTests
{
private static readonly CultureInfo Russian = CultureInfo.GetCultureInfo("ru-RU");

[Fact]
public void GetHashCodeForDifferentQuantitiesWithSameValuesAreNotEqual()
{
Expand Down Expand Up @@ -143,6 +145,79 @@ public void From_InvalidQuantityNameOrUnitName_ThrowsUnitNotFoundException()
Assert.Throws<UnitNotFoundException>(() => Quantity.From(5, "InvalidQuantity", "Kilogram"));
}

[Fact]
public void FromUnitAbbreviation_ReturnsQuantity()
{
IQuantity q = Quantity.FromUnitAbbreviation(5, "cm");
Assert.Equal(5, q.Value);
Assert.Equal(LengthUnit.Centimeter, q.Unit);
}

[Fact]
public void TryFromUnitAbbreviation_ReturnsQuantity()
{
Assert.True(Quantity.TryFromUnitAbbreviation(5, "cm", out IQuantity? q));
Assert.Equal(LengthUnit.Centimeter, q!.Unit);
}

[Fact]
public void FromUnitAbbreviation_MatchingCulture_ReturnsQuantity()
{
IQuantity q = Quantity.FromUnitAbbreviation(Russian, 5, "см");
Assert.Equal(5, q.Value);
Assert.Equal(LengthUnit.Centimeter, q.Unit);
}

[Fact]
public void TryFromUnitAbbreviation_MatchingCulture_ReturnsQuantity()
{
Assert.False(Quantity.TryFromUnitAbbreviation(Russian, 5, "cm", out IQuantity? q));
}

[Fact]
public void FromUnitAbbreviation_MismatchingCulture_ThrowsUnitNotFoundException()
{
Assert.Throws<UnitNotFoundException>(() => Quantity.FromUnitAbbreviation(Russian, 5, "cm")); // Expected "см"
}

[Fact]
public void TryFromUnitAbbreviation_MismatchingCulture_ThrowsUnitNotFoundException()
{
Assert.Throws<UnitNotFoundException>(() => Quantity.FromUnitAbbreviation(Russian, 5, "cm")); // Expected "см"
}

[Fact]
public void FromUnitAbbreviation_InvalidAbbreviation_ThrowsUnitNotFoundException()
{
Assert.Throws<UnitNotFoundException>(() => Quantity.FromUnitAbbreviation(5, "nonexisting-unit"));
}

[Fact]
public void TryFromUnitAbbreviation_InvalidAbbreviation_ThrowsUnitNotFoundException()
{
Assert.False(Quantity.TryFromUnitAbbreviation(5, "nonexisting-unit", out IQuantity? q));
Assert.Null(q);
}

[Fact]
public void FromUnitAbbreviation_AmbiguousAbbreviation_ThrowsAmbiguousUnitParseException()
{
// MassFraction.Percent
// Ratio.Percent
// VolumeConcentration.Percent
Assert.Throws<AmbiguousUnitParseException>(() => Quantity.FromUnitAbbreviation(5, "%"));
}

[Fact]
public void TryFromUnitAbbreviation_AmbiguousAbbreviation_ReturnsFalse()
{
// MassFraction.Percent
// Ratio.Percent
// VolumeConcentration.Percent
Assert.False(Quantity.TryFromUnitAbbreviation(5, "%", out IQuantity? q));
Assert.Null(q);
}

private static Length ParseLength(string str)
{
return Length.Parse(str, CultureInfo.InvariantCulture);
Expand Down
2 changes: 1 addition & 1 deletion UnitsNet.Tests/QuantityTypeConverterTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ public void ConvertFrom_GivenWrongQuantity_ThrowsArgumentException()
var converter = new QuantityTypeConverter<Length>();
ITypeDescriptorContext context = new TypeDescriptorContext("SomeMemberName", new Attribute[] { });

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

[Theory]
Expand Down
129 changes: 124 additions & 5 deletions UnitsNet/CustomCode/Quantity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using UnitsNet.Units;

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

/// <summary>
/// Dynamically construct a quantity.
/// Dynamically constructs a quantity from a numeric value and a unit enum value.
/// </summary>
/// <param name="value">Numeric value.</param>
/// <param name="unit">Unit enum value.</param>
/// <returns>An <see cref="IQuantity"/> object.</returns>
/// <exception cref="ArgumentException">Unit value is not a know unit enum type.</exception>
/// <exception cref="UnitNotFoundException">Unit value is not a known unit enum type.</exception>
public static IQuantity From(QuantityValue value, Enum unit)
{
return TryFrom(value, unit, out IQuantity? quantity)
? quantity
: 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?");
: 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?");
}

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

/// <summary>
/// Dynamically construct a quantity from a numeric value and a unit abbreviation using <see cref="CultureInfo.CurrentCulture"/>.
/// </summary>
/// <remarks>
/// This method is currently not optimized for performance and will enumerate all units and their unit abbreviations each time.<br/>
/// Unit abbreviation matching is case-insensitive.<br/>
/// <br/>
/// This will fail if more than one unit across all quantities share the same unit abbreviation.<br/>
/// Prefer <see cref="From(UnitsNet.QuantityValue,System.Enum)"/> or <see cref="From(UnitsNet.QuantityValue,string,string)"/> instead.
/// </remarks>
/// <param name="value">Numeric value.</param>
/// <param name="unitAbbreviation">Unit abbreviation, such as "kg" for <see cref="MassUnit.Kilogram"/>.</param>
/// <returns>An <see cref="IQuantity"/> object.</returns>
/// <exception cref="UnitNotFoundException">Unit abbreviation is not known.</exception>
/// <exception cref="AmbiguousUnitParseException">Multiple units found matching the given unit abbreviation.</exception>
public static IQuantity FromUnitAbbreviation(QuantityValue value, string unitAbbreviation) => FromUnitAbbreviation(null, value, unitAbbreviation);

/// <summary>
/// Dynamically construct a quantity from a numeric value and a unit abbreviation.
/// </summary>
/// <remarks>
/// This method is currently not optimized for performance and will enumerate all units and their unit abbreviations each time.<br/>
/// Unit abbreviation matching is case-insensitive.<br/>
/// <br/>
/// This will fail if more than one unit across all quantities share the same unit abbreviation.<br/>
/// Prefer <see cref="From(UnitsNet.QuantityValue,System.Enum)"/> or <see cref="From(UnitsNet.QuantityValue,string,string)"/> instead.
/// </remarks>
/// <param name="formatProvider">The format provider to use for lookup. Defaults to <see cref="CultureInfo.CurrentCulture" /> if null.</param>
/// <param name="value">Numeric value.</param>
/// <param name="unitAbbreviation">Unit abbreviation, such as "kg" for <see cref="MassUnit.Kilogram"/>.</param>
/// <returns>An <see cref="IQuantity"/> object.</returns>
/// <exception cref="UnitNotFoundException">Unit abbreviation is not known.</exception>
/// <exception cref="AmbiguousUnitParseException">Multiple units found matching the given unit abbreviation.</exception>
public static IQuantity FromUnitAbbreviation(IFormatProvider? formatProvider, QuantityValue value, string unitAbbreviation)
{
// TODO Optimize this with UnitValueAbbreviationLookup via UnitAbbreviationsCache.TryGetUnitValueAbbreviationLookup.
List<Enum> units = GetUnitsForAbbreviation(formatProvider, unitAbbreviation);
if (units.Count > 1)
{
throw new AmbiguousUnitParseException($"Multiple units found matching the given unit abbreviation: {unitAbbreviation}");
}

if (units.Count == 0)
{
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.");
}

Enum unit = units.Single();
return From(value, unit);
}

/// <inheritdoc cref="TryFrom(QuantityValue,System.Enum,out UnitsNet.IQuantity)"/>
public static bool TryFrom(double value, Enum unit, [NotNullWhen(true)] out IQuantity? quantity)
{
Expand Down Expand Up @@ -110,6 +162,54 @@ public static bool TryFrom(double value, string quantityName, string unitName, [
TryFrom(value, unitValue, out quantity);
}

/// <summary>
/// Dynamically construct a quantity from a numeric value and a unit abbreviation using <see cref="CultureInfo.CurrentCulture"/>.
/// </summary>
/// <remarks>
/// This method is currently not optimized for performance and will enumerate all units and their unit abbreviations each time.<br/>
/// Unit abbreviation matching is case-insensitive.<br/>
/// <br/>
/// This will fail if more than one unit across all quantities share the same unit abbreviation.<br/>
/// Prefer <see cref="From(UnitsNet.QuantityValue,System.Enum)"/> or <see cref="From(UnitsNet.QuantityValue,string,string)"/> instead.
/// </remarks>
/// <param name="value">Numeric value.</param>
/// <param name="unitAbbreviation">Unit abbreviation, such as "kg" for <see cref="MassUnit.Kilogram"/>.</param>
/// <param name="quantity">The quantity if successful, otherwise null.</param>
/// <returns>True if successful.</returns>
/// <exception cref="ArgumentException">Unit value is not a known unit enum type.</exception>
public static bool TryFromUnitAbbreviation(QuantityValue value, string unitAbbreviation, [NotNullWhen(true)] out IQuantity? quantity) =>
TryFromUnitAbbreviation(null, value, unitAbbreviation, out quantity);

/// <summary>
/// Dynamically construct a quantity from a numeric value and a unit abbreviation.
/// </summary>
/// <remarks>
/// This method is currently not optimized for performance and will enumerate all units and their unit abbreviations each time.<br/>
/// Unit abbreviation matching is case-insensitive.<br/>
/// <br/>
/// This will fail if more than one unit across all quantities share the same unit abbreviation.<br/>
/// Prefer <see cref="From(UnitsNet.QuantityValue,System.Enum)"/> or <see cref="From(UnitsNet.QuantityValue,string,string)"/> instead.
/// </remarks>
/// <param name="formatProvider">The format provider to use for lookup. Defaults to <see cref="CultureInfo.CurrentCulture" /> if null.</param>
/// <param name="value">Numeric value.</param>
/// <param name="unitAbbreviation">Unit abbreviation, such as "kg" for <see cref="MassUnit.Kilogram"/>.</param>
/// <param name="quantity">The quantity if successful, otherwise null.</param>
/// <returns>True if successful.</returns>
/// <exception cref="ArgumentException">Unit value is not a known unit enum type.</exception>
public static bool TryFromUnitAbbreviation(IFormatProvider? formatProvider, QuantityValue value, string unitAbbreviation, [NotNullWhen(true)] out IQuantity? quantity)
{
// TODO Optimize this with UnitValueAbbreviationLookup via UnitAbbreviationsCache.TryGetUnitValueAbbreviationLookup.
List<Enum> units = GetUnitsForAbbreviation(formatProvider, unitAbbreviation);
if (units.Count == 1)
{
Enum? unit = units.SingleOrDefault();
return TryFrom(value, unit, out quantity);
}

quantity = default;
return false;
}

/// <inheritdoc cref="Parse(IFormatProvider, System.Type,string)"/>
public static IQuantity Parse(Type quantityType, string quantityString) => Parse(null, quantityType, quantityString);

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

throw new ArgumentException($"Quantity string could not be parsed to quantity {quantityType}.");
throw new UnitNotFoundException($"Quantity string could not be parsed to quantity {quantityType}.");
}

/// <inheritdoc cref="TryParse(IFormatProvider,System.Type,string,out UnitsNet.IQuantity)"/>
Expand All @@ -144,5 +245,23 @@ public static IEnumerable<QuantityInfo> GetQuantitiesWithBaseDimensions(BaseDime
{
return InfosLazy.Value.Where(info => info.BaseDimensions.Equals(baseDimensions));
}

private static List<Enum> GetUnitsForAbbreviation(IFormatProvider? formatProvider, string unitAbbreviation)
{
// Use case-sensitive match to reduce ambiguity.
// Don't use UnitParser.TryParse() here, since it allows case-insensitive match per quantity as long as there are no ambiguous abbreviations for
// units of that quantity, but here we try all quantities and this results in too high of a chance for ambiguous matches,
// such as "cm" matching both LengthUnit.Centimeter (cm) and MolarityUnit.CentimolePerLiter (cM).
return Infos
.SelectMany(i => i.UnitInfos)
.Select(ui => UnitAbbreviationsCache.Default
.GetUnitAbbreviations(ui.Value.GetType(), Convert.ToInt32(ui.Value), formatProvider)
.Contains(unitAbbreviation, StringComparer.Ordinal)
? ui.Value
: null)
.Where(unitValue => unitValue != null)
.Select(unitValue => unitValue!)
.ToList();
}
}
}
3 changes: 2 additions & 1 deletion UnitsNet/GeneratedCode/Quantity.g.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit ee96b4f

Please sign in to comment.