Group 18 · Haotian Zha, Ruilin Dong, Tengteng Ji

Supervisor: Joseph Gitahi · Course: Geo Sensor Web and the Internet of Things (SS 2026)

Live result: Interactive comfort map


1. Task Description

1.1 Motivation

Cycling is promoted everywhere as clean city transport, but how comfortable a ride actually feels depends a lot on the road surface. Cobblestones, cracked asphalt, bridge joints or gravel produce vibrations that riders feel directly. City planners usually know the nominal surface type of a road (for example from OpenStreetMap), but not how it actually feels on a bike today, after years of wear.

Our project builds a small IoT measuring system, mounted on a normal bicycle, that records these vibrations together with GPS position. From the recorded data we compute a comfort index for every 50 m piece of road and show it on an interactive web map. The same data is also used to train a machine-learning classifier that recognizes the surface category from the vibration pattern, and to count strong single impacts (potholes, bridge joints) as a simple road-damage indicator.

For the other groups, the interesting part of this project is probably not only the result, but the typical IoT problems we ran into on the way: serial-port conflicts on a small microcontroller, LoRaWAN duty-cycle budgets, sensor mounting effects that silently corrupt a measurement, and how to check whether your own sensor readings can be trusted.

1.2 Description

The system consists of a Seeeduino LoRaWAN board (SAMD21) with an accelerometer (BMI088), a GNSS receiver (Air530), a vibration switch (SW-420), an environment sensor (BME680), a marker button, a small LCD, and a microSD card, all mounted in a 3D-printed enclosure on the bike.

During a ride the firmware samples acceleration at 100 Hz and writes the raw stream to the SD card. Every 30 s it sends an aggregated summary (frequency-weighted vibration level a_w, GPS position, speed, impact count, environment readings) over LoRaWAN via The Things Network into the course FROST server (OGC SensorThings API). After the ride, the raw SD data is processed offline into comfort segments, a surface classification, and the web map.

The expected outcome, as defined by the official project description, is a comfort index along real cycling routes in Munich, visualized on a web map — and this is what we deliver, plus air-quality/environment data (optional objective) and a surface-type ML model on top.

1.3 Challenges and Problems

The problems we expected at the start:

  • Limited bandwidth: LoRaWAN cannot transmit a 100 Hz signal. We solved this with a two-level design: raw data goes to the SD card, LoRa only carries 30 s summaries.
  • Sensor limits: a vibration switch like the SW-420 only says "shaking / not shaking" — it cannot measure comfort. The accelerometer has to do the quantitative work; the SW-420 was demoted to an impact counter.
  • Mounting effects: the device is never mounted perfectly level on the bike. This turned out to be the most important measurement-quality issue of the whole project (see section 7.2).
  • Reliability in the field: vibration shakes connectors loose. The firmware needed self-healing for I²C sensors and the SD card so that one loose contact does not silently kill a 1.5 h ride.

Problems we did not expect are described in the chapters where they appeared, e.g. the shared serial port between GPS and the LoRa modem (section 4.4) and duplicated observations in FROST (section 3.3).

1.4 Resources

Hardware:

ComponentModelRoleInterface / address
MCU boardSeeeduino LoRaWAN (ATSAMD21G18A + RHF76-052 LoRa)sampling, processing, uplinkUSB 115200
IMUGrove 6-axis BMI088 v1.1vibration measurement (main sensor)I²C 0x18 (accel) / 0x69 (gyro)
GNSSGrove GPS Air530 v1.1position + speed, GPS+BeiDouUART on D6/D7 (SERCOM5, RX-only)
Vibration switchSW-420impact event countingdigital D2
EnvironmentGrove BME680temperature, humidity, pressure, gas resistanceI²C 0x76
Marker buttonGrove Button v1.2ground-truth labeling during ridesdigital D3
LCDGrove 16x2 LCDon-bike status displayI²C 0x3E
StorageSD-Card Shield V4 + 16 GB microSD100 Hz raw loggingSPI, CS = D4
BatteryDTP18650-3P 1S 3.7 V 6600 mAhpower for full ridesJST-PH
Enclosure3D-printed (PETG, TUM DeFac)mounting on the bike

Total spending of enclosure cost around €60–70; everything else came from the course pool.

Software / tools: Arduino (arduino-cli, Seeed SAMD core), The Things Network, Node-RED (course instance), FROST-Server (OGC SensorThings API), MongoDB mapping (course instance), Python with uv (pandas, numpy, scipy, scikit-learn, osmnx), CadQuery for the enclosure, Leaflet 1.9.4 + Chart.js 4.4.1 for the web map, GitHub Pages for hosting.

1.5 Information Sources

  • Course materials and the course wiki (FROST, Node-RED, TTN setup).
  • The two papers from the official project description: Gao et al. (2018) on cycling comfort from vibration, and Miah et al. (2019) on an instrumented bicycle — see References.
  • ISO 2631-1 for frequency weighting and comfort scale of whole-body vibration.
  • Datasheets: Bosch BMI088, Bosch BME680, Air530 GNSS module, Seeeduino LoRaWAN wiki pages.
  • OpenStreetMap (surface=* tags) as an independent, "nominal" description of road surfaces.

2. Objectives

From the official project description:

  1. O1 — Develop a method using IoT sensors to measure cycling vibrations caused by uneven road surfaces.
  2. O2 — Collect data by riding through the city.
  3. O3 — Develop a comfort index based on the measured vibrations.
  4. O4 — Visualize cycling routes on a web map, showing the variation in comfort along routes.
  5. O5 (optional) — Measure pollution data using air quality sensors and integrate this data in the analysis and visualization.

We additionally set two internal objectives, because a single-sensor index seemed like too small a result for a team of three:

  1. O6 (own) — Train a machine-learning classifier that predicts the surface category (smooth / moderate / rough) from the vibration windows.
  2. O7 (own) — Check the trustworthiness of our own measurements: repeatability across repeated rides (ICC) and comparison against OSM surface tags.

Section 10 discusses, objective by objective, to which degree these were met.

3. System Architecture


System architecture: two data paths from the bike to the web map

Our system has two data paths that both end in the same web map:

  1. Online path (live): device → LoRaWAN/TTN → Node-RED (CayenneLPP decoding + MongoDB device mapping) → FROST-Server (Thing 658, nine datastreams) → Python fetcher → GeoJSON for the map. This path carries only the 30 s summaries. We used it during rides as a live health check — a small self-made monitor page polls FROST every 15 s so the rider can see that data actually arrives.
  2. Offline path (authoritative): device → 100 Hz raw log on microSD → cleaning and quality report → offline recomputation of the comfort index → 50 m segments → GeoJSON for the map.

Why two paths, and why is the offline one authoritative? LoRaWAN physically cannot carry the raw signal (fair-use budget ≈ 30 s airtime per day), so the device must aggregate on-board. But an on-board aggregate is only as good as the on-board assumptions — and one of them — that the device sits level, so the z-axis is vertical — turned out to be wrong in practice. Because the full 100 Hz stream is preserved on SD, we could detect this problem afterwards and recompute the index correctly (section 7.2). The online path stays valuable for live monitoring and for the course data platform, but every number shown on the final map comes from the offline path.

3.1 SensorThings datastreams

The device appears in the course FROST server as Thing 658 with nine datastreams (CayenneLPP channel → datastream): GPS position (1728), comfort a_w (1729), IMU die temperature (1730, housekeeping only), impact count (1731), environment temperature (1797), humidity (1798), pressure (1799), gas resistance (1800), speed (1840).

3.2 Node-RED and device mapping

The course Node-RED instance decodes CayenneLPP uplinks and looks up DevEUI → datastream IDs in a MongoDB mapping document, then POSTs Observations into FROST. We only had to register our mapping; no own bridge code was needed.

3.3 A data-quality surprise: duplicated observations

Every uplink is stored twice in FROST (two identical observations with different IDs). We verified with frame counters and TTN metadata that this is not our firmware retransmitting — the shared ingestion layer simply posts twice, and it is not ours to fix. Consequence: averages are unaffected, but counts and dose-type sums would double. All our fetch code therefore deduplicates by phenomenonTime before analysis. This is a good example of why sensor data should never be used without checking it first.

4. Hardware Setup

The three-layer stack with all Grove sensors connected

4.1 Three-layer stack

The hardware is a stack of three boards: Seeeduino LoRaWAN at the bottom, SD-Card Shield V4 in the middle, Grove Base Shield V2 on top. All Grove sensors plug into the top layer; the LoRa antenna and the battery connect to the bottom board. The stack works without conflicts because the on-board LoRa modem talks over UART while the SD card exclusively uses SPI (D10–D13, CS on D4) — they do not share a bus.

4.2 The sensors, and how they work

  • BMI088 accelerometer — a MEMS accelerometer originally designed for drones, with a low noise density (~175 µg/√Hz) and good robustness against vibration — exactly the environment a bike frame provides. We run it at 100 Hz output data rate with ±6 g range. Comfort is computed from its acceleration signal (section 7).
  • Air530 GNSS — a multi-constellation receiver. In our RX-only wiring it runs in its default GPS+BeiDou mode at 1 Hz. At typical riding speed (15–20 km/h) 1 Hz means one fix every ~4–5 m, which is more than enough for 50 m segments. Metre-level urban multipath drift is handled later in analysis (snapping to the OSM cycle network).
  • SW-420 vibration switch — this sensor is only a threshold switch (output LOW while shaking exceeds a potentiometer-set level). It cannot measure how much vibration there is, so it is useless as a comfort sensor. We use it as a cheap independent impact counter per 30 s window.
  • BME680 — temperature, humidity, pressure and gas resistance. The gas reading needs a heated sensor plate and one reading blocks the bus for ~150–200 ms, so we read it once per 30 s window instead of inside the 100 Hz loop. The gas resistance during our rides is dominated by the sensor's own warm-up ramp and is not a calibrated air-quality value.
  • Grove button — our ground-truth tool: short press = surface-boundary marker, long press = impact event marker (section 6.3).

4.3 Enclosure


CAD assembly diagram of the enclosure

Printed enclosure mounted on the bike

The enclosure is fully parametric CAD (CadQuery, all dimensions in one params.py): a base box, a carrier plate that holds the three-board stack above the battery, a sensor tray (GNSS antenna facing up under the lid, BME680 exposed to airflow), a lid with the marker button accessible, and a small fit-test coupon that we printed first to calibrate hole diameters. Two practical lessons: FDM printers shrink small holes (a nominal ⌀1.6 mm hole printed closed), so all screw holes were re-dimensioned for DIN 7500 thread-forming screws with measured compensation. Second, the first design placed the stack pillars inside the battery footprint; this was caught by an interference check in the CAD script before printing, not after. The box mounts to the bottle-cage bosses (M5, 64 mm spacing) of the bike.

4.4 Hardware challenge: GPS vs. LoRa on the same serial port

This was our biggest hardware debugging challenge. Initially the GPS was plugged into the Grove UART socket — which on this board maps to Serial1. What is not obvious: the LoRa modem library uses the same Serial1 internally. During every uplink, NMEA sentences from the GPS collided with the modem's AT-command responses on the same RX line; the modem answers got corrupted and transmissions were reported as failed.

The fix: move the GPS to digital pins D6/D7 and configure a separate hardware serial (SERCOM5) for it at register level. On the SAMD21, D6/D7 can only map to SERCOM5, and only as RX (the TX-capable pads are not routed) — so the GPS is read-only for us, which also means we cannot send configuration commands to it (e.g. to enable GLONASS). We accepted that limitation. After the change, the three serial ports are cleanly separated: USB debug, LoRa modem, GPS.

Routing the GPS to its own SERCOM5 UART (RX on D6/D7)
// Switch the SERCOM5 RX to the given pad/pin (disable -> change RXPO -> enable -> route pin).
// rxpo=2 -> PAD2 (D6/PA20), rxpo=3 -> PAD3 (D7/PA21); TXPO=0 keeps TX on PAD0 (not routed out), so D6/D7 are never driven.
static void gpsRouteRx(uint8_t rxpo, uint8_t pin) {
  SERCOM5->USART.CTRLA.bit.ENABLE = 0;
  while (SERCOM5->USART.SYNCBUSY.bit.ENABLE) {}
  SERCOM5->USART.CTRLA.bit.RXPO = rxpo;
  SERCOM5->USART.CTRLA.bit.TXPO = 0x0;
  SERCOM5->USART.CTRLA.bit.ENABLE = 1;
  while (SERCOM5->USART.SYNCBUSY.bit.ENABLE) {}
  pinPeripheral(pin, PIO_SERCOM);   // SERCOM5 on PA20/PA21 = peripheral function C
}

A related lesson: transferPacket() on this modem returns false even when the uplink succeeds. We confirmed via the TTN console that the packets arrive, and changed our definition of "delivered" to visible in TTN/FROST, not the local return code.

5. Firmware and Data Logging

Firmware: test_all.ino (single file, ~900 lines, in the source archive). It samples the IMU at 100 Hz, computes the comfort aggregate, logs raw data to SD, and sends a CayenneLPP uplink every 30 s.

5.1 Acquisition parameters — and why

ParameterValueReason
Accelerometer rate100 HzThe ISO 2631-1 Wk weighting concentrates on 4–12.5 Hz and strongly attenuates above ~16 Hz; with the sensor's internal 19 Hz low-pass there is no aliasing at 100 Hz. Also matches what the SAMD21 can reliably write to SD (100 Hz × 16 B ≈ 1.6 kB/s).
Range±6 gWeighted comfort RMS is small (<2 m/s²), but a rigid bike frame on cobblestones produces short peaks of several g; ±3 g risks clipping, which would cut exactly the peaks we care about.
Anti-aliasing19 Hz internal bandwidthMust be well below Nyquist (50 Hz); riding vibration energy relevant for comfort is below ~20 Hz anyway.
Uplink rateevery 30 s, SF7TTN fair use ≈ 30 s airtime/day. At SF7 one packet ≈ 50 ms, so a full ride (~180 packets ≈ 9 s) fits easily. At SF12 the same packet takes ~1.5 s and the daily budget would allow only ~20 packets. ADR is disabled because it assumes a static node.
On-device indexWk-weighted 1 s RMS → 30 s a_wLoRa cannot carry 100 Hz, so the device aggregates; the raw stream goes to SD.
ISO 2631-1 Wk weighting as three cascaded biquads (discretized with scipy, \
// === ISO 2631-1 Wk weighting filter ===
// fs = 100 Hz, discretized with scipy.signal (bilinear), 3 cascaded biquads (transposed DF-II).
// The 0.4 Hz high-pass also removes the gravity DC; |error| <= 2.1 dB vs. the ISO Wk curve
// in 0.5-20 Hz (>20 Hz is already removed by the 19 Hz hardware bandwidth).
struct Biquad { float b0, b1, b2, a1, a2; float z1, z2; };
Biquad wk[3] = {
  { 0.284598673f,  0.160496462f, -0.124102211f, -0.951635762f, 0.298659589f, 0.0f, 0.0f },
  { 1.000000000f, -1.799859740f,  0.822202439f, -1.678289655f, 0.732145082f, 0.0f, 0.0f },
  { 1.000000000f, -2.000000000f,  1.000000000f, -1.964462450f, 0.965082979f, 0.0f, 0.0f },
};
static inline float wkFilter(float x) {
  for (int i = 0; i < 3; i++) {
    float y = wk[i].b0 * x + wk[i].z1;
    wk[i].z1 = wk[i].b1 * x - wk[i].a1 * y + wk[i].z2;
    wk[i].z2 = wk[i].b2 * x - wk[i].a2 * y;
    x = y;
  }
  return x;
}

5.2 SD logging: two files per session

Each session writes a pair of files: IMU_NNNN.BIN — the pure 100 Hz stream, 16 bytes per sample (timestamp + 6 × int16 accel/gyro), written through a ring buffer so that SD write stalls never block the sampling loop — and EVT_NNNN.CSV — sparse events: a self-describing session header, ~1 Hz GPS lines, one UTC anchor, button markers, uplink-gap markers, 30 s environment readings and health snapshots. The binary format keeps the data rate low and the decoding deterministic; the CSV keeps everything that does not need 100 Hz in a human-readable form.

5.3 Fault tolerance

The firmware is designed so that every subsystem is allowed to fail:

  • Sensor or SD init failures degrade (bounded retries, then continue without that subsystem and say so) instead of blocking in an infinite loop.
  • During every 30 s uplink window the firmware pings all I²C devices; a disappeared sensor is marked down (LCD + log) and automatically re-initialized when it responds again.
  • If the SD card drops out, the firmware retries every 30 s and, on success, opens a new session and continues logging.
  • The LCD shows session number, GPS satellites, sample count and current a_w, so problems are visible while riding without a laptop.
Runtime I2C health check with automatic sensor re-initialization
// I2C ACK probe: a present chip ACKs on the bus (endTransmission == 0).
// Does not rely on the library's readSensor() return value (it is void).
static bool i2cAlive(uint8_t addr) {
  Wire.beginTransmission(addr);
  return Wire.endTransmission() == 0;
}
// -- Runtime I2C sensor check + self-healing (runs in the uplink window,
//    zero cost in the 100 Hz hot path; symmetric to the SD auto-resume) --
// accel: on resume it needs setRange + setOdr + a Wk warmup reset (the filter state is stale).
if (H.accel) { if (!i2cAlive(0x18)) markSensorDown(&H.accel, "accel"); }
else if (i2cAlive(0x18) && accel.begin() >= 0) {
  accel.setRange(Bmi088Accel::RANGE_6G);
  accel.setOdr(Bmi088Accel::ODR_100HZ_BW_19HZ);
  H.accel = true;
  warmupDone = false; warmupStartMs = millis();   // reset accumulated state on resume (same pattern as openSession)#if USE_GRAVITY_PROJECTION
  gravFrozen = false; gravSumX = gravSumY = gravSumZ = 0; gravN = 0;   // orientation may have changed, re-estimate g
#endif
  evtSensorEvent(millis(), "accel", "resume");
  Serial.println("[SENSOR] accel -> RESUMED");
  lcdRefresh();
}

6. Data Collection

6.1 Routes


Web map overview of one full Innerer Radlring loop

  • Main route: Innerer Radlring (Munich's inner cycling ring, ~15 km closed loop), ridden 3 times on different days and times of day (late night, morning, evening). We chose this route because it has the surface diversity built in: old-town cobblestones, smooth asphalt along the Isar, mixed-traffic asphalt, bridge joints and compacted gravel in parks — one loop produces a naturally graded dataset.
  • Control route: Nymphenburg canal (~1 km of uniform smooth asphalt), ridden as short sessions on one evening. The original plan was to use it as a strict "zero point" for the comfort index; in the end the a_w quality of these early sessions was not good enough for a hard baseline (short distance, partly stationary), so we used them as labeling reference and test data instead — discussed openly in section 10.
  • We also planned one reverse-direction loop to test direction invariance, but dropped it deliberately: on the Radlring the opposite direction uses the cycle lane on the other side of the street, which is physically a different surface — the comparison would not test what we wanted.

In total: 6 published sessions, ~56 km of riding, plus corridor test sessions used for ML training (9 recording sessions overall).

SessionDateContentLength
0003–00052026-06-21Nymphenburg canal corridor, short control/labeling rides~1 km each
00062026-06-24Innerer Radlring loop 1 (night)23.6 km incl. access
00072026-06-27Innerer Radlring loop 2 (morning)17.3 km
00082026-06-27Innerer Radlring loop 3 (evening)15.3 km

6.2 What one ride looks like

Start with the device stationary for ~10 s (the firmware estimates the gravity direction and settles the weighting filter), then ride normally at 15–20 km/h. The rider watches the LCD occasionally; a second person can watch the live FROST monitor page. After the ride, the SD card is read out and archived unchanged.

6.3 Ground-truth labeling with one button

For ML training we needed labeled data, but stopping to take notes every 200 m is not realistic. Our protocol: short press at every surface change (boundary marker), long press when hitting a pothole or bridge joint (event marker), plus a spoken memo ("cobblestone", "smooth asphalt", …) recorded on a phone. The firmware timestamps the press instant; the analysis side tolerates ±12 s reaction delay and snaps event markers to the nearest vibration peak. This gave us usable segment boundaries at almost zero hardware cost.

7. Data Processing and Comfort Index

7.1 Cleaning and quality control

clean_baseline.py decodes the raw SD files byte by byte and adds marker columns without touching the 100 Hz samples: UTC time (anchored once per session), per-sample GPS speed, moving (speed > 0.5 m/s), riding (stationary periods ≥ 2 min are excluded), and a gap ID that splits the stream at data holes. Every session gets an automatic quality report: effective sample rate, gap count, GPS fix coverage, clipping counters, and — important — the mean gravity vector.

7.2 Why we recompute the comfort index offline

This is the "check your own sensor readings" part of the project, so we describe it in detail:

The firmware computes a_w by applying the ISO 2631-1 Wk weighting to the z-axis of the accelerometer — implicitly assuming the device sits level. However on the real bike the gravity vector does not point along z: the box is mounted tilted. That means the device-side a_w (datastream 1729) partially weights the wrong axis and is quantitatively not trustworthy.

Because the full raw stream exists on SD, we could fix this: offline, we estimate the gravity direction ĝ continuously (zero-phase low-pass below 0.4 Hz per gap segment), project the 3-axis acceleration onto ĝ to get the true vertical component, and then apply exactly the same Wk filter coefficients as the firmware (they were ported 1:1). The only difference between device and offline computation is which axis gets weighted — which makes this a clean controlled comparison between a naive and a corrected reading of the same sensor.

Gravity projection: vertical component a_v and horizontal residual (the tilt correction)
def project(acc, gravity):
    """Project onto the gravity direction. Returns (a_v, a_res):

    a_v  : (N,) vertical scalar = acc . g_hat (still contains the gravity DC,
           which the 0.4 Hz Wk high-pass removes later).
    a_res: (N,3) horizontal residual = acc - (acc . g_hat) g_hat, in the plane
           perpendicular to g_hat (used for direction-independent features).
    """
    acc = np.asarray(acc, dtype=float)
    gravity = np.asarray(gravity, dtype=float)
    gnorm = gravity / np.linalg.norm(gravity, axis=1, keepdims=True)
    a_v = np.sum(acc * gnorm, axis=1)
    a_res = acc - a_v[:, None] * gnorm
    return a_v, a_res

The offline pipeline then computes a_w in non-overlapping 2 s windows (only windows fully in moving && riding state count), attaches the nearest GPS fix, and aggregates windows into 50 m segments by median. Why 2 s and not the firmware's 30 s: at riding speed a 30 s window smears ~150 m of possibly different surfaces into one number; 2 s ≈ 10 m keeps the spatial resolution, and the median over 4–6 windows per 50 m bin is robust against single outliers.

7.3 Comfort scale

Segments are classified with the ISO 2631-1 comfort reaction scale on the weighted vertical a_w: < 0.315 m/s² "not uncomfortable" (class 1) up to ≥ 1.6 m/s² "very uncomfortable" (class 5). We deliberately use the generic ISO scale and not the regression from Gao et al. (2018), because Gao's model was calibrated on a different quantity (multi-axis awv on different bikes); we cite it for the vibration–comfort correlation, not for the thresholds.

7.4 Are our measurements repeatable?

If the comfort index measures something real, riding the same road three times should give similar segment values. We tested this: 50 m segments of the three loops were merged into common physical segments by GPS centroid proximity (30 m radius), giving 146 segments measured in all three rides, and we computed the intraclass correlation coefficient ICC(2,1) — the standard measure for absolute agreement of repeated measurements.

ICC(2,1), two-way random effects, absolute agreement
def icc_2_1(matrix):
    """Segments x rides matrix (rows with NaN dropped) -> ICC(2,1),
    two-way random effects, absolute agreement, single measurement."""
    m = np.asarray(matrix, dtype=float)
    m = m[~np.isnan(m).any(axis=1)]
    n, k = m.shape if m.ndim == 2 else (0, 0)
    out = {"icc": float("nan"), "n_segments": int(n), "k_sessions": int(k),
           "MSR": float("nan"), "MSC": float("nan"), "MSE": float("nan")}
    if n < 2 or k < 2:
        return out
    grand = m.mean()
    SSR = k * np.sum((m.mean(axis=1) - grand) ** 2)        # between segments (rows)
    SSC = n * np.sum((m.mean(axis=0) - grand) ** 2)        # between rides (columns)
    SSE = np.sum((m - grand) ** 2) - SSR - SSC             # residual
    MSR, MSC, MSE = SSR / (n - 1), SSC / (k - 1), SSE / ((n - 1) * (k - 1))
    denom = MSR + (k - 1) * MSE + k * (MSC - MSE) / n
    out.update(MSR=float(MSR), MSC=float(MSC), MSE=float(MSE),
               icc=float((MSR - MSE) / denom) if denom != 0 else float("nan"))
    return out

Result: ICC(2,1) = 0.48 (n = 146 segments, k = 3 rides). This is moderate-at-best agreement, just below the usual 0.5 threshold, and we report it as such. The between-ride variance component is clearly nonzero — the three loops were ridden at different times of day with different traffic, different micro-routing around pedestrians, and different speeds, all of which genuinely change the vibration exposure of a segment. The realistic conclusion: a single ride gives a useful relative picture (rough spots are reliably rough), but stable absolute segment values need averaging over several rides.


Distribution/agreement of segment a_w across the three loops

8. Machine Learning: Surface Classification

On top of the comfort index we trained a classifier that predicts the surface category of each 50 m segment from vibration features: smooth / moderate / rough. Strong single impacts (potholes) are treated as a separate parallel event stream, not as a fourth class.

8.1 Features

From each 2 s window we extract 16 features, all computed on the gravity-aligned components (same tilt correction as section 7.2): RMS/peak/kurtosis/skewness/crest factor/VDV of the vertical component, its dominant frequency and band energies (0–5 / 5–15 / 15–30 Hz), direction-independent horizontal RMS via covariance eigenvalues (so the bike's heading does not matter), and GPS speed statistics. The final model uses 13 of them after feature selection.

8.2 Labels

Hand-labeling 14 000 windows was not an option, so labels come from three fused sources with an explicit priority: (1) the segment-median a_w with fixed thresholds does the main classification, (2) the button boundary markers refine the segmentation, and (3) OSM surface=* tags act only as weak support. We initially wanted OSM as the main label source, but discovered that nominal OSM roughness is not monotone with measured vibration — well-laid stone setts (nominally "rough") measure smoother than compacted gravel (nominally "moderate"). That finding is a small result in itself: the map does not capture how a surface actually feels to ride on.

8.3 Evaluation

Vibration windows are strongly spatially autocorrelated — neighboring windows on the same street are almost identical. A naive random cross-validation split would put windows from the same street into both training and test set, and the model would just memorize places. We therefore group all windows by physical segment (30 m spatial grid, merged across rides) and use StratifiedGroupKFold, so test segments are truly unseen roads. The headline metric is computed at segment level (majority vote of window predictions), because surfaces are a per-segment property.

Leakage-safe cross-validation: grouping by physical segment, segment-level majority vote
def eval_config(table, labels, model="rf", model_kw=None, weight_scheme=None,
                n_splits=5, drop_feats=None):
    """Window-level OOF (StratifiedGroupKFold by physical segment) ->
    window- and segment-level (majority vote per seg_uid) F1-macro."""
    model_kw = model_kw or {}
    feats = [f for f in FEATS if not drop_feats or f not in drop_feats]
    X = table[feats]; y = table["label"].to_numpy(); groups = table["physical_segment_id"].to_numpy()
    pcg = table.groupby("label")["physical_segment_id"].nunique()
    n_splits = int(min(n_splits, pcg.min()))
    sgkf = StratifiedGroupKFold(n_splits=n_splits, shuffle=True, random_state=0)
    sw = _sample_weight(table, weight_scheme)
    oof = np.empty(len(y), dtype=object)
    for tri, tei in sgkf.split(X, y, groups):
        pipe = _pipe(model, **model_kw)
        fit_kw = {"clf__sample_weight": sw[tri]} if sw is not None else {}
        pipe.fit(X.iloc[tri], y[tri], **fit_kw)
        oof[tei] = pipe.predict(X.iloc[tei])
    win_f1 = float(f1_score(y, oof, average="macro", labels=labels))
    # segment-level aggregation (window OOF predictions -> majority vote per seg_uid)
    seg = pd.DataFrame({"seg_uid": table["seg_uid"].values, "y": y, "pred": oof})
    st, sp = [], []
    for _, g in seg.groupby("seg_uid"):
        st.append(g["y"].iloc[0])
        sp.append(g["pred"].value_counts().idxmax())
    seg_f1 = float(f1_score(st, sp, average="macro", labels=labels))
    # ... (per-class P/R/F1, confusion matrix and return dict omitted)

8.4 Results

Random forest, 5-fold grouped CV over 14 238 windows / 541 labeled segments / 9 rides:

  • Segment-level F1-macro = 0.81 (window-level 0.65 — single windows are genuinely ambiguous).
  • Per class (segment level): smooth F1 = 0.90, rough F1 = 0.88, moderate F1 = 0.64 — the middle class is hardest, as expected; the dominant confusion is moderate ↔ smooth.
  • An ablation supports that the model learns more than a circular re-derivation of a_w: removing all a_w-proxy features (RMS, VDV, peak, crest) slightly improves segment F1 (0.816), i.e. the frequency-band and shape features carry real independent signal.


Segment-level confusion matrix of the 3-class surface model

Limitations worth stating explicitly: the class distribution is very skewed (78 % of windows are smooth); labels are weak supervision (threshold + button + OSM), not human ground truth; and there is no fully independent held-out ride — all 9 rides are inside the grouped CV.

9. Visualization


The interactive map: comfort layer with legend and segment popup


The same segments colored by ML surface prediction

The final map is a static Leaflet site (no backend needed), published at https://trouties.github.io/cycling-comfort-viz/ via GitHub Pages. Features:

  • Ride switcher for the 6 published sessions.
  • 50 m segment layer with four color modes: comfort class (ISO scale, green→red), ML surface prediction, and temperature / humidity as diverging colors relative to the ride median.
  • Per-window points layer and an optional OSM surface layer (the snapped OSM tags, so measured vs. nominal surface can be compared visually per street).
  • Popups with the underlying numbers (median a_w, window count, ML prediction with confidence, snap distance for OSM).
  • A sidebar with ride conditions (BME680), vibration/impact time series, and data-quality metadata.

We were deliberate about surfacing these caveats directly in the UI, rather than leaving them out. The ML layer is labeled "treat as a hint, not ground truth (segment F1 ≈ 0.81)." The environmental layers note that the device-mounted sensor has self-heating effects and roughly 125 m of lag, so readings aren't directly comparable across rides. The gas card is flagged as a warm-up ramp rather than a calibrated air-quality measurement.

10. Results and Discussion

What was achieved:

  • O1 (measurement method). The full chain works: 100 Hz tilt-corrected, ISO-weighted vibration measurement with GPS reference, packaged in a bike-mountable box, with live LoRaWAN monitoring and lossless raw logging.
  • O2 (city data collection). Three full Innerer Radlring loops (15–24 km each, different times of day) plus control-corridor sessions; 12 recording sessions overall, 6 published on the map.
  • O3 (comfort index). The index exists at two levels: the device-side live a_w and the authoritative offline recomputation. Repeatability is moderate (ICC = 0.48 over three rides): relative patterns are stable, absolute per-segment values benefit from averaging several rides.
  • O4 (web map). Interactive public map with comfort, surface, environment and OSM comparison layers.
  • O5 (optional air quality). The BME680 environment data (temperature, humidity, pressure, gas) is collected, uplinked, stored in FROST and visualized. But the gas-resistance signal during a 1–2 h ride is dominated by the sensor's warm-up ramp and would need calibration and much longer runtimes to mean anything.
  • O6 (own: ML surface classification). Segment-level F1-macro 0.81 with leakage-safe evaluation; weakest on the middle class, as expected.
  • O7 (own: trust checks). ICC repeatability analysis, OSM comparison (with the non-monotonicity finding), FROST duplicate detection, and per-session quality reports.

Deviations from the plan, and why:

  • The Nymphenburg control was ridden (sessions 0003–0005) but demoted from "hard zero-point baseline" to labeling/test data: the sessions were short and their a_w quality was not good enough to calibrate against. A proper baseline would need a longer, cleaner smooth-asphalt ride.
  • The reverse-direction loop was dropped deliberately after realizing that the opposite direction rides a physically different lane — the experiment would not have measured direction invariance, only lane difference.

11. Conclusion

The project delivers what the task asked for: an IoT vibration measurement method, real city data, an ISO-based comfort index, and a public web map — plus a surface classifier and several measurement-trust analyses on top. The result that we find most transferable to other IoT projects is not a number but a pattern: keep the raw data, and check your readings before you believe them. Every important correction in this project was only possible because we logged more than we transmitted and compared our data against independent references.

Smaller lessons: knowing which peripherals share which bus before wiring matters on small boards (the Serial1 conflict cost us days); a return code is not a delivery confirmation (TX FAIL); field hardware should let every subsystem fail and recover on its own (our SD card did exactly that mid-ride).

12. References

  1. Gao, J.; Sha, A.; Huang, Y.; Hu, L.; Tong, Z.; Jiang, W.: Evaluating the cycling comfort on urban roads based on cyclists' perception of vibration. Journal of Cleaner Production 192 (2018), pp. 531–541. DOI: 10.1016/j.jclepro.2018.04.275.
  2. Miah, S.; Milonidis, E.; Kaparias, I.; Karcanias, N.: Measuring cycle riding comfort in Southampton using an instrumented bicycle. In: 2019 6th International Conference on Models and Technologies for Intelligent Transportation Systems (MT-ITS), Cracow, Poland. IEEE, 2019, pp. 1–8.
  3. ISO 2631-1:1997: Mechanical vibration and shock — Evaluation of human exposure to whole-body vibration — Part 1: General requirements. International Organization for Standardization, Geneva, 1997.
  4. OGC SensorThings API Part 1: Sensing, Version 1.1. Open Geospatial Consortium, 2021. https://docs.ogc.org/is/18-088/18-088.html (accessed 2026-07-05).
  5. Bosch Sensortec: BMI088 6-axis Motion Tracking for High-performance Applications — Datasheet. https://www.bosch-sensortec.com/products/motion-sensors/imus/bmi088/ (accessed 2026-07-05).
  6. Bosch Sensortec: BME680 Low power gas, pressure, temperature & humidity sensor — Datasheet. https://www.bosch-sensortec.com/products/environmental-sensors/gas-sensors/bme680/ (accessed 2026-07-05).
  7. Seeed Studio: Seeeduino LoRaWAN — Wiki. https://wiki.seeedstudio.com/Seeeduino_LoRAWAN/ (accessed 2026-07-05).
  8. The Things Network: Fair Use Policy. https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/ (accessed 2026-07-05).
  9. FRAUNHOFER IOSB: FROST-Server. https://github.com/FraunhoferIOSB/FROST-Server (accessed 2026-07-05).
  10. OpenStreetMap contributors: OpenStreetMap (surface tagging). https://wiki.openstreetmap.org/wiki/Key:surface (accessed 2026-07-05).
  11. Boeing, G.: OSMnx: New methods for acquiring, constructing, analyzing, and visualizing complex street networks. Computers, Environment and Urban Systems 65 (2017), pp. 126–139.
  12. Pedregosa, F. et al.: Scikit-learn: Machine Learning in Python. Journal of Machine Learning Research 12 (2011), pp. 2825–2830.
  13. Leaflet contributors: Leaflet — a JavaScript library for interactive maps. https://leafletjs.com/ (accessed 2026-07-05).

13. Appendix: Source Code and Attachments

All source code is attached to this page as group18_source_code.zip. Every source file contains a header with authors, group number and last change date. Third-party components (Arduino cores, libraries, Python packages) are used unmodified and credited in the References / requirements files; the Wk filter coefficients were generated with scipy from the ISO 2631-1 Wk definition.

Path in archiveContent
codes/test_all/test_all.inoFirmware: sampling, comfort aggregation, SD logging, LoRa uplink, fault tolerance
codes/clean_baseline.pySD raw decoding, cleaning, quality reports
codes/comfort_offline.py, codes/ml/comfort_aw.pyOffline comfort index (gravity projection + Wk)
codes/comfort_pipeline.py, codes/frost_fetch.pyOnline FROST path, GeoJSON builders
codes/icc.pyRepeatability analysis (ICC)
codes/ml/Feature extraction, labeling, leakage-safe training
codes/monitor/Live ingestion monitor used during rides
web/The Leaflet web map
cad/Parametric enclosure (CadQuery)