-
Notifications
You must be signed in to change notification settings - Fork 32
Add validation of parsed general input #950
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}." | ||
| ) | ||
|
SAKavli marked this conversation as resolved.
|
||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. probably need an if check to only convert if is not None
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good call, also wrote test for this now. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
Uh oh!
There was an error while loading. Please reload this page.