Closed pixelzoom closed 10 months ago
In the above commits, I added toFixedPointString.ts, with unit tests in toFixedPointStringTests.js. (I decided to put this function in its own file, since Util.js has not been converted to TypeScript.)
Function toFixedPointString
has the same API as Number.toFixed
and Util.toFixed
, but performs no math operations. It does all of its work by manipulating strings. So it does not have the rounding and floating-point problems inherent in Number.toFixed
and Utils.toFixed
.
I'm planning to use this in ScientificNotationNode
. And I'd like to discuss whether this should be a replacement for Utils.toFixed
.
From dev meeting today, we will wait to discuss with @pixelzoom and @jonathanolson.
@liam-mulhall suggested a library like math.js that may have support for handling this. @pixelzoom: Yes, we have discussed before. It could be a big change, we would have to use that any time we work with numbers. But it could be good to investigate. @zepumph: Can we use a library in specific cases instead of using broadly? @kathy-phet @pixelzoom @zepumph: Maybe we could use Math.js just in Utils.toFixed at first.
@pixelzoom: Since this is a systemic problem, should anything be done for ScientificNotationNode and build-a-nucleus? @kathy-phet: That is not necessary, if we are going to address this it should be done generally.
@pixelzoom wrote a string manipulation algorithm to get around this in https://github.com/phetsims/dot/issues/113#issuecomment-1163780635. But he found it couldn't be used for ScientificNotationNode because division was required before the string manipulation.
@zepumph: Can we change Utils.toFixed
to use this?
@pixelzoom: Worried that it is not as performant as current implementation of Utils.toFixed
.
@kathy-phet: Lets bring this up during quarterly goals discussion, this might be a good project to work on generally.
This investigation should be handled over in the epic issue in https://github.com/phetsims/dot/issues/116
This StackOverflow post refers to an MDN polyfill implementation they describe as "reliable rounding implementation". https://stackoverflow.com/questions/42109818/reliable-js-rounding-numbers-with-tofixed2-of-a-3-decimal-number.
I tested it in the browser console and it appeared to have the correct behavior for the error case above (same good behavior on Chrome and Firefox):
Math.round10(35.855, -2);
35.86
Note this returns a number instead of a string, so would still need one more step before it could be used for toFixed
. I'm not sure if it addresses the case for ScientificNotationNode or not.
Also, we would want to adapt it to run as a regular function instead of monkey patching Math.
At 7/6/2022 quarterly-planning meeting, this was assigned to me for next steps, starting with the polyfill that @samreid identified in https://github.com/phetsims/dot/issues/113#issuecomment-1175314381.
This is on @pixelzoom's list. It is unclear that it needs to be in subgroups. We are moving to done discussing. Feel free to move it back to subgroups for any reason if you'd like.
I haven't gotten to this in > 1 year. It's not a priority, and (honestly) not really something that I'm interested in. So unassigning and reverting to the "To Be Discussed" column on the Developer Meeting project board.
@pixelzoom I think the Developer Meeting project board is no longer active. I'll add this as a discussion topic for the next developer meeting and we can discuss priority of allocating resources to address it in the future.
@samreid mentioned it may be pretty straight forward when calling toFixed
to assert that it is the same as toFixedPointString and then run local fuzzing to see if there are any instances in which they differ, to understand the scope of the bugginess of Utils.toFixed()
.
We aren't really sure on the priority though.
I'm testing this patch in aqua:
I'm through the letter 'c' and so far all values are the same. I tested the value pointed out above and saw that it corrected the problem:
phet.dot.Utils.toFixed( 35.855, 2 )
> false '35.85' '35.86'
@samreid and I will discuss the discrepancies that he is finding.
Results of local aqua:
With this patch, we found a couple cases where toFixedPointString
does not behave correctly:
e
. Like acid-base-soutions:
0.0000001000
1e-7.0000000000
2.7439999999999998, 7
2.7440000
2.74399910
0.396704, 2
0.40
0.310
I'll stop being formal in formatting and put some more here:
@samreid and I worked on this this afternoon. We found multiple cases in which toFixedPointString() was not correct. We also noted the cases where the current toFixed() was incorrect.
@samreid found a polyfill on mdn that is working quite well, and is correct for all cases where the two previous implementations were wrong.
As for performance, it calls toString()
on the number a couple times, but we are not worried about that. This is especially true because toFixed() is a view-side function that most likely is not being called within an inner loop many times per frame.
A few more things worth noting:
Here is the implementation ready for commit. Instead of first committing a code change where toFixed() now runs on the new implementation. We would want to start with an assertion that they match. That way we will know that regressions haven't occurred. I also added a few TODOs for final commit.
Search the PhET code base for "trailing zeros" and you'll find numerous places where Utils.toFixedNumber
is used so that trailing zeros are removed. Conversely, there are many places where I (and others?) have used Utils.toFixed
and I'm relying on the fact that it does NOT remove trailing zeros. So please verify that the solution here does not change the behavior of these 2 functions wrt trailing zeros.
With the patch above, I have these differences between legacy and proposed:
collision-lab Uncaught Error: Assertion failed: toFixed(1.275,2), proposedResult should match legacyResult, 1.28 !== 1.27
This new value seems preferable
gravity-and-orbits Uncaught Error: Assertion failed: toFixed(4.2052273743016777e+24,9), proposedResult should match legacyResult, 4.205227374301678e+24 !== 4.2052273743016777e+24
This is in the noise and I trust the proposed algorithm to be more accurate or acceptable.
hookes-law Uncaught Error: Assertion failed: toFixed(1.7814999999999999,3), proposedResult should match legacyResult, 1.782 !== 1.781
This seems incorrect, probably due to rounding error. Requires investigation.
least-squares-regression Uncaught Error: Assertion failed: toFixed(0,1.3010299956639813), proposedResult should match legacyResult, NaN !== 0.0
This seems incorrect. Investigation reveals the new implementation doesn't support numbers beginning with
0e
, but it can be solved with using Number.parseFloat instead of +(number).
ph-scale Uncaught Error: Assertion failed: toFixed(0.00010999999999999999,21), proposedResult should match legacyResult, 0.000109999999999999977 !== 0.000109999999999999990
The legacy result seems more accurate here.
Here is the patch with Number.parseFloat
. @zepumph it still seems there are 2 undesirable results, how should we proceed?
I tried decimal.js and saw it give correct results for all unit tests. The implementation looks like:
export default function toFixedPointString( value, decimalPlaces ) {
// let time = Date.now();
let result = new Decimal( value ).toFixed( decimalPlaces, Decimal.ROUND_HALF_UP );
// Construct the string for '-0.00...' based on decimalPlaces
const negativeZero = '-0.' + '0'.repeat( decimalPlaces );
// Check for negative zero and convert it to positive zero
if ( result === negativeZero ) {
result = '0.' + '0'.repeat( decimalPlaces );
}
// const newDate = Date.now();
// console.log( 'elapsed time for toFixedPointString', newDate - time, 'ms' );
return result;
}
let t = Date.now();
for (let i=0;i<1000000;i++){
toFixedPointString( Math.random(), 2 );
}
let t2 = Date.now();
console.log( 'elapsed time for toFixedPointString', t2 - t, 'ms' );
And on my macbook air m1, it ran 1,000,000 computations in 990ms. That is 0.001ms per computation. May generate some garbage. May be slower on chromebooks or ipads.
Maybe also check big.js if we want a smaller payload.
How hard would it be to just use the big number library just for the implementation of toFixed. I'm very interested in having that at our disposal as we encounter these sorts of issues that have just already been solved a thousand times.
This patch is working nicely on my working copy:
To run it, please apply the patch and unzip big.js-6.2.1
from https://github.com/MikeMcl/big.js/releases/tag/v6.2.1 to sherpa/lib.
Also please restart the transpiler.
If we like this direction, additional facets could be:
@zepumph can you please take a look and advise? I'm also happy to check in synchronously if you prefer.
Patch from collaboration with @zepumph
@samreid and I got to a commit point today. Thanks for the help! I removed toFixedPointString
, and updated all unit tests for toFixed()
. This is the first time we have imported .mjs
with typescript support from a third party, and it is working really well. Webpack didn't complain at all, and furthermore, I like not having the global leaked into the scope. It is only in Utils
. This encapsulation is really nice to me.
Search the PhET code base for "trailing zeros" and you'll find numerous places where Utils.toFixedNumber is used so that trailing zeros are removed. Conversely, there are many places where I (and others?) have used Utils.toFixed and I'm relying on the fact that it does NOT remove trailing zeros. So please verify that the solution here does not change the behavior of these 2 functions wrt trailing zeros.
I have confirmed this with unit tests. We have cases for both:
// Respects only the non zero decimals provided
assert.equal( Utils.toFixedNumber( 1000.100, 0 ).toString(), '1000' );
assert.equal( Utils.toFixedNumber( 1000.100, 0 ), 1000 );
assert.equal( Utils.toFixedNumber( 1000.100, 1 ).toString(), '1000.1' );
assert.equal( Utils.toFixedNumber( 1000.100, 1 ), 1000.1 );
// Adds on zeros to make the right number of decimal places
assert.equal( Utils.toFixed( 29495969594939.1, 3 ), '29495969594939.100' );
assert.equal( Utils.toFixed( 29495969594939.0, 3 ), '29495969594939.000' );
assert.equal( Utils.toFixed( 29495969594939., 3 ), '29495969594939.000' );
assert.equal( Utils.toFixed( 29495969594939, 3 ), '29495969594939.000' );
assert.equal( Utils.toFixed( 29495969594939, 0 ), '29495969594939' );
I ran performance testing with Big
like so, it seems to be about 5x slower. Not sure how much it matters or if we would be able to tell. In my opinion, having consistency in this low level, important feature of phet sims is more important than performance. Furthermore, it is so so sneaky where each of the other implementations fail. It would be nice to just consider this solved. It also fits with existing PhET philosophy in which we would generally rather be complete than focus on optimization.
@samreid, what is next here? Please review the commit.
Everything in the commit looks great, thanks! Regarding performance, 1400ms is for calling 1000000 times, so it is just 0.0014ms per call and not something that should cause concern (particularly since this is not often called in an iterative model loop). My only review comment is that WebStorm is showing:
So I'm not sure why it is not a tsc error. I wonder if we just want to get rid of those large numbers?
I kinda liked having one in that space, just in case Big had an awkward implementation (or at least different) for that class of numbers. Ready to close?
Looks like serving the mjs file on sparky with nginx is resulting in this error:
Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of
"application/octet-stream". Strict MIME type checking is enforced for module scripts per HTML spec.
A google search showed how to solve it: https://kas.kim/blog/failed-to-load-module-script
Adding our first mjs
file has caused some server problems. I had to patch mime types for withServer too. I'll go look for more mime types.
I didn't find any others, because js
isn't supported in this guy:
Oh, I'll look into bayes for phettest.
In https://stackoverflow.com/questions/61797225/using-mjs-file-extension-js-modules I found a good solution that worked when I put it at the .htaccess above phettest and deployed dev/rc versions. It should work from here.
I'm seeing an assertion come in about an undefined
for decimal places:
capacitor-lab-basics : fuzz : unbuilt
http://128.138.93.172/continuous-testing/ct-snapshots/1703282283784/capacitor-lab-basics/capacitor-lab-basics_en.html?continuousTest=%7B%22test%22%3A%5B%22capacitor-lab-basics%22%2C%22fuzz%22%2C%22unbuilt%22%5D%2C%22snapshotName%22%3A%22snapshot-1703282283784%22%2C%22timestamp%22%3A1703283021947%7D&brand=phet&ea&fuzz
Query: brand=phet&ea&fuzz
Uncaught Error: Assertion failed: decimal places must be an integer: undefined
Error: Assertion failed: decimal places must be an integer: undefined
at window.assertions.assertFunction (http://128.138.93.172/continuous-testing/ct-snapshots/1703282283784/assert/js/assert.js:28:13)
at assert (Utils.js:556:14)
at toFixed (DragHandleValueNode.js:68:33)
at setValue (DragHandleValueNode.js:55:9)
at (PlateSeparationDragHandleNode.js:65:21)
at (CLBCircuitNode.js:103:42)
at (CapacitanceCircuitNode.js:22:4)
at (CapacitanceScreenView.js:38:34)
at (CapacitanceScreen.js:41:15)
at createView (Screen.ts:307:22)
[URL] http://128.138.93.172/continuous-testing/aqua/html/sim-test.html?url=..%2F..%2Fct-snapshots%2F1703282283784%2Fcapacitor-lab-basics%2Fcapacitor-lab-basics_en.html&simQueryParameters=brand%3Dphet%26ea%2
east-squares-regression : multitouch-fuzz : unbuilt
http://128.138.93.172/continuous-testing/ct-snapshots/1703283437941/least-squares-regression/least-squares-regression_en.html?continuousTest=%7B%22test%22%3A%5B%22least-squares-regression%22%2C%22multitouch-fuzz%22%2C%22unbuilt%22%5D%2C%22snapshotName%22%3A%22snapshot-1703283437941%22%2C%22timestamp%22%3A1703283816403%7D&brand=phet&ea&fuzz&fuzzPointers=2&supportsPanAndZoom=false
Query: brand=phet&ea&fuzz&fuzzPointers=2&supportsPanAndZoom=false
Uncaught Error: Assertion failed: decimal places must be an integer: 1.3010299956639813
Error: Assertion failed: decimal places must be an integer: 1.3010299956639813
at window.assertions.assertFunction (http://128.138.93.172/continuous-testing/ct-snapshots/1703283437941/assert/js/assert.js:28:13)
at assert (Utils.js:556:14)
at toFixed (GraphAxesNode.js:285:54)
at (GraphAxesNode.js:76:8)
at (LeastSquaresRegressionScreenView.js:187:22)
at listener (TinyEmitter.ts:123:8)
at emit (ReadOnlyProperty.ts:329:22)
at _notifyListeners (ReadOnlyProperty.ts:276:13)
at unguardedSet (ReadOnlyProperty.ts:260:11)
at set (Property.ts:54:10)
[URL] http://128.138.93.172/continuous-testing/aqua/html/sim-test.html?url=..%2F..%2Fct-snapshots%2F1703283437941%2Fleast-squares-regression%2Fleast-squares-regression_en.html&simQueryParameters=brand%3Dphet%26ea%26fuzz%26fuzzPointers%3D2%26supportsPanAndZoom%3Dfalse&testInfo=%7B%22test%22%3A%5B%22least-squares-regression%22%2C%22multitouch-fuzz%22%2C%22unbuilt%22%5D%2C%22snapshotName%22%3A%22snapshot-1703283437941%22%2C%22timestamp%22%3A1703283816403%7D
[NAVIGATED] http://128.138.93.172/continuous-testing/aqua/html/sim-test.html?url=..%2F..%2Fct-snapshots%2F1703283437941%2Fleast-squares-regression%2Fleast-squares-regression_en.html&simQueryParameters=brand%3Dphet%26ea%26fuzz%26fuzzPointers%3D2%26supportsPanAndZoom%3Dfalse&testInfo=%7B%22test%22%3A%5B%22least-squares-regression%22%2C%22multitouch-fuzz%22%2C%22unbuilt%22%5D%2C%22snapshotName%22%3A%22snapshot-1703283437941%22%2C%22timestamp%22%3A1703283816403%7D
[NAVIGATED] about:blank
[NAVIGATED] http://128.138.93.172/continuous-testing/ct-snapshots/1703283437941/least-squares-regression/least-squares-regression_en.html?continuousTest=%7B%22test%22%3A%5B%22least-squares-regression%22%2C%22multitouch-fuzz%22%2C%22unbuilt%22%5D%2C%22snapshotName%22%3A%22snapshot-1703283437941%22%2C%22timestamp%22%3A1703283816403%7D&brand=phet&ea&fuzz&fuzzPointers=2&supportsPanAndZoom=false
[CONSOLE] enabling assert
[CONSOLE] continuous-test-load
Ok, CT is looking clear. @samreid can you please review?
Everything seems great, nice work @zepumph. Closing.
Reopening because there is a TODO marked for this issue.
The remaining todo is in ScientificNotationNode:
if ( options.exponent !== null ) {
// M x 10^E, where E is options.exponent
//TODO https://github.com/phetsims/dot/issues/113 division here and in Utils.toFixed can result in floating-point error that affects rounding
mantissa = Utils.toFixed( value / Math.pow( 10, options.exponent ), options.mantissaDecimalPlaces );
exponent = options.exponent.toString();
}
This TODO was added in https://github.com/phetsims/scenery-phet/commit/5a0322532f89448bce100dac91578fb496a03ef4. I also commented on a related TODO in ScientificNotationNode in https://github.com/phetsims/scenery-phet/issues/613
@zepumph and I changed Utils.toFixed to use big.js, which describes itself as "A small, fast JavaScript library for arbitrary-precision decimal arithmetic.". @pixelzoom can you please review the TODOs in ScientificNotationNode and check their status? Please zoom consult with me and/or @zepumph as needed.
@pixelzoom can you please review the TODOs in ScientificNotationNode and check their status?
Can do. But it looks like you've assigned that work to me in https://github.com/phetsims/scenery-phet/issues/613. So I have adjusted the TODO and will close this issue.
Discovered while working on https://github.com/phetsims/scenery-phet/issues/747.
Utils.toFixed
was created as a workaround for problems with built-intoFixed
, which rounds differently on different browsers.Here's the current implementation:
But
Utils.toFixed
does not return the correct value in all cases. It's subject to floating-point error.For example, this should be '35.86':
This is caused by
value * multiplier
in the implementation ofUtils.toFixed
:This problem could result in widespread errors in PhET sims. It's certainly affecting ScientificNotationNode.
Do we want to do anything about this? Can we do anything about this?