Geophone and the mystery of the vibrating apartment

The Quest

Since mid-August my home/office floor has been vibrating. Like a refrigerator/air conditioner compressor that rumbles 24/7. It’s insidious - can’t sleep, can’t focus on work, sometimes it makes me sea sick. Stayed in a hotel for a week just to regain my sanity.

There was a lot of work in this old building the past 4 months. I’ve talked to the builders and building management, but that led to no solutions. The surrounding neighbors turned off their power at the breaker to help isolate it, but it doesn’t seem to be coming from them or I’m so used to it I can’t tell when it stops.

If only for my own sanity I’ve been trying to measure the vibration and maybe find out where it is most intense.

  • I tried an accelerometer but cheap XYZ accelerometers aren’t sensitive enough to capture it clearly.
  • I tried several audio spectrograms, but it’s a vibration not a noise. I think I see resonate frequencies produced by the vibration, compared to say the quiet room of the library, but I’m not certain. The 47kHz line seems to be coming from the phone itself, its always present even outdoors on a quiet night.


Seismic accelerometer

This led me to the world of high-powered microscope installation, industrial vibration analysis and amateur seismology. The two options seem to be:

  • A seismic accelerometer used in building construction to spot dangerous vibrations before they cause severe damage.
  • A geophone, an analog sensor that can measure earthquakes thousands of miles away and traffic passing on the road outside.

The Geophone


Geophone

Seismic accelerometers are very expensive, so let’s look at geophones. Geophones are magnets inside a metal coil that produce a voltage when the magnet shakes.

This is the SM-24, which seems to be similar in specs to the geophone SparkFun sold at one point for $69 (vs $10 on Taobao…).

Parameter Seismic Geophones:

Parameter LGT-SM24 LGT-SM24/H
Natural frequency (Hz) 10±2.5% 10±2.5%
Open circuit damping 0.25±2.5% 0.271±2.5%
Closed circuit damping 0.6±5% (1339R)
0.6±2.5%
0.6±2.5%
Open circuit sensitivity (V/cm/s) 0.686±5% (1000R)
0.288±2.5%
0.288±2.5%
Closed circuit sensitivity (V/cm/s) 0.225±5% (1339R)
0.209±5% (1000R)
0.227±2.5%
Open circuit resistance (Ω) 375±2.5% 375±2.5%
Closed circuit resistance (Ω) 293 or 273 296±2.5%
Distortion ≤0.1% ≤0.1%
Transverse natural frequency (Hz) ≥250 ≥250
Overall mass (g) 11 11.3

The Amplification

Geophones make a tiny voltage when measuring something like an earthquake 2000km away. Amateur seismology devices use an op-amp to amplify the voltage, and tend to use high resolution ADCs (24bits+). However the gain doesn’t seem to be that significant, this example only uses a ADS1115 16bit I2C ADC with programmable 1-16x gain, and that seems sufficient. In comparison, the current measurement on the Bus Pirate uses a gain of 32+.

For something like foot step detection (or a vibrating floor) amplification may not be needed. We’ll experiment to find out :slight_smile: I picked up:

  • ADS1115 programmable gain ADC
  • Seismic Stream which evidently has a pretty good filter and uses the RP2040.

The Analysis

Most amateur seismograph applications are happy with a simple voltage/time graph. I actually need to identify the dominant frequency. Analysis has been a sticking point on this misery mystery tour - until yesterday.

Vibration research has several good resources about vibration measurement. This post on fast Fourier transform vs Power Spectral Density was the clue I needed to move forward.

Enter endaq which has an open source Python library for vibration analysis. The library is kind of outdated:

  • Must use Python 3.11 so it works with numpy<2.0
  • Must use numpy < 2.0
  • Must use plotly>=5.15.0
  • Comment out #endaq.plot.utilities.set_theme(‘endaq’)

The code for the tutorial is in some kind of Google virtual dev environment, which doesn’t work because the default packages are all too new. I’ve copy and pasted the code snippets below:

endaq Python examples
import endaq
#numpy<2.0" "plotly>=5.15.0
import plotly.express as px
import plotly.graph_objects as go

import numpy as np
import pandas as pd
import scipy
import os

#endaq.plot.utilities.set_theme('endaq')

# Create plots directory if it doesn't exist
os.makedirs('plots', exist_ok=True)

def save_plot(fig, name):
  fig.write_html('plots/'+name+'.html', include_plotlyjs='cdn')
  fig.write_image('plots/'+name+'.png')
  fig.write_image('plots/'+name+'.svg')

"""Generate Sample Data"""
# Get a Random Signal with Frequency Content Between 10 and 50 Hz
noise = endaq.calc.filters.band_limited_noise(min_freq=10, max_freq=50, duration=10, sample_rate=250, norm='rms')

# Add a Column with a Simple Sine Wave
noise['30 & 80.25 Hz Sine Wave'] = np.sin(noise.index * 2 * np.pi * 30) + np.sin(noise.index * 2 * np.pi * 80.25)

# Add a Column with Both
noise['Sine on Random'] = noise['30 & 80.25 Hz Sine Wave'] + noise['Noise from 10 to 50 Hz']

"""Plot Time Series"""
fig = px.line(noise).update_layout(yaxis_title_text='Acceleration (g)', legend_title_text='')
save_plot(fig, '1_noise_time_series')
fig.show()

"""Calculate PSD with Function"""
psd = endaq.calc.psd.welch(noise, bin_width=1)
psd

"""Plot PSD"""
fig = px.line(psd).update_layout(
    yaxis_title_text='Acceleration (g^2/Hz)', 
    xaxis_title_text='Frequency (Hz)', 
    legend_title_text='')
save_plot(fig, '2_noise_psd')
fig.show()


"""Various durations and bin widths"""
psd = pd.DataFrame()

for t in [1, 1.1, 2, 8, 9, 10]:
  psd_t = endaq.calc.psd.welch(noise[:t], bin_width=0.5).reset_index().melt(id_vars='frequency (Hz)')
  psd_t.columns = ['Frequency (Hz)', 'variable', 'value']
  psd_t['Time'] = '0 to ' + str(t)
  psd_t['Bin Width'] = '0.5 Hz'

  psd_t2 = endaq.calc.psd.welch(noise[:t], bin_width=1).reset_index().melt(id_vars='frequency (Hz)')
  psd_t2.columns = ['Frequency (Hz)', 'variable', 'value']
  psd_t2['Time'] = '0 to ' + str(t)
  psd_t2['Bin Width'] = '1 Hz'
  
  psd_t3 = endaq.calc.psd.welch(noise[:t], bin_width=2).reset_index().melt(id_vars='frequency (Hz)')
  psd_t3.columns = ['Frequency (Hz)', 'variable', 'value']
  psd_t3['Time'] = '0 to ' + str(t)
  psd_t3['Bin Width'] = '2 Hz'

  psd = pd.concat([psd, psd_t, psd_t2, psd_t3])

fig = px.line(
    psd,
    x='Frequency (Hz)',
    y='value',
    facet_col='variable',
    facet_col_spacing=0.03,
    facet_row='Bin Width',
    color='Time'
)
fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]))
fig.update_yaxes(showticklabels=True, matches='y', title_text='')
for i in range(0,3):
    fig.update_yaxes(showticklabels=True, matches=f'y{i+1}', col=i+1)
fig.update_layout(title_text='PSD of Varying Lengths (Amplitude^2/Hz vs Frequency)')
save_plot(fig, '3_noise_psd_length_bin')
fig.show()  

"""Demonstrate time segments"""
segments = pd.DataFrame()
for s in np.arange(19)/2:
  df = noise[['Noise from 10 to 50 Hz']][s:s+1]
  window = scipy.signal.windows.hann(len(df))
  df = df.mul(window, axis="rows")
  df['Center Time'] = s + 0.5
  segments = pd.concat([segments, df.reset_index()])

fig = px.line(
    segments,
    x='Time (s)',
    y='Noise from 10 to 50 Hz',
    color='Center Time'
).update_layout(title_text='Overlapping Segments with Hanning Window Applied')
save_plot(fig, '4_windows')
fig.show()

"""Compute FFTs for Each Segment"""
ffts = pd.DataFrame()
for t in segments['Center Time'].unique():
  df = segments[segments['Center Time'] == t][['Time (s)', 'Noise from 10 to 50 Hz']].set_index('Time (s)')
  df.columns = [t]

  fft = endaq.calc.fft.rfft(df.iloc[1:]).reset_index().melt(id_vars='frequency (Hz)')
  ffts = pd.concat([ffts, fft])
ffts['var'] = 'var'
ffts.variable = ffts.variable.astype(float)

waterfall = endaq.plot.spectrum_over_time(
    ffts, plot_type='Waterfall', 
    var_column='var', time_column='variable')
save_plot(waterfall, '5_waterfall')
waterfall.show()

"""Square, Normalize, and Convert to PSD Units"""
ffts['frequency (Hz)'] = np.round(ffts['frequency (Hz)'],1)
spec = ffts.pivot_table(index='frequency (Hz)',columns='variable',values='value')
psd = spec ** 2 # square
psd = psd / psd.index[1] # divide by bin width
psd = psd * 1.333 # deal with scaling due to windowing

from plotly import colors

color_sequence = colors.sample_colorscale(
    [[0.0, '#6914F0'],
      [0.2, '#3764FF'],
      [0.4, '#2DB473'],
      [0.6, '#FAC85F'],
      [0.8, '#EE7F27'],
      [1.0, '#D72D2D']], len(psd.columns))
fig = px.line(
    psd,
    color_discrete_sequence=color_sequence
).update_layout(yaxis_title_text='Amplitude ** 2 / Hz', xaxis_title_text='Frequency (Hz)', legend_title_text='')
fig.add_trace(go.Scattergl(
    x=psd.index,
    y=psd.mean(axis=1).to_numpy(),
    name='Average of All Segments',
    mode='lines',
    line_width=10,
    line_color='gray'
))
save_plot(fig, '6_psd_segments')
fig.show()

"""Using Spectrogram Function"""
spectrogram = endaq.calc.psd.rolling_psd(noise, bin_width=1, num_slices=20, add_resultant=False)
spectrogram

fig = px.line(
    spec,
    color_discrete_sequence=color_sequence
).update_layout(yaxis_title_text='Amplitude ** 2 / Hz', xaxis_title_text='Frequency (Hz)', legend_title_text='')
fig.add_trace(go.Scattergl(
    x=spec.index,
    y=spec.mean(axis=1).to_numpy(),
    name='Average of All Segments',
    mode='lines',
    line_width=10,
    line_color='gray'
))
save_plot(fig, '7_psd_segments_from_spectrogram')
fig.show()

animation = endaq.plot.spectrum_over_time(spectrogram, plot_type='Animation')
save_plot(fig, '8_animation')
animation.show()

The example creates this signal with lots of noise and no obvious major frequency component.

Through PSD analysis the major frequency components are obvious. That’s some good analysis!

The Goals

  1. Simply visualize/confirm that there is a vibration here that isn’t present in surrounding buildings. Have some actual data so I’m more than a mad man ranting about the walls closing in on me.
  2. Hopefully get some idea of the frequency and strength, which might lead to finding the cause.
  3. Maybe locate where it is strongest, the direction it’s coming from
  4. Do another round of turning off power around the building, but this time with actual data to see if the vibration stops
  5. If confirmed, and I can’t spur any action, I found a consultancy which will use a calibrated geophone and provide a “legally valid” report. There are regulations covering occupational vibration limits, and I’m pretty sure residential as well, so I could use that as a basis to request further action. More research needed here, hopefully we can DUI DIY (ED: too funny, leaving it in) our way out of this mystery.

Thank you for attending my TED talk.

3 Likes

This is a wild side quest :joy: Intently following this saga

2 Likes

Misery loves company, welcome to the quest :slight_smile:

via reddit

There are currently thousands of these GPS+geophone devices moving across Amsterdam, mapping the suitability of locations for heat pumps.

If I have any success with this, it might be cool to make an open hardware geophone + wireless network (EPS32?) to monitor multiple devices in real time. Based on my search results of “why is my floor shaking” - and the poor distraught fellow traveler I met at the hardware store who was also making DIY sound dampeners out of rockwool - I think a low cost sensor system might make the world a slightly better place.

2 Likes

Longetequ actually has a decent English website with specs. This is the SM-24 that seems pretty common.

1 Like

The geophone should be <10 degrees from vertical for best measurement. One way to test that is with an IMU like the MPU6050. The idea being a red/green LED on the top of a geophone device that indicates proper orientation.

I added a command to the Bus Pirate I2C mode that implements the mpu6050_light library (well, a machine translated C version of it…).

This is pretty bare bones and there’s a lot of obvious improvements, but it does give a pretty stable X axis angle measurement.

Obviously the temperature is messed up, it’s not 43 degrees in here.

The mpu6050 command is pushed to main and should be in the latest compile.

1 Like

Want to keep a link to this geophone amplifier + filter which is simple and seems well regarded in various forums.

1 Like
import plotly.express as px
import plotly.graph_objects as go
import numpy as np
import pandas as pd
import scipy.signal
import os

# Create plots directory if it doesn't exist
os.makedirs('plots', exist_ok=True)

def save_plot(fig, name):
    fig.write_html('plots/'+name+'.html', include_plotlyjs='cdn')
    fig.write_image('plots/'+name+'.png')
    fig.write_image('plots/'+name+'.svg')

"""Generate Sample Data"""
# Generate time array
sample_rate = 250
duration = 10
t = np.arange(0, duration, 1/sample_rate)

# Generate band-limited noise (10-50 Hz)
# Create white noise and apply bandpass filter
white_noise = np.random.randn(len(t))
sos = scipy.signal.butter(4, [10, 50], btype='bandpass', fs=sample_rate, output='sos')
band_noise = scipy.signal.sosfilt(sos, white_noise)
# Normalize to RMS
band_noise = band_noise / np.sqrt(np.mean(band_noise**2))

# Create sine waves
sine_wave = np.sin(t * 2 * np.pi * 30) + np.sin(t * 2 * np.pi * 80.25)

# Combine signals
sine_on_random = sine_wave + band_noise

# Create DataFrame
noise = pd.DataFrame({
    'Noise from 10 to 50 Hz': band_noise,
    '30 & 80.25 Hz Sine Wave': sine_wave,
    'Sine on Random': sine_on_random
}, index=t)
noise.index.name = 'Time (s)'

"""Plot Time Series"""
fig = px.line(noise).update_layout(yaxis_title_text='Acceleration (g)', legend_title_text='')
save_plot(fig, '1_noise_time_series')
fig.show()

"""Calculate PSD with Function"""
# Calculate PSD using Welch's method for each column
bin_width = 1  # Hz
nperseg = int(sample_rate / bin_width)

psd_dict = {}
for col in noise.columns:
    freqs, psd_values = scipy.signal.welch(
        noise[col].values, 
        fs=sample_rate, 
        nperseg=nperseg,
        scaling='density'
    )
    psd_dict[col] = psd_values

psd = pd.DataFrame(psd_dict, index=freqs)
psd.index.name = 'frequency (Hz)'

"""Plot PSD"""
fig = px.line(psd).update_layout(
    yaxis_title_text='Acceleration (g^2/Hz)', 
    xaxis_title_text='Frequency (Hz)', 
    legend_title_text='')
save_plot(fig, '2_noise_psd')
fig.show()

Here is a version of the script that skips the (dated) endaq library and just does the PSD calculation manually. I think it is right, and the graphs to match.

This script output.

Tutorial using endaq library.

1 Like

Thinking about how to make a heat map with intensity of frequency to help locate the source. This is random data for a hypothetical apartment.

This is super lazy LLM for the most part, but wow what a good starting point.

  • Slider to select frequency range
  • Animated
  • Needs some human love and care, but good start.

plot2.zip (3.1 KB)

Assuming you pip install plotly numpy pandas scipy this should run with random data.

1 Like

A floor plan for the heat map :slight_smile: Feeling professional :smiling_face_with_sunglasses:

floorplan.zip (5.8 KB)

2 Likes

This is the SeismicStream, a geophone capture board with an RP2040. This was recommended (with output comparison) in some forum post I’ll never be able to find again. It’s only sold in the UK, as far as I can tell, but that happens to be close so I went for it.

I initially got the impression that is was open firmware/closed hardware, but alas it seems to be closed everything.

20
100
0
-36

It spits out a stream of 16 bit signed ASCII numbers, one per line. This seems to be a basic version of the ASCII SEED format.

It would surprise me if it used the RP2040 ADC as it is only 12 bits and kind of flaky. Still, I get the impression that the chip in the center is a Programmable Gain Controller from Microchip.

Gain options are 1/2/4/8/16x, with 25/50/100 samples per second.

Is 100 SPS going to be enough? It seems 20Hz is the lowest frequency people normally hear and anything less is sometimes felt as “more as a physical rumble or vibration” (yeah, don’t I know buddy). 4x over sample of 20Hz is only 80SPS, so maybe it works out of the box. I expect to have to do some hacking though.

From the same shop I ordered an “old stock” geophone - perhaps a pulled part? No stats or datasheet whatsoever. It was inexpensive and arrives before the stuff from China.

2 Likes

thats where a good browser history helps. i can dig out stuff like that by scanning through the history if needed. (it goes back to 2010 if archeology is ever needed)

2 Likes

Geophone should arrive any minute. Everything is setup, wire is stripped, 1K dampening resistor at the ready.

serial_capture.zip (4.4 KB)

Test scripts to get started:

  • serial_capture.py - save samples from SeismicStream to CSV file
  • geo.py - load CSV file and do simple PSD analysis. If no CSV is provided, random data is used
serial_capture.py
usage: serial_capture.py [-h] [--list] [-d DURATION] [-s SAMPLES]
                         [port] [output]

Capture ASCII data from serial port and save to CSV

positional arguments:
  port                  Serial port (e.g., COM3, /dev/ttyUSB0)
  output                Output CSV file (default: serial_data.csv)

options:
  -h, --help            show this help message and exit
  --list                List available serial ports and exit
  -d, --duration DURATION
                        Capture duration in seconds
  -s, --samples SAMPLES
                        Number of samples to capture

Examples:
  # Capture from COM3
  python serial_capture.py COM3 data.csv

  # Capture for 60 seconds
  python serial_capture.py COM3 data.csv --duration 60

  # Capture 1000 samples
  python serial_capture.py COM3 data.csv --samples 1000

  # List available ports
  python serial_capture.py --list
geo.py
usage: geo.py [-h] [-r SAMPLE_RATE] [-o OUTPUT] [-c COLUMN] [csv_file]

Analyze Z-axis vibration data and generate PSD plots

positional arguments:
  csv_file              Path to CSV file containing Z-axis data (optional,
                        uses sample data if not provided)

options:
  -h, --help            show this help message and exit
  -r, --sample-rate SAMPLE_RATE
                        Sample rate in Hz (default: 100)
  -o, --output OUTPUT   Root name for output plots (default: CSV filename or
                        "sample")
  -c, --column COLUMN   Name of Z-axis column (default: z)

Examples:
  python geo.py                    # Use sample data
  python geo.py data.csv           # Analyze CSV file
  python geo.py data.csv --sample-rate 250
  python geo.py data.csv --sample-rate 250 --output my_analysis
  python geo.py data.csv -r 250 -o my_analysis

Example of random data overlaid on 30 and 80.25Hz sine waves.

Result of the PSD analysis reveal the 30 and 80.25Hz components.

I’m going to take a wild guess that the floor is vibrating around 12-18Hz. Anyone care to speculate?

1 Like

Still thinking failing bearings in a ventilation blower for it to a steady vibration. That’s said without knowing anything about the building or neighbors.

Waiting eagerly to see how your geophone works!

2 Likes

Soldered the positive side (bump in the top indicates +, I’m guessing), negative side and a 1K dampening resistor (which seems common). Masking tape, in lieu of kapton, to keep the wires from shorting on the metal ring around the two leads.

First test measurements:

  • 4x gain
  • 100sps
  • 1000 samples
  • on desk, no tack

We have two dominant frequencies! ~18Hz and ~32Hz. One of these is probably my laptop struggling to run python scripts.

The units still need to be adjusted, etc.

To see if this is meaningful we need to compare it to a non-vibrating floor. I’m packing up to go mobile now.

3 Likes

Captured some data at the library and the community center. It seems like the next step is wrangling the ADC measurements into the proper format to feed the PSD analysis. That’s where I’m at now.

I’m just going to assume a 28.8V/(m/s) geophone which is pretty standard. The SeismicStream gives the following voltage per count:
×1 = 0.64μV/count
×2 = 0.32μV/count
×4 = 0.16μV/count
×8 = 0.08μV/count
×16 = 0.04μV/count

2 Likes

I spent the whole weekend tinkering with this mess.

It seems that geophones lend themselves more naturally to (m/s)^2/Hz (velocity) PSD analysis, rather than g^2/Hz (acceleration) used for accelerometers. I believe I managed to convert the former to the later, but didn’t find it particularly useful.

g^2/Hz is the industry standard, so we won’t be able to compare to other measurements. That’s ok for now.

        sampling_rate = 100 # Hz
        gain = 8
        geophone_sensitivity = 28.8  # V/(m/s)
        
        # ADC conversion factors (μV/count)
        adc_factors = {
            1: 0.64e-6,   # V/count
            2: 0.32e-6,
            4: 0.16e-6,
            8: 0.08e-6,
            16: 0.04e-6
        }
        
        volts_per_count = adc_factors[gain]

        # Convert counts to volts
        voltage = np.array(adc_counts) * volts_per_count
        
        # Convert volts to velocity using geophone sensitivity
        velocity = voltage / geophone_sensitivity

Here’s the pseudo math/code to get the velocity from the raw ADC counts. At least, I think it does.

Using sine waves mixed with random noise it all looks pretty good.

This isn’t apples to apples because this version of the script is using g^2/Hz, but you can see the issue: real life data seems to just be garbage. This is with a rattling/vibrating fan running in the bathroom, two one minute samples taken one after the other. Vastly different scales and some random noise in the first recording.

This is the best I could sample with the fan off, but I’m cheating because these are the best two out of 10 or so one minute samples.

This is a hacked up live display that connects to the Seismic Stream and plots FFT and PSD in “real time”. This turned out to not be as useful as I had hoped.

Whilst not the most modern geophone, these are still capable of sensing vibration/movement through the ground and as such provide a low-cost starting point for educational purposes.

This is all the info available for this Geophone. I’m randomly guessing it is a 28.8V/(m/2) and 10Hz. This shouldn’t really matter - we can still do the PSD analysis without knowing this stuff. We can even do the PSD on the raw ADC counts, but the units will be unique to this setup (ADC counts)^2/Hz.

  • Don’t know the correct dampening resistor, guessed 1K because it seems common.
  • Don’t know the orientation. Assuming vertical, but it could indeed be horizontal which would account for really random readings.

Putting this on the back burner while we wait for the new geophones to arrive from China. They have datasheets with actual specs, and we know they’re for vertical use.

Goal

My next goal is to identify the vibration of various devices in the apartment. Identify the peaks created by fans, the fridge, the freezer, the radiators kicking on, etc. If I can’t pass that stage, I probably won’t be able to identify the vibrations coming from elsewhere.

3 Likes

Wow! I gave up and hired professionals to come find the issue after the holidays, but with this new geophone I think we might actually have a chance to see something!

Here is the [Longetequ](http://www.longetequ.com] SM24 geophone. Arrived wrapped in foil, I surprised customs didn’t want to open it up.

It comes with a shunt resistor installed, and a small PCB to protect the inner metal ring from shorting the attached leads (I used masking tape for the same reason with the previous geophone above).

Note that the +/- are marked, and the bump does seem to indicate positive so we probably got that right before.

This is much different than the previous geophone! The inner part shakes around when you move it. Do you think that means it’s much more sensitive? The previous geophone doesn’t do anything even when shaken rather violently.

After lunch I’ll get out the iron and attach some leads.

1 Like

The python scripts probably need a fresh start, but at a glance this looks much better. At least two consecutive sample have basically the same scale and shape.

2 Likes