aurora-opensource / au

A C++14-compatible physical units library with no dependencies and a single-file delivery option. Emphasis on safety, accessibility, performance, and developer experience.
Apache License 2.0
329 stars 21 forks source link

Questions regarding QuantityPoint #239

Closed StefanoD closed 5 months ago

StefanoD commented 6 months ago

Hello guys! 😊

You have an interesting project here and because the nholthaus-units library is stalled in development, we want to port our software from nholthaus-units to your Au library. We have grately simplified the usage of the library for better acceptance in our company and to make also the code simpler. That is,

Here one example how we use nholthaus-units:

using Celsius = units::temperature::celsius<double>;
using Kelvin = units::temperature::kelvin<double>;
using Fahrenheit = units::temperature::fahrenheit<double>;

That is, we are working exclusively with the aliases!

Now, we want to port this to the Au library and my straight-forward solution is this:

using Celsius = au::QuantityD<decltype(au::Celsius{})>;
using Kelvin = au::QuantityD<decltype(au::Kelvins{})>;
using Fahrenheit = au::QuantityD<decltype(au::Fahrenheit{})>;

// Quantity Makers
constexpr auto celsius = au::celsius_qty;
constexpr auto kelvin = au::kelvins;
constexpr auto fahrenheit = au::fahrenheit_qty;

After reading your documentation more thoroughly, I've found QuantityPoint and temperature units and length units are making use of this. After reading the reason to introduce QuantityPoint, I came to the conclusion that we actually not only don't need this, it would also be a dealbreaker, because it introduces complexity we actually don't want. That is, we value simplicity more than 100% correctness which has in practice (in our company) not really any value.

My questions are:

Thanks in advance for your answer! 😊

chiphogg commented 6 months ago

Thanks for the great questions!

Whenever I encounter complexity, I try to make a habit of asking whether it's essential or accidental. Essential complexity is intrinsic to the problem space, and any possible solution is going to have to deal with it, one way or another. Solutions that try to hide it in some use cases will end up making other use cases much worse. Accidental complexity is an extra cost that you have to pay in order to use one particular solution, but which doesn't help you handle the problem space.

Having two core data types instead of one represents extra complexity. So, is it essential or accidental?

IMO, it's an open question right now! In broader units library discussions, including with both this library and with mp-units, I've often encountered people who are convinced it's accidental and we can get rid of it. It would be really exciting if they turn out to be right --- I remember seeing mp-units go from 4 core types to 2, and it did wonders for that library's usability! But for now, I think it's more likely that this is "essential" complexity, and that attempts to circumvent it would do more harm than good.

The core reason is that there are at least two different concepts that you might have in mind when you talk about a "temperature", and they behave very differently. Consider this example, which provides the following two simple use cases:

  1. A user has a sensor which measures the outside temperature as 5 degrees celsius. Write the C++ source code that converts this to a temperature in Fahrenheit using your proposed units library interfaces.

  2. A user is modeling the climate effects of a catastrophic global temperature rise of 5 degrees celsius. Write the C++ source code that converts this temperature rise to Fahrenheit using your proposed units library interfaces.

The first refers to what Au calls "quantity points" (affine space types), and the second to simple "quantities". If we try to conflate them for "simplicity" --- say, by taking the linear offset into account when converting between Celsius and Fahrenheit --- then we can't simultaneously satisfy both of these simple use cases.

Incidentally, I was recently (2024) in a multi-units-library zoom meeting with others including @nholthaus, and he mentioned that he would have used two separate types if he could have gone back and changed things. (Nic, please correct me if I've mis-remembered or misunderstood what you said!)

With that background out of the way, I'll respond more directly to a few of your questions and comments.


You have an interesting project here and because the nholthaus-units library is stalled in development, we want to port our software from nholthaus-units to your Au library. We have grately simplified the usage of the library for better acceptance in our company and to make also the code simpler. That is,

  • we are always using aliases which is shorter
  • the underlying type of the aliases is always double as this simplifies the usage

This is a great option for many use cases! Au was designed to support this mode of operation from the beginning. Our unit-safe interfaces --- where users always name the unit explicitly at the callsite, on entering or exiting the library --- make this usage pattern safe.


Now, we want to port this to the Au library and my straight-forward solution is this:

using Celsius = au::QuantityD<decltype(au::Celsius{})>;
using Kelvin = au::QuantityD<decltype(au::Kelvins{})>;
using Fahrenheit = au::QuantityD<decltype(au::Fahrenheit{})>;

// Quantity Makers
constexpr auto celsius = au::celsius_qty;
constexpr auto kelvin = au::kelvins;
constexpr auto fahrenheit = au::fahrenheit_qty;

Quick comment: I'd suggest:

using Celsius = au::QuantityD<au::Celsius>;
using Kelvin = au::QuantityD<au::Kelvins>;
using Fahrenheit = au::QuantityD<au::Fahrenheit>;

i.e., Use <au::Celsius> instead of <decltype(au::Celsius{})>.

I'd also suggest using aliases that distinguish quantities and points, but that's a separate question. :slightly_smiling_face:


  • Can I port the nholthaus code to Au like I have shown above?

Sadly, no. Porting temperatures involves more friction than other units. This is for fundamental reasons rather than incompleteness --- i.e., I can't think of any library feature that I could write that would make this meaningfully easier.

The core thing that's going on here is that the nholthaus temperature types conflate two distinct ideas, which Au distinguishes. We've worked really hard to make the nholthaus -> Au path as frictionless as possible, even to the point where (for most cases) you can pass one library's types to APIs that take the other library's types! But if you're going from a domain that conflates, to one where the ideas are distinct, then there's no way around it: you'll need to clarify which concept each one is. Essential complexity.

If you did do the above as originally written, with quantity instead of quantity point, then it would "work"... but then, you'd always be using quantities, whereas in reality you sometimes want quantities and usually want quantity points. This means, for instance, that any temperature (point) conversions between Celsius and Fahrenheit would give incorrect results.

So, how about the obvious next question --- what would it take to convert nholthaus temperatures to Au? Here are some things we've learned from our experience.

  1. Convert one piece at a time. This is important because you need to figure out if the "real" meaning for each variable is a quantity, or a point. Fortunately, code using Au can coexist safely with code using nholthaus-units for an extended period of time. (The code to convert between them will be unit-safe, so the correctness of each line can be verified by inspection, in isolation!) The main point here is that you don't need to do a global search-and-replace: you can break it into more digestible pieces.
  2. Assume quantity point by default. In practice, it just turns out that most temperature uses are for points.
  3. Distinguish points and quantities in your aliases. For example, those who use aliases at Aurora use TemperatureDiffD and TemperatureDiffF for quantities, and TemperaturePointD and TemperaturePointF for points. (Our aliases happen to be dimension-based rather than unit-based. I might make a different decision today, but that's what we did in 2021.) In your case, I'd suggest CelsiusDiff and CelsiusPoint, KelvinsDiff and KelvinsPoint, etc.

  • Are there any limitations regarding calculation with QuantityD beyond the limitations which are shown in the Au documentation?

Not that I know of! The main limitation is that if you use quantities, but your use case wants quantity points, you'll get the wrong answer --- so, whatever you do, don't do that!

I have seen one instance where a team tried using Quantity instead of QuantityPoint for temperatures in a package, simply because they wanted a particular feature that we hadn't yet implemented for QuantityPoint. Fortunately, we caught this at an early stage of testing. The fix was just to implement that feature for QuantityPoint, so they could use what they really needed.


  • Does it behave like nholthaus-units?

No, but as discussed, this brings benefits in terms of increased expressivity. We can easily and correctly handle simple calculations that the current nholthaus-units temperature approach cannot represent.


Thanks in advance for your answer! 😊

Always happy to help, and thanks for your interest in Au!

StefanoD commented 6 months ago

@chiphogg Thanks for your fast and thorough reply!

I think, I get slowly the point. But what shall I do when QuantityPoints get propagated through compound units? I. e., shall I use QuantityPoints for the compound unit MetersPerSecond?

using MetersPerSecond = au::QuantityPointD<decltype(au::Meters{} / au::Seconds{})>;

// Quantity Makers 
constexpr auto metersPerSecond = (au::meters / au::second);

// This does not compile!
MetersPerSecond mps1 = metersPerSecond(1);
chiphogg commented 6 months ago

It's a good question, with a surprising answer. Turns out, this doesn't happen in practice, because affine space types (such as QuantityPoint) don't support multiplication or division! These operations wouldn't make much sense for point types. But you can subtract two quantity points, obtaining a quantity, and that is what would participate in multiplication and division.

So, the way to fix your concrete issue would be to change the first line like so (simply using Quantity):

using MetersPerSecond = au::QuantityD<decltype(au::Meters{} / au::Seconds{})>;

Some people have proposed experimenting with using "quantity point for everything". I can see the motivation, but I suspect that wouldn't be a great user experience in practice. Concretely, I've never made a quantity point type for speed, even though in some sense it might sound appealing (because speed is relative).

Overall, I think quantity point is:

StefanoD commented 6 months ago

Using QuantityD for MetersPerSecond was actually my old solution. It indeed surprises me that the problem which we have with meters, does not appear in compound units with meters. I will take as it is and try to play around.

Thanks again for your very quick reply and thorough answer! We are happy to found a promising candidate to replace nholthaus.

I will continue with the port on monday, because I have holidays. 🙂

StefanoD commented 5 months ago

@chiphogg I'm continuing porting and stumbling into the next problem. As you said, I declared MetersPerSecond as QuantityD.

using Meters = au::QuantityPointD<au::Meters>;
using MetersDiff = au::QuantityD<au::Meters>;

using MetersPerSecond = au::QuantityD<decltype(au::Meters{} / au::Seconds{})>;

// Quantity Makers
constexpr auto meters = au::meters_pt;
constexpr auto metersDiff = au::meters;

constexpr auto metersPerSecond = (au::meters / au::second);

MetersPerSecond mps1 = metersPerSecond(1);
Meters m = mps1 * seconds(1); // Does not compile as expected
MetersPerSecond mps2 = meters(50.0) / seconds(10.0); // Does not compile as expected

I know what to do in order to make it compilable, but from a user perspective, I can't naturally calculate like I could calculate with nholthaus or on paper.

I guess there is no good workaround available?

chiphogg commented 5 months ago

I can tell you how we approach the problem. Basically, we use "quantity" rather than "quantity point" for everything by default, except those use cases where experience has shown it to be likely to cause serious errors. In practice, temperatures are the overwhelmingly most common and important example of the latter.

So, in your case, I'd suggest these replacements.

Note that this brings your naming schema into greater harmony with Au's, which should reduce friction and confusion!


Wait, so: why did I mention "length" as a canonical "good use" of quantity point in the first place? :sweat_smile:

The reason is that I had a very specific use case in mind: labels for along-path positions. Basically, "mile markers".

I wrote some internal "path" APIs that underpin many of our testing libraries. Here, it's very useful to distinguish along-path positions (essentially, labels) from physical displacements and movements along those paths.

This also helps us deal with neighbouring curved paths, such as lanes on a curved road. Suppose we take one path in the scene as the "mile marker defining" path: we pick a point to call "0", and we label every other point by its true along-path physical distance. Now, for labeling adjacent paths, we can simply take the same label as whatever point on the mile marker defining path is laterally adjacent --- just like mile markers in the real world.

Of course, we can add a physical distance (a quantity) to a mile marker (a quantity point) and get a result, but in this case, we would take this result as a good approximation, rather than exact --- just like driving 2 miles from the 10 mile marker won't put you exactly at the 12 mile marker in the real world. And we can also give our paths APIs that can produce the exact answer when needed --- for example, you can ask the path how far exactly it is from the 10 to 12 mile markers, or where exactly you will end up if you drive 2 miles from the 10 mile marker.

Now (at last) the point. The reason quantity points help here is because they prevent confusion. They force the user to notice whether they are dealing with labeled points, or with physical displacements. It does mean that some use cases won't compile --- but in my experience, the code you have to write to make it compile will tend to be more obviously correct. (For example, instead of passing a displacement as a position, you would have to pass a named along-path point plus that displacement.)

(This approach isn't perfect. For example, I'd love to have a nice way to distinguish quantity points ("labels") from different paths. But it's worked really well for us overall for the last 3 years or so.)


Back to your example: I think you are right to point out that these compiler failures are not helpful. They don't guide the user to write more correct code, they just force them to jump through more hoops. I think the takeaway is that we should use quantity by default for lengths, and use quantity points for lengths only in specialized use cases where they do add that value.

StefanoD commented 5 months ago

@chiphogg Thanks for your quick reply! Regarding temperatures your old suggestion stays to use QuantityPoint per default?

  1. Distinguish points and quantities in your aliases. For example, those who use aliases at Aurora use TemperatureDiffD and TemperatureDiffF for quantities, and TemperaturePointD and TemperaturePointF for points. (Our aliases happen to be dimension-based rather than unit-based. I might make a different decision today, but that's what we did in 2021.) In your case, I'd suggest CelsiusDiff and CelsiusPoint, KelvinsDiff and KelvinsPoint, etc.
chiphogg commented 5 months ago

Basically, yep!

I will say that I think there's value in retaining the explicit "diff" and "point" vocabulary words for both. I worry that it would be surprising for users when most "unit named" things give them something that acts-like-a-quantity, but these other "unit named" things give them something that acts-like-a-point.

The way I think of it is that, in principle, every quantity maker should say "diff" or "qty" in its name, and every quantity point maker should say "point" in its name... but, in practice, since most things are quantities, we can just drop that part of the name without risking confusion. And when we come to something like temperatures where the defaults are reversed, then it's time to be explicit.

For extra credit, you could consider doing what we do for the "unadorned" version of our quantity makers for temperatures. See this example for celsius. By making it explicitly deprecated, you can ensure that when users try to do the "simple" thing they've come to expect, then they'll get an annotation telling them why it's dangerous, and what to do instead.

chiphogg commented 5 months ago

I'll close this for now, as I don't think there's any work left to do here, but if I've erred in doing so then please feel free to re-open. :slightly_smiling_face: