Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BaseUnit generation for the prefixed units #1485

Merged
merged 6 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 66 additions & 100 deletions CodeGen/Generators/QuantityJsonFilesParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,123 +6,89 @@
using System.IO;
using System.Linq;
using CodeGen.Exceptions;
using CodeGen.Helpers;
using CodeGen.Helpers.PrefixBuilder;
using CodeGen.JsonTypes;
using Newtonsoft.Json;
using static CodeGen.Helpers.PrefixBuilder.BaseUnitPrefixes;

namespace CodeGen.Generators
namespace CodeGen.Generators;

/// <summary>
/// Parses JSON files that define quantities and their units.
/// This will later be used to generate source code and can be reused for different targets such as .NET framework,
/// .NET Core, .NET nanoFramework and even other programming languages.
/// </summary>
internal static class QuantityJsonFilesParser
{
private static readonly JsonSerializerSettings JsonSerializerSettings = new()
{
// Don't override the C# default assigned values if no value is set in JSON
NullValueHandling = NullValueHandling.Ignore
};

private static readonly string[] BaseQuantityFileNames =
["Length", "Mass", "Duration", "ElectricCurrent", "Temperature", "AmountOfSubstance", "LuminousIntensity"];

/// <summary>
/// Parses JSON files that define quantities and their units.
/// This will later be used to generate source code and can be reused for different targets such as .NET framework,
/// .NET Core, .NET nanoFramework and even other programming languages.
/// </summary>
internal static class QuantityJsonFilesParser
/// <param name="rootDir">Repository root directory, where you cloned the repo to such as "c:\dev\UnitsNet".</param>
/// <returns>The parsed quantities and their units.</returns>
public static Quantity[] ParseQuantities(string rootDir)
{
private static readonly JsonSerializerSettings JsonSerializerSettings = new JsonSerializerSettings
{
// Don't override the C# default assigned values if no value is set in JSON
NullValueHandling = NullValueHandling.Ignore
};
var jsonDir = Path.Combine(rootDir, "Common/UnitDefinitions");
var baseQuantityFiles = BaseQuantityFileNames.Select(baseQuantityName => Path.Combine(jsonDir, baseQuantityName + ".json")).ToArray();

/// <summary>
/// Parses JSON files that define quantities and their units.
/// </summary>
/// <param name="rootDir">Repository root directory, where you cloned the repo to such as "c:\dev\UnitsNet".</param>
/// <returns>The parsed quantities and their units.</returns>
public static Quantity[] ParseQuantities(string rootDir)
{
var jsonDir = Path.Combine(rootDir, "Common/UnitDefinitions");
var jsonFileNames = Directory.GetFiles(jsonDir, "*.json");
return jsonFileNames
.OrderBy(fn => fn, StringComparer.InvariantCultureIgnoreCase)
.Select(ParseQuantityFile)
.ToArray();
}
Quantity[] baseQuantities = ParseQuantities(baseQuantityFiles);
Quantity[] derivedQuantities = ParseQuantities(Directory.GetFiles(jsonDir, "*.json").Except(baseQuantityFiles));

private static Quantity ParseQuantityFile(string jsonFileName)
{
try
{
var quantity = JsonConvert.DeserializeObject<Quantity>(File.ReadAllText(jsonFileName), JsonSerializerSettings)
?? throw new UnitsNetCodeGenException($"Unable to parse quantity from JSON file: {jsonFileName}");
return BuildQuantities(baseQuantities, derivedQuantities);
}

AddPrefixUnits(quantity);
OrderUnitsByName(quantity);
return quantity;
}
catch (Exception e)
{
throw new Exception($"Error parsing quantity JSON file: {jsonFileName}", e);
}
}
private static Quantity[] ParseQuantities(IEnumerable<string> jsonFiles)
{
return jsonFiles.Select(ParseQuantity).ToArray();
}

private static void OrderUnitsByName(Quantity quantity)
private static Quantity ParseQuantity(string jsonFileName)
{
try
{
quantity.Units = quantity.Units.OrderBy(u => u.SingularName, StringComparer.OrdinalIgnoreCase).ToArray();
return JsonConvert.DeserializeObject<Quantity>(File.ReadAllText(jsonFileName), JsonSerializerSettings)
?? throw new UnitsNetCodeGenException($"Unable to parse quantity from JSON file: {jsonFileName}");
}

private static void AddPrefixUnits(Quantity quantity)
catch (Exception e)
{
var unitsToAdd = new List<Unit>();
foreach (Unit unit in quantity.Units)
foreach (Prefix prefix in unit.Prefixes)
{
try
{
var prefixInfo = PrefixInfo.Entries[prefix];

unitsToAdd.Add(new Unit
{
SingularName = $"{prefix}{unit.SingularName.ToCamelCase()}", // "Kilo" + "NewtonPerMeter" => "KilonewtonPerMeter"
PluralName = $"{prefix}{unit.PluralName.ToCamelCase()}", // "Kilo" + "NewtonsPerMeter" => "KilonewtonsPerMeter"
BaseUnits = null, // Can we determine this somehow?
FromBaseToUnitFunc = $"({unit.FromBaseToUnitFunc}) / {prefixInfo.Factor}",
FromUnitToBaseFunc = $"({unit.FromUnitToBaseFunc}) * {prefixInfo.Factor}",
Localization = GetLocalizationForPrefixUnit(unit.Localization, prefixInfo),
ObsoleteText = unit.ObsoleteText,
SkipConversionGeneration = unit.SkipConversionGeneration,
AllowAbbreviationLookup = unit.AllowAbbreviationLookup
} );
}
catch (Exception e)
{
throw new Exception($"Error parsing prefix {prefix} for unit {quantity.Name}.{unit.SingularName}.", e);
}
}

quantity.Units = quantity.Units.Concat(unitsToAdd).ToArray();
throw new Exception($"Error parsing quantity JSON file: {jsonFileName}", e);
}
}

/// <summary>
/// Create unit abbreviations for a prefix unit, given a unit and the prefix.
/// The unit abbreviations are either prefixed with the SI prefix or an explicitly configured abbreviation via
/// <see cref="Localization.AbbreviationsForPrefixes" />.
/// </summary>
private static Localization[] GetLocalizationForPrefixUnit(IEnumerable<Localization> localizations, PrefixInfo prefixInfo)
/// <summary>
/// Combines base quantities and derived quantities into a single collection,
/// while generating prefixed units for each quantity.
/// </summary>
/// <param name="baseQuantities">
/// The array of base quantities, each containing its respective units.
/// </param>
/// <param name="derivedQuantities">
/// The array of derived quantities, each containing its respective units.
/// </param>
/// <returns>
/// An ordered array of all quantities, including both base and derived quantities,
/// with prefixed units generated and added to their respective unit collections.
/// </returns>
/// <remarks>
/// This method utilizes the <see cref="UnitPrefixBuilder" /> to generate prefixed units
/// for each quantity. The resulting quantities are sorted alphabetically by their names.
/// </remarks>
private static Quantity[] BuildQuantities(Quantity[] baseQuantities, Quantity[] derivedQuantities)
{
var prefixBuilder = new UnitPrefixBuilder(FromBaseUnits(baseQuantities.SelectMany(x => x.Units)));
return baseQuantities.Concat(derivedQuantities).Select(quantity =>
{
return localizations.Select(loc =>
{
if (loc.TryGetAbbreviationsForPrefix(prefixInfo.Prefix, out string[]? unitAbbreviationsForPrefix))
{
return new Localization
{
Culture = loc.Culture,
Abbreviations = unitAbbreviationsForPrefix
};
}

// No prefix unit abbreviations are specified, so fall back to prepending the default SI prefix to each unit abbreviation:
// kilo ("k") + meter ("m") => kilometer ("km")
var prefix = prefixInfo.GetPrefixForCultureOrSiPrefix(loc.Culture);
unitAbbreviationsForPrefix = loc.Abbreviations.Select(unitAbbreviation => $"{prefix}{unitAbbreviation}").ToArray();

return new Localization
{
Culture = loc.Culture,
Abbreviations = unitAbbreviationsForPrefix
};
}).ToArray();
}
List<Unit> prefixedUnits = prefixBuilder.GeneratePrefixUnits(quantity);
quantity.Units = quantity.Units.Concat(prefixedUnits).OrderBy(unit => unit.SingularName, StringComparer.OrdinalIgnoreCase).ToArray();
return quantity;
}).OrderBy(quantity => quantity.Name, StringComparer.InvariantCultureIgnoreCase).ToArray();
}
}
17 changes: 17 additions & 0 deletions CodeGen/Helpers/PrefixBuilder/BaseUnitPrefix.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Licensed under MIT No Attribution, see LICENSE file at the root.
// Copyright 2013 Andreas Gullberg Larsen ([email protected]). Maintained at https://github.com/angularsen/UnitsNet.

using CodeGen.JsonTypes;

namespace CodeGen.Helpers.PrefixBuilder;

/// <summary>
/// Represents a unique key that combines a base unit and a prefix.
/// </summary>
/// <param name="BaseUnit">
/// The base unit associated with the prefix. For example, "Gram".
/// </param>
/// <param name="Prefix">
/// The prefix applied to the base unit. For example, <see cref="JsonTypes.Prefix.Kilo" />.
/// </param>
internal readonly record struct BaseUnitPrefix(string BaseUnit, Prefix Prefix);
165 changes: 165 additions & 0 deletions CodeGen/Helpers/PrefixBuilder/BaseUnitPrefixes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Licensed under MIT No Attribution, see LICENSE file at the root.
// Copyright 2013 Andreas Gullberg Larsen ([email protected]). Maintained at https://github.com/angularsen/UnitsNet.

using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using CodeGen.JsonTypes;

namespace CodeGen.Helpers.PrefixBuilder;

/// <summary>
/// Represents a collection of base unit prefixes and their associated mappings.
/// </summary>
/// <remarks>
/// This class provides functionality to manage and retrieve mappings between base units and their prefixed
/// counterparts,
/// including scale factors and prefixed unit names. It supports operations such as creating mappings from a collection
/// of base units and finding matching prefixes for specific units.
/// </remarks>
internal class BaseUnitPrefixes
{
/// <summary>
/// A dictionary that maps metric prefixes to their corresponding exponent values.
/// </summary>
/// <remarks>
/// This dictionary excludes binary prefixes such as Kibi, Mebi, Gibi, Tebi, Pebi, and Exbi.
/// </remarks>
private static readonly Dictionary<Prefix, int> MetricPrefixFactors = PrefixInfo.Entries.Where(x => x.Key.IsMetricPrefix())
.ToDictionary(pair => pair.Key, pair => pair.Value.GetDecimalExponent());

/// <summary>
/// A dictionary that maps the exponent values to their corresponding <see cref="Prefix" />.
/// This is used to find the appropriate prefix for a given factor.
/// </summary>
private static readonly Dictionary<int, Prefix> PrefixFactorsByValue = MetricPrefixFactors.ToDictionary(pair => pair.Value, pair => pair.Key);

/// <summary>
/// Lookup of prefixed unit name from base unit + prefix pairs, such as ("Gram", Prefix.Kilo) => "Kilogram".
/// </summary>
private readonly Dictionary<BaseUnitPrefix, string> _baseUnitPrefixConversions;

/// <summary>
/// A dictionary that maps prefixed unit strings to their corresponding base unit and fractional factor.
/// </summary>
/// <remarks>
/// This dictionary is used to handle units with SI prefixes, allowing for the conversion of prefixed units
/// to their base units and the associated fractional factors. The keys are the prefixed unit strings, and the values
/// are tuples containing the base unit string and the fractional factor.
/// </remarks>
private readonly Dictionary<string, PrefixScaleFactor> _prefixedStringFactors;

private BaseUnitPrefixes(Dictionary<string, PrefixScaleFactor> prefixedStringFactors, Dictionary<BaseUnitPrefix, string> baseUnitPrefixConversions)
{
_prefixedStringFactors = prefixedStringFactors;
_baseUnitPrefixConversions = baseUnitPrefixConversions;
}

/// <summary>
/// Creates an instance of <see cref="BaseUnitPrefixes" /> from a collection of base units.
/// </summary>
/// <param name="baseUnits">
/// A collection of base units, each containing a singular name and associated prefixes.
/// </param>
/// <returns>
/// A new instance of <see cref="BaseUnitPrefixes" /> containing mappings of base units
/// and their prefixed counterparts.
/// </returns>
/// <remarks>
/// This method processes the provided base units to generate mappings between base unit prefixes
/// and their corresponding prefixed unit names, as well as scale factors for each prefixed unit.
/// </remarks>
public static BaseUnitPrefixes FromBaseUnits(IEnumerable<Unit> baseUnits)
{
var baseUnitPrefixConversions = new Dictionary<BaseUnitPrefix, string>();
var prefixedStringFactors = new Dictionary<string, PrefixScaleFactor>();
foreach (Unit baseUnit in baseUnits)
{
var unitName = baseUnit.SingularName;
prefixedStringFactors[unitName] = new PrefixScaleFactor(unitName, 0);
foreach (Prefix prefix in baseUnit.Prefixes)
{
var prefixedUnitName = prefix + unitName.ToCamelCase();
baseUnitPrefixConversions[new BaseUnitPrefix(unitName, prefix)] = prefixedUnitName;
prefixedStringFactors[prefixedUnitName] = new PrefixScaleFactor(unitName, MetricPrefixFactors[prefix]);
}
}

return new BaseUnitPrefixes(prefixedStringFactors, baseUnitPrefixConversions);
}

/// <summary>
/// Attempts to find a matching prefix for a given unit name, exponent, and prefix.
/// </summary>
/// <param name="unitName">
/// The name of the unit to match. For example, "Meter".
/// </param>
/// <param name="exponent">
/// The exponent associated with the unit. For example, 3 for cubic meters.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good refactoring and docs 👍 I like concrete examples like this.

/// </param>
/// <param name="prefix">
/// The prefix to match. For example, <see cref="Prefix.Kilo" />.
/// </param>
/// <param name="matchingPrefix">
/// When this method returns, contains the matching <see cref="BaseUnitPrefix" /> if a match is found;
/// otherwise, the default value of <see cref="BaseUnitPrefix" />.
/// </param>
/// <returns>
/// <see langword="true" /> if a matching prefix is found; otherwise, <see langword="false" />.
/// </returns>
/// <remarks>
/// This method determines if a given unit can be associated with a specific prefix, given the exponent of the
/// associated dimension.
/// </remarks>
internal bool TryGetMatchingPrefix(string unitName, int exponent, Prefix prefix, out BaseUnitPrefix matchingPrefix)
{
if (exponent == 0 || !_prefixedStringFactors.TryGetValue(unitName, out PrefixScaleFactor? targetPrefixFactor))
{
matchingPrefix = default;
return false;
}

if (MetricPrefixFactors.TryGetValue(prefix, out var prefixFactor))
{
var (quotient, remainder) = int.DivRem(prefixFactor, exponent);
// Ensure the prefix factor is divisible by the exponent without a remainder and that there is a valid prefix matching the target scale
if (remainder == 0 && TryGetPrefixWithScale(targetPrefixFactor.ScaleFactor + quotient, out Prefix calculatedPrefix))
{
matchingPrefix = new BaseUnitPrefix(targetPrefixFactor.BaseUnit, calculatedPrefix);
return true;
}
}

matchingPrefix = default;
return false;
}

private static bool TryGetPrefixWithScale(int logScale, out Prefix calculatedPrefix)
{
return PrefixFactorsByValue.TryGetValue(logScale, out calculatedPrefix);
}

/// <summary>
/// Attempts to retrieve the prefixed unit name for a given base unit and prefix combination.
/// </summary>
/// <param name="prefix">
/// A <see cref="BaseUnitPrefix" /> representing the combination of a base unit and a prefix.
/// </param>
/// <param name="prefixedUnitName">
/// When this method returns, contains the prefixed unit name if the lookup was successful; otherwise, <c>null</c>.
/// </param>
/// <returns>
/// <c>true</c> if the prefixed unit name was successfully retrieved; otherwise, <c>false</c>.
/// </returns>
internal bool TryGetPrefixForUnit(BaseUnitPrefix prefix, [NotNullWhen(true)] out string? prefixedUnitName)
{
return _baseUnitPrefixConversions.TryGetValue(prefix, out prefixedUnitName);
}

/// <summary>
/// Represents the scaling factor that is required for converting from the <see cref="BaseUnit" />.
/// </summary>
/// <param name="BaseUnit">Name of base unit, e.g. "Meter".</param>
/// <param name="ScaleFactor">The log-scale factor, e.g. 3 for kilometer.</param>
private record PrefixScaleFactor(string BaseUnit, int ScaleFactor);
}
Loading