Polarize
GitHub Get in touch

Work · Repository

olimex-shield

EEG/ECG/EMG with an Arduino + Olimex SHIELD-EKG/EMG: firmware, bridge, recording workbench, playback, BIDS export

  • Python
  • MIT licence
  • 1 post
  • 2 merged PRs
  • 1 star

View on GitHub → Changelog ↓

Posts about it

README

Record EEG, ECG or EMG with an Arduino UNO and an Olimex SHIELD-EKG-EMG, a single-channel amplifier that costs about C$75 with the Arduino. This repo holds the sketch that runs on the board, a small bridge that reads it over USB and checks whether the signal can be trusted, and a browser workbench to watch, record, tag, play back and export to EEG-BIDS.

It was built for the Polarize.Tech post Can a $70 board tell me I’m wrong?, which characterises the rig in numbers.

This is not a medical device. It is a hobby amplifier, and nothing it shows is a diagnosis. Read Safety before you put an electrode on anyone.

The rig

Olimex SHIELD-EKG-EMG board Olimex SHIELD-EKG-EMG-PRO electrode cable with three snap leads

The Olimex SHIELD-EKG-EMG and its SHIELD-EKG-EMG-PRO electrode cable. Photos: DigiKey, as used in the blog post.

Part Role
Olimex SHIELD-EKG-EMG Analog front end, 0.16–40 Hz, one differential channel
Olimex SHIELD-EKG-EMG-PRO cable 3.5 mm jack to three snap leads
Arduino UNO R3 10-bit converter and USB serial
Snap electrodes Disposable gel electrodes are best. The post used self-adhesive TENS pads, which are made for stimulation, not recording. Never ultrasound gel.

Wiring and flashing: apps/olimex-shield/README.md.

Quick start

You need Python 3.10+ and, for the hardware, pyserial.

git clone https://github.com/polarizetech/olimex-shield && cd olimex-shield
python3 -m pip install pyserial
python3 apps/web/serve.py

Open http://127.0.0.1:8150/. No board yet? Pick demo://synthetic. Synthetic data is badged as synthetic everywhere it goes, including recordings and exports.

What it can and cannot see

   
Band 0.16–40 Hz, set in hardware before the converter. Software cannot recover anything outside it.
Sample rate 250 Hz declared. The bridge measures the real rate, which runs slightly slow and drifts.
Resolution 10-bit. The µV scale comes from the shield’s built-in cal signal at its nominal amplitude and has not been checked externally (see hardware notes).
Intended for Resting alpha, ECG rhythm, muscle on/off. None of these is validated against a reference amplifier yet.
Not for Brainstem responses (they sit far above 40 Hz), skin conductance, or anything clinical.

Documentation

Safety

The workbench shows this list in full before every connection. Here it is too:

  1. This is NOT a medical device and nothing it shows is a diagnosis. If you are worried about your heart, your brain or your skin, this app is not the instrument to use.
  2. Run the board from a BATTERY-POWERED laptop, unplugged from mains. A mains-powered USB host puts your body between a wall socket and an electrode; the Olimex shield is not a medically isolated front end.
  3. Unplug EVERY mains-connected device from the laptop, not only the charger: an external monitor or HDMI cable, a powered USB hub or dock, an Ethernet cable, and any mains-powered audio interface or headphone amplifier used for stimuli. Each one is a path from mains, through the laptop, to the electrodes.
  4. If you cannot run on battery, a “properly isolated” power supply alone is NOT enough: isolate the USB data line too, with a USB isolator rated for patient connection (medical isolation, e.g. to IEC 60601-1). A general-purpose USB isolator is not a substitute.
  5. Never connect an electrode to anyone with an implanted pacemaker, ICD, or any other active implanted device.
  6. Never give current a path across the chest. While anything mains-referenced is connected, do not place electrodes on both arms or on both sides of the chest – an arm-to-arm or side-to-side pair puts the heart in that path. That includes this app’s own ECG limb leads: record them on battery only, with nothing else plugged in.
  7. Do not touch grounded metal – a radiator, a tap, a desktop PC’s case – while you are wired up. It gives any fault current a return path through you.
  8. Do not place electrodes on broken skin, on a rash, or over a wound.
  9. Stop immediately if the skin stings, burns, itches or reddens under an electrode.
  10. This app applies no current and delivers no stimulation of any kind. It only listens. If any part of the rig ever produces a sensation, disconnect it.
  11. One person, one rig, one session. Do not connect two people to the same amplifier.

Tests

python3 scripts/check.py

Status, citing and license

Released as-is for people who want to build the same rig; it is not yet packaged as a community open-source project. Versions are tagged, so download the latest release.

Cite with CITATION.cff. MIT licensed, see LICENSE. Vendored third-party files keep their own licences: the fonts in apps/web/vendor/design/fonts/ are SIL OFL (licence text beside each font), and the icons are a subset of Phosphor Icons (MIT, © Phosphor Icons).

Changelog

2026-10-02 #2

Fix spectrum(): summarise every bin in each display bucket

The bug

quality.spectrum() promised to summarise Goertzel bins into display buckets, but evaluated only every step-th DFT bin and skipped the rest. On a record longer than a couple of seconds a narrow line fell between the evaluated bins and disappeared. A 260-count 10 Hz sine in noise over 20 s drew its tallest point at 35.65 Hz, about 1.7 counts. The Monitor tab’s spectrum (POST /spectrum, up to 20 s every ~500 ms) was affected the same way.

The fix

  • Every transform bin in the range is evaluated, and each bucket reports the maximum amplitude of the bins it covers. A maximum keeps a narrow line at its own amplitude however long the record is; a mean of power would dilute it. The price, stated in the docstring: the noise floor of the display rises slowly with record length, and it is not a power density.
  • Cost: one stdlib radix-2 FFT of the Hann-windowed record, zero-padded to the next power of two at or above twice its length, over at most the most recent 16,384 samples (65 s at 250 Hz). The padding bounds the scalloping loss at 4 %.
  • freqs are now bucket centres. resolution_hz keeps its meaning (rate / samples used). New fields: bucket_hz, bin_hz, samples_used, summary. The web client needs no change.
Record Tallest point Amplitude (input 260) Time
2 s 10.01 Hz 259.5 3 ms
20 s 9.97 Hz 256.8 56 ms
60 s 9.98 Hz 257.0 119 ms

Checks

  • apps/server/dev_check.py: a 10 Hz sine in noise at 2 s, 20 s and 60 s must be the tallest point, within one bucket of 10 Hz and within 10 % of the input amplitude; plus a check on the sample cap.
  • python3 scripts/check.py: all seven suites pass. check_docs.py run against the blog post: in step. ruff clean.

Versioning

No version bump and no model-v* tag. quality.py is in the measuring chain, but spectrum() feeds only the drawing: nothing it returns is stored in a session or an export, and assess() and bands() are unchanged. The CHANGELOG entry says so. If this should be 0.2.1 instead, that is a follow-up commit here.

🤖 Generated with Claude Code

2026-10-02 #1

Docs site, generated by automatic-documentation-site

What this adds

A generated docs site for this repository, built by automatic-documentation-site. Nothing on it is written by hand: a page is fixed by fixing the Markdown file, docstring or story it names.

File What it is
docs-site.toml what the site is generated from
docs-site/stories/ four stories: real calls run at build time
.github/workflows/docs-site.yml rebuilds on every push to main and deploys to GitHub Pages
docs-site/README.md how to build and read it locally
.gitignore the build output and story cache

No code under apps/ changes.

What the site contains (22 pages)

  • Guides: the README (with Safety and the board photos), then the hardware, recording, standards, versioning, bridge, workbench and project documents.
  • API reference: apps/server and apps/web Python, and the exported browser modules. 85 public symbols, read without importing or running anything.
  • Stories, on synthetic or modelled inputs only, never a recording of a person:
    • what the front end can carry (rig.carries): alpha reachable, brainstem responses unreachable
    • which readouts fit in its band (readouts.gate)
    • the pre-flight verdict on a good, a flat and a railed channel (quality.assess)
    • the delivered sample rate (timebase.RateEstimator recovers a simulated 248.3 Hz)

One story records a check that does not hold

quality.spectrum of a clean 10 Hz sine has no peak at 10 Hz on a 20 s record. Its docstring says it averages Goertzel bins into display buckets; the code evaluates every step-th bin, so a narrow line between them is missed. The story shows the check as it came out (“absent”) and was not adjusted to pass. The fix belongs in a separate change.

Checked

  • docsite build . --refresh --strict --publish pages with the released tool (v0.2.1) and polarize-ui v0.5.15: 22 pages, 4/4 stories run.
  • python3 scripts/check.py: every suite passes.
  • The built site opens from a folder (file://) with plots, search and badges working.

After merging

The workflow runs on main and deploys to https://polarizetech.github.io/olimex-shield/ (GitHub Pages is enabled, source: GitHub Actions).

Based on main. The extension catalogue on the tool-only work is not on main yet; when it lands it is one [catalogue] block in docs-site.toml.

🤖 Generated with Claude Code