angularsen / UnitsNet

Makes life working with units of measurement just a little bit better.
https://www.nuget.org/packages/UnitsNet/
MIT No Attribution
2.63k stars 383 forks source link

Support for runtime conversion configuration #186

Closed BrandonLWhite closed 7 years ago

BrandonLWhite commented 8 years ago

In our industrial/scientific application, we have a need for allowing a user to configure an arbitrary floating point value acquired via digital acquisition (DAQ) into a unit of measure that they specify. Thus, the UnitClass must be specified in addition to the *Unit type.

We must then be able to convert this value to some other arbitrary *Unit within the same unit class in various calculations, views, reports, logging/trending and what not, again dictated by user configuration.

It would be awesome if UnitsNet could support this directly. What is needed:

  1. Enum for UnitClasses to support runtime configuration.
  2. A runtime conversion function such as `double Convert(UnitClass class, Unit from, Unit to, double value)'
angularsen commented 8 years ago

This is a topic that regularly comes up, and I have wanted it myself at various times, but then I also realized that converting between arbitrary, possibly incompatible, units is a slippery slope. There are very few inter-unit conversions, such as Mass.FromGravitationalForce(Force f) and operator overloads for common arithmetic, such as Speed * TimeSpan = Length. These are the exceptions.

If we were to represent units generically, I fear we would promote use where type safety is out the door. Type safety is one of the most valuable benefits to me in this library. Sure, giving it up would be optional, but it's something about helping people fall into the pit of success, so to speak.

These problems can typically be solved while staying type-safe. I don't know your application, but I would assume your user would have to choose what type of unit is coming in, so I would think it was natural to first select Force category, then Newton or Kilogram next. This allows you to work on Force measurements generically, while also knowing what specific ForceUnit the user chose, so you can easily present texts of readings using myForce.ToString(userSelectedForceUnit). If you need to convert it, you know it is a Force measurement, so you list all the other ForceUnit enum values for him to convert to, then use myForce.As(newForceUnit). It would not make sense to list Volume units in this case anyway.

My philosophy is that an application doesn't work with universal units if there are conversions involved, you have your set of types of units (Length, Mass, Volume) you care about, and you conditionally handle these separately. For reports/graphs, you usually fall back to numbers and unit abbreviation strings, but you don't really need to know more than that just to present it. If you need to convert them, you need to know a lot more.

If you really, really want universal units and conversions, you can probably implement this in your application by enumerating all ForceUnit, LengthUnit etc. values and converting them to objects like { Unit: "ForceUnit.Newton", Value: 1.0 }. Then say you want to convert this to ForceUnit.Kilogram, you do some string pattern matching and figure out the class name Force and unit ForceUnit.Newton, then use reflection to call Force.From(1.0, ForceUnit.Newton).Kilograms, but there are some issues like using the correct pluralization of property names etc. As I said, slippery.

If you believe I have misunderstood, or this approach would not work at all for you, I would like to learn more. I am open to facilitate this usecase if there is a real need for it and if it would truly be better handled in the core lib, rather in the application.

BrandonLWhite commented 8 years ago

Certainly we wouldn't want to treat all units as being in the same physical quantity class. It would be a runtime error (exception) if an attempt is made to convert from one unit class to another.

For the subsystem of our product that is the focus here, be aware that there is no business logic that is statically assuming any kind of physical quantity. Thus, there is no appropriate place in our code that would be operating on a ForceUnit.

However, there are other parts of our system that implement "baked in" scientific calculations for our application domain, and indeed we are incrementally adopting Units.NET for the compile-time safety it provides as well as other conveniences. Ultimately, user configured sensors or field devices would be able to feed in to some of these quantities, in which case we would certainly do the necessary runtime checks before accepting a quantity that came from the runtime configured module into the statically typed modules.

As for inter-unit conversions, I think it would suffice to simply not support such conversions via the dyanmic/runtime mechanism.

BrandonLWhite commented 8 years ago

One additional point of clarification. Addressing your point of "while also knowing what specific ForceUnit the user chose, so you can easily present texts of readings using myForce.ToString(userSelectedForceUnit)".

This would require a bunch of conditionals in our code to essentially convert from a runtime configuration data value (ie the PhysicalQuantity) to the appropriate static Units.NET type, so that we could then perform the conversion and get the display label (as per your example). This logic would be extremely coupled to Units.NET.

Certainly I will be implementing such a lookup method in my code as a temporary solution. But to me that is almost as bad as forking Units.NET.

You have a very comprehensive units of measurement library for .NET. It addresses a very important problem of mismatching units in static calculations in code. It reminds me very much of boost::units that I used years ago. This aspect is great when you are performing known scientific calculations in your own code. Compile time errors are always better than runtime errors.

But runtime errors are at least better than spaceships crashing and cancer patients getting nuked with incorrect radiation doses. I don't agree that you give up all of the value and safety of such a units management library by allowing runtime conversions within the same PhysicalQuantity class.

angularsen commented 8 years ago

Thank you for the details. Could you please provide me a pseudo code implementation? I'm not 100% sure how you propose we go about this in Units.NET.

It would be awesome if UnitsNet could support this directly. What is needed:

  1. Enum for UnitClasses to support runtime configuration.
  2. A runtime conversion function such as `double Convert(UnitClass class, Unit from, Unit to, double value)'

My current understanding is something like this:

// Generated
enum UnitClass {
  Force,
  Length
  //...more
}

// Generated
enum Unit {
  Force_Newton,
  Force_Kilogramforce,
  Length_Meter,
  Length_Centimeter
  //...more
}

static double Convert(UnitClass class, Unit from, Unit to, double value)
{
  // Generated
  if (UnitClass == UnitClass.Force) {
    ForceUnit fromForceUnit = GetForceUnit(from);
    ForceUnit toForceUnit = GetForceUnit(to);
    return Force.From(fromForceUnit, value).As(toForceUnit);
  }
  // ... other unit classes
}

static double? TryConvert(UnitClass class, Unit from, Unit to, double value)
{
  // Generated, returns null if units were incompatible instead of throwing exception
}

static ForceUnit GetForceUnit(Unit unit)
{
  // Generated, map from Unit enum to ForceUnit enum, throw if not compatible  
}

Please note that, as with existing enums, these enums would also not be safe to serialize due to how we generated code and cannot guarantee the enum integer values don't change when adding more units later. See Serialization section for details.

BrandonLWhite commented 8 years ago

Thanks for continuing to entertain this.

Perhaps we do not need to generate the master "Unit" enum. Maybe something like this:

static double Convert(UnitClass class, Enum from, Enum to, double value)
{
 // Throws if the Enums are not of the same type.  Needs further exploration for performance.
// However, the downcasts below in the generated code may suffice.
  EnsureSameUnitClass(from, to);

  // Generated
  if (UnitClass == UnitClass.Force) {
    return Force.From((ForceUnit)from, (ForceUnit)value).As(to);
  }
  // ... other unit classes
}

I like your TryConvert. Perhaps that could use ForceUnit fromForceUnit = from as ForceUnit with a null check along with the same for toForceUnit.

Regarding the enums not being safe to persist. I understand. Currently we are using strings in our database and configuration files. Perhaps UnitsNet could make this reasonably guaranteed that the enum names will not change. Otherwise this business of DB/config file persistence is another issue that would need to be addressed, presumably separately from this issue.

angularsen commented 8 years ago

I gave this a spin in LINQPad, and this piece of code seems to work:

static double? TryConvert(Enum from, Enum to, double value)
{
    // Generated
    LengthUnit? fromLengthUnit = from is LengthUnit ? (LengthUnit?)from : null;
    LengthUnit? toLengthUnit = to is LengthUnit ? (LengthUnit?)to : null;
    if (fromLengthUnit == null || toLengthUnit == null)
        return null;
    return Length.From(value, fromLengthUnit.Value).As(toLengthUnit.Value);
    // ... other unit classes
}

void Main()
{
    // Outputs: 100
    Console.WriteLine(TryConvert(LengthUnit.Meter, LengthUnit.Centimeter, 1));
}
BrandonLWhite commented 8 years ago

Ah, right you are, nullables are needed. You can omit the ternary and safe-cast to nullable:

        static double? TryConvert(Enum from, Enum to, double value)
        {
            // Generated
            LengthUnit? fromLengthUnit = from as LengthUnit?;
            LengthUnit? toLengthUnit = to as LengthUnit?;
            if (fromLengthUnit == null || toLengthUnit == null)
                return null;
            return Length.From(value, fromLengthUnit.Value).As(toLengthUnit.Value);
            // ... other unit classes
        }

[Test]
public void RuntimeConversion()
{
            Assert.AreEqual(100, TryConvert(LengthUnit.Meter, LengthUnit.Centimeter, 1));
            Assert.IsNull(TryConvert(LengthUnit.Meter, ForceUnit.PoundForce, 1));
}
angularsen commented 8 years ago

Alright, well I think this looks like straight forward implementation, that adds value by generating all the boiler plate code. Do you want to take a stab at the PR? I don't have a whole lot of time these days, might take me a while.

The TryConvert methods should probably go into the static UnitSystem class, and powershell scripts must be modified to generate the new code.

BrandonLWhite commented 8 years ago

Great I will give it a shot.

angularsen commented 8 years ago

Just an idea, it would be convenient to have a IList GetCompatibleUnits(Enum fromUnit) method so you can present relevant choices for the user to convert to.

angularsen commented 8 years ago

Just checking in, any progress here?

BrandonLWhite commented 8 years ago

Sorry, no. I still intend to do this but I haven't made any further progress since the previous PR.

angularsen commented 7 years ago

Just checking in on old issues, do you still intend to get around to this?

BrandonLWhite commented 7 years ago

Our feature that would leverage this Units.Net capability has been moved down the priority list. It is still something we need, just not yet. I still plan to implement this but if someone else wants to then certainly add a comment here indicating so.

I will comment here before I actually do begin coding something and have a firm idea of when there would be a subsequent pull request.

angularsen commented 7 years ago

Thanks for the update

On Tue, Jan 31, 2017, 18:22 Brandon White notifications@github.com wrote:

Our feature that would leverage this Units.Net capability has been moved down the priority list. It is still something we need, just not yet. I still plan to implement this but if someone else wants to then certainly add a comment here indicating so.

I will comment here before I actually do begin coding something and have a firm idea of when there would be a subsequent pull request.

— You are receiving this because you commented.

Reply to this email directly, view it on GitHub https://github.com/anjdreas/UnitsNet/issues/186#issuecomment-276430406, or mute the thread https://github.com/notifications/unsubscribe-auth/AAwFaI87nmDSbN3tpJ17mewU7GKu2r-7ks5rX23egaJpZM4KD9Mx .

angularsen commented 7 years ago

I think this is more or less covered by UnitConverter.ConvertByName() introduced in d57c7edba5ecdfb69236c873bd18130f14d6d424. You can't convert by enum values yet, but you can by using ToString() on the enums. We could add methods that take enums too, but closing this for now until someone wants to add that.