fslaborg / flips

Fsharp LInear Programming System
https://flipslibrary.com/#/
MIT License
252 stars 32 forks source link

Add support for Units of Measure for Decision #48

Closed matthewcrews closed 4 years ago

matthewcrews commented 4 years ago

Enable the use of Units of Measure with the Decision type

It would be nice for the user to be able to "opt-in" to using UoM with the basic building blocks of modeling but still be able to work without them. This would be akin to how float and float<'Measure> behaves in F#.

It would be nice for the following types to support UoM:

Here is how they should interact:

Note: Instead of writing out 'Measure for the UoM, I abbreviate using 'M, 'M1,'M2, etc.

Function signatures for the + operator

The addition operator should force the UoM to match on both sides of the operator for types where addition is allowed. All of the function signatures are commutative

// Note: The Scalar type is a single case DU to wrap a float to override equality
// Scalar, float addition
Scalar * float -> Scalar
Scalar<'M> * float<'M> -> Scalar<'M>
Scalar<'M> * float // Compiler error: UoM do not match
Scalar<'M1> * float<'M2> // Compiler error: UoM do not match

// Scalar, Scalar addition
Scalar * Scalar -> Scalar
Scalar<'M> * Scalar<'M> -> Scalar<'M>
Scalar<'M> * Scalar // Compiler error: UoM do not match
Scalar<'M1> * Scalar<'M2> // Compiler error: UoM do not match

// Scalar, Decision addition
Scalar * Decision -> LinearExpression
Scalar<'M> * Decision<'M> -> LinearExpression<'M> 
Scalar<'M> * Decision // Compiler error: UoM do not match
Scalar * Decision<'M> // Compiler error: UoM do not match
Scalar<'M1> * Decision<'M2> -> // Compiler error: UoM do not match

// Scalar, LinearExpression addition
Scalar * LinearExpression -> LinearExpression
Scalar<'M> * LinearExpression<'M> -> LinearExpression<'M>
Scalar<'M> * LinearExpression  // Compiler error: UoM do not match
Scalar * LinearExpression<'M> // Compiler error: UoM do not match
Scalar<'M1> * LinearExpression<'M2> // Compiler error: UoM do not match

// Decision, Decision addition
Decision * Decision -> LinearExpression
Decision<'M> * Decision<'M> -> LinearExpression<'M>
Decision<'M> * Decision // Compiler error: UoM do not match
Decision<'M1> * Decision<'M2> // Compiler error: UoM do not match

// Decision, LinearExpression addition
Decision * LinearExpression -> LinearExpression
Decision<'M> * LinearExpression<'M> -> LinearExpression<'M>
Decision<'M> * LinearExpression // Compiler error: UoM do not match
Decision * LinearExpression<'M> // Compiler error: UoM do not match

// LinearExpression, LinearExpression addition
LinearExpression * LinearExpression -> LinearExpression
LinearExpression<'M> * LinearExpression<'M> -> LinearExpression<'M>
LinearExpression<'M> * LinearExpression // Compiler error: UoM do not match
LinearExpression<'M1> * LinearExpression<'M2> // Compiler error: UoM do not match

Function signatures for the * operator

The * operator should have the same behaviors around UoM that the float type follows.

// Scalar, float multiplication
Scalar * float -> Scalar
Scalar<'M> * float -> Scalar<'M>
Scalar * float<'M> -> Scalar<'M>
Scalar<'M1> * float<'M2> -> Scalar<'M1 * 'M2>

// Scalar, Scalar multiplication
Scalar * Scalar -> Scalar
Scalar<'M> * Scalar -> Scalar<'M>

// Scalar, Decision multiplication
Scalar * Decision -> LinearExpression
Scalar<'M> * Decision -> LinearExpression<'M>
Scalar * Decision<'M> -> LinearExpression<'M>
Scalar<'M1> * Decision<'M2> -> LinearExpression<'M1 * 'M2>

// Scalar, LinearExpression multiplication
Scalar * LinearExpression -> LinearExpression
Scalar<'M> * LinearExpression -> LinearExpression<'M>
Scalar * LinearExpression<'M> -> LinearExpression<'M>
Scalar<'M1> * LinearExpression<'M2> -> LinearExpression<'M1 * 'M2>

The Comparison Operators

In Flips <==, >==, and == are binary infix operators and are used to create ConstraintExpression by comparing two differnet LinearExpression. The Comparison Operators should enforce UoM when they exist.

// All the comparison operators have the following signatures
LinearExpression * LinearExpression -> ConstraintExpression 
LinearExpression<'M> * LinearExpression<'M> -> ConstraintExpression 
LinearExpression<'M> * LinearExpression // Compiler error: UoM do not match
LinearExpression * LinearExpression<'M> // Compiler error: UoM do not match
LinearExpression<'M1> * LinearExpression<'M2> // Compiler error: UoM do not match

The challenge is that when we construct the ConstraintExpression we have to ensure UoM match. Once we have a ConstraintExpression, we no longer care about UoM. A ConstraintExpression is a element of a Constraint type and a Model contains a Constraint list. Since UoM is a kind of type information we need to drop it or ignore it at some point so that a Model can have a Constraint list where the LinearExpression inside may be of different UoM.

wxinix commented 4 years ago

This would be really cool. Looking forward to it.

matthewcrews commented 4 years ago

Yeah, I took an initial stab at it and saw that it was going to be a challenging lift. I have some other projects that need to be completed first before I can come back to this.

matthewcrews commented 4 years ago

I have updated the initial comment to have much more detail about how this would ideally behave.

matthewcrews commented 4 years ago

After mulling this over, I believe it will require creating new types Scalar<'Measure>, Decision<'Measure>, and LinearExpression<'Measure> in a separate module that users will need to open. The nice thing is that this would not break any existing code. The downside is that it create duplicate, boilerplate code.

I believe the new Module would be Flips.Domain.UOM. My biggest concern is that it would cause ambiguity and confusion for people who use the library. Opening Flips.Domain and Flips.Domain.UOM in the wrong order could cause confusion.

wxinix commented 4 years ago

After mulling this over, I believe it will require creating new types Scalar<'Measure>, Decision<'Measure>, and LinearExpression<'Measure> in a separate module that users will need to open. The nice thing is that this would not break any existing code. The downside is that it create duplicate, boilerplate code.

I believe the new Module would be Flips.Domain.UOM. My biggest concern is that it would cause ambiguity and confusion for people who use the library. Opening Flips.Domain and Flips.Domain.UOM in the wrong order could cause confusion.

šŸ‘ Sound a feasible/great design, except .... Flips.Domain.UOM doesn't appear to be ..... a nice name IMHO ....

matthewcrews commented 4 years ago

@wxinix I'm open to suggestions :)

Flips.Domain.UnitsOfMeasure ?

I am sure other libraries have done something like this, I just have not seen them. If there is an established precedent in the F# community, I would love to hear about it.

matthewcrews commented 4 years ago

As I mull, it is much simpler to update the existing types with UoM support. The downside is that the types will be forced to have a UoM information. For example:

// This
let x = Decision.createContinuous "Name" 0.0 infinity
> x // Decision

// Will now have the following type
> x // Decision<1>

Where <1> is the unit-less UoM in F#. I could see this additional notation being possibly confusing.

Approach Comparison

Augmenting Existing Types

Pros

Cons

Create Separate Module

Pros

Cons

wxinix commented 4 years ago

Probably a separate module is a better (clearer) design that doesn't mix tastes. Using separate modules provide two tastes, which is good.

matthewcrews commented 4 years ago

Probably a separate module is a better (clearer) design that doesn't mix tastes. Using separate modules provide two tastes, which is good.

Agreed. I've been poking around with some proof of concept stuff to see what could be done. The hard part will be to keep things cleanly separated but also easy to move back and forth when necessary. Clarity for the user is a high priority. UoM should make it easier to use the library, not harder.

matthewcrews commented 4 years ago

So, I think I have a first pass of this done and an open PR

I had to move the types around to avoid name conflicts. I don't think it will cause too much of an issue but I could not find a way around it.

I need to work some examples with the Units of Measure to make sure the ergonomics behave the way I want. Right now UOM is all-in or all out. You can't really mix them. This was purposeful because I thought that mixing them would cause horrible confusion. Once I put together some examples I am sure there will be some edge cases that need to be smoothed over.

The PR is out there if anyone wants to look at it or comment.

matthewcrews commented 4 years ago

So I got this working and the ergonomics are good. I need to add some functions for getting the results values in the original UoM but this is looking pretty close. This will likely be a 2.0.0 release since I had to move some stuff around and this is a significant increase in functionality (at least for me šŸ˜‚). The complexity of using the library hasn't increased, things were just not laid out as well as they should have been initially.

I consider keeping UoM code distinct from the non-UoM code to prevent a user from accidentally mixing them and causing horrible compiler errors that do not make sense. UoM is meant to be all or nothing.

I hope to release this by early next week. I am also wanting to include a major refactor of the README to turn it into cleaner docs.