The goal of this project is to build a Python based handheld and battery powered scientific calculator the size of a cigarette box (well, pocket calculator). Scientific, as in "reasonable precision". Float32 (single precision) is certainly acceptable for a display precision of, say 6 or 7 digits, but the follow-up rounding errors are not - at least not for me. I experimented with Decimal math before but ended up having to fight memory constraints with jepler-udecimal (https://github.com/jepler/Jepler_CircuitPython_udecimal) and my own extensions even on a Feather RP2040 with 256kB of user RAM. So I eventually decided to make a custom CircuitPython build and try to enable float64 (double precision) math. (Thanks to the Adafruit folks for their help!). Needless to say that float64 is entirely handled in C without the help of a potential floating-point unit (FPU) but then this approach is still much more CPU and memory efficient than implementing everything in Python.
The code for this project is on https://github.com/h-milz/circuitpython-calculator/ and will be discussed in this article.
The hardware is a Keyboard Featherwing V2 (https://www.solder.party/docs/keyboard-featherwing/rev2/) from arturo182 (https://github.com/arturo182), an Adafruit Feather M4 Express (https://www.adafruit.com/product/3857) and a 2000mAh LiPo battery. The provided diff is against CircuitPython 9.0.0 - the build script automates the build process, and sits on top of https://learn.adafruit.com/building-circuitpython/build-circuitpython. If you want to build a similar project with float64 math enabled, you should be familiar with building custom images, patches, diffs and other low-level stuff. On the other hand, most math stuff is likely to work with float32 (single precision) math as well, but it's less fun I suppose.
The terminal window displays a couple of features of this tiny machine, from top to bottom: calculating pi using arctan(1), the arsinh of a complex argument, multiplying two numbers with uncertainties, and multiplying two fractions including reducing it to lowest (positive) denominator.
The dongle on the lower right is a PCF8523 RTC hanging off the Keyboard Featherwing's STEMMA Qt connector (which, sadly, is unusable if you want a handheld with a back cover ... What did arturo182 think?)
On the backside you can see the Adafruit Feather M4 Express and the LiPo battery, which is fixed to the Featherwing using a double-sided adhesive foam strip.
Removing the Feather M4 Express reveals some solder hacking on the Featherwing. The small copper wire connecting the keyboard interrupt line to pin 12 is not required by Arturo's BBQ10 I2C keyboard driver (which is a polling driver) but is a leftover of some experiments I made with a GiantBoard running Debian 10.
Update 2024-03-24: The 2x 470k resistor divider brings the voltage at the USB pin down to the ADC measurement range 0..3.3V. The exact resistor values are irrelevant as long as they match by a percent or two. If in doubt, use a digital multimeter. The voltage is then measured at A2 and provides an automatic read/write remount of the flash if USB is not connected if the pin voltage is below ~4.2V. See boot.py
.
So let's have a look at some of the code, shall we?
code.py
The main file running the keyboard and display, as well as command dispatching. The UI looks and feels much like a real Python prompt but is fully terminalio emulated because you cannot simply run a local, interactive REPL. The UI runs Python expressions, statements and compound statements. The eval() and exec() calls are not yet hardened but I'll do that sooner or later (https://lybniz2.sourceforge.net/safeeval.html). (On the other hand, this is my machine, and why would I not be root on it?)
Most of the code is a mess but pretty straightforward imho. The display is connected via SPI and controlled by an ILI9341 chip and the standard Adafruit driver. Keyboard and touch are I2C connected and driven by arturo's own drivers. Likewise the RTC, which runs on the Adafruit driver again.
Next is some code concerning the command line history. All commands are appended to a Python list and supposed to be written to flash to make it permanent. I still have to figure out how to mount the flash read/write by default if USB is not connected. Maybe I'll use an additional SD Card for this.
The following code parts try to determine if a command is an expression, a statement or the first line of a compound statement. The process()
function sends the command to exec()
or eval()
and returns the result accordingly.
update_status()
is invoked every couple of seconds from the keyboard polling loop. Here you can see what I mentioned above concerning the battery voltage. The value mmax
was measured with a full battery, and mmin
is the corresponding value for 3.4V. date()
sets or reads the RTC. To set the clock, simply invoke date("2024-MM-DD HH:MM") from the command line.
Since you cannot run an interactive REPL easily on a local display, I emulate a REPL using terminalio
. The first line consists of the Blinka logo and a status line, and the rest of the display is the command window. (The Blinka logo was shamelessly stolen from the CircuitPython source tree as a bitmap, put into an XPM file and converted to BMP using netpbm on Linux.)
The terminal block cursor showing the inverted character was made as follows: In tools/gen_display_resources.py
(see info box) there is a script that gets run during each image build. It creates the Blinka logo and the builtin terminal font. I added some code at the end to expand the character map: the glyphs from 128-191 contain the same glyphs as 32-127, but bit inverted. Now if character 0x37 + 0x60 is displayed, it is actually inverted as shown on the image below (the '7').
With the help of the 5-way joystick, I can move the cursor left/right or move through the history up/down as you would expect. Backspace deletes the character left or the cursor, and typing characters inserts text just left of the cursor. To take the image above, I simply moved up the history 2 steps up and moved the cursor left a couple of ticks.
(By the way does someone know where to get a rubber cap for the 7x7mm joystick? The ones in the Adafruit shop won't fit because they are to big. TIA!)
Most of the keyboard evaluation loop should be pretty straightforward. Bad spaghetti code.
I find the ANS function on some pocket calculators very useful, so each time a usable result is returned, it is copied to the ans
variable, which you can pick up in the next command.
The Featherwing's keyboard controller (Arm Cortex-M0) is still running the stock firmware, but the 4 rubber buttons in the top row are freely assignable. I assigned the rightmost button to SYM because with the stock firmware, the lower SYM button is unused and the keyboard map is sparse. With the help of the custom SYM button I could assign some more popular characters like [] {} <> and whatnot. See keymap.py in the git repo to see the mapping.
If you're wondering what I did with the kbd.backlight2
in the key polling loop - I expanded Arturo's BBQ10 driver to also accept settings for the TFT backlight, in order to dim it down after a timeout of about 1 minute. See the code in the repo.
umath.py
umath.py is a simple wrapper to make real / complex handling fully transparent. Basically, each function simply checks the argument's type, and if it's complex, the complex routine is invoked from the cmath module, otherwise the real routine from math. Since MicroPython/CircuitPython's cmath is pretty sparse (I did not feel like patching py/modcmath.c
), some complex routines are written in Python, making use of the real math functions. Exceptions are sin()
, cos()
, sqrt()
, exp()
and log()
which are available in cmath.
Some math and physics constants are provided by the namedtuple on top, which makes sure I cannot inadvertently overwrite pi with a 3 and thus invent an entirely new kind of math. Eventually I may add some conversion constants as well.
The special functions like erf()
or gamma()
currently do not support complex numbers but then this is usually not something you do with a pocket calculator. But then, why not.
uncertainty.py
This is a simplified version of the popular uncertainties module from Python, which has a rather large memory footprint and does some clever things which I don't need on such a small device. For example, I'm fine with the tuple representation and don't feel like parsing an "a+/-b" format. But the basic algebra is there, as well as most functions from the math
module. The basic algebra represents Gaussian error propagation, and the derivatives of the math functions are analytical except for gamma()
and lgamma()
. This module was developed and tested on CPython 3.10 so it should work pretty much everywhere.
Usage example:
from uncertainty import ufloat as u a = u(3.14, 0.01) * u(2.71, 0.02)
ufractions.py
This is a simplified version of Python fractions.py which does not run on CircuitPython out of the box due to a number of missing dependencies. I felt like re-writing instead of porting so ...
The module is not complete as of this writing but the basics are there. Some work remains to be done.
This module was developed and tested on CPython 3.10 so it should work pretty much everywhere.
As fas ar input formats, there's the tuple format with fr(nominal, deviation)
, fr(float, number of decimals)
, and fr("float as string")
.
Usage example:
from ufractions import frac as fr b = fr(125, 375) * fr(27, 81) c = fr.from_float(3.1415926, 7) # number of decimals to fend off float64 rounding d = fr.from_float("3.1415926")
Numeric Integration
I extended ulab.scipy
by an "integration" attribute to provide four numeric integration methods:
- quad (tanh-sinh, exp-sinh and sinh-sinh methods)
- quadgk (Adaptive Gauss-Kronrod)
- romberg (Romberg' method)
- simpson (Adaptive Simpson's rule)
Usage example below. There is no help()
available as of yet.
>>> import ulab.scipy.integrate as i >>> dir (i) ['__class__', '__name__', '__dict__', 'quad', 'quadgk', 'romberg', 'simpson'] >>> f = lambda x: x**2 + 2*x + 1 >>> i.quad(f, 0, 5) (71.66666666666669, 4.040179044031267e-14) >>> i.romberg(f, 0, 5) 71.66666666666667 >>> i.simpson(f, 0, 5) 71.66666666666667 >>> i.quadgk(f, 0, 5) (71.66666666666667, 8.38549416476104e-14)
Synopses:
//| def quad( //| fun: Callable[[float], float], //| a: float, //| b: float, //| *, //| levels: int = 6 //| eps: float = 1e-14, //| ) -> float: //| """ //| :param callable f: The function to integrate //| :param float a: The left side of the interval //| :param float b: The right side of the interval //| :param float levels: The number of levels to perform (6..7 is optimal) //| :param float eps: The error tolerance value //| def romberg( //| fun: Callable[[float], float], //| a: float, //| b: float, //| *, //| steps: int = 100 //| eps: float = 1e-14, //| ) -> float: //| """ //| :param callable f: The function to integrate //| :param float a: The left side of the interval //| :param float b: The right side of the interval //| :param float steps: The number of equidistant steps //| :param float eps: The tolerance value //| def simpson( //| fun: Callable[[float], float], //| a: float, //| b: float, //| *, //| steps: int = 100 //| eps: float = 1e-14, //| ) -> float: //| """ //| :param callable f: The function to integrate //| :param float a: The left side of the interval //| :param float b: The right side of the interval //| :param float steps: The number of equidistant steps //| :param float eps: The tolerance value //| def quadgk( //| fun: Callable[[float], float], //| a: float, //| b: float, //| *, //| order: int = 5 //| eps: float = 1e-14, //| ) -> float: //| """ //| :param callable f: The function to integrate //| :param float a: The left side of the interval //| :param float b: The right side of the interval //| :param float order: Order of quadrature integration. Default is 5. //| :param float eps: The tolerance value
Custom image
Let's have a look at the diff representing my changes for float64 and complex math as well as the block cursor.
Since I experimented with the internal LIBM first and then managed to also activate the internal LIBM_DBL, there are some leftovers which should still work. I enable the internal LIBM using the INTERNAL_LIBM
switch as available, and introduced a new switch INTERNAL_LIBM_DBL
to also compile and link the code in lib/libm_dbl
. After I got it to work, I noticed some subtle rounding differences compared to the GNU libm, so I ended up to setting INTERNAL_LIBM=
and using the toolchain libm. Compiling with the internal libm initially threw some warnings as errors so I threw out -Werror
for float64. But in the latest versions, this is no longer required (I should clean this up, really.)
In ports/atmel-samd/boards/feather_m4_express/mpconfigboard.h
, I switch on all the stuff that are required to configure float64 and cmath:
-
MICROPY_FLOAT_IMPL_DOUBLE
makes sure the internal type for floats is 64 bit instead of 32. -
MICROPY_OBJ_REPR_A
forces CircuitPython to keep floats on the heap instead of in the pointers (keyword: object representation). -
MICROPY_PY_MATH_SPECIAL_FUNCTIONS
enables a number of math functions likegamma()
orerf()
. -
MICROPY_PY_MATH_FACTORIAL
together withMICROPY_OPT_MATH_FACTORIAL
should enable the use of the internalfactorial()
function but this seems not to work (another ticket candidate I suppose) so I implemented it in Python. -
MICROPY_PY_CMATH
, well, enables the use ofpy/modcmath.c
-
ULAB_SUPPORTS_COMPLEX
should enable ulab to support arrays with complex components but it does not. There is an open ticket for this. (https://github.com/adafruit/circuitpython/issues/9052) -
MICROPY_PY_BUILTINS_STR_UNICODE
switches off Unicode support which should be the default on most boards anyway. This is required for my block cursor to use the extra half of the font bitmap.
In ports/atmel-samd/boards/feather_m4_express/mpconfigboard.mk
, I disable some modules which I don't need, mainly because linking the GNU libm requires some space in flash.
The raspberrypi section for the Feather RP2040 is abandoned at the moment. The code compiles fine but there is a strange linker error in the last stage that I was not able to rule out for now. Anyway, as you can see, I add some RP2 SDK parts to enable float64. The other changes are similar to the ones for the M4 Express. Just configuration.
In py/circuitpy_defns.mk
, I make some compiler switches depend on float64 enabled or not. If not, the image should build like a stock image.
py/unicode.c, shared-module/terminalio/Terminal.c
, and shared-module/fontio/BuiltinFont.c
contain extensions for the extra font glyphs for the inverted block cursor.
The patch in shared-bindings/rgbmatrix/RGBMatrix.c
was needed because the return value of the function rgbmatrix_rgbmatrix_get_brightness_proto()
is hardwired to float. It should be mp_float_t
, I suppose.
And as mentioned before, the patch against tools/gen_display_resources.py
as mentioned before, extends the builtin font bitmap by the inverted glyphs.
Update 2024-03-24: The added files under extmod/ulab/code/scipy
provide the numeric integration as mentioned above.
If you want to use the patch or build a similar custom image, you can try the build script in the repo.
Hardware hacking
Sorry whoever designed the Feather M4 board but I had to hack the LiPo charger. I'm using a 2000 mAh battery and would like to charge faster, like the RP2040. The schematics (https://learn.adafruit.com/assets/57242, see top right corner) reveals that adding a second resistor in parallel to R8 does the trick. I soldered a 5.1k 0805 SMD part (marked "512") piggyback on the 10k part marked "10C" and now the board charges about 3x as fast (with ~300 mA). I did not want to go higher because even if the schematics says 1000 mAh max, the data sheet for the MCP73831 chip says 500 mAh, and if I have learned something in 40+ years of electronics, it's sticking to official data sheets ;-) . If Adafruit ever makes a new board revision please make LiPo charging faster as well. Or selectable by solder jumpers.
"Roadmap"
There is no fixed roadmap as such, but I have a number of ideas I would like to work on.
- Some cleanups are badly needed, and I would like to get the RP2040 port to run, also because the Feather RP2040 has a Stemma/Qt port for the RTC and the Feather M4 Express has not. Maybe I'll solder a Stemma socket on the M4 Express free space or something else which makes it pluggable.
- Numerical integration using various methods like Simpson's rule and such. ulab.numpy allows to sum up stuff really efficiently.
- The number of significant digits on the display should be selectable, also the number format SCI or ENG.
- more help functions
- the uncertainty module needs an output format where nominal value and standard deviation share the same exponent, like "(3.14, 0.01)e+6".
- ufractions may need some more functions, like more input formats. But for now it's okay.
- plotting. Maybe for some simple cases, creating an image and displaying it using displayio. Ideally with zooming and panning using the touchscreen.
- running short Python scripts from flash or SD card. Think of it like math or EE solution libraries.
- CAS or symbolic math would be way cool, but I'm afraid MCUs like the Cortex-M4 or the RP2040 are "slightly" underpowered as far as CPU performance and amount of memory. The Giantboard I mentioned earlier (running Debian Buster on ARM Cortex A5 with 128 MB of RAM, sadly discontinued and largely unsupported) can handle giac, octave-cli, or Python/Numpy/Sympy just fine albeit slow for complex calculations, and I also occasionally use Mathematica on a Raspberry Pi Zero 2W. Maybe pymbolic could help me to get something done, but you have to write the backend code yourself, which I am not savvy enough for. For Arduino mode, there are some really powerful C++ libraries like exprtk (https://www.partow.net/programming/exprtk/), but then I would have to write the whole frontend myself and not rely on Python to lift the heavyweight. Well ...
And there will be a simple back cover 3D printed with clear resin and polished to transparency. The thing will look like the following screenshot from FreeCAD.