Skip to content

Commit b72e283

Browse files
authored
Merge pull request #115 from CCPBioSim/83-sanity-checks-for-input-parameters
Sanity Checks for Input Parameters
2 parents 04b927f + a358aca commit b72e283

File tree

4 files changed

+272
-4
lines changed

4 files changed

+272
-4
lines changed

CodeEntropy/config/arg_config_manager.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,15 +85,54 @@ def load_config(self, file_path):
8585

8686
return config
8787

88+
def str2bool(self, value):
89+
"""
90+
Convert a string or boolean input into a boolean value.
91+
92+
Accepts common string representations of boolean values such as:
93+
- True values: "true", "t", "yes", "1"
94+
- False values: "false", "f", "no", "0"
95+
96+
If the input is already a boolean, it is returned as-is.
97+
Raises:
98+
argparse.ArgumentTypeError: If the input cannot be interpreted as a boolean.
99+
100+
Args:
101+
value (str or bool): The input value to convert.
102+
103+
Returns:
104+
bool: The corresponding boolean value.
105+
"""
106+
if isinstance(value, bool):
107+
return value
108+
value = value.lower()
109+
if value in {"true", "t", "yes", "1"}:
110+
return True
111+
elif value in {"false", "f", "no", "0"}:
112+
return False
113+
else:
114+
raise argparse.ArgumentTypeError("Boolean value expected (True/False).")
115+
88116
def setup_argparse(self):
89117
"""Setup argument parsing dynamically based on arg_map."""
90118
parser = argparse.ArgumentParser(
91119
description="CodeEntropy: Entropy calculation with MCC method."
92120
)
93121

94122
for arg, properties in self.arg_map.items():
95-
kwargs = {key: properties[key] for key in properties if key != "help"}
96-
parser.add_argument(f"--{arg}", **kwargs, help=properties.get("help"))
123+
help_text = properties.get("help", "")
124+
default = properties.get("default", None)
125+
126+
if properties.get("type") == bool:
127+
parser.add_argument(
128+
f"--{arg}",
129+
type=self.str2bool,
130+
default=default,
131+
help=f"{help_text} (default: {default})",
132+
)
133+
else:
134+
kwargs = {k: v for k, v in properties.items() if k != "help"}
135+
parser.add_argument(f"--{arg}", **kwargs, help=help_text)
97136

98137
return parser
99138

@@ -146,3 +185,62 @@ def merge_configs(self, args, run_config):
146185
handler.setLevel(logging.INFO)
147186

148187
return args
188+
189+
def input_parameters_validation(self, u, args):
190+
"""Check the validity of the user inputs against sensible values"""
191+
192+
self._check_input_start(u, args)
193+
self._check_input_end(u, args)
194+
self._check_input_step(args)
195+
self._check_input_bin_width(args)
196+
self._check_input_temperature(args)
197+
self._check_input_force_partitioning(args)
198+
199+
def _check_input_start(self, u, args):
200+
"""Check that the input does not exceed the length of the trajectory."""
201+
if args.start > len(u.trajectory):
202+
raise ValueError(
203+
f"Invalid 'start' value: {args.start}. It exceeds the trajectory length"
204+
" of {len(u.trajectory)}."
205+
)
206+
207+
def _check_input_end(self, u, args):
208+
"""Check that the end index does not exceed the trajectory length."""
209+
if args.end > len(u.trajectory):
210+
raise ValueError(
211+
f"Invalid 'end' value: {args.end}. It exceeds the trajectory length of"
212+
" {len(u.trajectory)}."
213+
)
214+
215+
def _check_input_step(self, args):
216+
"""Check that the step value is non-negative."""
217+
if args.step < 0:
218+
logger.warning(
219+
f"Negative 'step' value provided: {args.step}. This may lead to"
220+
" unexpected behavior."
221+
)
222+
223+
def _check_input_bin_width(self, args):
224+
"""Check that the bin width is within the valid range [0, 360]."""
225+
if args.bin_width < 0 or args.bin_width > 360:
226+
raise ValueError(
227+
f"Invalid 'bin_width': {args.bin_width}. It must be between 0 and 360"
228+
" degrees."
229+
)
230+
231+
def _check_input_temperature(self, args):
232+
"""Check that the temperature is non-negative."""
233+
if args.temperature < 0:
234+
raise ValueError(
235+
f"Invalid 'temperature': {args.temperature}. Temperature cannot be"
236+
" below 0."
237+
)
238+
239+
def _check_input_force_partitioning(self, args):
240+
"""Warn if force partitioning is not set to the default value."""
241+
default_value = arg_map["force_partitioning"]["default"]
242+
if args.force_partitioning != default_value:
243+
logger.warning(
244+
f"'force_partitioning' is set to {args.force_partitioning},"
245+
" which differs from the default ({default_value})."
246+
)

CodeEntropy/run.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,16 @@ def run_entropy_workflow(self):
127127
# Log all inputs for the current run
128128
logger.info(f"All input for {run_name}")
129129
for arg in vars(args):
130-
logger.info(f" {arg}: {getattr(args, arg) or ''}")
130+
logger.info(f" {arg}: {getattr(args, arg)}")
131131

132132
# Load MDAnalysis Universe
133133
tprfile = args.top_traj_file[0]
134134
trrfile = args.top_traj_file[1:]
135135
logger.debug(f"Loading Universe with {tprfile} and {trrfile}")
136136
u = mda.Universe(tprfile, trrfile)
137137

138+
self._config_manager.input_parameters_validation(u, args)
139+
138140
# Create LevelManager instance
139141
level_manager = LevelManager()
140142

config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ run1:
1212
thread:
1313
output_file:
1414
force_partitioning:
15-
disable_water_entropy:
15+
water_entropy:

tests/test_CodeEntropy/test_arg_config_manager.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,77 @@ def test_setup_argparse(self, mock_args):
137137
self.assertEqual(args.top_traj_file, ["/path/to/tpr", "/path/to/trr"])
138138
self.assertEqual(args.selection_string, "all")
139139

140+
@patch(
141+
"argparse.ArgumentParser.parse_args",
142+
return_value=MagicMock(
143+
top_traj_file=["/path/to/tpr", "/path/to/trr"],
144+
start=10,
145+
water_entropy=False,
146+
),
147+
)
148+
def test_setup_argparse_false_boolean(self, mock_args):
149+
"""
150+
Test that non-boolean arguments are parsed correctly.
151+
"""
152+
arg_config = ConfigManager()
153+
parser = arg_config.setup_argparse()
154+
args = parser.parse_args()
155+
156+
self.assertEqual(args.top_traj_file, ["/path/to/tpr", "/path/to/trr"])
157+
self.assertEqual(args.start, 10)
158+
self.assertFalse(args.water_entropy)
159+
160+
def test_str2bool_true_variants(self):
161+
"""Test that various string representations of True are correctly parsed."""
162+
arg_config = ConfigManager()
163+
164+
self.assertTrue(arg_config.str2bool("true"))
165+
self.assertTrue(arg_config.str2bool("True"))
166+
self.assertTrue(arg_config.str2bool("t"))
167+
self.assertTrue(arg_config.str2bool("yes"))
168+
self.assertTrue(arg_config.str2bool("1"))
169+
170+
def test_str2bool_false_variants(self):
171+
"""Test that various string representations of False are correctly parsed."""
172+
arg_config = ConfigManager()
173+
174+
self.assertFalse(arg_config.str2bool("false"))
175+
self.assertFalse(arg_config.str2bool("False"))
176+
self.assertFalse(arg_config.str2bool("f"))
177+
self.assertFalse(arg_config.str2bool("no"))
178+
self.assertFalse(arg_config.str2bool("0"))
179+
180+
def test_str2bool_boolean_passthrough(self):
181+
"""Test that boolean values passed directly are returned unchanged."""
182+
arg_config = ConfigManager()
183+
184+
self.assertTrue(arg_config.str2bool(True))
185+
self.assertFalse(arg_config.str2bool(False))
186+
187+
def test_str2bool_invalid_input(self):
188+
"""Test that invalid string inputs raise an ArgumentTypeError."""
189+
arg_config = ConfigManager()
190+
191+
with self.assertRaises(Exception) as context:
192+
arg_config.str2bool("maybe")
193+
self.assertIn("Boolean value expected", str(context.exception))
194+
195+
def test_str2bool_empty_string(self):
196+
"""Test that an empty string raises an ArgumentTypeError."""
197+
arg_config = ConfigManager()
198+
199+
with self.assertRaises(Exception) as context:
200+
arg_config.str2bool("")
201+
self.assertIn("Boolean value expected", str(context.exception))
202+
203+
def test_str2bool_unexpected_number(self):
204+
"""Test that unexpected numeric strings raise an ArgumentTypeError."""
205+
arg_config = ConfigManager()
206+
207+
with self.assertRaises(Exception) as context:
208+
arg_config.str2bool("2")
209+
self.assertIn("Boolean value expected", str(context.exception))
210+
140211
def test_cli_overrides_defaults(self):
141212
"""
142213
Test if CLI parameters override default values.
@@ -423,6 +494,103 @@ def test_empty_yaml_config(self, mock_exists, mock_file):
423494
self.assertIsInstance(config, dict)
424495
self.assertEqual(config, {})
425496

497+
def test_input_parameters_validation_all_valid(self):
498+
"""Test that input_parameters_validation passes with all valid inputs."""
499+
manager = ConfigManager()
500+
u = MagicMock()
501+
u.trajectory = [0] * 100
502+
503+
args = MagicMock(
504+
start=10,
505+
end=90,
506+
step=1,
507+
bin_width=30,
508+
temperature=298.0,
509+
force_partitioning=0.5,
510+
)
511+
512+
with patch.dict(
513+
"CodeEntropy.config.arg_config_manager.arg_map",
514+
{"force_partitioning": {"default": 0.5}},
515+
):
516+
manager.input_parameters_validation(u, args)
517+
518+
def test_check_input_start_valid(self):
519+
"""Test that a valid 'start' value does not raise an error."""
520+
args = MagicMock(start=50)
521+
u = MagicMock()
522+
u.trajectory = [0] * 100
523+
ConfigManager()._check_input_start(u, args)
524+
525+
def test_check_input_start_invalid(self):
526+
"""Test that an invalid 'start' value raises a ValueError."""
527+
args = MagicMock(start=150)
528+
u = MagicMock()
529+
u.trajectory = [0] * 100
530+
with self.assertRaises(ValueError):
531+
ConfigManager()._check_input_start(u, args)
532+
533+
def test_check_input_end_valid(self):
534+
"""Test that a valid 'end' value does not raise an error."""
535+
args = MagicMock(end=100)
536+
u = MagicMock()
537+
u.trajectory = [0] * 100
538+
ConfigManager()._check_input_end(u, args)
539+
540+
def test_check_input_end_invalid(self):
541+
"""Test that an 'end' value exceeding trajectory length raises a ValueError."""
542+
args = MagicMock(end=101)
543+
u = MagicMock()
544+
u.trajectory = [0] * 100
545+
with self.assertRaises(ValueError):
546+
ConfigManager()._check_input_end(u, args)
547+
548+
@patch("CodeEntropy.config.arg_config_manager.logger")
549+
def test_check_input_step_negative(self, mock_logger):
550+
"""Test that a negative 'step' value triggers a warning."""
551+
args = MagicMock(step=-1)
552+
ConfigManager()._check_input_step(args)
553+
mock_logger.warning.assert_called_once()
554+
555+
def test_check_input_bin_width_valid(self):
556+
"""Test that a valid 'bin_width' value does not raise an error."""
557+
args = MagicMock(bin_width=180)
558+
ConfigManager()._check_input_bin_width(args)
559+
560+
def test_check_input_bin_width_invalid_low(self):
561+
"""Test that a negative 'bin_width' value raises a ValueError."""
562+
args = MagicMock(bin_width=-10)
563+
with self.assertRaises(ValueError):
564+
ConfigManager()._check_input_bin_width(args)
565+
566+
def test_check_input_bin_width_invalid_high(self):
567+
"""Test that a 'bin_width' value above 360 raises a ValueError."""
568+
args = MagicMock(bin_width=400)
569+
with self.assertRaises(ValueError):
570+
ConfigManager()._check_input_bin_width(args)
571+
572+
def test_check_input_temperature_valid(self):
573+
"""Test that a valid 'temperature' value does not raise an error."""
574+
args = MagicMock(temperature=298.0)
575+
ConfigManager()._check_input_temperature(args)
576+
577+
def test_check_input_temperature_invalid(self):
578+
"""Test that a negative 'temperature' value raises a ValueError."""
579+
args = MagicMock(temperature=-5)
580+
with self.assertRaises(ValueError):
581+
ConfigManager()._check_input_temperature(args)
582+
583+
@patch("CodeEntropy.config.arg_config_manager.logger")
584+
def test_check_input_force_partitioning_warning(self, mock_logger):
585+
"""Test that a non-default 'force_partitioning' value triggers a warning."""
586+
args = MagicMock(force_partitioning=0.7)
587+
with patch.dict(
588+
"CodeEntropy.config.arg_config_manager.arg_map",
589+
{"force_partitioning": {"default": 0.5}},
590+
):
591+
ConfigManager()._check_input_force_partitioning(args)
592+
mock_logger.warning.assert_called_once()
593+
426594

427595
if __name__ == "__main__":
428596
unittest.main()

0 commit comments

Comments
 (0)