From ee96b4f9c6b2e624205318798c508abb3f1da2fe Mon Sep 17 00:00:00 2001 From: Andreas Gullberg Larsen Date: Sat, 17 Jun 2023 02:48:10 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20Quantity.From/TryFromUnitAbbr?= =?UTF-8?q?eviation=20(#1265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../UnitsNetGen/StaticQuantityGenerator.cs | 3 +- README.md | 29 ++++ UnitsNet.Tests/QuantityTests.cs | 75 ++++++++++ UnitsNet.Tests/QuantityTypeConverterTest.cs | 2 +- UnitsNet/CustomCode/Quantity.cs | 129 +++++++++++++++++- UnitsNet/GeneratedCode/Quantity.g.cs | 3 +- 6 files changed, 233 insertions(+), 8 deletions(-) diff --git a/CodeGen/Generators/UnitsNetGen/StaticQuantityGenerator.cs b/CodeGen/Generators/UnitsNetGen/StaticQuantityGenerator.cs index 4893cc3a6b..f1f19b9165 100644 --- a/CodeGen/Generators/UnitsNetGen/StaticQuantityGenerator.cs +++ b/CodeGen/Generators/UnitsNetGen/StaticQuantityGenerator.cs @@ -21,6 +21,7 @@ public string Generate() using UnitsNet.Units; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; #nullable enable @@ -71,7 +72,7 @@ public static IQuantity FromQuantityInfo(QuantityInfo quantityInfo, QuantityValu /// Unit enum value. /// The resulting quantity if successful, otherwise default. /// True if successful with assigned the value, otherwise false. - 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) {"); diff --git a/README.md b/README.md index 0ec14980f6..e7872947bd 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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( + 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`
[Download](https://github.com/angularsen/UnitsNet/releases/tag/UnitConverterWpf%2F2018-11-09) (release 2018-11-09 for Windows) diff --git a/UnitsNet.Tests/QuantityTests.cs b/UnitsNet.Tests/QuantityTests.cs index 349503bf42..8153ee3ab6 100644 --- a/UnitsNet.Tests/QuantityTests.cs +++ b/UnitsNet.Tests/QuantityTests.cs @@ -10,6 +10,8 @@ namespace UnitsNet.Tests { public partial class QuantityTests { + private static readonly CultureInfo Russian = CultureInfo.GetCultureInfo("ru-RU"); + [Fact] public void GetHashCodeForDifferentQuantitiesWithSameValuesAreNotEqual() { @@ -143,6 +145,79 @@ public void From_InvalidQuantityNameOrUnitName_ThrowsUnitNotFoundException() Assert.Throws(() => 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(() => Quantity.FromUnitAbbreviation(Russian, 5, "cm")); // Expected "см" + } + + [Fact] + public void TryFromUnitAbbreviation_MismatchingCulture_ThrowsUnitNotFoundException() + { + Assert.Throws(() => Quantity.FromUnitAbbreviation(Russian, 5, "cm")); // Expected "см" + } + + [Fact] + public void FromUnitAbbreviation_InvalidAbbreviation_ThrowsUnitNotFoundException() + { + Assert.Throws(() => 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(() => 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); diff --git a/UnitsNet.Tests/QuantityTypeConverterTest.cs b/UnitsNet.Tests/QuantityTypeConverterTest.cs index faafc1bfaa..199a754e9c 100644 --- a/UnitsNet.Tests/QuantityTypeConverterTest.cs +++ b/UnitsNet.Tests/QuantityTypeConverterTest.cs @@ -137,7 +137,7 @@ public void ConvertFrom_GivenWrongQuantity_ThrowsArgumentException() var converter = new QuantityTypeConverter(); ITypeDescriptorContext context = new TypeDescriptorContext("SomeMemberName", new Attribute[] { }); - Assert.Throws(() => converter.ConvertFrom(context, Culture, "1m^2")); + Assert.Throws(() => converter.ConvertFrom(context, Culture, "1m^2")); } [Theory] diff --git a/UnitsNet/CustomCode/Quantity.cs b/UnitsNet/CustomCode/Quantity.cs index 535b2b9b93..d28d8da7f8 100644 --- a/UnitsNet/CustomCode/Quantity.cs +++ b/UnitsNet/CustomCode/Quantity.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; +using UnitsNet.Units; namespace UnitsNet { @@ -53,17 +54,17 @@ public static bool TryGetUnitInfo(Enum unitEnum, [NotNullWhen(true)] out UnitInf UnitTypeAndNameToUnitInfoLazy.Value.TryGetValue((unitEnum.GetType(), unitEnum.ToString()), out unitInfo); /// - /// Dynamically construct a quantity. + /// Dynamically constructs a quantity from a numeric value and a unit enum value. /// /// Numeric value. /// Unit enum value. /// An object. - /// Unit value is not a know unit enum type. + /// Unit value is not a known unit enum type. 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?"); } /// @@ -73,7 +74,7 @@ public static IQuantity From(QuantityValue value, Enum unit) /// The invariant quantity name, such as "Length". Does not support localization. /// The invariant unit enum name, such as "Meter". Does not support localization. /// An object. - /// Unit value is not a know unit enum type. + /// Unit value is not a known unit enum type. public static IQuantity From(QuantityValue value, string quantityName, string unitName) { // 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 : throw new UnitNotFoundException($"Unit [{unitName}] not found for quantity [{quantityName}]."); } + /// + /// Dynamically construct a quantity from a numeric value and a unit abbreviation using . + /// + /// + /// This method is currently not optimized for performance and will enumerate all units and their unit abbreviations each time.
+ /// Unit abbreviation matching is case-insensitive.
+ ///
+ /// This will fail if more than one unit across all quantities share the same unit abbreviation.
+ /// Prefer or instead. + ///
+ /// Numeric value. + /// Unit abbreviation, such as "kg" for . + /// An object. + /// Unit abbreviation is not known. + /// Multiple units found matching the given unit abbreviation. + public static IQuantity FromUnitAbbreviation(QuantityValue value, string unitAbbreviation) => FromUnitAbbreviation(null, value, unitAbbreviation); + + /// + /// Dynamically construct a quantity from a numeric value and a unit abbreviation. + /// + /// + /// This method is currently not optimized for performance and will enumerate all units and their unit abbreviations each time.
+ /// Unit abbreviation matching is case-insensitive.
+ ///
+ /// This will fail if more than one unit across all quantities share the same unit abbreviation.
+ /// Prefer or instead. + ///
+ /// The format provider to use for lookup. Defaults to if null. + /// Numeric value. + /// Unit abbreviation, such as "kg" for . + /// An object. + /// Unit abbreviation is not known. + /// Multiple units found matching the given unit abbreviation. + public static IQuantity FromUnitAbbreviation(IFormatProvider? formatProvider, QuantityValue value, string unitAbbreviation) + { + // TODO Optimize this with UnitValueAbbreviationLookup via UnitAbbreviationsCache.TryGetUnitValueAbbreviationLookup. + List 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); + } + /// public static bool TryFrom(double value, Enum unit, [NotNullWhen(true)] out IQuantity? quantity) { @@ -110,6 +162,54 @@ public static bool TryFrom(double value, string quantityName, string unitName, [ TryFrom(value, unitValue, out quantity); } + /// + /// Dynamically construct a quantity from a numeric value and a unit abbreviation using . + /// + /// + /// This method is currently not optimized for performance and will enumerate all units and their unit abbreviations each time.
+ /// Unit abbreviation matching is case-insensitive.
+ ///
+ /// This will fail if more than one unit across all quantities share the same unit abbreviation.
+ /// Prefer or instead. + ///
+ /// Numeric value. + /// Unit abbreviation, such as "kg" for . + /// The quantity if successful, otherwise null. + /// True if successful. + /// Unit value is not a known unit enum type. + public static bool TryFromUnitAbbreviation(QuantityValue value, string unitAbbreviation, [NotNullWhen(true)] out IQuantity? quantity) => + TryFromUnitAbbreviation(null, value, unitAbbreviation, out quantity); + + /// + /// Dynamically construct a quantity from a numeric value and a unit abbreviation. + /// + /// + /// This method is currently not optimized for performance and will enumerate all units and their unit abbreviations each time.
+ /// Unit abbreviation matching is case-insensitive.
+ ///
+ /// This will fail if more than one unit across all quantities share the same unit abbreviation.
+ /// Prefer or instead. + ///
+ /// The format provider to use for lookup. Defaults to if null. + /// Numeric value. + /// Unit abbreviation, such as "kg" for . + /// The quantity if successful, otherwise null. + /// True if successful. + /// Unit value is not a known unit enum type. + public static bool TryFromUnitAbbreviation(IFormatProvider? formatProvider, QuantityValue value, string unitAbbreviation, [NotNullWhen(true)] out IQuantity? quantity) + { + // TODO Optimize this with UnitValueAbbreviationLookup via UnitAbbreviationsCache.TryGetUnitValueAbbreviationLookup. + List units = GetUnitsForAbbreviation(formatProvider, unitAbbreviation); + if (units.Count == 1) + { + Enum? unit = units.SingleOrDefault(); + return TryFrom(value, unit, out quantity); + } + + quantity = default; + return false; + } + /// public static IQuantity Parse(Type quantityType, string quantityString) => Parse(null, quantityType, quantityString); @@ -121,6 +221,7 @@ public static bool TryFrom(double value, string quantityName, string unitName, [ /// Quantity string representation, such as "1.5 kg". Must be compatible with given quantity type. /// The parsed quantity. /// Type must be of type UnitsNet.IQuantity -or- Type is not a known quantity type. + /// Type must be of type UnitsNet.IQuantity -or- Type is not a known quantity type. public static IQuantity Parse(IFormatProvider? formatProvider, Type quantityType, string quantityString) { if (!typeof(IQuantity).IsAssignableFrom(quantityType)) @@ -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}."); } /// @@ -144,5 +245,23 @@ public static IEnumerable GetQuantitiesWithBaseDimensions(BaseDime { return InfosLazy.Value.Where(info => info.BaseDimensions.Equals(baseDimensions)); } + + private static List 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(); + } } } diff --git a/UnitsNet/GeneratedCode/Quantity.g.cs b/UnitsNet/GeneratedCode/Quantity.g.cs index a24e640d3c..d82413486f 100644 --- a/UnitsNet/GeneratedCode/Quantity.g.cs +++ b/UnitsNet/GeneratedCode/Quantity.g.cs @@ -22,6 +22,7 @@ using UnitsNet.Units; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; #nullable enable @@ -300,7 +301,7 @@ public static IQuantity FromQuantityInfo(QuantityInfo quantityInfo, QuantityValu /// Unit enum value. /// The resulting quantity if successful, otherwise default. /// True if successful with assigned the value, otherwise false. - 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) {