Low-Cost Real-Time Geophone Monitoring Station with Arduino

Monitoring ground vibrations—whether from microseismic events, volcanic tremor, or anthropogenic noise—typically requires expensive digitizers and software. Here we describe a simple, portable system built from off-the-shelf components: an Arduino, an ADS1015 ADC, and any modern browser’s Web-Serial API. You’ll stream live velocity measurements (in mm/s) from up to four geophone channels and plot them in real time using SVG charts.

Hardware & Wiring
• Arduino Uno (or any 5 V/3.3 V board with USB-serial)
• Adafruit ADS1015 12-bit, four-channel ADC breakout
• Single-ended geophones (in our case wired to AIN0–AIN3 (common return to ADS1015 GND)
• USB cable to connect Arduino → PC or running browser device

System Block Diagram
[Geophones] → [ADS1015 ADC] → [Arduino I²C] → [USB-Serial] → [Browser Dashboard]

Wiring

If you’re using the Adafruit (or similar) ADS1015 “breakout” board rather than the bare chip, the wiring is almost identical—the module already carries I²C pull-ups and an address‐select jumper. Here’s exactly how to hook everything up.

  1. Power & Ground
    • ADS1015 VDD → Arduino 5 V (or 3.3 V if that’s your board’s logic rail)
    • ADS1015 GND → Arduino GND
  2. I²C Bus
    • ADS1015 SDA → Arduino SDA
    – On an Uno/Nano: analog pin A4
    – On a Mega: pin 20
    – On a Leonardo/Micro: the dedicated SDA pin next to SCL
    • ADS1015 SCL → Arduino SCL
    – On an Uno/Nano: analog pin A5
    – On a Mega: pin 21
    – On a Leonardo/Micro: the dedicated SCL pinThe breakout board includes 10 kΩ pull-ups from SDA/SCL → VDD, so you don’t need extra resistors for short runs.
  3. Address Selection (if you have multiple ADS1X15 boards)
    • The default ADS1015 I²C address is 0x48 (ADDR pin → GND).
    • If you must change it: move ADDR → VDD (0x49) or ADDR → SDA (0x4A) or ADDR → SCL (0x4B).
  4. Sensor Inputs (Single-Ended Mode)
    Each geophone is effectively a voltage source referenced to ground. On the ADS1015 breakout:• Geophone #0 “+” lead → ADS1015 A0 (labeled A0 or “AIN0”)
    • Geophone #1 “+” lead → ADS1015 A1 (“AIN1”)
    • Geophone #2 “+” lead → ADS1015 A2 (“AIN2”)
    • Geophone #3 “+” lead → ADS1015 A3 (“AIN3”)
    • All geophone “–” (negative) leads → ADS1015 GND (common ground)
  5. USB & Browser Link
    • Connect your Arduino’s USB port to the PC or tablet that will run the browser.
    • The browser page uses the Web-Serial API to open the Arduino’s COM port at 230 400 baud.

Arduino Sketch for Data Acquisition
We configure the ADS1015 for ±4.096 V range (GAIN_ONE) and the highest data rate (3 300 samples/s). A continuous, round-robin conversion cycle reads each channel in turn. Every 4 ms (250 Hz per channel), the loop:

  1. Reads four 16-bit signed counts via ads.getLastConversionResults().
  2. Converts raw counts → voltage → ground-velocity (mm/s) using a scale factor.
  3. Prints all four channel values as comma-separated ASCII floats, e.g.:
    0.123,-0.045,0.067,-0.012\n
  4. Re-starts the next conversion in the cycle.

Serial is set to 230 400 baud to sustain this throughput without buffer overruns.

Sketch

#include <Wire.h>
#include <Adafruit_ADS1X15.h>

Adafruit_ADS1015 ads;

// Geophone + ADS1015 parameters
const float V_PER_COUNT = 4.096f / 2048.0f;    // ±4.096 V full-scale → 2 mV/count
const float GEO_SENS    = 20.9f;              // V per (m/s) with 1 kΩ shunt
const float SCALE_MM_S  = (V_PER_COUNT / GEO_SENS) * 1000.0f; // mm/s per count

const uint32_t SAMPLE_INTERVAL_MS = 4; // 250 Hz → every 4 ms

void setup() {
  Serial.begin(230400);
  while (!Serial);

  if (!ads.begin()) {
    Serial.println("ERROR: ADS1015 not found");
    while (true);
  }
  ads.setGain(GAIN_ONE);                 // ±4.096 V
  ads.setDataRate(RATE_ADS1015_3300SPS); // 3 300 SPS
}

void loop() {
  static uint32_t last = 0;
  if (millis() - last < SAMPLE_INTERVAL_MS) return;
  last += SAMPLE_INTERVAL_MS;

  // read 4 single-ended channels in single-shot mode
  float v[4];
  for (uint8_t ch = 0; ch < 4; ch++) {
    int16_t raw = ads.readADC_SingleEnded(ch);
    v[ch] = raw * SCALE_MM_S;
  }

  // send as CSV float with 3 decimal places
  Serial.print(v[0], 3); Serial.print(',');
  Serial.print(v[1], 3); Serial.print(',');
  Serial.print(v[2], 3); Serial.print(',');
  Serial.println(v[3], 3);
}

Browser-Based Dashboard (HTML + JavaScript)
We leverage the Web-Serial API to open the Arduino’s COM port at 230 400 baud. Incoming bytes pass through a TextDecoderStream and accumulate until each newline. Each line—four comma-separated floats—is parsed, stamped with a timestamp, and pushed into per-channel circular buffers (up to 1 000 samples each).

Every ~33 ms (≈30 FPS), the page redraws four SVG charts. Charts feature:

• Auto-scaled Y-axes with gridlines at 0.25 mm/s steps
• Time labels on the X-axis (up to six per chart)
• Checkboxes to show/hide individual channels
• Smooth scrolling effect as new data arrives

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8"/>
  <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
  <title>Geophones On Arduino Live (mm/s)</title>
  <style>
    * { box-sizing:border-box; margin:0; padding:0 }
    body {
      font-family: sans-serif;
      background: #f5f7fa;
      color: #333;
      max-width: 90vw;
      margin: 2em auto;
      padding: 0 1em;
    }
    h1 { text-align:center; margin-bottom:1em }
    .controls { text-align:center; margin-bottom:1em }
    button {
      padding:.4em .8em; margin:0 .3em;
      font-size:.9em; border:none; border-radius:4px;
      cursor:pointer; background:#0366d6; color:#fff;
      transition:background .2s;
    }
    button:hover { background:#024f9b }
    button:disabled { background:#bbb; cursor:default }

    .charts { display:flex; flex-direction:column; gap:1em }
    .chart-card {
      background:#fff; border-radius:6px;
      box-shadow:0 1px 4px rgba(0,0,0,.1);
      overflow:hidden;
    }
    .chart-header {
      display:flex; justify-content:space-between; align-items:center;
      background:#f5f5f5; padding:.5em .75em;
      border-bottom:1px solid #e0e0e0; font-weight:600;
    }
    .chart-body { width:100%; height:200px; background:#fafafa }
    .chart-body svg { width:100%; height:100% }
    .grid-line { stroke:#ddd; stroke-width:1 }
    .axis      { stroke:#333; stroke-width:1.2 }
    .ylabel    { fill:#333; font-size:11px; text-anchor:end }
    .xlabel    { fill:#333; font-size:11px; text-anchor:middle }
    .line0     { fill:none; stroke:#e74c3c; stroke-width:2 }
    .line1     { fill:none; stroke:#3498db; stroke-width:2 }
    .line2     { fill:none; stroke:#27ae60; stroke-width:2 }
    .line3     { fill:none; stroke:#f39c12; stroke-width:2 }
  </style>
</head>
<body>
  <h1>Geophones On Arduino Live (mm/s)</h1>
  <div class="controls">
    <button id="btnConnect">Connect</button>
    <button id="btnDisconnect" disabled>Disconnect</button>
    <span id="status">Status: Disconnected</span>
  </div>

  <div class="charts">
    <div class="chart-card" id="card0">
      <div class="chart-header">
        <span>Channel 0 (AIN0)</span>
        <label><input type="checkbox" class="toggle-ch" data-idx="0" checked> Show</label>
      </div>
      <div class="chart-body"><svg id="svg0"></svg></div>
    </div>
    <div class="chart-card" id="card1">
      <div class="chart-header">
        <span>Channel 1 (AIN1)</span>
        <label><input type="checkbox" class="toggle-ch" data-idx="1" checked> Show</label>
      </div>
      <div class="chart-body"><svg id="svg1"></svg></div>
    </div>
    <div class="chart-card" id="card2">
      <div class="chart-header">
        <span>Channel 2 (AIN2)</span>
        <label><input type="checkbox" class="toggle-ch" data-idx="2" checked> Show</label>
      </div>
      <div class="chart-body"><svg id="svg2"></svg></div>
    </div>
    <div class="chart-card" id="card3">
      <div class="chart-header">
        <span>Channel 3 (AIN3)</span>
        <label><input type="checkbox" class="toggle-ch" data-idx="3"> Show</label>
      </div>
      <div class="chart-body" style="display:none"><svg id="svg3"></svg></div>
    </div>
  </div>

  <script>
    if (!("serial" in navigator)) {
      alert("Web Serial API not supported.");
    }

    const MAX_PTS     = 1000;
    const Y0_STEP     = 0.5;  // Y‐axis tick step
    const DRAW_INT_MS = 33;    // ~30 FPS

    const svgs    = [0,1,2,3].map(i => document.getElementById('svg'+i));
    const visible = [true,true,true,false];
    const data    = [[],[],[],[]];

    let port, reader, run = false, buf = '', lastDraw = 0;

    const btnConnect    = document.getElementById('btnConnect');
    const btnDisconnect = document.getElementById('btnDisconnect');
    const statusSpan    = document.getElementById('status');

    // Channel visibility toggles
    document.querySelectorAll('.toggle-ch').forEach(chk => {
      chk.onchange = () => {
        const idx = +chk.dataset.idx;
        visible[idx] = chk.checked;
        document
          .getElementById('card'+idx)
          .querySelector('.chart-body')
          .style.display = chk.checked ? '' : 'none';
        drawAll();
      };
    });

    // Connect button
    btnConnect.onclick = async () => {
      try {
        port = await navigator.serial.requestPort();
        await port.open({ baudRate: 230400 });
        const decoder = new TextDecoderStream();
        port.readable.pipeTo(decoder.writable);
        reader = decoder.readable.getReader();
        run = true;
        btnConnect.disabled    = true;
        btnDisconnect.disabled = false;
        statusSpan.textContent = 'Status: Connected';
        readLoop();
      } catch (e) {
        console.error(e);
        statusSpan.textContent = 'Error';
      }
    };

    // Disconnect button
    btnDisconnect.onclick = async () => {
      run = false;
      btnConnect.disabled    = false;
      btnDisconnect.disabled = true;
      statusSpan.textContent = 'Status: Disconnected';
      if (reader) { await reader.cancel(); reader.releaseLock(); }
      if (port)   { await port.close(); }
    };

    // Read incoming ASCII lines of "f0,f1,f2,f3\n"
    async function readLoop() {
      while (run) {
        const { value, done } = await reader.read();
        if (done) break;
        buf += value;
        let nl;
        while ((nl = buf.indexOf('\n')) >= 0) {
          const line = buf.slice(0, nl).trim();
          buf = buf.slice(nl + 1);
          const parts = line.split(',').map(parseFloat);
          if (parts.length === 4 && parts.every(v => !isNaN(v))) {
            const ts = Date.now();
            parts.forEach((v, i) => {
              data[i].push({ v, t: ts });
              if (data[i].length > MAX_PTS) data[i].shift();
            });
            const now = performance.now();
            if (now - lastDraw > DRAW_INT_MS) {
              lastDraw = now;
              drawAll();
            }
          }
        }
      }
    }

    // Draw all visible charts
    function drawAll() {
      svgs.forEach((svg,i) => drawChart(svg, data[i], visible[i], i));
    }

    // Single‐chart draw routine
    function drawChart(svg, arr, show, idx) {
      const W = svg.clientWidth, H = svg.clientHeight;
      const m = { left:40, right:40, top:20, bottom:30 };
      const w = W - m.left - m.right, h = H - m.top - m.bottom;
      while (svg.firstChild) svg.removeChild(svg.firstChild);
      if (!show || arr.length < 2) return;

      // dynamic Y‐range
      const absMax = Math.max(...arr.map(o => Math.abs(o.v)));
      const Y0     = Math.ceil(absMax / Y0_STEP) * Y0_STEP;
      const lines  = Math.ceil(Y0 / Y0_STEP);

      // horizontal grid + Y‐labels
      for (let i = -lines; i <= lines; i++) {
        const yVal = (i * Y0_STEP).toFixed(2);
        const f    = (i * Y0_STEP) / Y0;
        const yPos = m.top + ((1 - (f + 1)/2) * h);
        mk(svg,'line', { x1:m.left, y1:yPos, x2:m.left+w, y2:yPos, class:'grid-line' });
        mk(svg,'text',{ x:m.left-6, y:yPos+4, class:'ylabel' }, yVal);
      }

      // Y‐axis
      mk(svg,'line',{ x1:m.left, y1:m.top, x2:m.left, y2:m.top+h, class:'axis' });
      // X‐axis
      mk(svg,'line',{ x1:m.left, y1:m.top+h, x2:m.left+w, y2:m.top+h, class:'axis' });

      // X‐ticks + time labels (up to 6)
      const pts = arr.length, maxLab = 6, skip = Math.ceil(pts / maxLab);
      for (let i = 0; i < pts; i++) {
        const x = m.left + i * (w / (MAX_PTS - 1));
        if (i % skip === 0 || i === pts - 1) {
          mk(svg,'line',{
            x1:x, y1:m.top+h, x2:x, y2:m.top+h+4, class:'axis'
          });
          mk(svg,'text',{
            x:x, y:m.top+h+18, class:'xlabel'
          }, fmtTime(arr[i].t));
        }
      }

      // data polyline
      const ptsStr = arr.map((o,i) => {
        const x = m.left + i * (w / (MAX_PTS - 1));
        const y = m.top + h - ((o.v + Y0) / (2 * Y0)) * h;
        return `${x},${y}`;
      }).join(' ');
      mk(svg,'polyline',{ points:ptsStr, class:'line'+idx });
    }

    // format timestamp for X‐axis
    function fmtTime(ms) {
      return new Date(ms).toTimeString().substr(0,8);
    }

    // utility to make SVG elements
    function mk(svg,tag,attrs,text) {
      const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
      for (const k in attrs) el.setAttribute(k, attrs[k]);
      if (text != null) el.textContent = text;
      svg.appendChild(el);
    }

    window.addEventListener('resize', drawAll);
  </script>
</body>
</html>

Monitoring Geo-Activity
With this setup, you can:

• Visualize seismic arrivals and tremor in real time.
• Log data (e.g. via extending the JS to POST CSV) for offline analysis.
• Deploy multiple units for array-based localization.
• Demonstrate ground-motion sensing in field or classroom settings at minimal cost.

Conclusion
This Arduino + ADS1015 + Web-Serial combination yields a powerful yet accessible geophone monitoring station. You get live, zoom-free plots of ground-velocity in mm/s, full control over channel visibility, and the flexibility to extend both the data‐acquisition code and web‐based interface for logging, alarm triggers, or deeper signal processing.

Leave a Reply