diff --git a/doped/thermodynamics.py b/doped/thermodynamics.py index b8114a56..44fcbdbf 100644 --- a/doped/thermodynamics.py +++ b/doped/thermodynamics.py @@ -4908,11 +4908,11 @@ def pseudo_equilibrium_solve( """ # TODO: Allow matching of substring (e.g. "O_i" matching "O_i_C2" and "O_i_Ci") for fixed_defects # and update docstrings ("...where the _total_ concentration of all defect entries whose names - # begin with ``defect_name`` will...") & tests accordingly - # TODO: Related: Should allow just specifying an extrinsic element for ``fixed_defects``, - # to allow the user to specify the known concentration of a dopant (but with unknown relative - # populations of different possible defects) -- not possible with current py-sc-fermi - # implementation + # begin with ``defect_name`` will...") & tests accordingly, see _get_min_max_target_values + # TODO: Related: Should allow just specifying an element for ``fixed_defects``, to allow the + # user to specify the known concentration of a dopant (or over/under-stoichiometry of an + # element? (but with unknown relative populations of different possible defects) -- not possible + # with current py-sc-fermi implementation, see code in _get_min_max_target_values() # TODO: In future the ``fixed_defects``, ``free_defects`` and ``fix_charge_states`` options may # be added to the ``doped`` backend (in theory very simple to add) py_sc_fermi_required = fix_charge_states or free_defects or fixed_defects is not None @@ -5972,9 +5972,10 @@ def min_max_X( Search for the chemical potentials that minimise or maximise a target variable, such as electron concentration, within a specified tolerance. - This function iterates over a grid of chemical potentials and "zooms in" on - the chemical potential that either minimises or maximises the target variable. - The process continues until the relative change in the target variable is + See ``target`` argument description below for valid choices. This function + iterates over a grid of chemical potentials and "zooms in" on the chemical + potential that either minimises or maximises the target variable. The + process continues until the _relative_ change in the target variable is less than the specified tolerance. If ``annealing_temperature`` (and ``quenched_temperature``; 300 K by @@ -5991,12 +5992,15 @@ def min_max_X( Args: target (str): - The target variable to minimise or maximise, e.g., "Electrons (cm^-3)" or - "v_Cd_-2" or "Te_i". Valid ``target`` values are the column or row - (i.e. defect) names of the output ``DataFrame``\s from thermodynamic - analysis functions, including... - # TODO (and note in main body of docstring, and update docstrings below) - # TODO: Allow this to match a substring of the column name. + The target variable to minimise or maximise, e.g., "Electrons", + "Te_i", "Fermi Level" etc. Valid ``target`` values are column names (or + substrings), such as 'Electrons', 'Holes', 'Fermi Level', 'μ_X', etc., + or defect names (without charge states), such as 'v_O', 'Te_i', etc. + If a full defect name is given (e.g. Te_i_Td_Te2.83) then the + concentration of that defect will be used as the target variable. If + a defect name substring is given instead (e.g. Te_i), then the target + variable will be the summed concentration of all defects with that + substring in their name (e.g. Te_i_Td_Te2.83, Te_i_C3v etc). min_or_max (str): Specify whether to "minimise" ("min") or "maximise" ("max"; default) the target variable. @@ -6103,6 +6107,7 @@ def min_max_X( "free_defects": free_defects, "fix_charge_states": fix_charge_states, } + # TODO: Add option of just specifying an element, to min/max its summed defect concentrations # TODO: When per-charge option added, test setting target to a defect species (with charge) if len(el_refs) == 2: @@ -6145,7 +6150,11 @@ def _min_max_X_line( {k.replace("μ_", ""): v for k, v in chempot_series.items()} for chempot_series in starting_line ] - results_df = self.scan_chempots( + target_df, current_value, target_chempot, converged = self._scan_chempots_and_compare( + target=target, + min_or_max=min_or_max, + previous_value=previous_value, + tolerance=tolerance, chempots=chempots_dict_list, el_refs=el_refs, annealing_temperature=annealing_temperature, @@ -6155,19 +6164,9 @@ def _min_max_X_line( fixed_defects=fixed_defects, free_defects=free_defects, fix_charge_states=fix_charge_states, + verbose=previous_value is None, # first iteration, print info on target cols/rows ) - - target_df, current_value, target_chempot = _get_min_max_target_values( - results_df, target, min_or_max - ) - if ( # Check if the change in the target value is less than the tolerance - previous_value is not None - and ( - current_value == previous_value - or abs((current_value - previous_value) / (previous_value or current_value)) - < tolerance - ) - ): # divide by (previous_value or current_value) to avoid division by zero + if converged: break previous_value = current_value # otherwise update @@ -6194,6 +6193,77 @@ def _min_max_X_line( return target_df + def _scan_chempots_and_compare( # noqa: D417 + self, + target: str, + min_or_max: str, + previous_value: Optional[float] = None, + tolerance: float = 0.01, + chempots: Optional[Union[list[dict[str, float]], dict[str, dict]]] = None, + limits: Optional[list[str]] = None, + el_refs: Optional[dict[str, float]] = None, + annealing_temperature: Optional[float] = None, + quenched_temperature: float = 300, + temperature: float = 300, + effective_dopant_concentration: Optional[float] = None, + fixed_defects: Optional[dict[str, float]] = None, + free_defects: Optional[list[str]] = None, + fix_charge_states: bool = False, + verbose: bool = False, + ): + """ + Convenience method for use in the ``_min_max_X_...`` methods, which + scans over a set of chemical potentials and compares the target value + to a previous value, returning the new target dataframe, value and + corresponding chemical potentials. + + Args: + previous_value (float): + The previous value of the target variable. + verbose (bool): + Whether to print information on identified target + rows/columns. + *args: + All other arguments are the same as for the ``min_max_X`` method, + see its docstring for more details. + + Returns: + target_df (pd.DataFrame): + A ``DataFrame`` containing the current results of the optimisation, + including the optimal chemical potentials and corresponding values + of the target variable. + current_value (float): + The current (updated) value of the target variable. + target_chempot (pd.DataFrame): + The chemical potentials corresponding to the current value. + converged (bool): + Whether the search has converged to within ``tolerance``. + """ + results_df = self.scan_chempots( + chempots=chempots, + el_refs=el_refs, + annealing_temperature=annealing_temperature, + quenched_temperature=quenched_temperature, + temperature=temperature, + effective_dopant_concentration=effective_dopant_concentration, + fixed_defects=fixed_defects, + free_defects=free_defects, + fix_charge_states=fix_charge_states, + ) + + target_df, current_value, target_chempot = _get_min_max_target_values( + results_df, target, min_or_max, verbose=verbose + ) + converged = ( # Check if the change in the target value is less than the tolerance + previous_value is not None + and ( + current_value == previous_value + or abs((current_value - previous_value) / (previous_value or current_value)) < tolerance + ) + ) # divide by (previous_value or current_value) to avoid division by zero + + return target_df, current_value, target_chempot, converged + def _min_max_X_grid( self, target: str, @@ -6224,7 +6294,11 @@ def _min_max_X_grid( {k.replace("μ_", ""): v for k, v in chempot_series.to_dict().items()} for _idx, chempot_series in starting_grid.get_grid(n_points).iterrows() ] - results_df = self.scan_chempots( + target_df, current_value, target_chempot, converged = self._scan_chempots_and_compare( + target=target, + min_or_max=min_or_max, + previous_value=previous_value, + tolerance=tolerance, chempots=chempots_dict_list, el_refs=el_refs, annealing_temperature=annealing_temperature, @@ -6234,16 +6308,9 @@ def _min_max_X_grid( fixed_defects=fixed_defects, free_defects=free_defects, fix_charge_states=fix_charge_states, + verbose=previous_value is None, # first iteration, print info on target cols/rows ) - - # Find chemical potentials value where target is lowest or highest - target_df, current_value, target_chempot = _get_min_max_target_values( - results_df, target, min_or_max - ) - if ( # Check if the change in the target value is less than the tolerance - previous_value is not None - and abs((current_value - previous_value) / previous_value) < tolerance - ): + if converged: break previous_value = current_value # otherwise update @@ -6695,7 +6762,9 @@ def _get_py_sc_fermi_dos_from_fermi_dos( return DOS(dos=dos, edos=edos, nelect=nelect, bandgap=bandgap, spin_polarised=spin_pol) -def _get_min_max_target_values(results_df: pd.DataFrame, target: str, min_or_max: str) -> tuple: +def _get_min_max_target_values( + results_df: pd.DataFrame, target: str, min_or_max: str, verbose: bool = False +) -> tuple: """ Convenience function to get the minimum or maximum value(s) of a ``target`` column or row in a ``results_df`` DataFrame, and the corresponding chemical @@ -6712,10 +6781,20 @@ def _get_min_max_target_values(results_df: pd.DataFrame, target: str, min_or_max ``DataFrame`` outputs, appended together for multiple chemical potentials). target (str): - The target defect name or column label for minimising/maximising. + The target variable to minimise or maximise, e.g., "Electrons", + "Te_i", "Fermi Level" etc. Valid ``target`` values are column names (or + substrings), such as 'Electrons', 'Holes', 'Fermi Level', 'μ_X', etc., + or defect names (without charge states), such as 'v_O', 'Te_i', etc. + If a full defect name is given (e.g. Te_i_Td_Te2.83) then the + concentration of that defect will be used as the target variable. If + a defect name substring is given instead (e.g. Te_i), then the target + variable will be the summed concentration of all defects with that + substring in their name (e.g. Te_i_Td_Te2.83, Te_i_C3v etc). min_or_max (str): Whether to find the minimum or maximum value(s) of the target. Should be either "min" or "max". + verbose (bool): + Whether to print information on identified target rows/columns. Returns: tuple: @@ -6729,17 +6808,57 @@ def min_or_max_func(x): return x.min() if "min" in min_or_max else x.max() chempots_labels = [col for col in results_df.columns if col.startswith("μ_")] - if target in results_df.columns: - current_value = min_or_max_func(results_df[target]) - target_df = results_df[results_df[target] == current_value] + + # determine target; can be column, defect name, element (TODO), starting string of column name, + # starting string of defect name, column name subset or defect name subset, w/that preferential order: + + target_names = ( + [col for col in results_df.columns if col == target] + or [defect_name for defect_name in results_df.index if defect_name == target] + or [col for col in results_df.columns if col.lower().startswith(target.lower())] + or [ + defect_name + for defect_name in results_df.index + if defect_name.lower().startswith(target.lower()) + ] + or [col for col in results_df.columns if target in col] + or [defect_name for defect_name in (results_df.index) if target in defect_name] + ) + target_names = sorted(set(target_names), key=target_names.index) # preserve order + + if not target_names: + raise ValueError( + f"Target '{target}' not found in results DataFrame! Must be a column or defect " + f"name/substring! See docstring for more info." + ) + + column = next(iter(target_names)) in results_df.columns + + if verbose: + print( + f"Searching for chemical potentials which {min_or_max}imise the target " + f"{'column' if column else 'defect(s)'}: {target_names}..." + ) + + if column: + target_name = next(iter(target_names)) + if len(target_names) > 1: # can only match one column + warnings.warn( + f"Multiple columns with the name '{target}' found in the results DataFrame! " + f"Choosing the first match '{target_name}' as the target." + ) + current_value = min_or_max_func(results_df[target_name]) + target_df = results_df[results_df[target_name] == current_value] target_chempot = target_df[chempots_labels] else: - filtered_df = results_df[results_df.index == target] # filter df for the chosen defect - current_value = min_or_max_func(filtered_df["Concentration (cm^-3)"]) # find the extremum row - target_chempot = filtered_df[filtered_df["Concentration (cm^-3)"] == current_value][ - chempots_labels - ] # get chempots which min/maximise the target + filtered_df = results_df[results_df.index.isin(target_names)] # filter df for the chosen defect(s) + # group by chemical potentials, to sum values at the same chempots (e.g. for different defects): + summed_df = filtered_df.groupby(chempots_labels).sum() + # TODO: When adding element option, will need to subtract for vacancies... + current_value = min_or_max_func(summed_df["Concentration (cm^-3)"]) # find the extremum row + # get chempots which min/maximise the target: + target_chempot = summed_df[summed_df["Concentration (cm^-3)"] == current_value].index.to_frame() # get all DataFrame rows which have the chempots matching the extremum row: target_df = results_df[results_df[chempots_labels].eq(target_chempot.iloc[0]).all(axis=1)] diff --git a/tests/test_fermisolver.py b/tests/test_fermisolver.py index 92271429..6f702074 100644 --- a/tests/test_fermisolver.py +++ b/tests/test_fermisolver.py @@ -1358,14 +1358,30 @@ def test_min_max_X_electrons(self, backend): """ solver = self.solver_doped if backend == "doped" else self.solver_py_sc_fermi for min_max in ["min", "max"]: - result = solver.min_max_X( - target="Electrons (cm^-3)", - min_or_max=min_max, - annealing_temperature=800, - quenched_temperature=300, - tolerance=0.05, - n_points=5, - effective_dopant_concentration=1e16, + with patch("builtins.print") as mock_print: + result = solver.min_max_X( + target="Electrons (cm^-3)", + min_or_max=min_max, + annealing_temperature=800, + quenched_temperature=300, + tolerance=0.05, + n_points=5, + effective_dopant_concentration=1e16, + ) + mock_print.assert_called_once_with( + f"Searching for chemical potentials which {min_max}imise the target column: ['Electrons " + f"(cm^-3)']..." + ) + + assert result.equals( + solver.min_max_X( + target="e", # target as column substring also fine + min_or_max=min_max, + annealing_temperature=800, + tolerance=0.05, + n_points=5, + effective_dopant_concentration=1e16, + ) ) # should correspond to Cd-rich for max electrons, Te-rich for min electrons @@ -1454,14 +1470,19 @@ def test_min_max_X_holes(self, backend): """ solver = self.solver_doped if backend == "doped" else self.solver_py_sc_fermi for min_max in ["min", "max"]: - result = solver.min_max_X( - target="Holes (cm^-3)", - min_or_max=min_max, - annealing_temperature=800, - quenched_temperature=300, - tolerance=0.05, - n_points=5, - effective_dopant_concentration=1e16, + with patch("builtins.print") as mock_print: + result = solver.min_max_X( + target="Holes (cm^-3)", + min_or_max=min_max, + annealing_temperature=800, + quenched_temperature=300, + tolerance=0.05, + n_points=5, + effective_dopant_concentration=1e16, + ) + mock_print.assert_called_once_with( + f"Searching for chemical potentials which {min_max}imise the target column: ['Holes (" + f"cm^-3)']..." ) # should correspond to Te-rich for max holes, Cd-rich for min holes @@ -1513,10 +1534,14 @@ def test_min_max_X_defect(self, backend): solver = FermiSolver(self.Sb2S3_thermo, backend=backend) for annealing in [True, False]: temp_arg_name = "annealing_temperature" if annealing else "temperature" - result = solver.min_max_X( - target="V_S_3", # highest concentration V_S - min_or_max="min", - **{temp_arg_name: 603}, + with patch("builtins.print") as mock_print: + result = solver.min_max_X( + target="V_S_3", # highest concentration V_S + min_or_max="min", + **{temp_arg_name: 603}, + ) + mock_print.assert_called_once_with( + "Searching for chemical potentials which minimise the target defect(s): ['V_S_3']..." ) row = result.iloc[0] formal_chempots = {mu_col.strip("μ_"): row[mu_col] for mu_col in row.index if "μ_" in mu_col} @@ -1548,7 +1573,66 @@ def test_min_max_X_defect(self, backend): all(np.isclose(formal_chempots[el_key], limit[el_key], atol=5e-2) for el_key in limit) for limit in solver.defect_thermodynamics.chempots["limits_wrt_el_refs"].values() ) - # TODO: Test matching defect name substring when functionality added + + @parameterize_backend() + def test_min_max_X_multiple_defects(self, backend): + """ + Test ``min_max_X`` method to min/max a defect concentration, where now + we use a defect name substring to match multiple defects. + + Here we use the vacanies in Sb2S3 as an example case; see + 10.1021/acsenergylett.4c02722 for reference. + """ + solver = FermiSolver(self.Sb2S3_thermo, backend=backend) + for annealing in [True, False]: + temp_arg_name = "annealing_temperature" if annealing else "temperature" + with patch("builtins.print") as mock_print: + result = solver.min_max_X( + target="V_S", # V_S and V_Sb (renamed defect folders, not default doped names) + min_or_max="min", + **{temp_arg_name: 603}, + ) + mock_print.assert_called_once_with( + "Searching for chemical potentials which minimise the target defect(s): ['V_S_1', " + "'V_S_2', 'V_S_3', 'V_Sb_1', 'V_Sb_2']..." + ) + row = result.iloc[0] + formal_chempots = {mu_col.strip("μ_"): row[mu_col] for mu_col in row.index if "μ_" in mu_col} + + # here the minimising chempots are an intermediate chempot again, but slightly different + # to before: + for limit in solver.defect_thermodynamics.chempots["limits_wrt_el_refs"].values(): + for el_key in limit: # confirm that formal_chempots don't correspond to X-rich limits + assert not np.isclose(formal_chempots[el_key], limit[el_key], atol=5e-2) + + assert np.isclose(formal_chempots["Sb"], -0.277, atol=1e-3) + assert np.isclose(formal_chempots["S"], -0.231, atol=1e-3) + + expected_concentrations = solver._solve( + single_chempot_dict=formal_chempots, + append_chempots=True, + **{temp_arg_name: 603}, + ) + pd.testing.assert_frame_equal(result, expected_concentrations) + + # test that for a different defect (S_Sb), the extremum _is_ at a limiting chempot: + with patch("builtins.print") as mock_print: + result = solver.min_max_X( + "S_Sb", # less ambiguity this time + min_or_max="max", + **{temp_arg_name: 603}, + ) + mock_print.assert_called_once_with( + "Searching for chemical potentials which maximise the target defect(s): ['S_Sb_1', " + "'S_Sb_2']..." + ) + row = result.iloc[0] + formal_chempots = {mu_col.strip("μ_"): row[mu_col] for mu_col in row.index if "μ_" in mu_col} + single_chempot_dict, el_refs = solver._get_single_chempot_dict(limit="S-rich") + assert all( # for S_Sb total concentration, maximised at S-rich limit: + np.isclose(formal_chempots[el_key], single_chempot_dict[el_key], atol=5e-2) + for el_key in single_chempot_dict + ) @parameterize_backend() def test_min_max_X_fermi_level(self, backend): @@ -1563,9 +1647,24 @@ def test_min_max_X_fermi_level(self, backend): solver = self.solver_doped if backend == "doped" else self.solver_py_sc_fermi for min_max in ["min", "max"]: print(f"Testing {min_max}imising Fermi level...") - result = solver.min_max_X( - target="Fermi Level", min_or_max=min_max, annealing_temperature=973 # SK Thesis Fig. 6.17 + with patch("builtins.print") as mock_print: + result = solver.min_max_X( + target="Fermi Level", + min_or_max=min_max, + annealing_temperature=973, # SK Thesis Fig. 6.17 + ) + mock_print.assert_called_once_with( + f"Searching for chemical potentials which {min_max}imise the target column: ['Fermi " + f"Level']..." + ) + assert result.equals( + solver.min_max_X( + target="fermi", # target as column substring also fine + min_or_max=min_max, + annealing_temperature=973, # SK Thesis Fig. 6.17 + ) ) + row = result.iloc[0] formal_chempots = {mu_col.strip("μ_"): row[mu_col] for mu_col in row.index if "μ_" in mu_col} @@ -1599,7 +1698,11 @@ def test_min_max_X_chempot(self, backend): solver = self.solver_doped if backend == "doped" else self.solver_py_sc_fermi for min_max in ["min", "max"]: print(f"Testing {min_max}imising chemical potential...") - result = solver.min_max_X(target="μ_Te", min_or_max=min_max, annealing_temperature=973) + with patch("builtins.print") as mock_print: + result = solver.min_max_X(target="μ_Te", min_or_max=min_max, annealing_temperature=973) + mock_print.assert_called_once_with( + f"Searching for chemical potentials which {min_max}imise the target column: ['μ_Te']..." + ) row = result.iloc[0] formal_chempots = {mu_col.strip("μ_"): row[mu_col] for mu_col in row.index if "μ_" in mu_col} @@ -1618,6 +1721,100 @@ def test_min_max_X_chempot(self, backend): ) pd.testing.assert_frame_equal(result, expected_concentrations) + @parameterize_backend() + def test_min_max_X_invalid_target(self, backend): + """ + Test ``min_max_X`` method error with an invalid input target. + """ + solver = self.solver_doped if backend == "doped" else self.solver_py_sc_fermi + with pytest.raises(ValueError) as exc: + solver.min_max_X(target="WTF?") + assert ( + "Target 'WTF?' not found in results DataFrame! Must be a column or defect name/substring! " + "See docstring for more info." + ) in str(exc.value) + + @parameterize_backend() + def test_min_max_X_multiple_column_matches(self, backend): + """ + Test ``min_max_X`` method error with an input target which matches + multiple columns. + """ + solver = self.solver_doped if backend == "doped" else self.solver_py_sc_fermi + with warnings.catch_warnings(record=True) as w: + solver.min_max_X(target="cm^-3") + print([str(warning.message) for warning in w]) # for debugging + assert ( + "Multiple columns with the name 'cm^-3' found in the results DataFrame! Choosing the first " + "match" + ) in str(w[-1].message) + + # TODO: Add explicit type check for `min_max_X` functions, like: + # from typing import Callable + # + # # Define a callable signature + # MinMaxCall = Callable[ + # [ + # float, # target + # str, # min_or_max + # dict, # chempots + # float, # annealing_temperature + # float, # quenched_temperature + # float, # temperature + # float, # tolerance + # int, # n_points + # float, # effective_dopant_concentration + # dict, # fix_charge_states + # dict, # fixed_defects + # dict, # free_defects + # ], + # float, # return type + # ] + # + # + # # Example functions adhering to the same signature + # def _min_max_X_line( + # target: float, + # min_or_max: str, + # chempots: dict, + # annealing_temperature: float, + # quenched_temperature: float, + # temperature: float, + # tolerance: float, + # n_points: int, + # effective_dopant_concentration: float, + # fix_charge_states: dict, + # fixed_defects: dict, + # free_defects: dict, + # ) -> float: + # # Implementation here + # return 0.0 + # + # + # def _min_max_X_grid( + # target: float, + # min_or_max: str, + # chempots: dict, + # annealing_temperature: float, + # quenched_temperature: float, + # temperature: float, + # tolerance: float, + # n_points: int, + # effective_dopant_concentration: float, + # fix_charge_states: dict, + # fixed_defects: dict, + # free_defects: dict, + # ) -> float: + # # Implementation here + # return 0.0 + # + # + # # Assign functions to the Callable type to enforce signature matching + # func_line: MinMaxCall = _min_max_X_line + # func_grid: MinMaxCall = _min_max_X_grid + # + # # Now you can use mypy to ensure both functions' signatures match the `MinMaxCall` type. + @parameterize_backend() def test_get_interpolated_chempots(self, backend): """ @@ -1703,8 +1900,6 @@ def test_skip_vbm_check(self, backend): # TODO: Test free_defects with substring matching (and fixed_defects later when supported) - - # TODO: Use plots in FermiSolver tutorial as quick test cases here class TestFermiSolverWithLoadedData3D(unittest.TestCase): """ @@ -1813,72 +2008,5 @@ def test_scan_chemical_potential_grid_wrong_chempots(self): ) in str(exc.value) -# TODO: Add explicit type check for `min_max_X` functions, like: -# from typing import Callable -# -# # Define a callable signature -# MinMaxCall = Callable[ -# [ -# float, # target -# str, # min_or_max -# dict, # chempots -# float, # annealing_temperature -# float, # quenched_temperature -# float, # temperature -# float, # tolerance -# int, # n_points -# float, # effective_dopant_concentration -# dict, # fix_charge_states -# dict, # fixed_defects -# dict, # free_defects -# ], -# float, # return type -# ] -# -# -# # Example functions adhering to the same signature -# def _min_max_X_line( -# target: float, -# min_or_max: str, -# chempots: dict, -# annealing_temperature: float, -# quenched_temperature: float, -# temperature: float, -# tolerance: float, -# n_points: int, -# effective_dopant_concentration: float, -# fix_charge_states: dict, -# fixed_defects: dict, -# free_defects: dict, -# ) -> float: -# # Implementation here -# return 0.0 -# -# -# def _min_max_X_grid( -# target: float, -# min_or_max: str, -# chempots: dict, -# annealing_temperature: float, -# quenched_temperature: float, -# temperature: float, -# tolerance: float, -# n_points: int, -# effective_dopant_concentration: float, -# fix_charge_states: dict, -# fixed_defects: dict, -# free_defects: dict, -# ) -> float: -# # Implementation here -# return 0.0 -# -# -# # Assign functions to the Callable type to enforce signature matching -# func_line: MinMaxCall = _min_max_X_line -# func_grid: MinMaxCall = _min_max_X_grid -# -# # Now you can use mypy to ensure both functions' signatures match the `MinMaxCall` type. - - if __name__ == "__main__": unittest.main()