diff --git a/ledsa/core/file_handling.py b/ledsa/core/file_handling.py index 0defc07..225b583 100644 --- a/ledsa/core/file_handling.py +++ b/ledsa/core/file_handling.py @@ -1,4 +1,5 @@ import os +import re from typing import List, Union import numpy as np @@ -304,6 +305,32 @@ def create_binary_data(channel: int, path: str = '.') -> None: LEGACY_FIT_COLUMNS = BASE_COLUMNS + LED_CENTER_COLUMNS + FIT_COLUMNS[:-1] +def _find_first_result_file(channel: int) -> str: + """ + Find the step 3 result file with the lowest image ID of a channel. + + An image that could not be read has no result file at all, so the lowest analysed image ID is not + necessarily 1 and the result file of image 1 does not have to exist. + + :param channel: Channel number whose result files are searched. + :type channel: int + :return: Path of the result file with the lowest image ID. + :rtype: str + :raises FileNotFoundError: If the channel has no result file. + """ + dir_path = os.path.join('analysis', f'channel{channel}') + img_ids = [] + if os.path.isdir(dir_path): + for file_name in os.listdir(dir_path): + img = re.search(r"(\d+)_led_positions.csv", file_name) + if img is not None: + img_ids.append(int(img.group(1))) + if not img_ids: + raise FileNotFoundError(f'No analysis results in {dir_path}. Run the data extraction (-s3 or ' + f'-s3_fast) before the analysis.') + return os.path.join(dir_path, f'{min(img_ids)}_led_positions.csv') + + def _get_column_names(channel: int) -> List[str]: """ Get the column names for the specified channel from the number of columns of the CSV files. @@ -313,8 +340,9 @@ def _get_column_names(channel: int) -> List[str]: :return: List of column names. :rtype: List[str] :raises ValueError: If the file cannot be parsed or the number of columns matches no known layout. + :raises FileNotFoundError: If the channel has no result file. """ - file_path = os.path.join('analysis', f'channel{channel}', '1_led_positions.csv') + file_path = _find_first_result_file(channel) try: parameters = ledsa.core.file_handling.read_table(file_path, delim=',', silent=True, atleast_2d=True) except ValueError as error: diff --git a/ledsa/tests/AcceptanceTests/07_test_corrupted_images.robot b/ledsa/tests/AcceptanceTests/07_test_corrupted_images.robot index 3c37b3d..cb33ec7 100644 --- a/ledsa/tests/AcceptanceTests/07_test_corrupted_images.robot +++ b/ledsa/tests/AcceptanceTests/07_test_corrupted_images.robot @@ -40,6 +40,19 @@ Analysis Skips Images Without Extraction Results # mismatch of the time axes and must not be reported as one Extco Time Axis Should Be Accepted +Analysis Reads The Column Names From The First Existing Result File + [Documentation] The column names of the analysis are read from one result file. An image that could + ... not be read has none at all, so an unreadable first image must not keep the analysis + ... from reading the names from the first file that does exist. + Corrupt Image test_img_1.jpg truncate + Execute Ledsa -s3_fast + Corrupted Images Report Should List test_img_1.jpg extraction of the LED intensities + # image 1 is the default reference image and has no results here, so image 2 provides the reference + Create And Fill Config Analysis linear num_ref_images=2 + Execute Ledsa --analysis + Analysis Should Exclude Image test_img_1.jpg + Extco Time Axis Should Be Accepted + *** Keywords *** Restore Test Images Restore Corrupted Images diff --git a/ledsa/tests/AcceptanceTests/LedsaATestLibrary.py b/ledsa/tests/AcceptanceTests/LedsaATestLibrary.py index 7939873..d9e6aaf 100644 --- a/ledsa/tests/AcceptanceTests/LedsaATestLibrary.py +++ b/ledsa/tests/AcceptanceTests/LedsaATestLibrary.py @@ -311,10 +311,11 @@ def create_and_fill_config(self, first=1, last=4): conf.save() @keyword - def create_and_fill_config_analysis(self, solver, reference_property='sum_col_val'): + def create_and_fill_config_analysis(self, solver, reference_property='sum_col_val', num_ref_images=1): + """Write config_analysis.ini. num_ref_images lets a test take the reference from another image than the first.""" conf = ConfigDataAnalysis(load_config_file=False, camera_position=None, num_layers=20, domain_bounds=None, - led_array_indices=0, num_ref_images=1, camera_channels=0, num_cores=1, - reference_property=reference_property, + led_array_indices=0, num_ref_images=int(num_ref_images), camera_channels=0, + num_cores=1, reference_property=reference_property, average_images=False, solver=solver, weighting_preference=-6e-4, weighting_curvature=1e-7, num_iterations=2000, lambda_reg=1e-3) diff --git a/ledsa/tests/UnitTests/test_file_handling.py b/ledsa/tests/UnitTests/test_file_handling.py new file mode 100644 index 0000000..63d5548 --- /dev/null +++ b/ledsa/tests/UnitTests/test_file_handling.py @@ -0,0 +1,56 @@ +import os + +import pytest + +from ledsa.core.file_handling import _find_first_result_file, _get_column_names + + +class TestFindFirstResultFile: + + def _write_result_files(self, tmp_path, img_ids): + channel_dir = tmp_path / 'analysis' / 'channel0' + channel_dir.mkdir(parents=True) + for img_id in img_ids: + (channel_dir / f'{img_id}_led_positions.csv').write_text('# header\n', encoding='utf-8') + + def test_lowest_image_id_wins(self, tmp_path, monkeypatch): + # the IDs are compared as numbers, so 2 comes before 10 + self._write_result_files(tmp_path, [10, 2, 3]) + monkeypatch.chdir(tmp_path) + assert _find_first_result_file(0) == os.path.join('analysis', 'channel0', '2_led_positions.csv') + + def test_image_without_results_is_skipped(self, tmp_path, monkeypatch): + # image 1 could not be read, so it has no result file at all + self._write_result_files(tmp_path, [2, 4]) + monkeypatch.chdir(tmp_path) + assert _find_first_result_file(0) == os.path.join('analysis', 'channel0', '2_led_positions.csv') + + def test_other_files_are_ignored(self, tmp_path, monkeypatch): + self._write_result_files(tmp_path, [3]) + (tmp_path / 'analysis' / 'channel0' / 'all_parameters.h5').write_text('', encoding='utf-8') + monkeypatch.chdir(tmp_path) + assert _find_first_result_file(0) == os.path.join('analysis', 'channel0', '3_led_positions.csv') + + def test_channel_without_results_raises(self, tmp_path, monkeypatch): + self._write_result_files(tmp_path, []) + monkeypatch.chdir(tmp_path) + with pytest.raises(FileNotFoundError, match='Run the data extraction'): + _find_first_result_file(0) + + def test_missing_channel_directory_raises(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with pytest.raises(FileNotFoundError, match='No analysis results'): + _find_first_result_file(0) + + +class TestColumnNamesWithoutTheFirstImage: + + def test_names_are_read_from_the_first_existing_file(self, tmp_path, monkeypatch): + # the reported case: image 1 could not be read, so the analysis has to fall back to image 2 + channel_dir = tmp_path / 'analysis' / 'channel0' + channel_dir.mkdir(parents=True) + row = ','.join(str(float(i)) for i in range(6)) + (channel_dir / '2_led_positions.csv').write_text(f'# header\n{row}\n{row}\n', encoding='utf-8') + monkeypatch.chdir(tmp_path) + assert _get_column_names(0) == ["img_id", "led_id", "led_array_id", "sum_col_val", "mean_col_val", + "max_col_val", "bgsub_sum_col_val", "width", "height"]