openwebwork / pg

Problem rendering engine for WeBWorK
http://webwork.maa.org/wiki/Category:Authors
Other
45 stars 76 forks source link

Number with units in MathObject List doesn't work. #1092

Open drgrice1 opened 3 months ago

drgrice1 commented 3 months ago

Here is a MWE:

DOCUMENT();

loadMacros('PGstandard.pl', 'PGML.pl', 'parserNumberWithUnits.pl');

$ans = List(NumberWithUnits(60, 'deg'), NumberWithUnits(240, 'deg'));

BEGIN_PGML
Find all exact solutions of the following equation in the interval
[` 0^{\circ} \leq \theta < 360^\circ `].  If there is more than one answer,
enter them as a comma separated list. 

    [`  \tan(\theta) = \sqrt{3}`]

[` \theta = `] [_]{$ans}{15}
END_PGML

ENDDOCUMENT();

If MathQuill is disabled and you enter 60 deg, 240 deg, the message "'deg' is not defined in this context" is given. If Mathquill is enabled and you enter the same thing (of course the deg turns into a degree symbol), then the message "Unexpected character '°'" is given.

I found an old forum post at https://webwork.maa.org/moodle/mod/forum/discuss.php?d=3314 (that apparently was never answered) that is related.

It would be nice to be able to do this, but the MathObject parser fails when the unit is encountered.

dpvc commented 3 months ago

Yes, the NumberWithUnits has never been a first-class citizen. The handling of the units is a real kludge, as the units are stripped off before any other parsing is done, and the units are never actually parsed. That means a NumberWithUnits can't be used in a compound setting, like in a list, as the parser will choke on the units, as you have found out.

I had always hoped to get back to them and make an honest MathObject out of them, but never did. One of the difficulties at the time was that the list of units were not available within the problem (they were in one of the modules in pg/lib, if I recall correctly), so the unit names would need to be duplicated in a MathObject, and that is a maintenance issue. But I think that changes to the unit library since then have made the units available, so it would be possible to use them to define the needed units within the NumberWithUnits class.

It would be possible, then, to do things like 1 m + 3 cm, and so on. There would probably want to be contexts that allow that and other LimitedNumberWithUnits contexts that require only a number with a unit (like the other Limited* contexts).

Of course, some units have single letter names, like m, and you probably would want to avoid defining that as a unit in a situation where it wasn't needed. So one might need to specify the category of units (length, volume, force, etc.) and only have the context define the units for that category (or several categories, if needed). So it would probably take some thought.

But for now, you can't use NumberWithUnits in a list. Sorry about that!

drgrice1 commented 3 months ago

Yeah, I though that would be the answer. I posted the issue to make note of it for now at least. Perhaps you (or I) will get to it at some point and upgrade numbers with units.

dpvc commented 2 months ago

Well, your issue has had me thinking about the number-with-units problem, and I took a couple of days and worked out an implementation that I think works pretty well. I need to do some cleanup and add comments and some documentation, but the basic framework is in place. It worked out better than I thought. More details when I have some actual code to share. Just wanted to let you know that there is no need to work on this yourself at this point, in case you were thinking about that.

drgrice1 commented 2 months ago

Cool. That was not something I was going to get to anytime soon. I am looking forward to seeing what you have.

dpvc commented 2 months ago

OK, I have finished the code, and added the documentation (that took two days just by itself!).

contextUnits.pl.zip

The POD documentation is at the top, but I include it here for ease of viewing:

NAME

contextUnits.pl - Implements a MathObject class for numbers with units

DESCRIPTION

This file implements a MathObject Unit class that provides the ability to use units within computations, within lists, and so on. There are two pre-defined units contexts, but you can add units to other existing contexts, if they are compatible with units.

To load, use

loadMacros('contextUnits.pl');

and then select the Units or LimitedUnits context and enable the units that you want to use. E.g.,

Context("Units")->withUnitsFor("length");

or

Context("LimitedUnits")->withUnitsFor("angles");

For the LimitedUnits context, you are not allowed to perform any operations, like addition or multiplication, or any function calls, so can only enter a single number, unit, or number with unit.

You can include as many categories as you want, as in

Context("Units")->withUnitsFor("length", "volume");

The categories of units are the following:

angles          (fundamental units "rad")
time            (fundamental units "s")
length          (fundamental units "m", except for those in "atomics" and "astronomy" below)
metric-length   (same as length except no imperial lengths)
imperial-length (in, ft, mi, furlong, and their aliases)
volume          (fundamental units "m^3")
velocity        (fundamental units "m/s")
mass            (fundamental units "kg", except for those in "astronomy" below)
temperature     (fundamental units "defC", "defF", "K")
frequency       (fundamental units "rad/s")
force           (fundamental units "(kg m)/(s^2)")
energy          (fundamental units "(kg m^2)/(s^2)")
power           (fundamental units "(kg m^2)/(s^3)" except for those in "astronomy" below)
pressure        (fundamental units "kg/(m s^2)")
electricity     (fundamental units "amp", "amp/s", "(kg m)/(amp s^-3)", "(amp s^-3)/(kg m)",
                                   "(amp^2 s^4)/(kg m^2)", "(kg m^2)/(amp^2 s^3)", and "(amp^2 s^3)/(kg m^2)")
magnatism       (fundamental units "kg/(amp s^2)" and "(kg m)/(amp s^2)")
luminosity      (fundamental units "cd/(rad^2)" and "cd/(rad m)^2")
atomics         (amu, me, barn, a0, dalton)
radiation       (fundamental units "(m^2)/(s^2)" and "s^-1")
biochem         (fundamental units "mol" or "mol/s")
astronomy       (kpc, Mpc, solar-mass, solar-radii, solar-lum, light-year, AU, parsec)
fundamental     (m, kg, s, rad, degC, degF, K, mol, amp, cd)

You can add specific named units via the addUnits() method of the context, as in

Context("Units")->withUnitsFor("volume")->addUnits("m", "cm");

or

$context = Context("Units");
$context->addCategories("volume");
$context->addUnits("m", "cm");

to get a units context with units for volume as well as m and cm and any aliases for these units (e.g., meter, meters, etc.). Use addUnitsNotAliases() in place of addUnits() to add just the named units without adding any aliases for them.

Custom units

You can define your own units in terms of the fundamental units. E.g., to define the unit acres, you could use

Context("Units")->withUnitsFor("length")->addUnits(acres => {factor => 4046.86, m => 2});

which indicates that 1 acre is equal to 4046.86 square meters.

You can even make up your own fundamental units. For example, to define apples and oranges as units, you could do

Context("Units")->addUnits(
  apples => { apples => 1, aliases => ["apple"] },
  oranges => { oranges => 1, aliases => ["orange"] }
);

BEGIN_PGML
If you have 5 apples and give your friend 2 of them,
what do you have left? [___________]{"3 apples"}
END_PGML

and the student can answer 3 oranges but will be marked incorrect (with a message about the units being incorrect). Note that apples and apple are synonymous in this context, and that 1 apple is accepted, but is displayed as 1 apples, as no attempt is made to handle plurals.

On the other hand, you could also do

Context("Units")->addUnits(
  apples => { fruit => 1, aliases => ["apple"] },
  oranges => { fruit => 1, aliases => ["orange"] }
);

Compute("3 apples") == Compute("3 oranges"); # returns 1

will consider apple and oranges as the same unit (both are the fundamental unit of `fruit>).

Finally,

Context("Units")->addUnits(
  apples => { fruit => 1, aliases => ["apple"] },
  oranges => { fruit => 1, aliases => ["orange"], factor => 2 }
);

Compute("1 apple") == Compute("2 oranges"); # returns 1

will make an apple equivalent to two oranges by making both apples and oranges be examples of the fundamentaul unit fruit.

You can remove individual units from the context using the removeUnits() method of the context. For example

Context("Units")->withUnitsFor("length")->removeUnits("ft", "inch", "mile", "furlong");

removes the English units and their aliases, leaving only the metric units. To remove a unit without removing its aliases, use removeUnitsNotAliases() instead.

Note that the units are stored in the context as constants, so to list all the units, together with other contants, use

Context()->constants->names;

The constants that are units have the isUnit property set. So

grep {Context()->constants->get($_)->{isUnit}} (Context()->constants->names);

will get the list of units.

Adding units to other contexts

The Units and LimitedUnits contexts are based on the Numeric and LimitedNumeric contexts. You can add units to other contexts using the context::Units::extends() function. For example,

Context(context::Units::extending("Fraction")->withUnitsFor("length"));

would allow you to use fractions with units.

In addition to the name of the context to extend, you can pass options to context::Units::extending(), as in

$context = Context(context::Units::extending("LimitedFraction", limited => 1));
$context->addUnitsFor("length");

In this case, the limited => 1 option indicates that no operations are allowed between numbers with units, and since the LimitedFraction context doesn't allow operations otherwise, you will only be able to enter fractions or whole numbers, with or without units, or a unit without a number.

The available options and their defaults are

keepNegativePowers => 1,     Preserve use of negative powers so `m s^-1` will
                             not be shown as `m/s` (but will still match it).
useNegativePowers => 0       Always use negative powers instead of fractions?
limited => 0                 Don't allow operations on numbers with units.
exactUnits => 0              Require student units to exactly match correct ones
                               in both order and use of negative powers
sameUnits => 0               Require student units to match correct ones
                               not scaled versions
partialCredit => .5          Partial credit if answer is right but units

The first two and last three can also be set as context flags after the context is created. There is a limitedOperators flag that is set by the limited option that controls whether operations are allowed on numbers with units, but if you set it, you might also need to do

Context()->parens->set( '(' => { close => ')', type => 'Units' } );

to allow parentheses around units if the parentheses have been removed from the original context (as they are in the LimitedNumeric context, for instance). This makes it possible to enter units of the form kg/(m s) in such contexts.

Creating unit and number-with-unit objects

In the units contexts, units are first-class citizens, and unit and number-with-unit objects can be created just like any other MathObject. So you can use

$n = Compute("3 m/s");

to get a numer-with-units object for 3 meters per second. You can also use the word per in place of /, as in

$n = Compute("3 meters per second");

You can use the words squared and cubed with units in place of ^2 and ^3, so that

$n = Compute("3 meters per second squared");

will produce an equivalent result to

$n = Compute("3 m/s^2");

There are also square and cubic that can be used to precede a unit, such as

$n = Compute("3 square meters");

as an alternative to Compute("3 m^2").

Note that the space between the number and units is not strictly necessary, and neither is the space between units, unless the combined unit names have a different meaning. For example

$n = Compute("3m");     # instead of "3 m"
$n = Compute("3 kgm");  # instead of "3 kg m"

are both fine, but

$n = Compute("3 ms");

would treat ms as the single unit for milliseconds, rather than meter-seconds, in a context that includes both length and time units.

In order to have more than one unit in the denominator, you can either use multiple division signs (or per operations), or enclose the denominator in parentheses, as in

$n = Compute("3 kg/m/s");
$n = Compute("3 kg/(m s)");
$n = Compute("3 kg per meter per second");

Units can be preceded by formulas as well as numbers. For example

$f = Compute("2x meters");

makes $f be a Formula returning a Number-with-Unit. Note, however, that since the space before the unit has the same precedence as multiplication (just as it does within a formula), if the expression before the unit includes addition, you need to enclose it in parentheses:

$n = Compute("(1+4) meters");
$f = Compute("(1+2x) meters");

Using Compute() is not the only way to produce a number or formula with units; there are also constructor functions that are sometimes useful when writing a problem involving units.

$n = NumberWithUnits(3, "m/s");
$f = FormulaWithUnits("1+2x", "meters");

These are most useful when the numeric part is the result of a computation or a value held in a variable:

$n = NumberWithUnits(random(1,5), "m");

Since units are themselves MathObjects, you can work with units without a preceding number. These can be created through Compute() just as with other MathObject, or you can use the Unit() constructor.

$u = Compute("meters per second per second");
$u = Unit("m/s^2");

This allows you to ask a student to say what units should be used for a particular setting, without the need for a quntity.

Working with numbers with units

Because units and numbers with units are full-fledged MathObjects, you can do computations with them, just as with other MathObejcts. For example, you can do

$n = Compute("3 m + 10 cm");

to get the equivalent of 3.1 m. Similarly, you can do

$velocity = Compute("100 miles / (2 hours)");  # equals "50 mi/h"
$area = Compute("(5 m) * (3 m)");              # equals "15 m^2"

to get numbers with compound units.

As with other MathObjects, units and numbers with units can be combined using perl operations:

$distance = Compute("100 miles");
$time = Compute("2 hours");
$velocity = $distance / $time;  # equivalent to "50 miles/hour"

$m = Compute("m");
$s = Compute("s");
$a = 9.8 * $m / $s**2;

$x = Compute("x");
$f = (3 * $x**2 - 2) * $m;  # equivalent to Compute("(3x^2 - 2) m");

The units objects provide functions for converting from one set of units to another (compatible) set via the toUnits() and toBaseUnits() methods. For example:

$m = Compute("5 m");
$ft = $m->toUnits("ft");                 # returns "16.4042 ft"

$cm = Compute("5.21 m")->toUnits("cm");  # returns "521 cm"

$a = Compute("32 ft/s^2")->toBaseUnits;  # returns "9.7536 m/s^2"

For a given number with units, you may wish to obtain the numeric portion or the units portion separatly. This can be done using the number and unit methods:

$n = Compute("5 m");
$r = $m->number;         # returns 5 as a Real MathObject
$u = $m->unit;           # returns "m" as a Unit MathObject

You can also use the Real() and Unit() constructors to do the same thing:

$n = Compute("5 m");
$r = Real($m);           # returns 5 as a Real MathObject
$u = Unit($m);           # returns "m" as a Unit MathObject

You can get the numeric portion of the number-with-units object relative to the base units using the quantity method:

$q = Compute("3 ft")->quantity;    # returns .9144

Using $m->quantity is equivalent to calling $m->toBaseUnits->number.

Finally, you can get the factor by which the given units must be multiplied to obtain the quantity in the fundamental base uses using the factor method:

$f = Compute("3 ft")->factor;    # returns 0.3048

Similarly, you can use the factor method of a unit object to get the factor for that unit.

Most functions, such as sqrt() and ln(), will report an error if hey are passed a number with units (or a bare unit). Important exceptions are the trigonometric and hyperbolic functions, which accept a number with units provided the units are angular units. For example,

$v = Compute("sin(30 deg)");

will return 0.5, and so will

$a = Compute("60 deg");
$sin_a = sin($a);

as the perl functions have been overloaded to handle numbers with units when the units are anglular units.

The other exception is abs(), which can be applied to numbers with units, and returns a number with units hacing the same units, but the quantity is the absolute value of the original quantity.

Answer checking for units and numbers with units

You can use units and numbers with units within PGML or ANS() calls in the same way that you use any other MathObject. For example

BEGIN_PGML
What are the units for acceleration? [_______]{"m/sec^2"}
END_PGML

Here, the student can answer any equivalent units, such as ft/s^2 or even mi/h^2, and get full credit. If you wish to require the units to being the same as the correct answer, you can use the sameUnits option on the answer checker (ot set the sameUnits flag in the units context):

$u = Compute("m/s^2");
BEGIN_PGML
What are the metric units for acceleration? [_______]{$u->cmp(sameUnits => 1)}
END_PGML

If the student entered ft/sec^2, they would get partial credit, and a message indicating that their units are correct but are not the same as the expected units. The amount of partial credit is determined by the partialCredit answer-checkeroption (or context flag), whose default value is .5 for half credit. So you can use

$u->cmp(sameUnits => 1, partialCredit => .75)

to increase the credit to 75%, or

$u->cmp(sameUnits => 1, partialCredit => 0)

to give no partial credit.

Similarly, if the correct answer is given with units of m, then when sameUnits => 1 is set, an answer using cm instead will be given only partical credit.

In the case where the units include products of units, like m s, the sameUnits option requires both be present, but they can be in either order. So a student can enter s m and still get full credit. If you want to require the order to be the same as in the correct anser, then use the exactUnits option. Again, partial credit is given for answers that have the right units but not in the right order.

If the correct answer is m/s^2, a student usually can enter m s^-2 and their answer will be counted as correct. Similarly, if the correct answer is given as m s^-2, then m/s^2 is also marked as correct. When exactUnits => 1 is set, however, in addition to using the units in the same order, the student's answer must use the same form (either fraction or negative power) for units in the denominator, and will only get the particalCredit value for using the other form.

Answers that are numbers with units are treated in a similar manner, and can use the sameUnits, exactUnits, and partialCredit flags to control what answers are given full credit.

Note that in the Units context, students can perform operations on numbers with units, as described in the previous section. For example, if the correct answer is 3.02 m, then a student can enter 3 m + 2 cm and be marked correct. Similarly, for the answer 50 mi/h a student could enter (100 miles) / (2 hours).

If you want to prevent students from performing such computations, then set the limitedOperations flag in the context or in the cmp() call. So

$ans = Compute("50 mi/h")->cmp(limitedOperations => 1);
BEGIN_PGML
If you travel 100 miles in 2 hours, then your
average velocity is [_______]{$ans}
END_PGML

will prevent the student from dividing two numbers with units, though they can still enter (100/2) mi/h. To prevent any operations at all, use the LimitedUnits context instead of the Units context.

Note that you can add the limitedOperations and other flags to the MathObject itself, rather than the context or answer checker, as in

$av = Compute("50 mi/h")->with(limitedOperations => 1, sameUnits => 1);
BEGIN_PGML
If you travel 100 miles in 2 hours, then your
average velocity is [_______]{$av}
END_PGML

and still be able to use the result in computations in the perl code. Note that the flags will be passed on to any results involving the original that had the flags set.

dpvc commented 2 months ago

I should point out that the file included above introduces a new approach to adding functionality to an existing context without losing the old functionality. Most of the features of a context's operators, functions, etc., are implemented through setting the perl class used in their definitions. For example, to change the action of the + operator, one sets its class to a new class that is a subclass of Parser::BOP::add, and overrides the methods that need to have their behavior changed.

The problem with this approach is that you can't easily modify an arbitrary context this way, as you need to know the original class in order to subclass it. Since the original context may have already replaced the operator's class, you can't tell ahead of time which class you are going to replace, and hence must subclass.

The new approach creates subclasses on the fly, using multiple classes in the @ISA variable. The first one is a template class that implements the new behavior that overrides the original functionality, and the second class is the original one taken from the original context's definition. For example, if + was originally using Parser::BOP::add, then the Units context would create a new class like

package context::Units::1::BOP::add;
@ISA = ('context::Units::BOP::add', 'Parser::BOP::add');

(the 1 in the class name is a number that is incremented every time a new Units context is created by extending an existing one, so that different extensions will have different dynamically created subclasses, as they may need different @ISA values).

Here, context::Units::BOP::add is the template class that holds the new behavior. For example, it can override the _check() method to handle the new Unit and Numer-with-Unit objects, and pass on any other types of objects to the original _check() method from Parser::BOP::add class. Unfortunately, the new _check() can't just call SUPER::_check() to get the one from Parser::BOP::add. That's because context::Units::1::BOP::add get's _check() from the template context::Units::BOP::add class, and that class doesn't have Parser::BOP::add in its @ISA array, so SUPER::_check() in it's _check() method only sees the _check() from the parent class of context::Units::BOP::add.

The solution is that context::Units::BOP::add has a super() method that looks up the _check() method from the other class that is in the @ISA array of context::Units::1::BOP::add in order to get the proper method from Parser::BOP::add. So the overridden _check() uses &{$self->super('_check')}($self) to call the original _check() method. Not quite as easy, but not too bad, either. (You could also access other values from the original class in a similar way.)

A similar approach can be used to override the various Parser and Value object classes when replacing the values of $context->{parser}{...} or $context->{value}{...}. One would use new dynamically created classes that have in @ISA array consisting of a template class for the overridden or new methods followed by the original class in the {parser} or {variable} hash. The template would use a similar super() function to access the original class.

I think this opens up a number of possibilities for extending existing contexts. For example, the current Fraction context could be implemented as an extension of an arbitrary existing context, so that one could add fractions to the Matrix or Complex classes, or perhaps even the LimitedNumeric class. This could make things much more flexible in the long run.

drgrice1 commented 2 months ago

This all sounds very nice and promising. I like the idea of implementing the Fraction context as you suggest.

dpvc commented 2 months ago

One more thing. I'm not sure the unit categories are all the best choices, so they may need modifications. I will welcome suggestions from those who use units as to what the best choices are.

dpvc commented 2 months ago

I have made a few updates to the file I linked to above. This is the current version:

contextUnits.pl.zip

This version makes the following updates: