Skip to content

Conversation

dandm1
Copy link

@dandm1 dandm1 commented Jan 21, 2020

Extensions to the Quantity class to allow the registration of classes implementing IQuantity from outside the UnitsNet library, and to support JSON serialisation and Quantity.From with this classes.

I am a developer on v4 of the CumulusMX weather station software. The weather measurements on this are a combination of measures that have units and simple scalars. To simplify the code, we have decided to implement the scalars as a custom IQuantity - but were finding that this prevented us effectively serialising and deserialising the state. By registering the custom type using the new functionality in this pull request we should resolve that.

@codecov-io
Copy link

codecov-io commented Jan 21, 2020

Codecov Report

Merging #739 into master will increase coverage by 3.35%.
The diff coverage is 86.5%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #739      +/-   ##
==========================================
+ Coverage   58.58%   61.94%   +3.35%     
==========================================
  Files         171      173       +2     
  Lines       38448    38547      +99     
==========================================
+ Hits        22525    23878    +1353     
+ Misses      15923    14669    -1254
Impacted Files Coverage Δ
UnitsNet/GeneratedCode/Quantities/Information.g.cs 68.44% <ø> (+1.33%) ⬆️
...Net.Serialization.JsonNet/UnitsNetJsonConverter.cs 80.91% <100%> (-5.12%) ⬇️
UnitsNet/CustomCode/Quantity.cs 85.71% <100%> (+1.93%) ⬆️
UnitsNet/QuantityInfo.cs 94.52% <100%> (+0.4%) ⬆️
UnitsNet/CustomCode/QuantityFactory.cs 80.72% <80.72%> (ø)
UnitsNet/CustomQuantityOptions.cs 85.71% <85.71%> (ø)
...sNet/InternalHelpers/ReflectionBridgeExtensions.cs 42.3% <0%> (-3.85%) ⬇️
UnitsNet/GeneratedCode/Quantity.g.cs 80.7% <0%> (-0.78%) ⬇️
UnitsNet/GeneratedCode/UnitConverter.g.cs 100% <0%> (ø) ⬆️
... and 100 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 5f13303...10738a6. Read the comment docs.

@angularsen
Copy link
Owner

Hi and thanks for sharing your approach with us. I like the idea of better supporting third-party quantities, but some comments below on how we can maybe fit it better into the library.

1. Quantity.AddUnit() and Quantity.ExternalQuantities cannot be static.

As an example, in your tests you are adding a unit mapping in one test, and that mapping lives on to the next tests that run. It is prone to cause bugs like that.

From your test case
https://github.com/angularsen/UnitsNet/pull/739/files#diff-4593c2e3fa9c32725a300f36f3951db4R47-R53

it seems you want this in order to be able to do IQuantity q = Quantity.From(1, myCustomUnit).

I propose we do the same as we did for UnitConverter in

/// <summary>
/// Sets the conversion function from two units of the same quantity type.
/// </summary>
/// <typeparam name="TQuantity">The type of quantity, must implement <see cref="IQuantity"/>.</typeparam>
/// <param name="from">From unit enum value, such as <see cref="LengthUnit.Kilometer" />.</param>
/// <param name="to">To unit enum value, such as <see cref="LengthUnit.Centimeter"/>.</param>
/// <param name="conversionFunction">The quantity conversion function.</param>
public void SetConversionFunction<TQuantity>(Enum from, Enum to, ConversionFunction<TQuantity> conversionFunction)
where TQuantity : IQuantity
{
var quantityType = typeof(TQuantity);
var conversionLookup = new ConversionFunctionLookupKey(quantityType, from, quantityType, to);
SetConversionFunction(conversionLookup, conversionFunction);
}
.

[Theory]
[InlineData(1, HowMuchUnit.Some, HowMuchUnit.Some, 1)]
[InlineData(1, HowMuchUnit.Some, HowMuchUnit.ATon, 2)]
[InlineData(1, HowMuchUnit.Some, HowMuchUnit.AShitTon, 10)]
public void ConversionForUnitsOfCustomQuantity(double fromValue, Enum fromUnit, Enum toUnit, double expectedValue)
{
// Intentionally don't map conversion Some->Some, it is not necessary
var unitConverter = new UnitConverter();
unitConverter.SetConversionFunction<HowMuch>(HowMuchUnit.Some, HowMuchUnit.ATon, x => new HowMuch(x.Value * 2, HowMuchUnit.ATon));
unitConverter.SetConversionFunction<HowMuch>(HowMuchUnit.Some, HowMuchUnit.AShitTon, x => new HowMuch(x.Value * 10, HowMuchUnit.AShitTon));
var foundConversionFunction = unitConverter.GetConversionFunction<HowMuch>(fromUnit, toUnit);
var converted = foundConversionFunction(new HowMuch(fromValue, fromUnit));
Assert.Equal(expectedValue, converted.Value);
Assert.Equal(toUnit, converted.Unit);
}

Use singleton pattern to make it testable, yet easy to manipulate and use via the Default property.

Maybe something like this?

public class QuantityFactory
{
    public static QuantityFactory Default { get; } = new QuantityFactory();

    public void SetCreateFunction<TQuantity>(Enum unit, Func<QuantityValue, TQuantity>)
		where TQuantity : IQuantity
    { /* ...magic here... */ }

    public TQuantity Create<TQuantity>(QuantityValue value, Enum unit)
		where TQuantity : IQuantity
    { /* ...magic here... */ }
}

Note that QuantityValue is a wrapper type that implicitly casts decimal or double values. We introduced this to reduce the binary size, because 1000+ units with twice the method count actually became significantly larger.

With this, I don't think we need Quantity.From() anymore, but we could make that work too if we wanted to.

2. Take QuantityFactory as parameter in the JsonConverter

Now that we have a configurable and testable way to construct quantities we can pass in an QuantityFactory instance to the converter. QuantityFactory.Default would then be the default instance passed unless otherwise specified.

Thoughts?

@dandm1
Copy link
Author

dandm1 commented Jan 22, 2020 via email

@dandm1
Copy link
Author

dandm1 commented Jan 25, 2020

OK - much wider changes to the code now, but a nicer solution without the static list.

I didn't quite follow the suggestion on implementation approach, preferring instead to mostly change leave the existing signatures alone. I moved the object generation functions into QuantityInfo - where they can be created by reflection when the type is built. I then created a dictionary of types in a QuantityFactory type as suggested and moved the quantity generation logic from both Quantity.cs and the JsonSerialiser class into this.

As a result of this there is a greatly reduced need for Quantity.g.cs - it is now only used in generating the Infos list.

I expected changing to reflection approaches to have a bad impact on performance, but I'm not actually seeing that. My profiling seems to suggest this approach is no slower, though would be worth verifying that.

@dschuermans
Copy link
Contributor

How will this affect #732? I've created the PR today (see #746)

@dandm1
Copy link
Author

dandm1 commented Feb 5, 2020

I think this an #732 fit pretty well together. Biggest change necessary to #732 to accommodate this is that your UnitsNetBaseJsonConverter.ConvertValueUnit function just becomes a call to QuantityFactory.FromValueAndUnit - as well as adding a constructor to take the QuantityFactory.

@dandm1 dandm1 requested a review from angularsen February 5, 2020 18:17
@dschuermans
Copy link
Contributor

Well, i'm not a big fan of adding a QuantityFactory parameter to the ctors of the JsonConverters because it adds an explicit dependency on the QuantityFactor and it might become troublesome when used in a dependency injection setup

If i understand correctly, the problem you are running into is that you are unable to deserialize a json
e.g { "unit": "HowMuchUnit.Some", "value": 123 }
into your custom "HowMuch" type defined in an assembly outside UnitsNet, because the ConvertValueUnit code builds the UnitsNet.Units.HowMuch,UnitsNet fully qualified type name and it can't find that?

If i were to go this approach, I would expect some kind of service / factory like your QuantityFactory to exist in UnitsNet, which already knows about all the available units shipped with UnitsNet.
This factory can be used to construct any unit, given its type, unit and value (e.g. typeof(Power), PowerUnit.Watts, 20)

It is then up to your application, during its bootstrapping phase to tell UnitsNet that you have defined some additional units in your assemblies and where UnitsNet can find them and how they should be constructed.

Then the UnitsNetBaseJsonConverter can simply access the QuantityFactory directly (without it having to be provided through the ctor) and attempt to construct any type from the data it extracted from the JSON string.

Psudo example:
image

These are my 2 cents 😄

Copy link
Owner

@angularsen angularsen left a comment

Choose a reason for hiding this comment

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

I've made quite a bit of modifications to the PR just to move this along faster. Please take a look at my commits and let me know what you think.

The main changes beyond a bit of cleanup and renames, are

  • Remove reflection code
  • Replace with CustomQuantityOptions that custom quantities should implement and pass into QuantityInfo ctor. I made it optional for now, so I didn't have to fix all the generated code.

One realization I had was that we should probably move Quantity.TryFrom generated code into QuantityInfo.TryFrom, so that the same design is used for both generated quantities and custom quantities - similar to how we do for mapping built-in and third-party conversion functions between units.

I have a feeling we should add some more tests, there are a bunch of new public members in this PR and not so many tests added even though they are probably called indirectly. I generally like tests on all public members, but I don't have more time today. If you could take a look at that, it would be great.

@angularsen
Copy link
Owner

angularsen commented Feb 6, 2020

@dschuermans I'm short on time, but I think maybe we are getting best of both worlds now?
If I understood correctly;

  1. JSON converter will use the default (global singleton instance) of QuantityFactory by default. You can do QuantityFactory.Default.AddUnit() at application startup to configure this globally, if you so wish.
  2. Alternatively, you can pass in your own instance of QuantityFactory to the JSON converter ctor to be sure the instance has not been tampered with by other nugets or code you have running.

If i were to go this approach, I would expect some kind of service / factory like your QuantityFactory to exist in UnitsNet [..] to construct any unit, given its type, unit and value (e.g. typeof(Power), PowerUnit.Watts, 20)

I think this is what we are getting with this design? You can now do

IQuantity oneMeter = QuantityFactory.Default.From(1, LengthUnit.Meter);

QuantityFactory.Default.AddUnit(HowMuch.Info);
IQuantity oneSillyTon = QuantityFactory.Default.Parse(typeof(HowMuch), "1 sillyton");

Let me know if I got anything wrong, I kinda rushed a bit to get through this PR tonight.

@dschuermans
Copy link
Contributor

dschuermans commented Feb 6, 2020

@dschuermans I'm short on time, but I think maybe we are getting best of both worlds now?
If I understood correctly;

  1. JSON converter will use the default (global singleton instance) of QuantityFactory by default. You can do QuantityFactory.Default.AddUnit() at application startup to configure this globally, if you so wish.
  2. Alternatively, you can pass in your own instance of QuantityFactory to the JSON converter ctor to be sure the instance has not been tampered with by other nugets or code you have running.

I agree with point 1.

About 2:
I think it shouldn't be the JsonConverter's respsonsibility to determine which instance of QuantityFactory to use.
Secondly, this feels like a YAGNI scenario, similar to that whole IComparable serialization / deserialization scenario 😥
EDIT: If the provided JsonConverter(s) do not fit your application's needs, you can always create your own. Maybe it's a better idea to allow overriding of the custom methods (e.g. ConvertValueUnit) from the UnitsNetBaseJsonConverter and also allow inheriting from UnitsNetIQuantityJsonConverter (i think i marked it sealed).

@angularsen
Copy link
Owner

I see your concern, but I really don't think it's all that bad.

We could very well let Quantity.From() and Quantity.Parse() methods be a facade for QuantityFactory.Default.From() and similar methods, if that reads better.

At least this way, we have the opportunity to fully control the configuration at runtime - but more importantly we can write tests that don't affect other tests that happen to use the same static values.

Global static values is a painful design in my opinion, singletons offers a good compromise of being convenient yet testable.

Copy link
Author

@dandm1 dandm1 left a comment

Choose a reason for hiding this comment

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

Agree on the reversion of changes to ToValueUnit. At one point in revisions the quantityName had a special case for custom units - but wasn't needed in the end.

@dandm1
Copy link
Author

dandm1 commented Feb 6, 2020

I think the pattern of using a QuantityFactory.Default in almost all cases here, but having the ability to construct alternatives, is the right balance here. I struggle to think of any use cases outside of unit testing where you would want to have different quantity factories - but I can't think how you'd do unit testing well without it.

I don't really agree with the philosophy of moving away from reflection in these cases - though I suspect some of the reflection can be done more cleanly. It feels to me like there is a lot of non-obvious boilerplate code required to make a custom instance of IQuantity work well. I was working to a philosophy of minimising the amount of code needed in IQuantity - even if that made the QuantityInfo class more complex. The AddUnit call in QuantityFactory is a case in point - I see virtue in being able to call it from external code using type names and leaving the AddUnit call to do the heavy lifting of creating an instance to get the QuantityInfo data - rather than requiring anyone calling AddUnit to create an instance. Supporting both might be an option?

@angularsen
Copy link
Owner

angularsen commented Feb 6, 2020 via email

@angularsen
Copy link
Owner

@dschuermans A follow up:

I think it shouldn't be the JsonConverter's respsonsibility to determine which instance of QuantityFactory to use.

I agree, and I believe that is exactly what we achieve by having the default ctor pick the singleton and the more specific ctor take an explicit factory object, which is entirely optional to use. The converter does not have to decide anything on that regard.

The whole reason for adding this factory was to support third-party quantities. The reason for making it possible to inject the factory into the converter was to make it testable.

We already had support for converting between third-party quantities/units, now we also have support for deserializing and serializing third-party quantities.

Maybe it's a better idea to allow overriding of the custom methods

Maybe, but I generally try to avoid inheritance if I can. It tends to create more complexity than composition and injecting dependencies.

@dschuermans
Copy link
Contributor

It's all good. Are you first going to merge this or my rework?

@angularsen
Copy link
Owner

@dschuermans I'm not sure, depends on which one feels more ready to merge I guess. I have yet to look at yours, but I plan to in a few moments if I get enough time.

@angularsen
Copy link
Owner

@dandm1

It feels to me like there is a lot of non-obvious boilerplate code required to make a custom instance of IQuantity work well.

I agree, but please mind that IQuantity was never designed for the purpose of third-party quantities. We are now trying to retrofit support for that via this type.

I'm all for reducing the boilerplate needed to add support for new quantities, but I really want to avoid reflection to achieve that if we can. It's just too prone to bugs in the future when we refactor and change method signatures and if we missed a test case.

A holistic approach to third-party quantities

Okay, I just had to ramble down some thoughts to clear my own mind a bit.

It's out of scope for this PR, but I think we should at least give thought to how we can best solve support for third-party quantities as a whole.

These are the things I can think of that we need to solve (or have solved):

  1. Represent quantities generically (solved with IQuantity and IQuantity<TUnitEnum>)

    • Value members - Value, Unit, Type, Dimensions
    • Metadata - QuantityInfo
    • Formatting strings - ToString() with various overloads
    • Conversion members - As(Enum)
  2. Construct quantities given value/unit

    • new Length(double, LengthUnit) for strongly typed construction, only built-in types
    • Quantity.From(double, Enum) currently only supports built-in types, this PR adds support for third-party types via QuantityFactory.Default.AddUnit().
  3. Convert from value/unit to unit

    • new Length(1, LengthUnit.Meter).As(LengthUnit.Centimeter) for strongly typed conversion, only for built-in types
    • UnitConverter.Convert(QuantityValue, Enum, Enum) for any two units, even from different quantities. Use UnitConverter.Default.SetConversionFunction() to configure third-party units, the same is used for built-in units.
  4. Parse quantities

    • Length Length.Parse(string) for strongly typed parsing, only built-in types
    • IQuantity Quantity.Parse(Type, string) currently only supports built-in types, this PR adds support for third-party types via QuantityFactory.Default.AddUnit().
  5. JSON serialization

    • Previously only built-in types supported, this PR adds support for third-party quantities via QuantityFactory.Default.AddUnit()

Some challenges

  • QuantityType enum is used by several methods and can't support third-party quantities.
  • IQuantity is very thick, maybe we could slim it down to the absolute bare minimum needed to support use cases like serialization/deserialization,
  • Extension points seems a bit "spread out" and the design feels inconsistent
    • Use UnitConverter to add conversion functions
    • Use QuantityFactory to add create/parse functions
    • QuantityParser exists, but instead of adding third-party parsing capabilities it is only used internally for the actual parsing logic. This feels inconsistent with the above two.

Idea - Introduce a facade type to configure Units.NET

To make the API more consistent for built-in types vs third-party types and to gather everything related to global configuration of Units.NET, I think we should consider adding a facade configuration type.

  1. Configure built-in types the exact same way as third-party types (inspired by UnitConverter), at least strive to where possible.
    Some examples in pseudo code:
    a. UnitsNetConfig.Default.SetConversionFunction((value, fromUnit, toUnit) => {})
    b. UnitsNetConfig.Default.SetCreateFunction((value, unit) => {})
    c. UnitsNetConfig.Default.SetParseFunction((string, quantityType) => {})
    d. UnitsNetConfig.Default.AddQuantity(QuantityInfo) that maybe calls the above methods to set everything up?

@angularsen
Copy link
Owner

For this PR though, to move this forward, I think we are not too far off. I still favor configuring convert/parse/create functions like in the proposal above, instead of reflection.

To reduce boilerplate for this PR, we can start by moving the create/parse functions from QuantityFactory and QuantityInfo to a new UnitsNetConfig type - as per the proposal. HowMuch will still have to implement all the stuff in IQuantity, we can't easily change that right now, but we can later consider introducing facets of the interface that are more minimal, such as ISimpleQuantity with only value/unit and IFormattableQuantity with all the ToString() methods - etc. I haven't thought this through yet, but the idea is to be able to serialize/deserialize third-party quantities without having to implement unnecessary stuff.

Thoughts?

@dschuermans
Copy link
Contributor

KISS :wink

@angularsen
Copy link
Owner

That is a fair point, it is easy to get overboard on this stuff.

Supporting third-party stuff is not trivial though, requires some thinking to fit it into all the existing code and make it intuitive to use. We also need to consider how many will actually use Units.NET for third party quantities and whether it is really worth too big changes.

@dandm1
Copy link
Author

dandm1 commented Feb 7, 2020

I definitely agree that there is a risk of over engineering here in the pursuit of a low yield use case. The units in Units.NET are quite comprehensive already, so the places where something new would be required are quite limited. The use case in Cumulus MX is almost embarrassingly trivial for example - simply trying to be able to work with a single IStatistics implementation rather than needing a special case for IStatistics.

The thinking in @angularsen's notes on add-in support are all very good. A few observations:

  • Custom types can of course support a call to new HowMuch(double,HowMuchUnit and .as(HowMuchUnit) - but support can't be guaranteed.
  • The QuantityType enum kept causing me problems. At the least it would be good to add a QuantityType.Custom to avoid the need to overload one of the existing members. Biggest use I can currently see for this is in enumerating the defined types on construction of Quantity. I considered changing to using reflection to get rid of this use case, but didn't seem necessary.
  • Bringing all the config points together seems like a positive approach - but probably a backlog item.

There might be a viable middle way of avoiding reflection in the base code, but having a (suggested?) CustomQuantityBase class that takes a minimalist interface and uses it to reflect the other details necessary to create a full IQuantity.

@angularsen
Copy link
Owner

angularsen commented Feb 9, 2020

@dandm1 Could you please elaborate a bit on your usecase? It's very useful to have a concrete example to discuss around. What is IStatistics, what does it represent and how do you use it? Why does it benefit you to treat it as an IQuantity (if you do)?

This PR first started off as something that seemed easy enough and valuable to add. It then triggered some deeper thoughts about third-party quantity support and the scope suddenly increased to solve it with a better overall design. Then it raised the question about whether many will actually use this. I feel I need to mature this thinking a bit before we can continue this PR, so we don't just add something that doesn't solve many real needs and complicates the Units.NET design further.

@dandm1
Copy link
Author

dandm1 commented Feb 11, 2020

In the CumulusMX context we are using various IQuantities to capture the observations from a weather station. There is an IStatistics<> class that calculates statistics based on those observations - maximums, minimums, averages, rates of change, etc over daily, monthly, annual and rolling horizons. We/I then save the results by serialising the whole structure as JSON.

So far so good, except that some of the observable quantities are unitless - doubles and integers - so we ended up with two different implementations of IStatistics :
StatisticsQuantity<TQuantity> : IStatistics<TQuantity>
StatisticsDouble : IStatistics<double>

And a whole lot of branching based on the generic type. This became too much duplication, so in the name of simplicity I implemented Number : IQuantity which simply wrapped a double into an IQuantity so all of the measures could use the StatisticsQuantity implementation. But then the JSON Serialisation failed - so we ended up with this PR.

As I said, an almost embarrassingly simple use case - and certainly there are other approaches:

  • Go back to forked code.
  • Add a unitless double equivalent to the core of UnitsNet.
  • Wrap the half-dozen units we need in wrapper classes with a common interface which also has a 'double' implementation.
  • Unseal the classes and extend them with a common interface which also extends 'double' (this was my first approach).

@angularsen
Copy link
Owner

Thanks, will get back to you when I find time.

@angularsen
Copy link
Owner

Ok, a couple of thoughts.

  1. Min/max/avg of IQuantity is already supported, added in UnitMath extension methods (Min, Max, Sum, Average) #692. Would that solve your need?

https://dotnetfiddle.net/K11USx

		var masses = new IQuantity[]{ Mass.FromKilograms(50), Mass.FromKilograms(100) };
		var lengths = new IQuantity[]{ Length.FromCentimeters(50), Length.FromCentimeters(100) };
		IQuantity avgMass = masses.Average(MassUnit.Gram);
		IQuantity sumLength = lengths.Sum(LengthUnit.Meter);
		IQuantity minLength = lengths.Min(LengthUnit.Meter);
		IQuantity maxLength = lengths.Max(LengthUnit.Meter);

		Console.WriteLine("Avg: {0}", avgMass); // Avg: 75,000 g
		Console.WriteLine("Sum: {0}", sumLength); // Sum: 1.5 m
		Console.WriteLine("Min: {0}", minLength); // Min: 0.5 m
		Console.WriteLine("Max: {0}", maxLength); // Max: 1 m
  1. Could you do with Ratio? It is unitless.

@stale
Copy link

stale bot commented Apr 17, 2020

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@stale stale bot added the wontfix label Apr 17, 2020
@stale stale bot closed this Apr 24, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants