Open drgrice1 opened 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!
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.
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.
Cool. That was not something I was going to get to anytime soon. I am looking forward to seeing what you have.
OK, I have finished the code, and added the documentation (that took two days just by itself!).
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 inContext("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
andcm
and any aliases for these units (e.g.,meter
,meters
, etc.). UseaddUnitsNotAliases()
in place ofaddUnits()
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 useContext("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
andoranges
as units, you could doContext("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 thatapples
andapple
are synonymous in this context, and that1 apple
is accepted, but is displayed as1 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
andoranges
be examples of the fundamentaul unitfruit
.You can remove individual units from the context using the
removeUnits()
method of the context. For exampleContext("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. Sogrep {Context()->constants->get($_)->{isUnit}} (Context()->constants->names);
will get the list of units.
Adding units to other contexts
The
Units
andLimitedUnits
contexts are based on theNumeric
andLimitedNumeric
contexts. You can add units to other contexts using thecontext::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 theLimitedFraction
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 thelimited
option that controls whether operations are allowed on numbers with units, but if you set it, you might also need to doContext()->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 formkg/(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
andcubed
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
andcubic
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 theUnit()
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()
andtoBaseUnits()
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
andunit
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()
andUnit()
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()
andln()
, 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 exampleBEGIN_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 evenmi/h^2
, and get full credit. If you wish to require the units to being the same as the correct answer, you can use thesameUnits
option on the answer checker (ot set thesameUnits
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 thepartialCredit
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 whensameUnits => 1
is set, an answer usingcm
instead will be given only partical credit.In the case where the units include products of units, like
m s
, thesameUnits
option requires both be present, but they can be in either order. So a student can enters m
and still get full credit. If you want to require the order to be the same as in the correct anser, then use theexactUnits
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 enterm s^-2
and their answer will be counted as correct. Similarly, if the correct answer is given asm s^-2
, thenm/s^2
is also marked as correct. WhenexactUnits => 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 theparticalCredit
value for using the other form.Answers that are numbers with units are treated in a similar manner, and can use the
sameUnits
,exactUnits
, andpartialCredit
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 is3.02 m
, then a student can enter3 m + 2 cm
and be marked correct. Similarly, for the answer50 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 thecmp()
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 theLimitedUnits
context instead of theUnits
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.
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.
This all sounds very nice and promising. I like the idea of implementing the Fraction
context as you suggest.
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.
I have made a few updates to the file I linked to above. This is the current version:
This version makes the following updates:
factorUnits
option that factors units out of addition and subtraction if they are the same (this increases the efficiency of using formulas with units).->unit
and ->number
on formulas with units, not just numbers with units.Unit()
and Real()
to be called on formulas with units.super()
method works for dynamically created classes, making it more generic.
Here is a MWE:
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.