mpusz / mp-units

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

feat: raise level of abstraction from dimension to structure describing a quantity #405

Closed JohelEGP closed 1 year ago

JohelEGP commented 1 year ago

401 is too big of a step.

The quantity class template takes a template argument for the quantity dimension. The quantity_kind class template slightly extends them to describe the "is a kind of" property. The same happens for quantity_point and quantity_point_kind.

These are insufficient. And I don't think extending quantity, like quantity_kind, did is the way to go. There are more properties than "is a kind of".

A suitable structure, which describes these properties, should be used in place of the dimension of quantity. For starters:

The last two are enough to also resolve #232, e.g. Fahrenheit is a factor of โตโ„โ‚‰ kelvin, and its points are also offset by 459.67 K.

mpusz commented 1 year ago

I think that might be a good idea, but we have to do some POC based on V2 first and find what suits here and what does not. Please also note that we do not have much time if we want to write the first papers for Issaquah.

mpusz commented 1 year ago

I saw your approach to defining such quantities some time ago. It was really nice and clear. However, the more I think about it the more I am concerned about use case for it.

For example, do we really want the below to compile?

static_assert(quantity_cast<diameter>(2 * radius[m]).number() == 4);

I think it would be really surprising to the users that the conversion between a radius and diameter type happens automatically. Lots of users will like to write this 2x multiplier by themselves in their codebase.

If the information you propose will not be used as above then why to provide it at all?

JohelEGP commented 1 year ago

I saw your approach to defining such quantities some time ago. It was really nice and clear. However, the more I think about it the more I am concerned about use case for it.

For example, do we really want the below to compile?

static_assert(quantity_cast<diameter>(2 * radius[m]).number() == 4);

I think we do.

I think it would be really surprising to the users that the conversion between a radius and diameter type happens automatically. Lots of users will like to write this 2x multiplier by themselves in their codebase.

Not only will they have to write 2, they'll also have to ensure the multiplier is correct, and use a different type or risk introducing bugs.

mpusz commented 1 year ago

Not only will they have to write 2, they'll also have to ensure the multiplier is correct, and use a different type or risk introducing bugs.

I am not sure what you mean here. My assumption is that:

auto r = 5 * radius[m];        // 5 m
quantity<diameter[m]> d = r;   // 10 m

Where do they need to multiply by 2?

JohelEGP commented 1 year ago

That's right. I think I misunderstood https://github.com/mpusz/units/issues/405#issuecomment-1333743705 because there's a 2 in the code and "2x" in the text, but they're different things.

mpusz commented 1 year ago

I planned to provide kinds just by deriving from dimensions. For example:

inline constexpr struct radius : decltype(isq::length) {} radius;
inline constexpr struct diameter : decltype(isq::length) {} diameter;

With that the following was not meant to compile:

auto r = 5 * radius[m];
quantity<diameter[m]> d = 2 * r;

and one of the following was meant to be needed:

constexpr quantity_of<diameter> auto to_diameter(quantity_of<radius> auto r)
{
  return quantity_cast<diameter>(2 * quantity_cast<isq::length>(r));
}
constexpr quantity_of<diameter> auto to_diameter(quantity_of<radius> auto r)
{
  return quantity_cast<diameter>(2 * r);
}

depending on how powerful we want a quantity_cast to be (only for interconvertible dimensions or also for all equivalent ones).

mpusz commented 1 year ago

But I am open to other ideas as well. We need a POC here.

JohelEGP commented 1 year ago

I'm not a fan of to_diameter. There's many quantities that are a factor or coefficient of another, like

inline constexpr auto rotation = defn<"๐˜•", "๐˜• = ๐œ‘/(2ฯ€)", rotational_displacement("๐œ‘")>();
inline constexpr auto angular_frequency    = defn<"๐œ”", "๐œ” = 2ฯ€๐˜ง", frequency("๐˜ง")>();
inline constexpr auto magnetic_polarization = defn<"๐™…โ‚˜", "๐™…โ‚˜ = ๐œ‡โ‚€๐™ˆ", magnetic_constant("๐œ‡โ‚€"), magnetization("๐™ˆ")>();
inline constexpr auto power_attenuation_coefficient        = defn<"", "๐˜ฎ = 2๐›ผ", attenuation_coefficient("")>();
inline constexpr auto rest_energy                  = defn<"", "๐˜Œโ‚€ = ๐˜ฎโ‚€๐˜คโ‚€ยฒ", rest_mass(""), speed_of_light("๐˜คโ‚€"), "๐˜Œโ‚€", energy("๐˜Œ")>();
inline constexpr quantity charge_number            = defn<"", "dim ๐˜ค = ๐˜ฒ๐˜ฆโปยน", electric_charge("๐˜ฒ"), elementary_charge("")>();
inline constexpr auto Larmor_frequency                                       = defn<"", "๐˜ท_L = ๐œ”_L/(2ฯ€)", Larmor_angular_frequency("")>();
inline constexpr auto Compton_wavelength                                     = defn<"", "๐œ†_C = ๐˜ฉ/(๐˜ฎ๐˜คโ‚€)", Planck_constant("๐˜ฉ"), rest_mass(""), speed_of_light("๐˜คโ‚€")>();
inline constexpr auto level_width                                            = defn<"", "๐›ค = โ„/๐œ", reduced_Planck_constant(""), mean_duration_of_life("")>();

So it'd be better to centralize the handling of factors, rather than having to write 2N functions for converting to and from (and probably even more at the call site because there's no swift transition, e.g. from frequency to a quantity that's a factor of angular_frequency, or having to write more than 2N functions).

depending on how powerful we want a quantity_cast to be (only for interconvertible dimensions or also for all equivalent ones).

quantity_cast should also start looking beyond equivalent dimensions if there's more information at hand. For example, to reject conversions between quantities of equal dimension but different kinds.

mpusz commented 1 year ago

I agree with you. We should definitely look into this. If we can achieve this while still having easy to understand code and compiler error messages it would be great.

mpusz commented 1 year ago

quantity_cast should also start looking beyond equivalent dimensions if there's more information at hand. For example, to reject conversions between quantities of equal dimension but different kinds.

Yes, this is why I provided two to_diamter functions. The first one assumes that quantity_cast cannot cast through different kinds (so has to go through length), and the second one assumes we gave it some superpowers ๐Ÿ˜‰ to be different from what a quantity converting constructor can do by default.

mpusz commented 1 year ago

How would you implement propagation_coefficient, which is equivalent to ฮฑ + iฮฒ where ฮฑ denotes attenuation and ฮฒ the phase coefficient of a plane wave? As long as we do have support for multiplication and division of quantities, for now, we do not support addition and irrational stuff.

JohelEGP commented 1 year ago

There might be some overlap with https://github.com/BobSteagall/wg21/issues/36.

For now, I just define it as

inline constexpr auto propagation_coefficient = defn<"๐›พ", "๐›พ = ๐›ผ + i๐›ฝ", attenuation("๐›ผ"), phase_coefficient("๐›ฝ")>();

๐›ฝ does not necessarily represent an imaginary number, so if you define i to make it so and give it an appropriate type, you don't have to reuse quantity's additive operators to allow such an expression.

mpusz commented 1 year ago

I am not sure if I follow you. How to actually construct such a quantity from the attenuation and phase coefficient and how to validate such a construction? I mean in the dimensional analysis sense here.

JohelEGP commented 1 year ago
q<attenuation> ฮฑ;
q<phase_coefficient> ฮฒ;
q<propagation_coefficient, std::complex<double>> ฮณ = ฮฑ + i * ฮฒ;

i could make the above work, but there's no standard interface for complex numbers beyond std::complex.

mpusz commented 1 year ago

I started to work on that on the V2 branch already.

mpusz commented 1 year ago

Which of the following should compile directly, which should require quantity_cast, and which should not compile at all? How to enforce that?

quantity<isq::speed[km / h]> s1 = 100 * isq::distance[km] / (2 * isq::duration[h]);        // exact match
quantity<isq::speed[km / h]> s2 = 100 * isq::length[km] / (2 * isq::time[h]);              // different kinds (more generic)
quantity<isq::speed[km / h]> s3 = 100 * isq::diameter[km] / (2 * isq::duration[h])         // different kinds (more specific)
quantity<isq::velocity[km / h]> s4 = 100 * isq::distance[km] / (2 * isq::duration[h]);     // different characteristics (scalar not vector)
quantity<isq::speed[km / h]> s5 = 100 * isq::position_vector[km] / (2 * isq::duration[h]); // different characteristics (vector not scalar)
quantity<isq::velocity[km / h]> s6 = 100 * isq::acceleration[km] * (2 * isq::duration[h]);  // exact match
quantity<isq::velocity[km / h]> s7 = 100 * isq::acceleration[km] * (2 * isq::time[h]);      // time vs duration (should they simplify in the resulting type)
quantity<isq::speed[km / h]> s8 = 100 * isq::acceleration[km] * (2 * isq::duration[h]);     // different characteristics (vector not scalar)
JohelEGP commented 1 year ago
quantity<isq::speed[km / h]> s1 = 100 * isq::distance[km] / (2 * isq::duration[h]);        // different kinds (more specific)
quantity<isq::speed[km / h]> s2 = 100 * isq::length[km] / (2 * isq::time[h]);              // exact match
quantity<isq::speed[km / h]> s3 = 100 * isq::diameter[km] / (2 * isq::duration[h])         // different kinds (more specific)

I fixed the comments. Both distance and diameter and kinds of length, so they can stand in for a length.

quantity<isq::velocity[km / h]> s4 = 100 * isq::distance[km] / (2 * isq::duration[h]);     // different characteristics (scalar not vector)

Dimensional analysis should fail. It can't be left up to the number type, as they could be convertible, like double to complex<double>. Same for scalar = vector.

quantity<isq::velocity[km / h]> s7 = 100 * isq::acceleration[km] * (2 * isq::time[h]);      // time vs duration (should they simplify in the resulting type)

They should be synonyms. For simplicity, one of them should be a reference, rather than a "strong alias".

None of these should require quantity_cast.

mpusz commented 1 year ago

I fixed the comments.

I think that speed should be defined in terms of distance, not length.

Please note that every scalar can also be a vector and a tensor. It is not the case in the reverse direction.

mpusz commented 1 year ago

They should be synonyms.

I am also not sure if time and duration should be synonyms. duration is a time difference and time can be used to define a time_point.

mpusz commented 1 year ago

I am also afraid that if most of the above are to compile then it might be hard to prevent compilation of some kinds derived from i.e. speed:

struct speed_kind1 : speed {};
struct speed_kind2 : speed {};

At least I cannot invent the algorithm to cover all the bases here.

JohelEGP commented 1 year ago

I fixed the comments.

I think that speed should be defined in terms of distance, not length.

I'm not sure. ISO just defines speed as "magnitude of the velocity". By a definition that uses "distance", I think the example in https://en.wikipedia.org/wiki/Speed#Difference_between_speed_and_velocity, paragraph 2, would also have the speed be 0, because the distance from the starting and end points are 0.

Please note that every scalar can also be a vector and a tensor. It is not the case in the reverse direction.

In general, I don't think it's so clear-cut. Force is a vector quantity that is prominently used as a scalar in quantity definitions:

inline constexpr auto normal_stress  = defn<"๐œŽโ‚™", "๐œŽโ‚™ = d๐˜โ‚™/d๐˜ˆ", force("๐˜โ‚™"), area("๐˜ˆ")>();
inline constexpr auto shear_stress   = defn<"๐œโ‚›", "๐œโ‚› = d๐˜โ‚œ/d๐˜ˆ", force("๐˜โ‚œ"), area("๐˜ˆ")>();
inline constexpr auto static_friction_factor    = defn<"๐œ‡โ‚›", "๐˜โ‚˜โ‚โ‚“ = ๐œ‡โ‚›๐˜•", static_friction_force("๐˜โ‚˜โ‚โ‚“"), force("๐˜•")>();
inline constexpr auto kinetic_friction_factor   = defn<"๐œ‡", "๐˜_ฮผ = ๐œ‡๐˜•", kinetic_friction_force("๐˜_ฮผ"), force("๐˜•")>();
inline constexpr auto rolling_resistance_factor = defn<"๐˜Šแตฃแตฃ", "๐˜ = ๐˜Šแตฃแตฃ๐˜•", force("๐˜"), force("๐˜•")>();
inline constexpr auto drag_coefficient          = defn<"๐˜Š_D", "๐˜_D = ยฝ๐˜Š_D๐œŒ๐˜ทยฒ๐˜ˆ", drag_force("๐˜_D"), mass_density("๐œŒ"), speed("๐˜ท"), area("๐˜ˆ")>();

Some of these are components of the vector, others their magnitude, IIRC. It's necessary to support these uses.

They should be synonyms.

I am also not sure if time and duration should be synonyms. duration is a time difference and time can be used to define a time_point.

That's indeed how ISO defines it. And it remarks that "duration is often just called time". And indeed, most quantity definitions outside ISO 80000-3 use "time" instead of "difference" for what's a "time difference between to events".

inline constexpr auto force                   = defn<"๐™", "dim ๐™ = ๐˜ฎ๐˜ญ๐˜ตโปยฒ", mass("๐˜ฎ"), length("๐˜ญ"), time("๐˜ต")>(); // Part 4
inline constexpr quantity energy            = defn<"๐˜Œ", "dim ๐˜Œ = ๐˜ฎ๐˜ญยฒ๐˜ตโปยฒ", mass("๐˜ฎ"), length("๐˜ญ"), time("๐˜ต")>(); // Part 5
inline constexpr auto electric_charge              = defn<"๐˜˜", "d๐˜˜ = ๐˜d๐˜ต", electric_current("๐˜"), time("๐˜ต")>(); // Part 6
inline constexpr auto radiant_flux      = defn<"๐›ทโ‚‘", "๐›ทโ‚‘ = d๐˜˜โ‚‘/d๐˜ต", radiant_energy<electromagnetism>("๐˜˜โ‚‘"), time("๐˜ต")>(); // Part 7
inline constexpr auto sound_particle_velocity     = defn<"๐™ซ", "๐™ซ = โˆ‚๐žญ/โˆ‚๐˜ต", sound_particle_displacement("๐žญ"), time("๐˜ต")>(); // Part 8
inline constexpr auto reverberation_time = defn<"๐˜›_๐˜ฏ", duration>(); // Part 9
inline constexpr auto decay_constant                                         = defn<"", "๐œ† = -(1/๐˜•)(d๐˜•/d๐˜ต)", number_of_entities<E>("๐˜•(X)"), time("๐˜ต")>(); // Part 10

I am also afraid that if most of the above are to compile then it might be hard to prevent compilation of some kinds derived from i.e. speed:

struct speed_kind1 : speed {};
struct speed_kind2 : speed {};

At least I cannot invent the algorithm to cover all the bases here.

You could allow defining kinds not by deriving.

mpusz commented 1 year ago

I'm not sure. ISO just defines speed as "magnitude of the velocity". By a definition that uses "distance", I think the example in https://en.wikipedia.org/wiki/Speed#Difference_between_speed_and_velocity, paragraph 2, would also have the speed be 0, because the distance from the starting and end points are 0.

I do not agree with that. Actually, the usage of length could mean 0. distance is defined in ISO 80000 as the "shortest path length" and path_length is specified as the length over the path, which should give a non-zero result here, right?

In general, I don't think it's so clear-cut.

Please see the tensor definition here: https://en.wikipedia.org/wiki/Physical_quantity#Size. I think that the framework should assume that scalar is also a vector and a tensor, and that a vector is also a tensor. So the same scalar representation type could be used for all three abstractions. For example, I can imagine a coordinate system where an int may be used to mean a vector (or tensor) over a default axis (i.e. the axis of the airplane). This type could also be implicitly convertible to a dedicated vector type. In such case, the magnitude would be provided by int and the "direction" would be the default one. On the other hand, any tensor or any vector value cannot always be represented as a scalar. So to summarize, I think that we are dealing with a hierarchy of abstraction here that should allow conversion in one but not the other.

most quantity definitions outside ISO 80000-3 use "time" instead of "difference" for what's a "time difference between to events"

Good point.

You could allow defining kinds not by deriving.

Deriving is just an implementation detail here. In general, I just cannot find a good algorithm that will prevent conversions between those two but will allow all the rest to work.

JohelEGP commented 1 year ago

On the other hand, any tensor or any vector value cannot always be represented as a scalar.

Of course it can't, it loses the information on direction. But you should somehow be able to represent a component or magnitude of a vector. The simplest way would be to allow replacing the number representation type of a vector quantity with a scalar.

I'm not sure. ISO just defines speed as "magnitude of the velocity". By a definition that uses "distance", I think the example in https://en.wikipedia.org/wiki/Speed#Difference_between_speed_and_velocity, paragraph 2, would also have the speed be 0, because the distance from the starting and end points are 0.

I do not agree with that. Actually, the usage of length could mean 0. distance is defined in ISO 80000 as the "shortest path length" and path_length is specified as the length over the path, which should give a non-zero result here, right?

Once you do a rotation and end up in the same starting position, the distance is 0 and the path length $2ฯ€๐˜ณ$.

You could allow defining kinds not by deriving.

Deriving is just an implementation detail here. In general, I just cannot find a good algorithm that will prevent conversions between those two but will allow all the rest to work.

I think it's simpler with values (or reflection). So rather than deriving from the speed, derive from something which makes it easy to implement (due to the lack of reflection).

mpusz commented 1 year ago

the distance is 0 and the path length 2pir.

ISO 80000 defines distance as the "shortest path length" so they should have the same value here assuming that your path is circular in your example. I would argue that actually a length could be zero in this case.

derive from something which makes it easy to implement

Again, the problem is not with inheritance but with the algorithm itself. Having all of the recipes for all the quantities I cannot find a good algorithm that covers all of the cases here.

JohelEGP commented 1 year ago

the distance is 0 and the path length 2pir.

ISO 80000 defines distance as the "shortest path length" so they should have the same value here. I would argue that actually a length could be zero in this case.

There are infinite path lengths from point A to point A, one being a rotation around the center of a circle. The shortest one from and to the same point, the distance, is 0.

derive from something which makes it easy to implement

Again, the problem is not with inheritance but with the algorithm itself. Having all of the recipes for all the quantities I cannot find a good algorithm that covers all of the cases here.

What cases?

mpusz commented 1 year ago

As long as I can somehow imagine user not being super confused by:

auto r = 5 * radius[m];        // 5 m
quantity<diameter[m]> d = r;   // 10 m

what about kinetic energy being defined as T = m * v^2 / 2. Does that mean that:

auto mass = 10 * isq::mass[kg];
auto speed = 2 * isq::speed[m/s];
auto e1 = mass * pow<2>(speed);                             // 40 J
auto e2 = quantity_cast<kinetic_energy>(e1);                // 20 J
quantity<kinetic_energy[J]> e3 = e1;                        // 20 J
quantity<kinetic_energy[J]> e4 = mass * pow<2>(speed) / 2;  // 10 J

I think that omitting the need to divide the above by 2 because it will be done automatically by the framework may be really error-prone ๐Ÿ˜ž

What do you think about it? Feedback is welcomed from everyone on this.

JohelEGP commented 1 year ago

$๐˜› = ยฝ๐˜ฎ๐˜ทยฒ$ is different. If $๐˜›$ were a function taking the variables in the expression $๐˜ฎ$ and $๐˜ท$ as parameters (which can be synthesized from the equation), that'd make sense. Notice that you already have to add that pow<2> call above. The conversion support is only for factors, defined in ISO 80000-1 A.2, when one quantity is a factor of another.

mpusz commented 1 year ago

I am not so sure if that is true. m * v^2 is a derived quantity X with the same dimension as kinetic_energy. kinetic_energy is defined as half of X so in such case 1/2 is a factor which exactly the same case as radius being 1/2 of a diameter.

In my opinion, the only difference between this and diameter/radius problem we discussed before is that this case uses derived quantities and the other base quantities. The rest is exactly the same.

JohelEGP commented 1 year ago

X can't just be any quantity of the same dimension. $m$ has to be the mass of a body and $v$ its speed. That's different from $๐˜ณ = ยฝ๐˜ฅ$, where $๐˜ฅ$ is a kind of diameter. Or more generally $๐˜˜โ‚€ = ๐–ฅ๐˜˜โ‚$, where $๐–ฅ$ is the factor and $๐˜˜โ‚$ a kind of some specific quantity. If the X was named (and thus expressed in the type system) and specified to exclude the factor ยฝ, converting to $T$ would just be $ยฝ$X. But unlike the $๐˜˜โ‚€ = ๐–ฅ๐˜˜โ‚$ I'm suggesting to support, where $๐˜˜โ‚$ is well specified, you can't generally assume X has the desired properties to convert to $T$.

mpusz commented 1 year ago

What cases?

template<QuantitySpec Q1, QuantitySpec Q2> [[nodiscard]] consteval bool interconvertible(Q1, Q2) { if constexpr (NamedQuantitySpec && NamedQuantitySpec) // check if quantity kinds are convertible (across one hierarchy) return std::derived_from<Q1, Q2> || std::derived_from<Q2, Q1>; else { // check weather the quantity spec's dimension is the same // TODO Can we improve that to account for quantity kinds (i.e. altitude / time -> sink_rate) return Q1::dimension == Q2::dimension; } }

The above is the biggest issue. A few questions:

  1. Should quantities of different characters (scalar/vector/tensor) be interconvertible? Please note that a scalar can be a vector of a default dimension, and we know that only when we have a representation type (not available here).
  2. In order to be able to validate kinds of quantities, I compare named quantities. But that is not enough. I should be also able to do the same for named and derived quantities.
  3. derived_quantity_spec expression preserves the operation that was used to define and create it. Thanks to that, we can for example, infer quantity characters. Also, it allows us to know about the kinds of derived quantities being involved in the expression. However, with that, the types forming a specific quantity from different expressions are not necessarily the same. For example: velocity = position_vector / duration and velocity can also be created by acceleration * duration. Those form two different derived_quantity_spec types that are not the same and do not inherit from each other but should be interconvertible.
  4. To support the interconvertibility of the above, I compare dimensions, but that is a really weak comparison. With dimensions, I lose all the information about the kind so width, height, position_vector are equivalent.

Do you have any ideas on how to improve that?

JohelEGP commented 1 year ago
  1. Should quantities of different characters (scalar/vector/tensor) be interconvertible?

We should allow modelling math and physics. So we need examples.

2. In order to be able to validate kinds of quantities, I compare named quantities. But that is not enough. I should be also able to do the same for named and derived quantities.

Please, clarify. Do you mean as in the code's comment, regarding sink_rate?

3. and velocity can also be created by acceleration * duration

This seems like a programming design space issue. I suppose it's about how verbose do we require the user to confirm that $๐˜ข๐˜ต$ is indeed $๐˜ท$.

mpusz commented 1 year ago

Please, clarify.

velocity derives from derived_quantiy_spec<position_vector, per<duration>> and is and should be interconvertible with it even though the derived quantity spec is not named. However, we can create velocity in another way (as specified in point 3) which will not make the result interconvertible with velocity.

Do you mean as in the code's comment, regarding sink_rate?

This is actually point 4 above.

JohelEGP commented 1 year ago

Looking at this case in isolation, you can do $๐™ซ = ๐™–๐˜ต = (๐™ซ/๐˜ตโƒฅ)๐˜ตโƒฅ = ๐™ซ$. So the problem is having a generalized algorithm, right?

mpusz commented 1 year ago

If we "unpack" acceleration to pieces, we lose some information necessary to detect interconvertibility:

So the problem is having a generalized algorithm, right?

Yes, this is exactly what I stated above with "Again, the problem is not with inheritance but with the algorithm itself. Having all of the recipes for all the quantities I cannot find a good algorithm that covers all of the cases here."

JohelEGP commented 1 year ago

For the implicit conversion target(source), each kind ๐˜’โ‚› in source should be a kind of ๐˜’โ‚œ present in target. For example,

mpusz commented 1 year ago

Yes, I agree with that. "Unpacking" for acceleration would work here. However, if there are derived quantities that are constructed the same way but then are branched to different kinds then "unpacking" will not work. For example drag_coefficient depends on drag_force that is a kind of force. If I "unpack" drag_force to ingredients than I will not know if it is a drag_force or a different kind of force anymore. This is why I could not find the best way to follow here. Any ideas here are welcomed.

JohelEGP commented 1 year ago

Peek into the makeup of the quantity without unpacking.

mpusz commented 1 year ago

I am not sure if it will work for all cases either. With this I will be able to create drag_coefficient from any force and not only drag_force.

JohelEGP commented 1 year ago

Why? If the force in the source type is not a kind of drag force, it should fail.

mpusz commented 1 year ago

Because you suggested that if quantity is not convertible I should also peek into its makeup to check again. So instead just comparing if we have drag_force I will also check if force was created in a correct way which is the same as the drag_force. I hope I am missing something here. It is hard to talk unless we can share godbolt examples. I hope to be able to merge the branch to the mainline soon. But there is always something that prevents me from doing that and requires more work...

BTW, I still need to make the branch to work on other compilers.

burnpanck commented 1 year ago

I think the convertibility of "kinds" could be formalised to follow the same rules of dimensional analysis, where kind-specialisations introduce a new dimension for dimensional analysis. Breaking up the kind into it's constituent dimensions (or more general kinds) is only allowed at the source side (up-casting/widening is allowed, down-casting/narrowing isn't). Thus, anything defined in terms of drag_force needs to be made up from an atomic drag_force, but never a plain force. Breaking up a drag_force into it's parts is considered a widening, so assigning a drag_force / acceleration to a mass is acceptable, but mass * acceleration can only be assigned to a plain force, but not a drag_force. Of course, that implies that drag_force / force should not be simplified to a dimensionless quantity within an expression, as it is convertible to a dimensionless quantity but not equivalent: (drag_force / force) * mass * acceleration should probably remain a drag_force. This, in turn would require us to be careful to distinguish "plain" dimensions from "kinds"/narrowed dimensions, otherwise force would be considered a narrowing from it's constituent dimensions $ML/T^2$, and wouldn't be convertible from `mass acceleration`.

burnpanck commented 1 year ago

As for the other cause of trouble, the scalar/vector/tensor character, there may be a way to follow the same logic, in that conversion from vector->scalar may be allowed, but not the other way around. However, I have to admit that I rarely distinguish the two concepts, so I don't know if that is really helping here.

JohelEGP commented 1 year ago

I think the convertibility of "kinds" could be formalised to follow the same rules of dimensional analysis, where kind-specialisations introduce a new dimension for dimensional analysis.

But a dimension is "a product of powers of factors corresponding to the base quantities". So the only way to extend dimensional analysis would be by adding base quantities. I agree with your safety improvements, but it shouldn't affect the dimensional analysis.

burnpanck commented 1 year ago

I definitely do not want to change the concept of dimension or the dimensional analysis, or add more base quantities. Instead I was proposing some sort of "kind analysis", which is a refinement of dimensional analysis (i.e. matching kinds should imply matching dimensions). With that, I believe we can solve the problem of the "generalised algorithm" to do the matching:

However, maybe I misunderstood what was the actual discussion/problem here. Before the drag_force example, you have been discussing diameter vs. radius and kinetic_energy vs. $M L^2 / T^2$. To allow automatic conversion between diameter and radius, those two would have to be quantity specifications of the same "kind", but a different factor. On the other hand, no automatic conversion would occur between quantities matching the dimension of energy, and quantities specified to be of the kind kinetic_energy. So contrary to the comment above by @mpusz, the difference would not be base vs. derived quantities, but matching "kinds":

In my opinion, the only difference between this and diameter/radius problem we discussed before is that this case uses derived quantities and the other base quantities. The rest is exactly the same.

That said, I do question the value of automatic conversion between radius and diameter somewhat, given the risks of unexpected surprises.

JohelEGP commented 1 year ago

(i.e. matching kinds should imply matching dimensions)

That's a truth.

NOTE 2 Quantities of the same kind within a given system of quantities have the same quantity dimension. [...] -- https://jcgm.bipm.org/vim/en/1.2.html

To allow automatic conversion between diameter and radius, those two would have to be quantity specifications of the same "kind", but a different factor.

Indeed. And additionally, one is a factor of the other.

That said, I do question the value of automatic conversion between radius and diameter somewhat, given the risks of unexpected surprises.

That isn't too different from today's master branch. Kilometres and metres are interconvertible. Although those are prefixes, and not different quantities or a factor between them.

burnpanck commented 1 year ago

That said, I do question the value of automatic conversion between radius and diameter somewhat, given the risks of unexpected surprises.

That isn't too different from today's master branch. Kilometres and metres are interconvertible. Although those are prefixes, and not different quantities or a factor between them.

I claim that only semantically equivalent objects should be interconvertible, but what exactly we consider equivalent is maybe a bit subjective. We probably agree that "1 km" and "1000 m" describe the same physical object, a quantity of dimension length. Do a "radius of 1 m" and a "diameter of 2 m" describe the same physical object? Maybe. As properties of a circle, they certainly describe the same circle (but is a "radius" equivalent to a "circle"?). If a "radius of 1 m" is equivalent to a "diameter of 2 m", then it should definitely not also describe a physical length of 1 m (otherwise it would be transitively equivalent to a "diameter of 1 m"). I think preferring equivalence between radius and diameter over radius and length isn't out of the question, but also isn't that obvious.

When there is a risk of subjective misinterpretation, I tend to prefer to err on the cautious side in accordance with the principle of least surprise. Maybe a radius should neither be considered fully equivalent to a diameter nor a general physical length - instead both of those could require an explicit cast.

We may potentially draw inspiration from the quantities framework within astropy, another python library that I have worked with in the past. The have a concept of equivalencies: Conversion between quantities that are considered equivalent under some circumstances only is opt-in. That said, they do not have a concept of quantity character apart from the dimension. The standard example is conversion between vacuum wavelength and frequency of light. In particular, their dimensionless_angles() equivalency does not correctly apply the $2 \pi$ factor to convert between angular frequency $\omega$ and frequency $f$ in Hz. It may be instructing to try to make that work, independent of if the equivalency is implicit or explicit.

JohelEGP commented 1 year ago

Do a "radius of 1 m" and a "diameter of 2 m" describe the same physical object? Maybe. As properties of a circle, they certainly describe the same circle (but is a "radius" equivalent to a "circle"?). If a "radius of 1 m" is equivalent to a "diameter of 2 m", then it should definitely not also describe a physical length of 1 m (otherwise it would be transitively equivalent to a "diameter of 1 m").

Have we discussed this before? The example feels pretty familiar.

There should be no transitivity involved. I admit that something feels off. Perhaps it's because a "quantity kind" is not precisely defined. As a library which provides safety over ints among other conveniences (one of them interconvertibility between these two quantities), we're trying to conveniently define "quantity kind".

If we look at their definitions, I think that a "radius of 1 m" equals a "diameter of 2 m", although their length is not the same. Here are the definitions of these quantities from ISO 80000-3:

Item No. Name Definition
3-1.1 length linear extent in space between any two points
3-1.2 width minimum length of a straight line segment between two parallel straight lines (in two dimensions) or planes (in three dimensions) that enclose a given geometrical shape
3-1.5 diameter width (item 3โ€‘1.2) of a circle, cylinder or sphere
3-1.6 radius half of a diameter (item 3โ€‘1.5)

So the library's interpretation of "quantity kind" shouldn't allow a radius to equal two different lengths due to its connection to diameter. As for the length of a "radius of 1 m" not being equal to the length of a "diameter of 2 m", of course not. The linear extent of the circle's radius and diameter are not equal.

JohelEGP commented 1 year ago

When there is a risk of subjective misinterpretation, I tend to prefer to err on the cautious side in accordance with the principle of least surprise.

I agree. There's no escaping subjective misinterpretation. But the case at hand is systematically well specified, so there's a single objective interpretation.

mpusz commented 1 year ago

up-casting/widening is allowed, down-casting/narrowing isn't

This is where I started, but it turned out heavily not efficient on the interface side then we got rid of the downcasting facility. Let's see the following code:

void foo1(quantity<isq::speed[m/s], int> s);
void foo2(quantity_of<isq::speed> s);

auto q1 = isq::length[m](1) / isq::time[s](1);   // derived_quantity_spec<isq::length, per<isq::time>>    (not isq::speed)
quantity<isq::speed[m/s], int> q2 = isq::length[m](1) / isq::time[s](1);   // compile-time error
auto q3 = q1 + isq::speed[m / s](2);  // derived_quantity_spec<isq::length, per<isq::time>>    (not isq::speed)

foo1(q1);   // compile-time error
foo2(q1);   // compile-time error

Forcing the user to put quantity_cast everywhere to downcast to a specific type/kind is counterproductive, and a lot of people will complain about such interfaces. This is why at some point, I introduced the opposite, which allowed the programmer to be more efficient:

void foo1(quantity<isq::speed[m/s], int> s);
void foo2(quantity_of<isq::speed> s);
void foo3(weak_quantity_of<isq::speed> s);

auto q1 = isq::length[m](1) / isq::time[s](1);   // derived_quantity_spec<isq::length, per<isq::time>>    (not isq::speed)
quantity<isq::speed[m/s], int> q2 = isq::length[m](1) / isq::time[s](1);   // OK, implicit conversion on initialization
auto q3 = q1 + isq::speed[m / s](2);  // isq::speed (consistent with the implicit conversion above)

foo1(q1);   // OK
foo2(q1);   // compile-time error
foo3(q1);   // OK

If you have any ideas on how to improve the above, please let me know. Otherwise, I am afraid that we cannot forbid some of the conversions you mentioned because the library will turn out unusable. Believe me, I tried that already...

burnpanck commented 1 year ago

There should be no transitivity involved.

Equivalence should definitely be transitive (see equivalence relation) and so is "the equality" (see equality(mathematics); relation with equivalence). There may be multiple equivalence relations, but "equality" is the "finest" of all of them, i.e. equality implies equivalence in "all equivalence relations" on a set. Thus a "radius of 1m" should probably not be considered "equal" to a "diameter of 2m", but potentially equivalent, at least under a certain equivalence relation. We are encountering two relevant but different equivalence relations here

Whenever we convert between equivalent quantities, it should be very clear, which equivalence relation is being followed. If we have an explicit C++ cast, the keyword used to name the cast may be used to disambiguate. But implicit casts and explicit constructor calls should only do one of the two, and I would prefer if it would be "vertical" in the kind hierarchy.