mpusz / mp-units

The quantities and units library for C++
https://mpusz.github.io/mp-units/
MIT License
1.1k stars 90 forks source link

Interconvertibility of derived quantities #427

Closed mpusz closed 1 year ago

mpusz commented 1 year ago

I just added a new discussion thread (#426) about the V2 design rules. As stated in TL;DR, the main idea is to:

Should the following quantities be interconvertible?

  1. length * length and area
    • this is trivial and most probably should be true
  2. width * height and area
    • both width and height are interconvertible with length so this probably should be true as well
  3. path_length * distance and pow<2>(path_length)
    • similar case to the above but this time we have two "unnamed" quantities
  4. width * distance and path_length * width
    • distance is interconvertible with path_length and not with width
  5. altitude * distance and path_length * height
    • altitude is interconvertible with height, and distance is interconvertible with path_length
  6. width * length and length * height
    • depending how you look at it both width and height are interconvertible with length
    • but width is not interconvertible with height
    • the final result can depend on the ordering of the "cancelling" operations
  7. length * distance and path_length * altitude
    • depending how you look at it both distance is interconvertible with path_length and length is interconvertible with altitude
    • but also path_length is interconvertible with length and then distance is NOT interconvertible with altitude
    • the final result can depend on the ordering of the "cancelling" operations
mpusz commented 1 year ago

Is it better to be on the safe sound and disallow non-trivial conversions? Or maybe it is better to be user-friendly and disallow only what we are sure that should not be interconvertible?

mpusz commented 1 year ago

@burnpanck, @JohelEGP, @chiphogg

Do we have any ideas on how to resolve that?

The only thing that somehow resonates with me here is to allow implicit interconvertibility only if we are sure about the result. This covers the following cases:

In case when some combinations result in a lack of interconvertibility (but there is at least one valid combination) a quantity_cast should be used to make an explicit conversion if needed.

Otherwise, the quantities should not be interconvertible.

What do you think about that?

JohelEGP commented 1 year ago

only if we are sure about the result

  • we have only one interconvertible quantity of a specific dimension in the equation

(but there is at least one valid combination)

Is that possible without using friend?

mpusz commented 1 year ago

I hope so :-) I am going to compare quantity_spec and not quantity types. I am thinking about something like that:

namespace detail {

enum class interconvertible_result { no, yes, maybe };

template<typename... Q1, typename... Q2>
interconvertible_result are_ingredients_interconvertible(derived_quantity_spec<Q1...> q1,
                                                         derived_quantity_spec<Q2...> q2)
{
  // order ingredients by their complexity (number of base quantities involved in the definition)
  // if first ingredients are of different complexity extract the most complex one
  // repeat above until first ingredients will have the same complexity and dimension
  // check interconvertibility of quantities with the same dimension and continue to the nex one if successful
  // repeat until the end of the list or not interconvertible quantities are found
}

template<QuantitySpec Q1, QuantitySpec Q2>
[[nodiscard]] consteval interconvertible_result are_interconvertible(Q1 q1, Q2 q2)
{
  using enum interconvertible_result;

  if constexpr (Q1::dimension != Q2::dimension)
    return no;
  else if constexpr (have_common_base(q1, q2))
    return (std::derived_from<Q1, Q2> || std::derived_from<Q2, Q1>) ? yes : no;
  else if constexpr (DerivedQuantitySpec<Q1> && DerivedQuantitySpec<Q2>)
    return are_ingredients_interconvertible(q1, q2);
  else
    return no;
}

}  // namespace detail

template<QuantitySpec Q1, QuantitySpec Q2>
[[nodiscard]] consteval bool interconvertible(Q1 q1, Q2 q2)
{
  return are_are_interconvertible(q1, q2) == interconvertible_result::yes;
}

If are_interconvertible returns maybe, in such case quantities are not implicitly interconvertible, but the explicit conversion can be forced with quantity_cast. I hope that will work as expected, but still, I am not sure how to deal with a list of consecutive quantities of the same dimension but different types/kinds. That is why I started this thread, and I hope that https://github.com/mpusz/units/issues/427#issuecomment-1407367145 might be good enough. Unless you have some better proposals?

JohelEGP commented 1 year ago

Maybe I'm misunderstanding something. Let's consider the given example:

  • we have only one interconvertible quantity of a specific dimension in the equation (i.e. length / time should be interconvertible with speed)

A user can add a new quantity of dimension equal to those of length / time and speed. So a pure function can't answer whether "we have only one" by looking only at its inputs, when the set of such quantities is extensible. Would you be able to do it using virtual?

mpusz commented 1 year ago

By "we have only one interconvertible quantity of a specific dimension in the equation" I mean there is only one ingredient quantity of each dimension in Q1... above, and we have to check if it is interconvertible with the ingredient quantity of the same dimension in Q2.... Q1... and Q2... are the ingredients used in the quantity equation used to define two derived quantities I am comparing for interconvertibility.

So to make this rule happy two quantities of dimension length and two quantities of dimension time should be interconvertible. So length / time will be interconvertible with width / duration, but height / time should not be interconvertible with width / time. I think this is quite straightforward to reason about and easy to implement. The problems start when there is more than one ingredient quantity of the same dimension in the equation (i.e. width * length / time and length * height / time).

So a pure function can't answer whether "we have only one" by looking only at its inputs, when the set of such quantities is extensible.

I do not care if there are any other derived quantities of the same dimension besides the two I am comparing now. In fact. there might be an infinite number of quantities of the same dimension.

burnpanck commented 1 year ago

I still believe that the hierarchical model I presented previously should be able to give a clear and consistent answer to all of these questions. One important thing to note is that my hierarchical model does not speak of "interconvertibility", because that word implies a symmetric relationship between the two involved quantities. I don't think that the conversion rules should necessarily be symmetric. If two quantities really are interconvertible, then it means you can substitute one for the other. That is an equivalence relation. But as such, it should be transitive: If you can substitute A for B in every place and you can substitute B for C everywhere, then you should be able to substitute A for C. Thus, I believe it is impossible to make both height and length "interconvertible", and simultaneously width and length "interconvertible", but not width and height. Either they are all "interconvertible", or at least one of them is not "interconvertible" to any of the other.

Given the constraint that we want to make width and height incompatible with each other, but each of them individually to be compatible in some suitable sense with length, I see only one solution: That compatibility necessarily has to be an asymmetric relation. That is, between width and length, we want one to be able to stand in for the other, but not the other way around. In principle we can choose either direction, but the only choice that lets us have width and height incompatible for algebra is when length can stand in for width, but not the other way around. They still remain "compatible" for algebraic purposes, as I will try to highlight below:

Let us have a look at a quantity expression length + width:

burnpanck commented 1 year ago

So long story short: "interconvertibility" is not the right concept that allows us model the type of "compatibility" that we are looking for, both in function arguments and algebraic operations. We need to consider asymmetric conversion rules.

burnpanck commented 1 year ago

If we accept such asymmetric conversion rules, it means we could draw a directed graph of all possible quantities and their allowed (implicit) conversions. That graph would be infinite, as we need to consider all sorts of composed quantities too. Due to the associativity of quantity calculus, the graph further needs to be transitive: if A is convertible to B and B is convertible to C, then A should be convertible to C: The expression (B + A) + C has a result of type C, but algebraically is the same expression as B + (A + C). Thus, once we find a cycle in this directed graph of quantities, we immediately know that each of the involved quantities are convertible to all of them and thus truly are interconvertible or equivalent for all purposes. We follow from this that the graph will form groups or clusters of equivalent quantities and a higher-order directed acyclic graph of cluster emerges (if there were a cycle among the clusters, the clusters were all equivalent and thus a single cluster - proof by contradiction).

So far we have only looked at addition. We also need to make sure that this meta-DAG of equivalent quantities respects the quantity algebra of multiplication. Distributivity says that if A is convertible to B, then A*C should be convertible to B*C, because (A+B) * C is algebraically equivalent to A*C + B*C. So this will be another constraint that limits conversion graphs we may propose without breaking quantity calculus.

Apart from those two constraints, we are free to draw this graph of convertibility such that it matches the expectations of our users, or the scientific and engineering communities in general. There is a simple way to ensure both constraints, both by design and in the implementation: With every convertibility we declare explicitly, we make sure that all other convertibilities that are needed to fulfil those constraints are allowed implicitly. Thus if we declare length can be converted to width (but not vice-versa), then, automatically, length * time needs to become convertible to width * time. I did describe a more detailed algorithm how to "build" that graph of convertibility from a few basic/atomic definitions in another issue before: https://github.com/mpusz/units/issues/405#issuecomment-1368941910. I did provide an illustration showing a subset of the resulting graph here: https://github.com/mpusz/units/issues/405#issuecomment-1371257667.

burnpanck commented 1 year ago

Now finally: Let us explore some potential choices for this graph and what it implies for the examples given in the initial issue description.

Trivial choice 1: Dimensional analysis only

Every quantity is equivalent/interconvertible with every other quantity of the same dimension. This is what most units libraries do. But it makes width + height acceptable, and so will it make frequency + radioactivity (now my favourite examples of incompatible kinds because it is explicitly in the standard).

Trivial choice 2: "Extended dimensional analysis" only

We draw some clusters of equivalent quantities following the constraints given before, but do not allow any conversion anywhere between non-equivalent quantities. This is essentially the same as dimensional analysis, except that we effectively introduce additional dimensions. For the width + height example, we could make all of length, width and height into a separate, non-equivalent group of quantities. None of them is compatible with any of the other. Crucially, we could define area as either as a) length^2 or as b) width * height, but the quantities length^2 and width*height necessarily would remain non-equivalent and thus incompatible in this design. Only one of them can be the area. Note that to "define" area could be interpreted as defining a new quantity area that is equivalent/interconvertible with, say, length^2, or it could simply be an alias. Because interconvertibility is an equivalence relation, it is of no further consequence, as the two should remain substitutable in all sitauations anyway. We can exploit this in the implementation so as to make area a strong type which is a separate but essentially equivalent quantity to length^2.

Non-trivial choices

Instead, we may want to hand-craft some of the convertibilities, and see where it leads us. To explore the examples of the initial issue description, let me just assume the following set of hand-crafted basic conversions:

Consistency with quantity calculus leads to the following results for the examples provided in the first post:

  1. length * length and area are interconvertible/equivalent by definition.
  2. area is convertible to width * height, but not vice-versa: The expression length * length + width * height has a result of quantity type width * height. Converting that to area loses information and thus should not be allowed without explicit input from the user (quantity cast).
  3. path_length * distance and path_length^2 are equivalent/interconvertible as required by distributivity path_length * (path_length + distance) = path_length^2 + path_length * distance, and the term in brackets on the left-hand side are equivalent by definition.
  4. width * distance and path_length * width are necessarily equivalent by the symmetry of * and distributivity again (and as expected for dimensional analysis; there is no "order" just the power of the dimension matters).
  5. path_length * height is convertible to altitude * distance, but not vice-versa: altitude is "more refined" than height.
  6. width * length and length * height are incompatible / neither is convertible to the other. There is no allowed conversion to any of the "parts" that leads to a common quantity type.
  7. length * distance is convertible to path_length * altitude, but not vice-versa: altitude is more refined than length (transitively through height).

To me, all of these results look reasonable. What do you think? I might be able to sketch an implementation of these rules at the start of the next week.

JohelEGP commented 1 year ago

Crucially, we could define area as either as a) length^2 or as b) width * height, but the quantities length^2 and width*height necessarily would remain non-equivalent and thus incompatible in this design. Only one of them can be the area.

An area has dimension L². Both $l l$ and $w h$ (where $l$, $w$, and $h$ are quantities of length, width, and height, respectively) are valid expressions of area. It'd be nice if the library didn't require either to be more verbose just due to the way it defines area.

All those implicit conversions in "non-trivial choices" seem backwards and unsafe. A width is always a length, and so the library could make width implicitly convertible to length. But a length doesn't necessarily represent a width, so a library that allows implicitly converting a length to a width opens up opportunities for logical errors that could be caught by the type system.

burnpanck commented 1 year ago

It'd be nice if the library didn't require either to be more verbose just due to the way it defines area.

I completely agree. I don't want to implement the trivial choice 2. I was trying to prove that we need a non-trivial choice if we want both "L x L" and "W x H" to be compatible with "A" (even if not interconvertible!) in the same way that "L" and "W" should be compatible, but at the same time we want "W" and "H" to be incompatible.

All those implicit conversions in "non-trivial choices" seem backwards and unsafe. A width is always a length, and so the library could make width implicitly convertible to length. But a length doesn't necessarily represent a width, so a library that allows implicitly converting a length to a width opens up opportunities for logical errors that could be caught by the type system.

I feel you, that was my first thought too, and in the V2 thread I had initially proposed rules to be the other way around. However, I believe that is a reflex coming from object oriented programming, where refining an object adds instance state. In that case, the state space actually becomes "wider" in some sense. However, refining a quantity adds a compile-time constraint to the physical phenomenon that is described by the quantity; it does not in fact change the state-space, but narrows down the space of applicability of the quantity. I believe that we have here a case of a contravariant relationship between quantities and expressions involving them. The C++ type hierarchy unfortunately does not implement contravariance even though there are many situations where that would be needed as Arthur O’Dwyer points out in the article I linked. So indeed, if we were to make actual instances of quantities to form an inheritance tree according to these refinement rules, then at least for pointers to quantities, C++ would allow us to provide a width for a length. But we are not talking about quantity instances here, but a hierarchy of quantity types/kinds. We can in fact implement contravariance easily in modern C++ if we do not make the quantity instances form a corresponding class hierarchy.

Let me now discuss why I think that contravariance is in fact intuitively correct for the quantity hierarchy. Consider an expression width + "1 meter". I believe everyone would intuitively agree that the result of this operation is still a width, despite "1 meter" not being a width explicitly. This can be formalised as follows: Replacing a general quantity with a refined quantity in an expression is like adding in a constraint. An expression involving multiple constraints can only be valid if all constraints are fulfilled. Thus, converting from refined to general actually removes a constraint, that is, removing information about the quantity. Therefore, converting from width to length is in fact unsafe, as it removes an explicit statement about the applicability of the quantity to a physical phenomenon (and therefore the applicability to algebraic expressions modelling that phenomenon).

That the converse does not lead to the expected result can be illustrated with the width + length + height example. If we do not want to redefine the rules of algebra, then that expression should be equivalent to (width + length) + height. If we discard the width constraint and say it is just a length, then ultimately, we have length + height, which again should become a length. But the rules of algebra also say that the same expression could also be evaluated as (width + height) + length. This is exactly the type of expression you would want to forbid, because width and height each have a constraint on applicable physical phenomenons that are mutually disjunct such that the combined expression is not applicable to any physical phenomenon at all.

mpusz commented 1 year ago

@burnpanck Thanks a lot for your answers.

Yeah, we went a long way back and forth with the design options here. I agree with you on most points, and actually, I was the one that said that in order for 1 Hz + 1/s + 1 Bq to not compile, we need to make 1 Hz + 1/s return a frequency. Following this logic, width + length + heigth should not compile as well, and the problem would be solved.

However, ISO 80000 explicitly states in multiple places:

The quantities diameter, circumference, and wavelength are generally considered to be quantities of the same kind, namely, of the kind of quantity called length.

Quantities may be grouped together into categories of quantities that are mutually comparable. Diameters, distances, heights, wavelengths and so on would constitute such a category, generally called length. Mutually comparable quantities are called quantities of the same kind.

Quantities that are comparable are said to be of the same kind and are instances of the same general quantity. Hence, diameters and heights are quantities of the same kind, being instances of the general quantity length.

the diameter of a cylindrical rod can be compared to the height of a block

However, it also states:

Quantities of the same kind within a given system of quantities have the same quantity dimension. However, quantities of the same dimension are not necessarily of the same kind.

The quantities moment of force and energy are, by convention, not regarded as being of the same kind, although they have the same dimension. Similarly for heat capacity and entropy, as well as for number of entities, relative permeability, and mass fraction.

However, in some cases special measurement unit names are restricted to be used with quantities of specific kind only. For example, the measurement unit ‘second to the power minus one’ (1/s) is called hertz (Hz) when used for frequencies and becquerel (Bq) when used for activities of radionuclides. As another example, the joule (J) is used as a unit of energy, but never as a unit of moment of force, i.e. the newton metre (N · m).

Two or more quantities cannot be added or subtracted unless they belong to the same category of mutually comparable quantities. Hence, quantities on each side of an equal sign in an equation must also be of the same kind

Of course, we can ignore the above requirements in ISO 80000 and do mathematical and strong-typing correct things. But I am not sure if that is a good idea. I hoped that we could somehow invent a way to do comparisons and arithmetics in terms of kinds and convertibility in terms of their quantity hierarchy.

Please note that function templates (i.e. op+()) will not do automatic implicit conversions between arguments to make the code compile. So even if we make width and height interconvertible with length, they will not do that conversion by themselves.

The interconvertibility requirement comes from the usability fact that I believe that the following should work fine:

void foo(quantity<isq::length[m]>);
void boo(quantity<isq::height[m]>);

foo(isq::height(42 * m));
boo(42 * m);

otherwise, the users may complain the library is not user-friendly. However, I feel that it should not be allowed to call boo(isq::width(42 * m)) to prevent errors in interfaces.

With the rules you described, it seems that foo(isq::height(42 * m)); should not compile without an explicit cast, but this might be the most common case here. Most generic libraries will define functions like:

quantity_of<isq::speed> auto avg_speed(quantity_of<isq::length> auto, quantity_of<isq::time> auto);

If the user works on more specific types in his/her code, then it will need to explicitly cast them to use such generic interfaces. I think this is what @JohelEGP was worried about above.

But as such, it should be transitive: If you can substitute A for B in every place and you can substitute B for C everywhere, then you should be able to substitute A for C. Thus, I believe it is impossible to make both height and length "interconvertible", and simultaneously width and length "interconvertible", but not width and height. Either they are all "interconvertible", or at least one of them is not "interconvertible" to any of the other.

Not necessary. Even in the C++ language, in most cases, we support only one implicit conversion step at a time. For example, if class X is implicitly convertible from std::string, class X will not be implicitly convertible from const char * as this requires two steps at a time. So a conversion between A and C is possible but not implicitly in one step. A user will have to explicitly provide an intermediate step of B, which will be good for code readability and maintenance.

We need to consider asymmetric conversion rules.

Sure, I am open to it. However, please keep the above foo() and boo() examples in mind. Which one do you think should compile, and which one not?

and so will it make frequency + radioactivity (now my favourite examples of incompatible kinds because it is explicitly in the standard).

This would not work because even though they have the same dimension and are part of the same hierarchy tree, they are different kinds, and we can easily define them like that in the library.

path_length implicitly converts to distance, and vice versa:

I am not sure why they should be interconvertible as distance derives from path_length the same as path_length derives from length.

area is equivalent to length^2, they are interconvertible/equivalent.

Does it mean that energy and moment of force are equivalent as well?

JohelEGP commented 1 year ago
void boo(quantity<isq::height[m]>);
boo(42 * m);

This is what I'm worried about. Conversion from length to height should be explicit.

mpusz commented 1 year ago

But this is what we wanted to achieve:

std::array<quantity<isq::height[m]>, 3> a = { 1 * m, 2 * m, 3 * m };
auto h = isq::height(42 * m);

If the conversion is explicit, the first one will not compile. I am not sure about the second one.

burnpanck commented 1 year ago

I agree with @mpusz here: I think the boo example should compile, and even you @JohelEGP requested this at some point in the V2 review (state-space controller initialisation). As for the foo example, I'm not sure we would want to let that one compile.

mpusz commented 1 year ago

And what do you think about the ISO 80000 requirement of comparing and adding/subtracting quantities? Should we allow that or do exactly the opposite, which was suggested above? I have implemented the ISO's behavior so far but still struggle with what to do with conversions. I think we have the following options here:

Comparison and arithmetics:

  1. Follow the ISO and allow width + height but not allow frequency + radioactivity as those will be defined as different kinds.
  2. Implement strict arithmetics described above. With that width + height and frequency + radioactivity do not compile and we do not have to play with kinds at all to achieve that.

Conversions:

  1. Interconvertibility across the same quantity branch
  2. Implicit downcasting (length -> width) across the same quantity branch
  3. Implicit upcasting (width -> length) across the same quantity branch

It seems that options 1. and 5. are the ISO's preferred approach, but that does not provide any type-safety. With that, I would prefer to abandon quantity types and quantity kinds at all, and just use dimensions. The only problem here is what @JohelEGP mentioned some time ago that there is no such thing as a speed dimension, so naming things could be harder. Such behavior would be really easy to implement, document, and standardize.

I tried hard to achieve 1. and 3. as a compromise. However, those are inconsistent in mathematical ways, as pointed out above. I am not sure if we should explore this path more?

The most C++ type-safety provides 2. and 4. which is suggested above by @burnpanck. It is consistent as well but not compatible with ISO 80000.

JohelEGP commented 1 year ago

Yeah, you're right. Does the implicit conversion generalizes to kinds (such that a path length implicitly converts to a distance, which is a logic error if the path length is not a distance, i.e. the shortest path length between two points in a metric space) or is it limited somehow?

JohelEGP commented 1 year ago
  1. Implicit downcasting (length -> width) across the same quantity branch
  2. Implicit upcasting (width -> length) across the same quantity branch

It seems that options 1. and 5. are the ISO's preferred approach, but that does not provide any type-safety.

The most C++ type-safety provides 2. and 4. which is suggested above by @burnpanck.

It seems to me you have the type-safety backwards. 4 is not type safe because a length isn't always a width, but 5 is type safe because a width is always a length.

mpusz commented 1 year ago

It seems to me you have the type-safety backwards. 4 is not type safe because a length isn't always a width, but 5 is type safe because a width is always a length.

Agree. I maybe phrased my thoughts in a wrong way.

This is why I actually claim that we should provide interconvertibility. One is for usability and the other one is for type safety. And I hope we do not have to extend that to arithmetics and comparison so we can implement ISO's behavior. See TL;DR chapter of https://github.com/mpusz/units/discussions/426.

burnpanck commented 1 year ago

Ok, I understand now better what you are trying to achieve. My proposal was geared at clarity and consistency, by using the same set of rules for conversion and arithmetics/comparison. I also applied those same set of rules both to frequency vs. radioactivity (which ISO explicitly states as separate kinds), as well as for width vs. height, which are both of the same kind in ISO. My approach distinguished the latter case from the former only through hierarchy, i.e. by making width and height some sort of sub-kinds which still are both length but not compatible with each other.

I believe clarity and consistency are very valuable, and we should be careful about introducing more concepts, because it makes it even more difficult for us to just even define the behaviour, let alone verify it and communicate it to the users.

Let me think a little more if I can formalise the rules that you have been suggesting under the assumption that conversion may have different rules than compatibility for algebra and comparison, so that we can make width and height the same kind (i.e. equivalent for comparison and algebra purposes), but not equivalent for conversion.

mpusz commented 1 year ago

My proposal was geared at clarity and consistency, by using the same set of rules for conversion and arithmetics/comparison.

I love it. I would be for it if only we could make it consistent with ISO and also type-safe. However, the more experience we get, the more it looks like we need some compromise here or be not consistent with ISO 80000 rules.

Let me think a little more if I can formalise the rules that you have been suggesting under the assumption that conversion may have different rules than compatibility for algebra and comparison, so that we can make width and height the same kind (i.e. equivalent for comparison and algebra purposes), but not equivalent for conversion.

Thanks! I tried to state such rules in https://github.com/mpusz/units/discussions/426, but maybe we can find something even better.

burnpanck commented 1 year ago

Ok, let me recap/restate what we believe to be true and the goal that we want to achieve.

Assumptions:

For comparison/addition:

For conversion:

Do we all agree on that?

mpusz commented 1 year ago

I agree with all of the above :-)

We want to model ISO kinds exactly

Probably it would be good to do so unless we decide to ignore ISO 80000. One thing to note here is that ISO does not specify which quantities are of different kinds. It just provides a few examples and claims that the division into kinds is to some extent arbitrary.

"type safety" - though I'm not sure that this term has any meaning here

By "type safety" I mean that we should not be allowed to call void foo(quantity<isq::width> w, quantity<isq::height> h); with reversed order of arguments. This in my opinion is the best we can do in those circumstances but, of course, I am open to some better proposals.

JohelEGP commented 1 year ago
  • "implicit narrowing" (length to width)

I wouldn't call that narrowing. It's implicitly adding semantics to a value by type conversion, from a more generic type to a more specific type.

burnpanck commented 1 year ago

Also, please clarify what your feeling towards path_length and distance is. I assume they are of the general kind of "length", so comparison to width and height should be allowed. So among those four, are they all on separate branches , or are path_length and distance "closer" in some way. (Ideally, draw me the hierarchy).

mpusz commented 1 year ago

This is my understanding

        length
   /      |          \
width   height    path_length
                       |
                   distance

at least this is how I implemented it here: https://github.com/mpusz/units/blob/v2_framework/src/systems/isq/include/mp_units/systems/isq/space_and_time.h.

burnpanck commented 1 year ago

"implicit narrowing" (length to width)

I wouldn't call that narrowing. It's implicitly adding semantics to a value by type conversion, from a more generic type to a more specific type.

Ok, then let's not call it narrowing. @JohelEGP Please confirm what type of "vertical" conversions you would like to allow, and which you don't. My understanding is you want to allow width to length but not the other way around?

JohelEGP commented 1 year ago

I agree with all of the above :-)

We want to model ISO kinds exactly

I agree, too.

or are path_length and distance "closer" in some way.

See https://github.com/mpusz/units/issues/405#issuecomment-1339621239.

JohelEGP commented 1 year ago

My understanding is you want to allow width to length but not the other way around?

Only for conversions.

Length to path length, path length to distance, and length to distance. All those would treat the source value as valid for the target type. You could make arguments for length, which is used to construct more specific quantities of length. But a path length doesn't necessarily represent a distance. So permitting such a conversion implicitly, when it can be avoided in the type system, seems like a bad design choice.

mpusz commented 1 year ago

But a path length doesn't necessarily represent a distance

I think this exactly the same case as "length doesn't necessarily represent a width" but I assume you would like to be able to write:

quantity<isq::path_length> pl = 42 * m;

or call:

void foo(quantity<isq::path_length>);

with 42 * m without any additional casts, or do isq::path_length(42 * m). The question here is if it should be possible to call it with isq::distance(42 * m), but I think that for the sake of consistency, it should. The reason why is that I think that all of the 42 * m, isq::length(42 * m), and 42 * isq::length[m] should mean exactly the same. However, they will result in quantities of different types. The first case will return quantity<si::metre, int> while the other two will return quantity<reference<isq::length, si::metre>, int>. So, in theory, we could create a rule that quantity can be downcasted only if it is specified in terms of a unit and not a reference type. Giving it a specific reference could mean something more than just a unit, even if the quantity_spec is exactly the same in both cases.

mpusz commented 1 year ago

Following on the above:

burnpanck commented 1 year ago

That can actually work. So every quantity has a kind, but there are quantity that do not have a "quantity type", and there are quantities that do have a "quantity type". Rules for compatibility for comparison and algebra are only specified in terms of the kind and never take into account the "quantity type". Rules for implicit casting are as follows:

This does indeed seem to tick all the boxes. There may still be some bikeshedding around how to call that "quantity type" concept.

What we will still have to figure out however is how to define the kind of a product of two quantities, with or without type. I believe the "quantity type hierarchy" is still this infinite DAG that follows the rules of quantity calculus as discussed before, and as such I don't believe there is any problem here. My gut feeling is that we may have to put some extra work in still to make the kinds work in the case of energy and moment of force. But I'm confident we will find a solution here!

burnpanck commented 1 year ago

Some questions to consider:

  1. Do we want to allow energy + force*length? If we don't, how do I integrate the energy stored in an elastic material given it's stress/strain curve?
  2. Do we want to allow energy + current*voltage*time? If we don't, how do I integrate the energy stored in a capacitor?
  3. Do we want to allow count/time < frequency? If we don't, then how do I produce the output of a "frequency counter"?
  4. Do we want to allow count/time < radioactivity? If we don't, then how do we compare the result from a geiger counter against an exposure limit?
  5. Do we want to allow count < frequency*time? IMHO it should be allowed exactly iff 3. is allowed.
  6. We definitely don't want to allow frequency < radioactivity.
  7. We most probably also don't want to allow frequency*length < radioactivity*length.
  8. We may not want to allow frequency*time < radioactivity*time either.

What are your thoughts here?

mpusz commented 1 year ago

Just to provide some examples:

Quantities without a quantity type implicitly convert to a quantity of the same kind and any type (assuming the representation type also qualifies for implicit casting).

void foo(quantity<isq::width[m]> w, quantity<isq::height[m]>);

foo(42 * m, 42 * m);   // OK
foo(42 * isq::width[m], isq::height(42 * m)); // OK
foo(isq::height(42 * m), 42 * isq::width[m]); // ERROR
foo(isq::length(42 * m), 42 * isq::length[m]); // ERROR

Quantities with a quantity type do only implicitly cast "upwards".

I do not think that is good. I think it would be good to allow the following to work without any explicit casts:

quantity<isq::speed[m/s]> avg_speed(quantity<isq::length[m]>, quantity<isq::time[s]>);

avg_speed(isq::distance(42 * m), isq::duration(2 * s));

What we will still have to figure out however is how to define the kind of a product of two quantities, with or without type

The way I have implemented it is that the kind of a product of two quantities is always a product of their kinds. So both length / time and distance / duration will create a quantity of length / time kind. This works already, and I believe is correct.

The more interesting question is what should be a kind of 42 * N. Should it be mass * length / time^2, or should it be force? In the latter case, we will have to provide a quantity kind for every named SI unit, which is fine by me as we wanted to do it for Hz, Bq, and J anyway. Extending this requirement to all named derived units in the SI system seems reasonable,

mpusz commented 1 year ago

Do we want to allow energy + force*length? If we don't, how do I integrate the energy stored in an elastic material given it's stress/strain curve?

Yes, this is tricky. As force * length will be of a different kind (but the same dimension) than energy according to ISO 80000 it should not compile. To make it compile user will have to do energy + isq::energy(force * length).

An alternative approach could be the one I started at https://github.com/mpusz/units/issues/429. We could assume that kinds also form a hierarchy, and then energy + force*length will produce a quantity that is of force * length kind. Such a quantity could be converted to energy again. Implicit conversion would be allowed for 42 * J + 42 * N * (2 * m) and explicit if we write isq::energy(42 * J) + isq::force(42 * N) * isq::height(2 * m).

The same answer is for 2.

Which one do you prefer?

mpusz commented 1 year ago

3-5 are tricky indeed. ISO claims those should be not comparable if we define them as being of different kinds. Although users may be confused by the fact that they do not compile and the error message they get might not be the easiest one to understand.

I agree with 6-8.

mpusz commented 1 year ago

Another interesting point is what should we get from isq::energy(42 * J) + 42 * N * (2 * m) or 42 * J + 42 * N * isq::height(2 * m). I believe that in such cases the quantities should use their kinds as types and form a quantity with a specific type. Does it make sense?

JohelEGP commented 1 year ago

isq::path_length(42 * m)

If isq::path_length were a type, that'd be able to invoke an explicit constructor, so it's syntax I'd expect to work.

but I assume you would like to be able to write:

quantity<isq::path_length> pl = 42 * m;

or call:

void foo(quantity<isq::path_length>);

Those would require implicit conversions. As I replied at https://github.com/mpusz/units/issues/427#issuecomment-1412417240:

You could make arguments for length, which is used to construct more specific quantities of length. But a path length doesn't necessarily represent a distance. So permitting such a conversion implicitly, when it can be avoided in the type system, seems like a bad design choice.

JohelEGP commented 1 year ago

Replying to https://github.com/mpusz/units/issues/427#issuecomment-1412551747.

Some questions to consider:

For 1-4, the answer can be "explicitly cast the lhs to the rhs' type". Otherwise, it should be a question of how much verbosity we require (because those operations should be possible) and possible safety concerns in allowing those.

burnpanck commented 1 year ago

The way I have implemented it is that the kind of a product of two quantities is always a product of their kinds. So both length / time and distance / duration will create a quantity of length / time kind. This works already, and I believe is correct.

I agree. To be extra precise, this is the behaviour I propose: Dividing a quantity of "type" distance (which is of kind length) by a quantity of "type" duration (which is of kind time) results in a quantity of "type" distance*duration^-1, which is of kind length*time^-1.

Yes, this is tricky. As force * length will be of a different kind (but the same dimension) than energy according to ISO 80000 it should not compile.

There is a loophole that we could invoke: We may argue that multiplying a force by a length is not exactly the same as specifying a quantity in "newtonmetre". Thus, while the latter clearly is a quantity of ISO 80000 kind moment_of_force, there is some room of interpretation what the former product actually is. One interpretation could be exactly as you say:

An alternative approach could be the one I started at https://github.com/mpusz/units/issues/429. We could assume that kinds also form a hierarchy

This allows us to declare compatibility rules for summation of non-equivalent kinds of quantities, making use of that hierarchy. Then the product of a force and a length could be a "base" kind force*length, while both energy and moment_of_force are refined/narrower kinds. But as discussed earlier, to prevent moment_of_force from being compatible with energy due to the transitivity implied by the associativity of summation, the sum of force*length with a energy would have to produce a energy, not a force*length. So while this is not a case of conversions, the result would be the same as if we had explicitly down-casted force*length to an energy in the operation.

We have established that we want to decouple the conversion rules from the compatibility rules for addition, and furthermore that conversions shall be determined by the "quantity type" while compatibility for addition shall be determined by the "quantity kind" which do not necessarily need to overlap. Thus converting only upwards in the quantity type hierarchy and additions resulting in the most refined type in the quantity kind hierarchy is not necessarily a problem.

With that arrangement we can also make both frequency and radioactivity sub-kinds of time^-1 in the quantity kind hierarchies and we can allow all of 1-5 while still prevent 6-8. But that may be too complicated.

For 1-4, the answer can be "explicitly cast the lhs to the rhs' type". Otherwise, it should be a question of how much verbosity we require (because those operations should be possible) and possible safety concerns in allowing those.

The questions were specifically meant to be understood as "implicitly allowed" without explicit cast being required. I would rather not have to write quantity_cast in those cases, because quantity_cast is a vastly too powerful beast for this purpose (you know my feeling towards the all-powerful quantity_cast by now ;-D). But I agree that isq::energy(force*time) is clear here, and I'm fine with that. I have a feeling that making a separate hierarchy for kinds that behaves somewhat differently to the quantity type hierarchy may be confusing. So if we are happy that 1-5 requires an explicit cast, then we don't need a hierarchy of kinds as two kinds are either exactly the same, or they are incompatible.

mpusz commented 1 year ago

additions resulting in the most refined type in the quantity kind hierarchy is not necessarily a problem

In such a case, what would be the result of the width + length + height that ISO 80000 requires to work?

mpusz commented 1 year ago

As I stated in #429, the following is really confusing:

quantity<isq::frequency, int> f = 1 * Hz + 1 * Bq + 1 * Bd;
burnpanck commented 1 year ago

width + length + height is easy. All of them are of the ISO kind of length, so it actually doesn't matter if we are implementing a hierarchy of kinds or not. As for the "quantity type", we all agree that width and height are both more restricted than length. So the result is still a quantity of kind length but also "type" length, as this is the only logical type. It happens to be consistent with our conversion rules for the "quantity type* too!

mpusz commented 1 year ago

you know my feeling towards the all-powerful quantity_cast by now ;-D

I am not sure if you have seen 5.2.1 in https://github.com/mpusz/units/discussions/426? 😉 With that quantity_cast could be left only to explicitly cast quantity types and kinds.

burnpanck commented 1 year ago

Sorry, didn't have time to look at #426 yet, I'm still supposed to do some work for my company :-D.

As for the 1 * Hz + 1 * Bq case: Our "tick-all-the-boxes approach" says that this is the sum of two quantities that have no type, but of incompatible kinds frequency and radioactivity. Thus the expression should be disallowed, and there is no need to define either a resulting kind or type. The same applies to frequency + radioactivity. While both quantities do have a "type" now, they are still incomaptible kinds.

mpusz commented 1 year ago

Ahh OK. I thought that by the "additions resulting in the most refined type in the quantity kind hierarchy is not necessarily a problem" you meant that we should find a common the most refined base kind for incompatible kinds.

burnpanck commented 1 year ago

Things are different if we look at (1/(1*s)) + 1*Hz, 1/time + frequency and 1/(1*s) + frequency though. Let us first discuss the "quantity type" assuming the expressions were allowed, because that one is easier. The first expression is a sum of two quantities with no type and by assumption compatible kind, so the result still has no type, and we'll discuss the kind later. The second is the sum of a quantity of "type" time^-1 with a quantity of "type" frequency. Assuming frequency is a more refined "type" than time^-1, then the result is of the more general "type" time^-1, consistent with the width + height -> length example and our conversion rules that we defined for the "type". The third example finally would be the sum of a quantity with no type with a quantity of type frequency, for which I propose the result to be a quantity of "type" frequency (consistent with our conversion rule exception that allows no-type quantities to down-cast to any refined type).

burnpanck commented 1 year ago

So if we want to declare frequency and 1/time compatible for addition, I just presented consistent rules for the "quantity type". The challenge however is the rule for the kind which tells that these are in fact compatible, while still keeping frequency and radioactivity incompatible. We know we cannot make 1/time compatible with frequency by making them the same kind, because we should then equally do the same between 1/time and radioactivity, and then frequency and radioactivity would be transitively compatible too. So we have to make 1/time and frequency different kinds that are still compatible. This either contradicts ISO 80000 right away, unless we call up the loophole that we define a hierarchy of kinds which ISO 80000 doesn't mention. Then, we can achieve our desired behaviour by making the resulting kind of 1/time + frequency be frequency (even though we have established that the resulting "type" is time^-1). But, as you see that this can quickly become confusing, as we now have a general "type" for a refined kind, and I haven't thought it through if that is really going to work out. We may be better off to just make frequency and 1/time incompatible.

mpusz commented 1 year ago

Yeah, I have been asking similar questions to myself for a few weeks now. Thanks a lot for your help! Hopefully, together we will be able to find something reasonable.