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

Quantity points arithmetics #668

Open
mpusz opened this issue Jan 23, 2025 · 16 comments
Open

Quantity points arithmetics #668

mpusz opened this issue Jan 23, 2025 · 16 comments
Labels
design Design-related discussion
Milestone

Comments

@mpusz
Copy link
Owner

mpusz commented Jan 23, 2025

For quantities, we allow arithmetic between different quantity types:

quantity q = isq::height(1 * m) + isq::width(1 * m);    // results with `isq::length(2 * m)`

We had something similar for quantity points, but I am starting to think, if that is correct.

For example, for lengths, quantities represent a distance/delta between two points. Adding such deltas of length measured in different ways/dimensions makes sense and is required by the ISO 80000. Quantity points of length represent a distinct point in, for example, 3D space. The same point may have many representations from different origins in this 3D space, but they all still describe exactly the same point.

Subtracting two points of width gives a delta of width. However, what does it mean to subtract points of height and width or add a delta of width to the point of height?

quantity q1 = quantity_point{isq::height(1 * m)} - quantity_point{isq::width(1 * m)};    // results with ????
quantity_point qp1 = quantity_point{isq::height(1 * m)} + isq::width(1 * m);    // results with ????

I start to think that the above should not compile. Do you agree?

The above examples contained quantities from different branches of the length hierarchy tree. What about the same branch?

quantity q2 = quantity_point{isq::height(1 * m)} - quantity_point{isq::length(1 * m)};    // results with ????
quantity q3 = quantity_point{isq::length(1 * m)} - quantity_point{isq::height(1 * m)};    // results with ????
quantity_point qp2 = quantity_point{isq::height(1 * m)} + isq::length(1 * m);    // results with ????
quantity_point qp3 = quantity_point{isq::length(1 * m)} + isq::height(1 * m);    // results with ????

A similar question to the above might be if it should be possible to define a point of height with the origin being a point of length or vice versa?

inline constexpr struct zero_length final : mp_units::absolute_point_origin<mp_units::isq::length> {} zero_length;
inline constexpr struct zero_height final : mp_units::absolute_point_origin<mp_units::isq::height> {} zero_height;

quantity_point qp4 = zero_length + isq::height(1 * m);
quantity_point qp5 = zero_height + isq::length(1 * m);

Please let me know your thoughts.

@mpusz
Copy link
Owner Author

mpusz commented Jan 23, 2025

@burnpanck, you always cared for quantity points and had good ideas on how to support those. Your feedback is welcomed here.

@Spammed
Copy link

Spammed commented Jan 23, 2025

I usually understand 'height' and 'width' as quantities of different (spatial) dimensions.
Therefore, my answer would be 'no, not allowed' both times.

Only if the points are defined in a common (vector) space, you can determine (vector) distances or offset them with (vector) distances of this space.

However, one might be tempted to interpret the 'length' in the expression implicitly as 'belonging to' in each case:
quantity_point{isq::height(1 * m)} + isq::length(1 * m) == quantity_point{isq::height(2 * m)};
I suspect that would not be a good idea.

@mpusz
Copy link
Owner Author

mpusz commented Jan 24, 2025

Therefore, my answer would be 'no, not allowed' both times.

Yes, this is why I submitted this issue. I think that we need to fix a few things.

I suspect that would not be a good idea.

Right. isq::length represents a generic/unspecified length but not any length. We represent any length with kind_of<QS> (or just a unit), and I think the below should work:

quantity_point{isq::height(1 * m)} + 1 * m == quantity_point{isq::height(2 * m)};

A bit more interesting may be the following cases:

quantity_point{1 * m} + isq::height(1 * m) == ???; // point of isq::height or any length ?
quantity_point{isq::height(1 * m)} - quantity_point{1 * m} == ???; // quantity<isq::height[m]> ?

@mpusz
Copy link
Owner Author

mpusz commented Jan 24, 2025

So it seems that isq::height and isq::width should have a common type, compare with each other, be convertible, and be possible to add or subtract only in case of quantities but not points.

This is yet another reason to consider a redesign of the quantity_point that I mentioned in my last blog article.

@burnpanck
Copy link
Contributor

I do care a lot about quantity points. However, in my opinion, the concept of quantity types is quite orthogonal. For me, the benefit of what we call "quantity type" (I still struggle a bit with that name) are a scheme of incrementally narrowing down the applicability. I think it should not matter here if we are talking about points or distances - if we decide that two related quantity specs are "compatible" then this also applies to their points, and vice versa.

Now what should be compatible with each other? I think we have some choice here. We want to disallow obviously wrong things. We want to allow practically useful things if they are unambiguous. If you want to know the boundary length of a rectangle, you end up summing height and width. There is only one way to do that, it is unambiguous. It is, because you are adding the length of those things. (That said, the last time I participated in the discussion about quantity types, we were still classifying "sibling types" like those as incompatible).

I don't think that quantity_point{isq::height(1 * m)} + isq::width(2*m) -> quantity_point{3*m} is any more surprising than than isq::height(1 * m) + isq::width(2 * m) -> isq::length(3 * m).

@mpusz
Copy link
Owner Author

mpusz commented Jan 27, 2025

@burnpanck, it is good to hear from you again 😃

(That said, the last time I participated in the discussion about quantity types, we were still classifying "sibling types" like those as incompatible)

It depends on what you mean by incompatible. We can't convert from isq::width to isq::height, but we can add them and the result will be isq::length.

if we decide that two related quantity specs are "compatible" then this also applies to their points, and vice versa

I have a hard time imagining what it means to subtract a point of isq::height from a point of isq::width 😕

@mpusz
Copy link
Owner Author

mpusz commented Jan 27, 2025

BTW @burnpanck, you still have a few PRs open. Will you have some time to finish them soon?

@mpusz mpusz added this to the v2.5.0 milestone Jan 28, 2025
@mpusz mpusz added the design Design-related discussion label Feb 10, 2025
@mpusz
Copy link
Owner Author

mpusz commented Feb 14, 2025

I thought about this a bit more.

Let's analyze such a simple tree:

flowchart TD
    parent["parent (e.g., length)"]
    parent --- child["child (e.g., height)"]
    parent --- other_child["other_child (e.g., radius)"]
Loading

Delta (quantity)

The below is obvious and we have it like this for a long time. I put it here for reference:

static_assert(delta<child> + delta<child> == delta<child>);
static_assert(delta<child> - delta<child> == delta<child>);
static_assert(delta<child> % delta<child> == delta<child>);
static_assert(delta<child> + delta<parent> == delta<parent>);
static_assert(delta<child> - delta<parent> == delta<parent>);
static_assert(delta<child> % delta<parent> == delta<parent>);
static_assert(delta<child> + delta<other_child> == get_common_quantity_spec(delta<child>, delta<other_child>));
static_assert(delta<child> - delta<other_child> == get_common_quantity_spec(delta<child>, delta<other_child>));
static_assert(delta<child> % delta<other_child> == get_common_quantity_spec(delta<child>, delta<other_child>));
static_assert(delta<child> + delta<kind_of<parent>> == delta<child>);
static_assert(delta<child> - delta<kind_of<parent>> == delta<child>);
static_assert(delta<child> % delta<kind_of<parent>> == delta<child>);

Points

I think it is reasonable to say that in the case of points and deltas, the point quantity type should be preserved. For example, if I put a wheel on a box, I might get the height of the top of the wheel by adding its radius multiplied by 2.

static_assert(point<parent> + delta<child> == point<parent>);
static_assert(point<parent> - delta<child> == point<parent>);
static_assert(point<parent> + delta<parent> == point<parent>);
static_assert(point<parent> - delta<parent> == point<parent>);
static_assert(point<parent> + delta<kind_of<parent>> == point<parent>);
static_assert(point<parent> - delta<kind_of<parent>> == point<parent>);
static_assert(point<child> + delta<child> == point<child>);
static_assert(point<child> - delta<child> == point<child>);
static_assert(point<child> + delta<parent> == point<child>);
static_assert(point<child> - delta<parent> == point<child>);
static_assert(point<child> + delta<other_child> == point<child>);
static_assert(point<child> - delta<other_child> == point<child>);
static_assert(point<child> + delta<kind_of<parent>> == point<child>);
static_assert(point<child> - delta<kind_of<parent>> == point<child>);

The tricky part is what to do if we have points of other types:

static_assert(point<parent> - point<parent> == delta<parent>);
static_assert(point<child> - point<child> == delta<child>);
static_assert(point<child> - point<other_child> == ???);
static_assert(point<parent> - point<child> == ???);
static_assert(point<child> - point<parent> == ???);
static_assert(point<parent> - point<kind_of<parent>> == ???);
static_assert(point<child> - point<kind_of<parent>> == ???);
static_assert(point<kind_of<parent>> - point<parent> == ???);
static_assert(point<kind_of<parent>> - point<child> == ???);

I do not know a good answer to the above 7 cases. If you agree, I would like those not to compile.

@burnpanck
Copy link
Contributor

I can agree with the proposed rules for mixed point and delta arithmetics: The basic "rule" leading to your proposed results are:

  • point is strictly "stronger" than delta
  • In such an operation, the "weaker" delta can pass through "explicit casting paths" to enable the operation

But for operations between two points, I would prefer if the results were consistent with the implicit casting rules. So if point<child> can implicitly convert to point<parent>, then the difference of the two should be delta<parent>. If they are not implicitly convertible, then the operation should not compile either.

Finally, if delta<child> - delta<other_child> == delta<some-common-spec> (i.e. find a common spec where both operands can be implicitly converted to), then I think the same should apply to points as-well.

@mpusz
Copy link
Owner Author

mpusz commented Feb 15, 2025

I would prefer if the results were consistent with the implicit casting rules. So if point can implicitly convert to point, then the difference of the two should be delta. If they are not implicitly convertible, then the operation should not compile either.

Ohh, I forgot to write about this as well. Yes, I think that converting between points should also not be enabled as they may describe different spaces. However, I might be wrong. In such a case, please do let me know.

I would prefer to be conservative here and extend the design if compelling use cases emerge.

@burnpanck
Copy link
Contributor

Yes, I think that converting between points should also not be enabled as they may describe different spaces

If they were, then that reasoning should also apply to deltas. After all, we claim that deltas are differences between points in the same space. If we say child is a parent allows them to be mixed for delta operations, then I feel that should apply to point operations as-well.

@mpusz
Copy link
Owner Author

mpusz commented Feb 15, 2025

Yeah, I'm trying to wrap my head around this.

My mental model is that, for example, for 1-D lengths, points represent an axis that can't be rotated, and we can only move back and forth through it. For example, altitude has a vertical axis. If we want to add a delta diameter to points on the altitude axis, we first need to "rotate" the diameter to align with the altitude axis. As a result, we end up with another altitude. As you wrote, points are strictly "stronger" than deltas.

Now, if you have a vertical axis for altitude and another for horizontal length, trying to subtract or convert between points on those two is impossible. Also, a parent quantity is a more generic quantity that, again, may be an "unspelled" horizontal length or an axis in any other direction.

Deltas are different. They express the exact amount and can be "rotated" to align to any other quantity. However, they lose their character after rotation, and we look for a common, more generic quantity that describes both of them.

Of course, my mental model might be wrong, and please let me know in such a case.

@chiphogg
Copy link
Collaborator

  • point is strictly "stronger" than delta

This seems reasonable to me too, at least as far as the quantity category goes. But I wanted to clarify that this is not true for the units: rather, all the usual rules for "common unit" must apply. So, for the example of a point of altitude and a delta of diameter: if the point-of-altitude is measured in meters, and the delta-of-diameter in cm, then we should get a point-of-altitude measured in cm.

I think this is pretty uncontroversial, but I wanted to lay it out explicitly.

@burnpanck
Copy link
Contributor

But I wanted to clarify that this is not true for the units: rather, all the usual rules for "common unit" must apply.

I absolutely agree. But:

If the point-of-altitude is measured in meters, and the delta-of-diameter in cm, then we should get a point-of-altitude measured in cm.

I'm not so sure there in general. The semantics of an actual physical quantity are completely transparent to the unit. The unit is "accidental" in that it is required to be able to represent the physical quantity through a number and a specification of some universally understood reference quantity.

Instead, I would want to specify the library behaviour with respect to range and precision. These concepts are not part of the semantics of the physical quantity itself, but how we represent quantities as C++ object, with the necessary compromise between range, precision, storage and computation. In particular, the choice of the "greatest common divisor" of all input units as the output units is bad IMHO, because it may expose the use to unexpected risks around the range of the number representation. Even for floating-point types, you quickly reach the point where the apparent "exact" conversion to the smaller unit becomes an illusion due to the finite number of significant digits.

I know that you @chiphogg prefer to see the units::quantity type family (including quantity_point) to model "exact physical quantities", where no approximation/rounding should ever be applied, if possible. For a given specific value, it isn't that hard to determine if an exact operation is possible (and indeed, you seem to provide such functionality in your Au library). However, in general, almost every operation cannot exactly represent a significant part of the input range. Instead, I prefer to see the units::quantity to model "an approximation to a physical quantity", similar to how double represents an approximation to a real number. In this interpretation, every quantity comes with an inherent error, which enables considering operations which do not significantly increase that error to respect the semantics. For me, these are useful semantics, because in all code where I work with physical quantities, those are descriptions of the real world (typically measurements), which come with an error anyway.

@chiphogg
Copy link
Collaborator

If the point-of-altitude is measured in meters, and the delta-of-diameter in cm, then we should get a point-of-altitude measured in cm.

I'm not so sure there in general. The semantics of an actual physical quantity are completely transparent to the unit. The unit is "accidental" in that it is required to be able to represent the physical quantity through a number and a specification of some universally understood reference quantity.

Well stated: I completely agree.

Instead, I would want to specify the library behaviour with respect to range and precision. These concepts are not part of the semantics of the physical quantity itself, but how we represent quantities as C++ object, with the necessary compromise between range, precision, storage and computation.

Representing range and precision explicitly in the quantity type system sounds like a very intriguing and appealing idea. I hope you (or someone else) is able to show what this would look like in practice, so we can find out whether it works, and whether there are any meaningful downsides. I'd really like to see this explored further!

One interesting question will be what we mean by "precision": will we support absolute, relative, or both? Currently, the integer reps (and fixed-point) give us something more like absolute precision, while floating point reps give us relative precision. So perhaps the precision "category" (i.e., relative vs. absolute) won't be fully independent from the rep. It'll be especially interesting to see what the rules are for deducing the output precision, range, and rep from the inputs of a mathematical operation.

In particular, the choice of the "greatest common divisor" of all input units as the output units is bad IMHO, because it may expose the use to unexpected risks around the range of the number representation. Even for floating-point types, you quickly reach the point where the apparent "exact" conversion to the smaller unit becomes an illusion due to the finite number of significant digits.

I wouldn't say that it's "bad"; I'd just say that it has risks, such as this one that you've pointed out. I think this risk can be effectively mitigated with an adaptive overflow safety surface, which does a good job in practice of permitting many safe operations, and forbidding many risky ones. It isn't perfect, but it's a huge leap forward IMO compared to the previous state of the art (which was, essentially, "use GCD, and users are on their own for overflow risks").

As for floating point: you are right on the money. No libraries, including Au and mp-units, have advanced at all (AFAIK) beyond the std::chrono policy of "consider floating point operations to be value preserving". In a lot of ways, this is OK in practice, because by choosing a particular floating point rep, users are effectively choosing the degree of relative error that they're OK with. But I still think this is a promising and underexplored direction for future expansion. (Maybe the APIs you suggest, based on precision and range, will help us get there!)

I know that you @chiphogg prefer to see the units::quantity type family (including quantity_point) to model "exact physical quantities", where no approximation/rounding should ever be applied, if possible. For a given specific value, it isn't that hard to determine if an exact operation is possible (and indeed, you seem to provide such functionality in your Au library). However, in general, almost every operation cannot exactly represent a significant part of the input range. Instead, I prefer to see the units::quantity to model "an approximation to a physical quantity", similar to how double represents an approximation to a real number. In this interpretation, every quantity comes with an inherent error, which enables considering operations which do not significantly increase that error to respect the semantics. For me, these are useful semantics, because in all code where I work with physical quantities, those are descriptions of the real world (typically measurements), which come with an error anyway.

It's a great point that individual values rarely come from exact sources. If we could take that uncertainty into account in the ways you suggest, it could make a lot of things nicer. (Here's an example that resonates with me: we measure our timestamps with integer nanosecond precision, and this means QuantityD<Seconds> won't implicitly convert to our timestamp duration types, because Au considers the floating point to integer conversion "lossy". It'd be nice if we could specify our desired precision in a way that would reduce some of this friction.)

Absent a robust framework for handling those precisions, at least I'd like to avoid adding to the error. So we use the common unit for mixed-unit operations, and trust in the safety surface to mitigate risk. It's pretty satisfying overall.

One principle I do want to see a range/precision approach respect is symmetry in the arguments. For example, the unit of the result of adding or subtracting two quantities should never change when we swap the order. I also think that unit should never directly depend on the rep (although the precision might conceivably have an effect, and the rep might not be independent of the precision). But these are just abstract ideas until we have a real set of range/precision based quantity APIs to see how they work in practice.

@mpusz
Copy link
Owner Author

mpusz commented Feb 24, 2025

I think that this discussion derailed a bit from the quantity point arithmetic subject, but I will add one more thing to it 😉

We have had the following issue open for a long time: #464. Proper modeling of measurement uncertainties will allow us to model IAU units and many other things mentioned above. The problem is that implementing an uncertainty or measurement type that will properly propagate, accumulate, and simplify measurement errors is really hard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
design Design-related discussion
Projects
None yet
Development

No branches or pull requests

4 participants