From b4f6da7c72ff4caba4652c62810e0e9abbeb76f6 Mon Sep 17 00:00:00 2001 From: eq-sandkav Date: Thu, 20 Aug 2026 11:51:26 +0200 Subject: [PATCH 1/3] Spread utility accross multiple files Creating new files quickly create circular dependencies with _excel_to_dict.py. Spreading these functions accross multiple files eases the dependency problems. --- src/semeio/fmudesign/_excel_to_dict.py | 265 +++------------------- src/semeio/fmudesign/read_background.py | 107 +++++++++ src/semeio/fmudesign/read_correlations.py | 60 +++++ src/semeio/fmudesign/utils.py | 74 ++++++ 4 files changed, 274 insertions(+), 232 deletions(-) create mode 100644 src/semeio/fmudesign/read_background.py create mode 100644 src/semeio/fmudesign/read_correlations.py diff --git a/src/semeio/fmudesign/_excel_to_dict.py b/src/semeio/fmudesign/_excel_to_dict.py index dc800d046..097274483 100644 --- a/src/semeio/fmudesign/_excel_to_dict.py +++ b/src/semeio/fmudesign/_excel_to_dict.py @@ -3,21 +3,45 @@ by the DesignMatrix class to generate design matrices. """ -import collections import contextlib -import math from collections import Counter from collections.abc import Hashable, Sequence from pathlib import Path from typing import Any, cast -import numpy as np import openpyxl import pandas as pd import yaml -from semeio.fmudesign.design_distributions import read_correlations -from semeio.fmudesign.utils import seeds_from_extern +from semeio.fmudesign.read_background import read_background +from semeio.fmudesign.read_correlations import parse_sensitivity_correlations +from semeio.fmudesign.utils import ( + _has_value, + find_sheet, + resolve_path, + seeds_from_extern, +) + + +def _read_general_input( + input_filename: str, general_input_sheet: str +) -> dict[str, Any]: + general_input = ( + pd.read_excel( + input_filename, + general_input_sheet, + header=None, + index_col=0, + engine="openpyxl", + ) + .dropna(axis=0, how="all") + .dropna(axis=1, how="all") + .loc[:, 1] + .to_dict() + ) + return {str(k): v for k, v in general_input.items()} | { + "input_filename": input_filename + } def excel_to_dict( @@ -88,30 +112,6 @@ def inputdict_to_yaml(inputdict: dict[str, Any], filename: str) -> None: yaml.dump(inputdict, stream) -def find_sheet(name: str, names: list[str]) -> str: - """Search for Excel sheets with a soft matching. Raises ValueError if zero - or more than one match is found. - - Examples: - >>> find_sheet('general_input', ['generalinput', 'designinput', 'defaultinput']) - 'generalinput' - >>> find_sheet('variable_input', ['generalinput', 'designinput', 'defaultinput']) - Traceback (most recent call last): - ... - ValueError: No match for variable_input: ['generalinput', 'designinput', 'defaultinput'] - """ # ruff: ignore[line-too-long] - - def sanitize(inputstring: str) -> str: - return inputstring.lower().strip().replace("_", "") - - found = [name_i for name_i in names if sanitize(name) == sanitize(name_i)] - if len(found) > 1: - raise ValueError(f"More than one match for {name}: {found}") - if len(found) == 0: - raise ValueError(f"No match for {name}: {names}") - return found[0] - - def _check_designinput(dsgn_input: pd.DataFrame) -> None: """Checks for valid input in designinput sheet""" # Filter out rows where sensname has no value @@ -150,35 +150,6 @@ def _check_for_mixed_sensitivities(sens_name: str, sens_group: pd.DataFrame) -> ) -def resolve_path(input_filename: str, reference: str | None) -> str | None: - """The path `input_filename` is an Excel sheet, and `reference` is a cell - value that *might* be a reference to another file. Resolve the path to - `reference` and return. If no such file exists, return `reference`. - """ - # The reference is None, so just return it back - if reference is None: - return reference - - # It's a string but not a reference to another file - if not str(reference).endswith(("xlsx", "csv")): - return reference - - # If the reference is e.g. 'C:/Users/USER/files/doe1.xlsx' - reference_path = Path(reference) - if reference_path.is_absolute() and reference_path.exists(): - return str(reference_path.resolve()) - - # If the reference is e.g. 'doe1.xlsx' - full_path = Path(input_filename).parent / reference_path - if full_path.exists(): - return str(full_path.resolve()) - - if reference_path.exists(): - return str(reference_path.resolve()) - - raise ValueError(f"Failed to resolve path for file: {reference}") - - def _excel_to_dict_onebyone( input_filename: str, *, @@ -282,7 +253,7 @@ def parse_value(value: object) -> object: elif background.endswith(("csv", "xlsx")): output[key] = {"extern": resolve_path(input_filename, background)} else: - output[key] = _read_background(input_filename, background) + output[key] = read_background(input_filename, background) output["defaultvalues"] = _read_defaultvalues(input_filename, default_values_sheet) @@ -343,7 +314,9 @@ def parse_value(value: object) -> object: sensdict["parameters"] = _read_dist_sensitivity(group) sensdict["correlations"] = None if "corr_sheet" in group: - sensdict["correlations"] = _read_correlations(group, input_filename) + sensdict["correlations"] = parse_sensitivity_correlations( + group, input_filename + ) elif sens_type == "extern": sensdict["extern_file"] = resolve_path( @@ -457,107 +430,6 @@ def _read_dependencies( return depend_dict -def _read_background(inp_filename: str, bck_sheet: str) -> dict[str, Any]: - """Reads excel sheet with background parameters and distributions - - Args: - inp_filename (str): name of Excel workbook - bck_sheet (str): name of sheet with background parameters - - Returns: - dict with parameter names and distributions - """ - backdict: dict[str, Any] = {} - paramdict: dict[str, Any] = {} - with pd.ExcelFile(inp_filename, engine="openpyxl") as workbook: - sheet_names = [str(name) for name in workbook.sheet_names] - try: - bck_sheet = find_sheet(bck_sheet, names=sheet_names) - except ValueError as err: - raise ValueError( - f"Sheet {bck_sheet!r} with background parameters, specified in the " - f"general input sheet, was not found in {inp_filename!r}.\n" - f"Sheets in workbook: {sheet_names}\n" - "Use 'None' as background in the general input sheet if no " - "background parameters are wanted." - ) from err - bck_input = ( - pd.read_excel(inp_filename, bck_sheet, engine="openpyxl") - .dropna(axis=0, how="all") - .loc[:, lambda df: ~df.columns.astype(str).str.contains("^Unnamed")] - ) - - backdict["correlations"] = None - if "corr_sheet" in bck_input: - backdict["correlations"] = _read_correlations( - bck_input, inp_filename, group_description=f"background sheet {bck_sheet!r}" - ) - - if "dist_param1" not in bck_input.columns.to_numpy(): - bck_input["dist_param1"] = float("NaN") - if "dist_param2" not in bck_input.columns.to_numpy(): - bck_input["dist_param2"] = float("NaN") - if "dist_param3" not in bck_input.columns.to_numpy(): - bck_input["dist_param3"] = float("NaN") - if "dist_param4" not in bck_input.columns.to_numpy(): - bck_input["dist_param4"] = float("NaN") - - for row in bck_input.itertuples(): - if not _has_value(row.param_name): - raise ValueError( - "Background parameters specified " - "where one line has empty parameter " - "name " - ) - if not _has_value(row.dist_param1): - raise ValueError( - f"Parameter {row.param_name} has been input " - "in background sheet but with empty " - "first distribution parameter " - ) - if not _has_value(row.dist_param2) and _has_value(row.dist_param3): - raise ValueError( - f"Parameter {row.param_name} has been input in " - "background sheet with " - 'value for "dist_param3" while ' - '"dist_param2" is empty. This is not ' - "allowed" - ) - if not _has_value(row.dist_param3) and _has_value(row.dist_param4): - raise ValueError( - f"Parameter {row.param_name} has been input in " - "background sheet with " - 'value for "dist_param4" while ' - '"dist_param3" is empty. This is not ' - "allowed" - ) - distparams = [ - item - for item in [ - row.dist_param1, - row.dist_param2, - row.dist_param3, - row.dist_param4, - ] - if _has_value(item) - ] - if "corr_sheet" in bck_input: - corrsheet = None if not _has_value(row.corr_sheet) else row.corr_sheet - else: - corrsheet = None - paramdict[str(row.param_name)] = [str(row.dist_name), distparams, corrsheet] - backdict["parameters"] = paramdict - - if "decimals" in bck_input: - decimals: dict[str, Any] = {} - for row in bck_input.itertuples(): - if _has_value(row.decimals) and _is_int(row.decimals): # type: ignore[arg-type] - decimals[row.param_name] = int(row.decimals) # type: ignore[arg-type, index] - backdict["decimals"] = decimals - - return backdict - - def _read_scenario_sensitivity(sensgroup: pd.DataFrame) -> dict[str, Any]: """Reads parameters and values for scenario sensitivities @@ -704,77 +576,6 @@ def _read_dist_sensitivity(sensgroup: pd.DataFrame) -> dict[str, Any]: return paramdict -def _read_correlations( - sensgroup: pd.DataFrame, inputfile: str, group_description: str | None = None -) -> dict[str, Any] | None: - """Parse correlation information from a sensitivity group. - - Args: - sensgroup: rows describing the parameters, either a sensitivity group - from the designinput sheet or the background sheet. - inputfile: name of the Excel workbook holding the correlation sheets. - group_description: how to refer to `sensgroup` in error messages. - Defaults to the sensname of the group. - """ - - # No correlation sheet column exists - if "corr_sheet" not in sensgroup.columns: - return None - - # The column exists, but it is all blank - if sensgroup["corr_sheet"].dropna().empty: - return None - - if group_description is None: - group_description = f"sensitivity group {sensgroup['sensname'].iloc[0]!r}" - - correlations: dict[str, Any] = {"inputfile": inputfile} - - # Create a mapping 'corr_to_params' like: - # {'corr1': ['var_A', 'var_B', ...], ...} - corr_to_params = collections.defaultdict(list) - for _, row in sensgroup.iterrows(): - if not _has_value(row["corr_sheet"]): - continue - corr_to_params[row["corr_sheet"]].append(row["param_name"]) - - # Open the correlation sheet and peek at it - # We want to verify that if variables ['A', 'B'] point to the corr sheet, - # then exactly those variables are also defined in the sheet - for corr_sheet, parameters in corr_to_params.items(): - df_corr = read_correlations(excel_filename=inputfile, corr_sheet=corr_sheet) - if set(df_corr.columns) != set(parameters): - msg = f"Mismatch between parameters in {group_description} " - msg += f"pointing to\ncorrelation sheet {corr_sheet!r} and " - msg += "parameters specified in that correlation sheet.\n" - msg += f"Parameters in {group_description}: {sorted(set(parameters))}\n" - msg += f"Parameters in correlation sheet: {sorted(set(df_corr.columns))}\n" - msg += "These parameters must be specified one-to-one." - raise ValueError(msg) - - correlations["sheetnames"] = list(set(corr_to_params.keys())) - - return correlations - - -def _has_value(value: Any) -> bool: # ruff: ignore[any-type] - """Returns False only if the argument is np.nan""" - try: - return not np.isnan(value) - except TypeError: - return True - - -def _is_int(teststring: str) -> bool: - """Test if string is a finite integer""" - try: - if not np.isnan(int(teststring)): - return math.isclose((float(teststring) % 1), 0, abs_tol=1e-14) - return False # It was a "number", but it was NaN. - except ValueError: - return False - - def _raise_if_duplicates(container: Sequence[Hashable]) -> None: """Raises a descriptive error if there are duplicates in the container.""" duplicates = {k: v for (k, v) in Counter(container).items() if v > 1} diff --git a/src/semeio/fmudesign/read_background.py b/src/semeio/fmudesign/read_background.py new file mode 100644 index 000000000..7594eae41 --- /dev/null +++ b/src/semeio/fmudesign/read_background.py @@ -0,0 +1,107 @@ +from typing import Any + +import pandas as pd + +from semeio.fmudesign.read_correlations import parse_sensitivity_correlations +from semeio.fmudesign.utils import _has_value, _is_int, find_sheet + + +def read_background(inp_filename: str, bck_sheet: str) -> dict[str, Any]: + """Reads excel sheet with background parameters and distributions + + Args: + inp_filename (str): name of Excel workbook + bck_sheet (str): name of sheet with background parameters + + Returns: + dict with parameter names and distributions + """ + backdict: dict[str, Any] = {} + paramdict: dict[str, Any] = {} + with pd.ExcelFile(inp_filename, engine="openpyxl") as workbook: + sheet_names = [str(name) for name in workbook.sheet_names] + try: + bck_sheet = find_sheet(bck_sheet, names=sheet_names) + except ValueError as err: + raise ValueError( + f"Sheet {bck_sheet!r} with background parameters, specified in the " + f"general input sheet, was not found in {inp_filename!r}.\n" + f"Sheets in workbook: {sheet_names}\n" + "Use 'None' as background in the general input sheet if no " + "background parameters are wanted." + ) from err + bck_input = ( + pd.read_excel(inp_filename, bck_sheet, engine="openpyxl") + .dropna(axis=0, how="all") + .loc[:, lambda df: ~df.columns.astype(str).str.contains("^Unnamed")] + ) + + backdict["correlations"] = None + if "corr_sheet" in bck_input: + backdict["correlations"] = parse_sensitivity_correlations( + bck_input, inp_filename, group_description=f"background sheet {bck_sheet!r}" + ) + + if "dist_param1" not in bck_input.columns.to_numpy(): + bck_input["dist_param1"] = float("NaN") + if "dist_param2" not in bck_input.columns.to_numpy(): + bck_input["dist_param2"] = float("NaN") + if "dist_param3" not in bck_input.columns.to_numpy(): + bck_input["dist_param3"] = float("NaN") + if "dist_param4" not in bck_input.columns.to_numpy(): + bck_input["dist_param4"] = float("NaN") + + for row in bck_input.itertuples(): + if not _has_value(row.param_name): + raise ValueError( + "Background parameters specified " + "where one line has empty parameter " + "name " + ) + if not _has_value(row.dist_param1): + raise ValueError( + f"Parameter {row.param_name} has been input " + "in background sheet but with empty " + "first distribution parameter " + ) + if not _has_value(row.dist_param2) and _has_value(row.dist_param3): + raise ValueError( + f"Parameter {row.param_name} has been input in " + "background sheet with " + 'value for "dist_param3" while ' + '"dist_param2" is empty. This is not ' + "allowed" + ) + if not _has_value(row.dist_param3) and _has_value(row.dist_param4): + raise ValueError( + f"Parameter {row.param_name} has been input in " + "background sheet with " + 'value for "dist_param4" while ' + '"dist_param3" is empty. This is not ' + "allowed" + ) + distparams = [ + item + for item in [ + row.dist_param1, + row.dist_param2, + row.dist_param3, + row.dist_param4, + ] + if _has_value(item) + ] + if "corr_sheet" in bck_input: + corrsheet = None if not _has_value(row.corr_sheet) else row.corr_sheet + else: + corrsheet = None + paramdict[str(row.param_name)] = [str(row.dist_name), distparams, corrsheet] + backdict["parameters"] = paramdict + + if "decimals" in bck_input: + decimals: dict[str, Any] = {} + for row in bck_input.itertuples(): + if _has_value(row.decimals) and _is_int(row.decimals): # type: ignore[arg-type] + decimals[row.param_name] = int(row.decimals) # type: ignore[arg-type, index] + backdict["decimals"] = decimals + + return backdict diff --git a/src/semeio/fmudesign/read_correlations.py b/src/semeio/fmudesign/read_correlations.py new file mode 100644 index 000000000..e6b148f2b --- /dev/null +++ b/src/semeio/fmudesign/read_correlations.py @@ -0,0 +1,60 @@ +from collections import defaultdict +from typing import Any + +import pandas as pd + +from semeio.fmudesign.design_distributions import read_correlations +from semeio.fmudesign.utils import _has_value + + +def parse_sensitivity_correlations( + sensgroup: pd.DataFrame, inputfile: str, group_description: str | None = None +) -> dict[str, Any] | None: + """Parse correlation information from a sensitivity group. + + Args: + sensgroup: rows describing the parameters, either a sensitivity group + from the designinput sheet or the background sheet. + inputfile: name of the Excel workbook holding the correlation sheets. + group_description: how to refer to `sensgroup` in error messages. + Defaults to the sensname of the group. + """ + + # No correlation sheet column exists + if "corr_sheet" not in sensgroup.columns: + return None + + # The column exists, but it is all blank + if sensgroup["corr_sheet"].dropna().empty: + return None + + if group_description is None: + group_description = f"sensitivity group {sensgroup['sensname'].iloc[0]!r}" + + correlations: dict[str, Any] = {"inputfile": inputfile} + + # Create a mapping 'corr_to_params' like: + # {'corr1': ['var_A', 'var_B', ...], ...} + corr_to_params = defaultdict(list) + for _, row in sensgroup.iterrows(): + if not _has_value(row["corr_sheet"]): + continue + corr_to_params[row["corr_sheet"]].append(row["param_name"]) + + # Open the correlation sheet and peek at it + # We want to verify that if variables ['A', 'B'] point to the corr sheet, + # then exactly those variables are also defined in the sheet + for corr_sheet, parameters in corr_to_params.items(): + df_corr = read_correlations(excel_filename=inputfile, corr_sheet=corr_sheet) + if set(df_corr.columns) != set(parameters): + msg = f"Mismatch between parameters in {group_description} " + msg += f"pointing to\ncorrelation sheet {corr_sheet!r} and " + msg += "parameters specified in that correlation sheet.\n" + msg += f"Parameters in {group_description}: {sorted(set(parameters))}\n" + msg += f"Parameters in correlation sheet: {sorted(set(df_corr.columns))}\n" + msg += "These parameters must be specified one-to-one." + raise ValueError(msg) + + correlations["sheetnames"] = list(set(corr_to_params.keys())) + + return correlations diff --git a/src/semeio/fmudesign/utils.py b/src/semeio/fmudesign/utils.py index f35ab2063..b70e877cd 100644 --- a/src/semeio/fmudesign/utils.py +++ b/src/semeio/fmudesign/utils.py @@ -2,8 +2,11 @@ Module for utility functions that do not belong elsewhere. """ +import math +from pathlib import Path from typing import Any +import numpy as np import pandas as pd @@ -214,3 +217,74 @@ def map_dependencies( print(f" {from_} => {to_}") return df + + +def find_sheet(name: str, names: list[str]) -> str: + """Search for Excel sheets with a soft matching. Raises ValueError if zero + or more than one match is found. + + Examples: + >>> find_sheet('general_input', ['generalinput', 'designinput', 'defaultinput']) + 'generalinput' + >>> find_sheet('variable_input', ['generalinput', 'designinput', 'defaultinput']) + Traceback (most recent call last): + ... + ValueError: No match for variable_input: ['generalinput', 'designinput', 'defaultinput'] + """ # ruff: ignore[line-too-long] + + def sanitize(inputstring: str) -> str: + return inputstring.lower().strip().replace("_", "") + + found = [name_i for name_i in names if sanitize(name) == sanitize(name_i)] + if len(found) > 1: + raise ValueError(f"More than one match for {name}: {found}") + if len(found) == 0: + raise ValueError(f"No match for {name}: {names}") + return found[0] + + +def _has_value(value: Any) -> bool: # ruff: ignore[any-type] + """Returns False only if the argument is np.nan""" + try: + return not np.isnan(value) + except TypeError: + return True + + +def _is_int(teststring: str) -> bool: + """Test if string is a finite integer""" + try: + if not np.isnan(int(teststring)): + return math.isclose((float(teststring) % 1), 0, abs_tol=1e-14) + return False # It was a "number", but it was NaN. + except ValueError: + return False + + +def resolve_path(input_filename: str, reference: str | None) -> str | None: + """The path `input_filename` is an Excel sheet, and `reference` is a cell + value that *might* be a reference to another file. Resolve the path to + `reference` and return. If no such file exists, return `reference`. + """ + # The reference is None, so just return it back + if reference is None: + return reference + + # It's a string but not a reference to another file + if not str(reference).endswith(("xlsx", "csv")): + return reference + + # If the reference is e.g. 'C:/Users/USER/files/doe1.xlsx' + reference_path = Path(reference) + if reference_path.is_absolute() and reference_path.exists(): + return str(reference_path.resolve()) + + # If the reference is e.g. 'doe1.xlsx' + full_path = Path(input_filename).parent / reference_path + if full_path.exists(): + return str(full_path.resolve()) + + if reference_path.exists(): + return str(reference_path.resolve()) + + raise ValueError(f"Failed to resolve path for file: {reference}") From c498c7989eca4a152b16d90cd510f1977de1fd5f Mon Sep 17 00:00:00 2001 From: eq-sandkav Date: Thu, 20 Aug 2026 11:55:18 +0200 Subject: [PATCH 2/3] Create pydantic model for general input --- src/semeio/fmudesign/_excel_to_dict.py | 142 ++---- src/semeio/fmudesign/general_input.py | 88 ++++ src/semeio/fmudesign/utils.py | 2 +- .../data/config/design_input_onebyone.xlsx | Bin 30495 -> 31020 bytes tests/fmudesign/test_general_input.py | 421 ++++++++++++++++++ 5 files changed, 540 insertions(+), 113 deletions(-) create mode 100644 src/semeio/fmudesign/general_input.py create mode 100644 tests/fmudesign/test_general_input.py diff --git a/src/semeio/fmudesign/_excel_to_dict.py b/src/semeio/fmudesign/_excel_to_dict.py index 097274483..c5f7bf4da 100644 --- a/src/semeio/fmudesign/_excel_to_dict.py +++ b/src/semeio/fmudesign/_excel_to_dict.py @@ -3,16 +3,16 @@ by the DesignMatrix class to generate design matrices. """ -import contextlib from collections import Counter from collections.abc import Hashable, Sequence from pathlib import Path -from typing import Any, cast +from typing import Any, Literal, cast import openpyxl import pandas as pd import yaml +from semeio.fmudesign.general_input import GeneralInput from semeio.fmudesign.read_background import read_background from semeio.fmudesign.read_correlations import parse_sensitivity_correlations from semeio.fmudesign.utils import ( @@ -31,17 +31,15 @@ def _read_general_input( input_filename, general_input_sheet, header=None, - index_col=0, engine="openpyxl", ) .dropna(axis=0, how="all") .dropna(axis=1, how="all") + .set_index(0) .loc[:, 1] .to_dict() ) - return {str(k): v for k, v in general_input.items()} | { - "input_filename": input_filename - } + return {str(k): v for k, v in general_input.items()} def excel_to_dict( @@ -73,29 +71,13 @@ def excel_to_dict( design_input_sheet = find_sheet(design_input_sheet, names=xlsx.sheetnames) default_values_sheet = find_sheet(default_values_sheet, names=xlsx.sheetnames) - generalinput = ( - pd.read_excel( - input_filename, - general_input_sheet, - header=None, - index_col=0, - engine="openpyxl", - ) - .dropna(axis=0, how="all") - .dropna(axis=1, how="all") - .loc[:, 1] - .to_dict() - ) + general_input_dict = _read_general_input(input_filename, general_input_sheet) - if (design_type := generalinput.get("designtype")) != "onebyone": - raise ValueError( - "Generation of DesignMatrix only implemented " - f"for type 'onebyone', not {design_type}" - ) + general_input = GeneralInput.from_dict(general_input_dict, input_filename) return _excel_to_dict_onebyone( input_filename=input_filename, - general_input_sheet=general_input_sheet, + general_input=general_input, design_input_sheet=design_input_sheet, default_values_sheet=default_values_sheet, ) @@ -153,7 +135,7 @@ def _check_for_mixed_sensitivities(sens_name: str, sens_group: pd.DataFrame) -> def _excel_to_dict_onebyone( input_filename: str, *, - general_input_sheet: str, + general_input: GeneralInput, design_input_sheet: str, default_values_sheet: str, ) -> dict[str, Any]: @@ -161,103 +143,39 @@ def _excel_to_dict_onebyone( Args: input_filename (str): Name of excel workbook - general_input_sheet (str): name of general input sheet + general_input (GeneralInput): Validated general input design_input_sheet (str): name of design input sheet default_values_sheet (str): name of default value sheet Returns: dict on format for DesignMatrix.generate """ - output: dict[str, Any] = { - "input_file": input_filename - } # This is the config that we read and return - # Read the general input sheet to a dictionary - generalinput = ( - pd.read_excel( - input_filename, - general_input_sheet, - header=None, - engine="openpyxl", - ) - .dropna(axis=0, how="all") - .dropna(axis=1, how="all") - .set_index(0) - .loc[:, 1] - .to_dict() - ) + if isinstance(seeds := general_input.rms_seeds, Path): + rms_seeds: Literal["default"] | list[int] | None = seeds_from_extern(seeds) + else: + rms_seeds = seeds - def parse_value(value: object) -> object: - if pd.isna(value): # type: ignore[call-overload] - return None - if isinstance(value, str): - return value.strip() - return value - - # Convert NaN values to None and strip other values - generalinput = { - str(key).strip(): parse_value(value) for (key, value) in generalinput.items() - } - - # Check that there are no wrong keys or typos, e.g. 'repets' - ALLOWED_KEYS = { - "designtype", - "repeats", - "correlation_iterations", - "distribution_seed", - "seed_strategy", - "rms_seeds", - "background", - } - extra_keys = set(generalinput.keys()) - set(ALLOWED_KEYS) - if extra_keys: - msg = ( - "In the general input sheet, the following parameter(s) are not" - f"recognized and cannot be parsed:\n{extra_keys!r}\n" - f"Allowed keys:{ALLOWED_KEYS!r}" - ) - raise LookupError(msg) - - # Copy keys over if they exist - keys = [ - "designtype", - "repeats", - "correlation_iterations", - "distribution_seed", - "seed_strategy", - ] - for key in keys: - if key not in generalinput: - continue - output[key] = generalinput[key] - - # Copy the 'rms_seeds' key over. It is called 'seeds' further down in - # the code for historical reasons. - key = "seeds" - with contextlib.suppress(KeyError): - output[key] = generalinput["rms_seeds"] - - # If 'seeds' / 'rms_seed' is a file, then read it - if key in output: - maybe_path = resolve_path(input_filename, output[key]) - if isinstance(maybe_path, str) and Path(maybe_path).exists(): - output[key] = seeds_from_extern(maybe_path) - - # The 'background' key is either blank/'None', a reference to another file - # or the name of a sheet in this workbook. - key = "background" - value = generalinput.get(key) - background = "" if value is None else str(value).strip() - if background.lower() in {"", "none"}: - output[key] = None - elif background.endswith(("csv", "xlsx")): - output[key] = {"extern": resolve_path(input_filename, background)} + if isinstance(bgr := general_input.background, Path): + background = {"extern": str(bgr)} + elif isinstance(bgr, str): + background = read_background(input_filename, bgr) else: - output[key] = read_background(input_filename, background) + background = None - output["defaultvalues"] = _read_defaultvalues(input_filename, default_values_sheet) + output: dict[str, Any] = { + "input_file": input_filename, + "designtype": general_input.designtype, + "repeats": general_input.repeats, + "distribution_seed": general_input.distribution_seed, + "background": background, + "seeds": rms_seeds, + "correlation_iterations": general_input.correlation_iterations, + "seed_strategy": general_input.seed_strategy, + "defaultvalues": _read_defaultvalues(input_filename, default_values_sheet), + "sensitivities": {}, + } # This is the config that we read and return - output["sensitivities"] = {} designinput = ( pd.read_excel(input_filename, design_input_sheet, engine="openpyxl") .dropna(axis=0, how="all") diff --git a/src/semeio/fmudesign/general_input.py b/src/semeio/fmudesign/general_input.py new file mode 100644 index 000000000..c528b8195 --- /dev/null +++ b/src/semeio/fmudesign/general_input.py @@ -0,0 +1,88 @@ +from collections.abc import Collection +from pathlib import Path +from typing import Any, Literal, Self + +import pandas as pd +from pydantic import ( + BaseModel, + ConfigDict, + Field, + FilePath, + NonNegativeInt, + PositiveInt, + field_serializer, +) + +from semeio.fmudesign.config_validation import SeedStrategy +from semeio.fmudesign.utils import resolve_path + + +def parse_value(value: object) -> object: + if isinstance(value, str): + return value.strip() + # pd.isna(Collection) -> NDArray, which is ambiguous + if not isinstance(value, Collection) and pd.isna(value): # type: ignore[call-overload] + return None + return value + + +class GeneralInput(BaseModel): + designtype: Literal["onebyone"] + repeats: PositiveInt + distribution_seed: NonNegativeInt | None + rms_seeds: FilePath | Literal["default"] | None + correlation_iterations: NonNegativeInt = 0 + seed_strategy: SeedStrategy = Field( + default=SeedStrategy.JOINT, validate_default=True + ) + background: FilePath | str | None = None + + model_config = ConfigDict(extra="forbid", use_enum_values=True) + + @classmethod + def from_dict(cls, inputdict: dict[str, Any], input_filename: str = "") -> Self: + general_input: dict[str, Any] = { + str(key).strip(): parse_value(value) for key, value in inputdict.items() + } + + # Boolean values are interpreted as valid ints, and are not caught by + # pydantic's validation. It can be caught using strict=True, but that + # removes the flexibility of allowing numeric strings for numeric fields. + # No values should be boolean, so we check for all. + for key, value in general_input.items(): + if isinstance(value, bool): + raise ValueError( + f"key '{key}' cannot have boolean value, got '{value}'" + ) + + for key in ["seed_strategy", "correlation_iterations"]: + val = general_input.get(key) + is_none = general_input.get(key) is None + is_none_str = isinstance(val, str) and val.lower() == "none" + if is_none or is_none_str: + print( + f"'{key}' not set in general input sheet. " + f"Setting to default " + f"{GeneralInput.model_fields[key].default}." + ) + general_input.pop(key, None) + + for key in ["rms_seeds", "background"]: + val = general_input.get(key) + if isinstance(val, str): + resolved = resolve_path(input_filename, val) + assert isinstance(resolved, str) + if Path(resolved).exists(): + general_input[key] = Path(resolved) + elif resolved.lower() == "none": + general_input[key] = None + else: + general_input[key] = resolved + + return cls(**general_input) + + @field_serializer("rms_seeds", "background") + def serialize_paths(self, field: Path | None) -> str | None: # ruff: ignore[no-self-use] + if field is None: + return None + return str(field) diff --git a/src/semeio/fmudesign/utils.py b/src/semeio/fmudesign/utils.py index b70e877cd..57420474f 100644 --- a/src/semeio/fmudesign/utils.py +++ b/src/semeio/fmudesign/utils.py @@ -35,7 +35,7 @@ def parameters_from_extern(filename: str) -> pd.DataFrame: ) -def seeds_from_extern(filename: str) -> list[int]: +def seeds_from_extern(filename: Path | str) -> list[int]: """Read parameter values or background values from specified file. Format either Excel ('xlsx') or csv. diff --git a/tests/fmudesign/data/config/design_input_onebyone.xlsx b/tests/fmudesign/data/config/design_input_onebyone.xlsx index 7331fc219dd495cefef7d3827ca175ddac81cb94..6bda60b2e5886bb7c78bc8b823c8d418b68471cc 100644 GIT binary patch delta 15395 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 delta 14830 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 Date: Mon, 24 Aug 2026 15:43:42 +0200 Subject: [PATCH 3/3] Add formatting for pydantic ValidationErrors --- src/semeio/fmudesign/fmudesignrunner.py | 27 +++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/semeio/fmudesign/fmudesignrunner.py b/src/semeio/fmudesign/fmudesignrunner.py index 83345e2fe..dce105e7a 100644 --- a/src/semeio/fmudesign/fmudesignrunner.py +++ b/src/semeio/fmudesign/fmudesignrunner.py @@ -27,6 +27,7 @@ from pathlib import Path from packaging.version import Version +from pydantic import ValidationError import semeio from semeio.fmudesign import DesignMatrix, excel_to_dict @@ -312,6 +313,7 @@ def main() -> None: """semeio.fmudesign is a command line utility for generating design matrices Wrapper for the the semeio.fmudesign module""" + warnings.filterwarnings("ignore", category=DeprecationWarning) warnings.filterwarnings("ignore", category=FutureWarning) @@ -329,18 +331,27 @@ def main() -> None: parser.print_help() sys.exit(0) + err_guide_msg = ( + "\n \n" + "fmudesign failed. Read the error message above and fix the input file.\n" + " - Documentation: https://equinor.github.io/fmu-tools/fmudesign.html\n" + " - Course docs: https://fmu-docs.equinor.com/docs/fmu-coursedocs/fmu-howto/sensitivities/index.html \n" # ruff: ignore[line-too-long] + " - Issues/feature requests: https://github.com/equinor/semeio/issues\n" + "If you believe this error is a bug or are unable to fix it, create an issue or contact the scout team \n" # ruff: ignore[line-too-long] + ) try: args.func(args) + except ValidationError as e: + for err in e.errors(include_url=False): + print( + f"Validation error for '{err['loc'][0]}': " + f"{err['msg']}, was '{err['input']}'" + ) + print(err_guide_msg) + sys.exit(1) except Exception: # ruff: ignore[blind-except] traceback.print_exc() - print( - "\n \n", - "fmudesign failed. Read the error message above and fix the input file.\n", - " - Documentation: https://equinor.github.io/fmu-tools/fmudesign.html\n", - " - Course docs: https://fmu-docs.equinor.com/docs/fmu-coursedocs/fmu-howto/sensitivities/index.html \n", # ruff: ignore[line-too-long] - " - Issues/feature requests: https://github.com/equinor/semeio/issues\n", - "If you believe this error is a bug or are unable to fix it, create an issue or contact the scout team \n", # ruff: ignore[line-too-long] - ) + print(err_guide_msg) sys.exit(1) # Exit with a non-zero status code (required for smoke tests!) print(