Most water quality sensors work by sticking electrodes into the water and measuring electrical conductivity. It works, but it means the sensor degrades over time, requires calibration, and has to make direct contact with what you’re measuring.
I wanted to see if spectroscopy could do the same job without touching the water at all.
The result: a non-contact TDS (total dissolved solids) measurement device using a 14-channel spectroscopy sensor and a TinyML regression model on Raspberry Pi. 85% prediction accuracy in the 0-1000 ppm range. Measurement cycle under 5 seconds.
Here’s how it works, what I got wrong, and what the accuracy number actually means.
Why Spectroscopy
Water absorbs and reflects light differently depending on what’s dissolved in it. At high TDS concentrations, this effect is measurable across specific wavelength bands, particularly in the near-infrared range.
The AS7265x sensor from SparkFun covers 18 channels across visible and near-infrared wavelengths (410nm to 940nm). I settled on 14 channels after initial testing showed that 4 of the channels in the visible range contributed almost no predictive signal for TDS and just added noise to the model.
The core hypothesis: train a regression model on spectral signatures and TDS ground-truth measurements, then use that model to predict TDS from new spectral readings alone. No electrodes. No contact with the water.
Building the Dataset
The dataset was the most time-consuming part. I prepared water samples across the 0-1000 ppm range using distilled water and food-grade salt, verified with a reference EC meter. 500+ samples across 10 concentration levels, collected over about 6 weeks.
Every sample was measured under controlled lighting conditions: sensor mounted in a 3D-printed housing with a dark enclosure to block ambient light. This turned out to be critical. Early samples collected without the enclosure showed high variance at the same concentration level, because ambient light was contaminating the spectral readings.
Sample preparation detail: the salt-to-water ratio changes the refractive index of the solution, not just its conductivity. TDS meters measure conductivity as a proxy for dissolved solids. The spectroscopic approach is measuring something slightly different, which partially explains why the model doesn’t perfectly generalise. More on that below.
The Model
I tried three approaches:
Linear regression: R² of 0.71. Not good enough, and the residuals showed a clear non-linear pattern, especially above 600 ppm.
Random Forest: R² of 0.89 on the training set, 0.82 on held-out test data. Reasonable, but the model size (serialised to disk) was around 4MB, which felt large for an edge device.
Gradient Boosting (scikit-learn GradientBoostingRegressor): R² of 0.91 on training, 0.85 on test. Similar accuracy to Random Forest but more compact model (around 1.2MB). This is what I shipped.
The 85% accuracy figure comes from the test set R² of 0.85. In practice this means: for a sample at 500 ppm, the model’s prediction typically falls within 80-120 ppm of the true value. For a rough field indicator, that’s useful. For regulatory water testing, it’s not.
Raspberry Pi Deployment
The model runs on a Raspberry Pi 3B+ in inference-only mode. scikit-learn serialised with joblib, loaded once at startup, running predictions from the AS7265x readings over I2C.
The measurement cycle:
- Trigger sensor integration (800ms for all 18 channels at gain 64x)
- Read 14 channel values over I2C (~50ms)
- Normalise readings against baseline calibration
- Run model inference (~8ms on Pi CPU)
- Return prediction
Total: about 900ms per measurement. I ran three measurements and averaged them, giving a 5-second total cycle for improved stability.
Power draw was around 3.2W during active measurement, 1.1W at idle. On a 10,000mAh battery pack, that’s roughly 8 hours of intermittent field use.
What 85% Actually Means in the Field
The model was trained on salt-dissolved-in-distilled-water samples. Real water doesn’t work like that.
Field water contains a mix of dissolved minerals, organic compounds, and contaminants, each with different spectral signatures. When I tested the device on tap water, river water, and bottled mineral water, accuracy dropped to around 65-70% for tap and bottled water, and lower for the river sample which had visible organic content that the model had never seen.
This is the fundamental limitation of the approach: the model learns spectral patterns from training samples. If the dissolved compound has a different spectral signature than what’s in the training set, the prediction drifts.
For real-world deployment, you’d need a training set that includes representative samples from the specific water source you’re monitoring, which is a different problem from what I built.
Where This Approach Makes Sense
Despite the accuracy limitation, non-contact spectroscopic monitoring has real use cases:
- Process monitoring in industrial settings where the water composition is known and consistent. Train on samples from that specific process, and 85%+ accuracy becomes achievable.
- Trend detection rather than absolute measurement. If TDS jumps from 200 ppm to 600 ppm in an hour, the model will catch it even if the absolute values are off.
- Hygienic applications where contaminating a sensor is unacceptable, such as pharmaceutical water systems.
The project showed that the approach is viable, not that it’s a drop-in replacement for contact sensors. That distinction matters when writing up results honestly.
What I’d Do Differently
More diversity in the training data, collected from the start. I built the dataset with a narrow set of compounds and only noticed the generalisation problem during final testing. Collecting 200 samples from varied real-world sources alongside the controlled samples would have given a clearer picture of where the model breaks down.
I’d also try a small neural network instead of gradient boosting. With 14 input features and a regression target, a shallow MLP might generalise better across compound types. scikit-learn was the right choice for speed of iteration, but it’s not the end of the story for this problem.