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

Nested quantity kinds #656

Closed
mpusz opened this issue Dec 10, 2024 · 14 comments
Closed

Nested quantity kinds #656

mpusz opened this issue Dec 10, 2024 · 14 comments
Labels
design Design-related discussion help wanted Extra attention is needed question Further information is requested
Milestone

Comments

@mpusz
Copy link
Owner

mpusz commented Dec 10, 2024

We need to define the best possible logic for nested quantity kinds. The library's results should be correct and not surprise anyone.

For our analysis, let's use two simplified trees of length and dimensionless quantities:

flowchart TD
    length["<b>length</b><br>[m]"]
    length --- height["<b>height</b>"]
    length --- width["<b>width</b>"]
    dimensionless["<b>dimensionless</b><br>[one]"]
    dimensionless --- angular_measure["<b>angular_measure</b><br>[rad, one]"]
    dimensionless --- solid_angular_measure["<b>solid_angular_measure</b><br>[sr, one]"]
Loading

Before we dig into details, let's mention that the discussion below relates to the above trees. If a user wants to model any of the above quantities differently to provide more or less safety, it is OK and possible, but it is not the subject of the discussion below. Let's scope on how the code should work when the trees are defined as above.

We model the above dimensionless quantities this way because ISQ explicitly states that angular measure should be allowed to be measured in both one and rad. The same is true for solid angular measure with the unit sr. This is why we nest their kind trees in the tree of dimensionless quantities. They are defined with a special is_kind tag in the definition:

inline constexpr struct angular_measure final       : quantity_spec<dimensionless, arc_length / radius, is_kind> {} angular_measure;
inline constexpr struct solid_angular_measure final : quantity_spec<dimensionless, area / pow<2>(radius), is_kind> {} solid_angular_measure;

This allows us not only to state that the trees nest but also to assign unique units for them:

inline constexpr struct radian final    : named_unit<"rad", metre / metre, kind_of<isq::angular_measure>> {} radian;
inline constexpr struct steradian final : named_unit<"sr", square(metre) / square(metre), kind_of<isq::solid_angular_measure>> {} steradian;

As a reminder, according to ISQ, it should be possible to compare, add, and compare quantities of the same kind. Additionally, in mp-units, when we just spell a unit, we say that we mean any quantity of its kind.

For lengths it is simple to reason about:

auto height = isq::height(1 * m);
auto width = isq::width(1 * m);
auto q1 = height + 1 * m;              // isq::height(2 * m)
auto q2 = height + isq::length(1 * m); // isq::length(2 * m)
auto q3 = height + width;              // isq::length(2 * m)

We can also compare q1 to:

bool cmp1 = (q1 == 2 * m);
bool cmp2 = (q1 == isq::length(2 * m));
bool cmp3 = (q1 == isq::width(2 * m));

However, it gets more interesting when we have several subkinds in our tree:

auto angle = isq::angular_measure(1 * rad);
auto solid_angle = isq::solid_angular_measure(1 * sr);
auto q4 = angle + 1 * one;                         // isq::angular_measure(2 * rad)
auto q5 = 1 * rad + 1 * one;                       // 2 * rad
auto q6 = angle + dimensionless(1 * one);          // ???
auto q7 = 1 * rad + dimensionless(1 * one);        // ???
auto q8 = angle.in(one) + dimensionless(1 * one);  // ???
auto q9 = angle + solid_angle;                     // ???
auto q10 = angle.in(one) + solid_angle.in(one);    // ???
auto q11 = 1 * rad + 1 * sr;                       // ???

The q4 and q5 cases are fairly easy. Adding angular measure and a quantity that behaves as any quantity of the dimensionless tree results in angular measure. Please also note that a unit of one is promoted to rad here to provide a more user-friendly result.

Should q6 - q8 compile? What should be the result? We could end up with a dimensionless quantity. This would force angular measure to go outside of its kind tree. Also, the unit could not be rad, which would conflict with unit promotion in q4 and q5. What is the physical sense of such an equation anyway? Maybe it should not compile? Maybe in order to make such things compile, a user would need to explicitly convert angular measure to dimensionless and then continue with arithmetics?

q9 and q11 are also interesting. Should they not compile or just result in a dimensionless quantity?

And what about comparisons? Let's see the following options:

bool cmp4 = (q4 == 2 * rad);                             // OK
bool cmp5 = (q4 == isq::angular_measure(2 * rad));       // OK
bool cmp6 = (q4 == isq::angular_measure(2 * one));       // OK
bool cmp7 = (q4 == 2 * one);                             // OK
bool cmp8 = (q4 == dimensionless(2 * one));              // ???
bool cmp9 = (q4 == isq::solid_angular_measure(2 * sr));  // Compile-time error?
bool cmp10 = (q4 == 2 * sr);                             // Compile-time error?

cmp4 - cmp7 are probably reasonable. What about the rest?

What about conversion rules? We know that every height is length and not every length is height. Also, height is never a width but both are lengths. This is why we have the following logic:

static_assert(implicitly_convertible(isq::height, isq::length));
static_assert(explicitly_convertible(isq::length, isq::height));
static_assert(castable(isq::width, isq::height));

What about nested kinds? How should those behave?

  1. Should we allow implicit conversion from angular measure to dimensionless or should it be explicit to make it straight that we are going outside of its tree?
  2. Explicit conversion from dimensionless to angular measure is probably fine.
  3. We probably should not be allowed to convert from solid angular measure to angular measure even with a cast.

Let's discuss those cases. Ideally, the conversions, comparisons, and additions should be consistent with each other and not surprising to our users.

@mpusz mpusz added help wanted Extra attention is needed question Further information is requested design Design-related discussion labels Dec 10, 2024
@mpusz
Copy link
Owner Author

mpusz commented Dec 10, 2024

Regarding mixing two subkinds, my personal opinion is that q9 - q11 and cmp9 - cmp10 should not compile (even with an explicit quantity_cast) as this requires an operation on two separate kinds (even though they also share a common dimensionless kind).

If someone really wants to add or compare them, they should first be converted to dimensionless and then added or compared. For example:

auto q = dimensionless(angle.in(one)) + dimensionless(solid_angle.in(one));

Please upvote this comment if you agree with the above.

@mpusz
Copy link
Owner Author

mpusz commented Dec 11, 2024

Answering the remaining questions is much harder as an angular measure belongs to both dimensionless and angular measure kinds.

Counts and ratios are dimensionless, and they should be convertible to the root of the hierarchy. It should also be possible to add and compare them.

Technically, an angular measure is also defined in terms of a ratio (arc_length / radius), but in my opinion, it is not just a ratio. It has its own unit, expresses angles, and has its own subtree of its kind. Moreover, some scientists have argued for years to make it a strong dimension and a base quantity in ISQ.

Let's assume that for our analysis, a dimensionless quantity we use is a count of cows. Should it really be possible to add a count of cows and an angle without any explicit annotations?

There are two options here:

  1. We can provide usual conversions and upcasting rules. We can say that every angular measure is a dimensionless quantity, so it can always be implicitly converted to it. Also, adding dimensionless and angular measure should result in dimensionless. This, however, has a few issues:

    • Does adding a count of cows to an angle make sense?
    • This may mean that one + rad should not be rad but one. This also means that isq::angular_measure(1 * rad) + 1 will result in isq::angular_measure[one].
    • This also puts a question mark next to the statements from the previous comment. If angular measure and solid angular measure are implicitly convertible to dimensionless, then why they can't be added to each other?
  2. angular measure kind (a subkind) could be considered having a priority here. This means that upcasting to the root of its hierarchy is implicit, but going beyond it (from the root of angular measure kind outside to dimensionless quantities) requires an explicit cast. This would:

    • Preserve one + rad -> rad
    • Provide a proper reasoning for the statements from the previous comments (angular measure and solid angular measure should not compile without a cast)
    • Result with q6 - q8 and cmp8 not compiling without a cast.

I personally prefer option 2.

@Spammed
Copy link

Spammed commented Dec 11, 2024

It doesn't feel good for me if auto q5 = 1 * rad + 1 * one; compiles without cast.
Which, apparently desired, use case am I overlooking here that would otherwise not work?

@mpusz
Copy link
Owner Author

mpusz commented Dec 11, 2024

ISO 80000 explicitly states that both one and rad are valid units for angular measure. If this was not the case, then angular measure would probably not be a dimensionless quantity, and we would not have this issue 😉

The same applies to auto q = 1 * rad + 1;.

I just fixed the graph in the first post to make it more explicit.

@Spammed
Copy link

Spammed commented Dec 11, 2024

Are there any other arguments besides the, admittedly strong, 'ISO 80000 compliant' argument?

@mpusz
Copy link
Owner Author

mpusz commented Dec 11, 2024

This is how SI works as well:

Plane and solid angles, when expressed in radians and steradians respectively, are in effect also treated within the SI as quantities with the unit one. ... However, it is a long-established practice in mathematics and across all areas of science to make use of rad = 1 and sr = 1.

rad is an alias to one as it is defined as rad = m/m. As it is equivalent to one, it disappears in many derived units. For example, as stated in https://mpusz.github.io/mp-units/latest/users_guide/systems/strong_angular_system, a proper unit for angular momentum should be J s / rad instead of just J s.

@Spammed
Copy link

Spammed commented Dec 12, 2024

Currently I think I would like 180° - pi() and 90° == pi()/2 just works.
But I also think that pi() should be dimensionless
and that e.g. sin() can be called with a dimensionless and/or/incl. an angle quantity.
(I don't know whether it should be allowed to call sin() with a solid angle argument).

That means I should accept 1 rad + 1 == 2 after all, right?

@mpusz
Copy link
Owner Author

mpusz commented Dec 12, 2024

The following works already today:

using std::numbers::pi;
constexpr quantity q1 = 180 * deg + pi * one;
constexpr quantity q2 = isq::angular_measure(180 * deg) + pi * rad;
constexpr quantity q3 = (180. * deg).in(one) + pi;

I am not trying to change it here. What I am trying to prevent here are:

constexpr quantity q4 = 180 * deg + dimensionless(pi * one);
constexpr quantity q5 = 180 * deg + number_of_cows(pi * one);

@rothmichaels
Copy link
Contributor

I personally prefer option 2.

I think I also prefer option 2

@rothmichaels
Copy link
Contributor

Expanding on @mpusz 's example of counting cows there are a number of cases of developing custom dimensionless quantity for digital audio. For example to describe time expressed in digital samples or a digital sampling rate it is necessary to define a quantity for a count of digital samples:

inline constexpr struct sample_count final : quantity_spec<dimensionless, is_kind> {} sample_count;
inline constexpr struct sample_duration final : quantity_spec<isq::time> {} sample_duration;
inline constexpr struct sample_rate final : quantity_spec<isq::frequency, sample_count / isq::time> {} sample_rate;

inline constexpr struct sample final : named_unit<"Smpl", one, kind_of<sample_count>> {} sample;

inline constexpr auto smpl = sample;

Another digital audio example of counting is time expressed in musical note durations and beats-per-minute requires a counting a number of musical beats:

inline constexpr struct beat_count final : quantity_spec<dimensionless, is_kind> {} beat_count;
inline constexpr struct beat_duration final : quantity_spec<isq::time> {} beat_duration;
inline constexpr struct tempo final : quantity_spect<isq::frequency, beat_count / isq::time> {} tempo;

inline constexpr struct beat : named_unit<"beat", one, kind_of<beat_count>> {} beat;
inline constexpr struct quarter_note final : named_unit<"q", one, kind_of<beat_duration>> {} quarter_note;
inline constexpr struct beats_per_minute final : named_unit<"bpm", quarter_note / non_si::minute> {} beats_per_minute;

I agree with @mpusz on what should not compile for adding or comparing sub-kinds of dimensionless both because these separate counts should not be compatible quantities and we would lose type safety and understandability if they operations on a beat_count would result in a dimensionless quantity.

Variations on the examples of above with these audio examples:

auto beats = beat_count(1 * beat);
auto samples = sample_count(1 * smpl);

auto q4 = beats + 1 * one; // beat_count(2 * beat)

// might be okay if these examples compile but it does seem a little confusing what the meaning or purpose
// if it does compile the result should probably be a beat_count quantity
// using a cast would be more clear so "option 2" above making this not compile is okay
auto q6 = beats + dimensionless(1 * one);

// might be okay if these examples compile but it does seem a little confusing what the meaning or purpose
// if it does compile the result should probably be a raw unit of smpl
// using a cast would be more clear so "option 2" above making this not compile is okay
auto q7 = 1 * smpl + dimensionless(1 * one);

// I'm not sure this is useful to allow this without a cast
// using a cast would be more clear so "option 2" above making this not compile is okay
auto q8 = beats.in(one) + dimensionless(1 * one);

// it should not be possible to add unrelated counts; it would not make sense to add a count of cows either
auto q9 = beats + samples;

// I'm not sure this is useful to allow this without a cast
// using a cast would be more clear so "option 2" above making this not compile is okay
auto q10 = beats.in(one) + samples.in(one);

// just like q9 this also does not make sense
auto q11 = 1 * beat + 1 * smpl;

// I'm not sure this is useful to allow this without a cast
// using a cast would be more clear so "option 2" above making this not compile is okay
bool cmp8 = (q4 == dimensionless(2 * one));

// just like q9 this also does not make sense
bool cmp9 = (q4 == sample_count(2 * smpl));

// just like q9 this also does not make sense
bool cmp10 = (q4 == 2 * smpl);

@rothmichaels
Copy link
Contributor

Is dimensionless our only example that has nested quantity kinds? Am I correct that our solution would work for any nested quantities even for custom dimensions and this is not special casing for dimensionless?

@mpusz
Copy link
Owner Author

mpusz commented Dec 12, 2024

Yes, it is a generic solution that is not specific to dimensionless quantities. Although dimensionless quantities may have the most use cases for it.

There might be other cases as well. We planned to use is_kind for apparent power (measured in VA) and reactive power (measured in var) as well, but in the end, we will probably model them as separate trees. It is still in my backlog.

@mpusz
Copy link
Owner Author

mpusz commented Dec 23, 2024

I have nearly finished implementing this. The library framework is much simpler now. Many concepts could be simplified (e.g., UnitOf and QuantitySpecOf). Those never allowed the use of one as a unit of angular measure, but did not allow the use of rad as a unit of dimensionless (as not every dimensionless quantity is an angle and rad is reserved for angles). With the new design, their implementation is much shorter.

The only side effect I saw so far that is worth mentioning happened for geographic.h. spherical_distance() does a quantity_cast:

return quantity_cast<isq::distance>(earth_radius * central_angle);

This does not work because it says that the unit of km * rad is not compatible with the requested quantity isq::distance. We no longer allow measuring dimensionless in rad, and isq::angular_measure (the associated quantity of rad) is no longer implicitly convertible to dimensionless.

The solution for it is:

return quantity_cast<isq::distance>((earth_radius * central_angle).in(earth_radius.unit));

where we explicitly remove the rad part and convert the unit to just km. Then, we perform a quantity_cast on the remaining quantity.

Please let me know your thoughts, especially if you think that it is an issue (SI typically treats angles as dimensionless in derived quantities and their units). I personally think that it adds a bit more safety than SI does daily. isq::distance quantity does not mention any angular component to rad is not allowed for it.

@mpusz mpusz added this to the v2.5.0 milestone Dec 30, 2024
@mpusz
Copy link
Owner Author

mpusz commented Dec 30, 2024

Resolved with 06cbfae

@mpusz mpusz closed this as completed Dec 30, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
design Design-related discussion help wanted Extra attention is needed question Further information is requested
Projects
None yet
Development

No branches or pull requests

3 participants