From 48cdf84508f35aaa05ee5d7045f54366d51e315b Mon Sep 17 00:00:00 2001 From: Alex Nimmo Smith Date: Thu, 18 Jun 2026 15:30:57 +0100 Subject: [PATCH] Fix path_length silently ignored in CalculateImageStats data['settings']['general'] is a plain dict, so getattr(general, 'path_length', 40) always returned the default, silently ignoring any path_length configured in config.toml when calculating volume concentration (vc) and sample_volume. Every existing test happened to use path_length=40, masking the bug. Use general.get('path_length', 40) instead, and add a regression test with a non-default path_length to catch this in future. Fixes #363 --- pyopia/__init__.py | 2 +- pyopia/process.py | 2 +- pyopia/tests/test_pipeline.py | 48 +++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/pyopia/__init__.py b/pyopia/__init__.py index c4ed6cf..b15cdcb 100644 --- a/pyopia/__init__.py +++ b/pyopia/__init__.py @@ -1 +1 @@ -__version__ = "2.16.6" +__version__ = "2.16.7" diff --git a/pyopia/process.py b/pyopia/process.py index 5d18189..eeee21a 100644 --- a/pyopia/process.py +++ b/pyopia/process.py @@ -734,7 +734,7 @@ def __call__(self, data): d50 = pyopia.statistics.d50_from_stats(data['stats'], pixel_size) data['image_stats'].loc[data['timestamp'], 'd50'] = d50 - path_length = getattr(data['settings']['general'], 'path_length', 40) + path_length = data['settings']['general'].get('path_length', 40) if path_length is not None: # Get image dimensions from the corrected image image = data.get('imraw') diff --git a/pyopia/tests/test_pipeline.py b/pyopia/tests/test_pipeline.py index 1cd8f4c..d659422 100644 --- a/pyopia/tests/test_pipeline.py +++ b/pyopia/tests/test_pipeline.py @@ -189,6 +189,54 @@ def test_silcam_pipeline(): ' Something has altered the number of particles detected') +def test_calculate_image_stats_uses_configured_path_length(): + '''Verifies CalculateImageStats uses the path_length configured in general settings, + rather than silently falling back to the default of 40mm. + + Regression test: data['settings']['general'] is a plain dict, so a previous + implementation using getattr(general, 'path_length', 40) always returned the + default, silently ignoring any configured path_length. + ''' + import pandas as pd + from pyopia.statistics import nc_vc_from_stats + + pixel_size = 28.0 + path_length = 123.0 # deliberately not the default of 40, to catch silent fallback + imy, imx = 100, 200 + + timestamp = pd.Timestamp('2026-01-01T00:00:00') + stats = pd.DataFrame({ + 'major_axis_length': [10.0], + 'minor_axis_length': [8.0], + 'equivalent_diameter': [9.0], + 'saturation': [1.0], + 'export_name': ['D20260101T000000.000000-PN0'], + 'timestamp': [timestamp], + }) + + data = { + 'settings': {'general': {'pixel_size': pixel_size, 'path_length': path_length}}, + 'imraw': np.zeros((imy, imx, 3), dtype=np.uint8), + 'stats': stats, + 'timestamp': timestamp, + } + + step = pyopia.process.CalculateImageStats() + data = step(data) + + expected_nc, expected_vc, expected_sample_volume, expected_junge = nc_vc_from_stats( + stats, pixel_size, path_length, imx=imx, imy=imy) + + result = data['image_stats'].loc[data['timestamp']] + np.testing.assert_allclose(result['vc'], expected_vc) + np.testing.assert_allclose(result['sample_volume'], expected_sample_volume) + + # Sanity check that this isn't coincidentally matching the old (broken) default of 40mm + wrong_nc, wrong_vc, wrong_sample_volume, wrong_junge = nc_vc_from_stats( + stats, pixel_size, 40, imx=imx, imy=imy) + assert not np.isclose(result['vc'], wrong_vc) + + def test_per_class_concentration(): '''Verifies PerClassConcentration writes timestamp-indexed per-class number concentrations (numbers/L) to CSV across multiple images.