diff --git a/process/core/init.py b/process/core/init.py index 8a77a2764c..1c3b5aff67 100644 --- a/process/core/init.py +++ b/process/core/init.py @@ -289,6 +289,33 @@ def check_process(inputs, data): # noqa: ARG001 "Iteration variables 13 and 140 cannot be used simultaneously", ) + # An inboard TF coil must have a positive thickness. dr_tf_inboard is + # only derived from the winding pack and case thicknesses when + # dr_tf_wp_with_insulation (ixc = 140) is an iteration variable (see + # Build.calculate_radial_build); otherwise the input-file value is what + # the radial build uses, and the default of 0 silently removes the TF + # coil. This applies to superconducting and resistive conductors alike, + # and also when dr_tf_inboard itself is the iteration variable + # (ixc = 13): the input value seeds the first model evaluation, an + # exactly-zero value is only caught later by the generic + # iteration-variable check, and a negative value is not caught at all + # (the 1/value scaling in load_iteration_variables inverts the + # variable's bounds). Stellarators calculate dr_tf_inboard during the + # model run, so are excluded from this check. + if ( + data.stellarator.istell == 0 + and data.ife.ife == 0 + and not (data.numerics.ixc[: data.numerics.n_iteration_variables] == 140).any() + and data.build.dr_tf_inboard <= 0.0 + ): + raise ProcessValidationError( + "dr_tf_inboard is not positive: the inboard TF coil has no" + " thickness. Set a positive dr_tf_inboard, or make" + " dr_tf_wp_with_insulation an iteration variable (ixc = 140) so that" + " dr_tf_inboard is derived from the winding pack and case thicknesses", + dr_tf_inboard=data.build.dr_tf_inboard, + ) + # Can't use c_tf_turn as iteration var, constraint or # input if i_tf_turns_integer == 1 if ( diff --git a/tests/unit/core/test_init.py b/tests/unit/core/test_init.py new file mode 100644 index 0000000000..491612932e --- /dev/null +++ b/tests/unit/core/test_init.py @@ -0,0 +1,105 @@ +"""Unit tests for input sanity checks in process.core.init.check_process.""" + +import pytest + +from process.core.data_structure.base import DataStructure +from process.core.exceptions import ProcessValidationError +from process.core.init import check_process +from process.models.tfcoil.base import TFConductorModel + + +def _validation_error_message(data): + """Run check_process and return any validation error message. + + Later, unrelated checks may still fire on an otherwise-default + DataStructure, so callers assert on the message content rather than + on whether an error was raised. + """ + try: + check_process(None, data) + except ProcessValidationError as error: + return str(error) + return "" + + +def test_zero_thickness_superconducting_tf_is_rejected(): + """SC TF with dr_tf_inboard left at 0 and neither ixc=13 nor ixc=140 + active must fail validation instead of silently building a machine + with no inboard TF coil. + """ + data = DataStructure() + data.tfcoil.i_tf_sup = TFConductorModel.SUPERCONDUCTING + data.build.dr_tf_inboard = 0.0 + + with pytest.raises(ProcessValidationError, match="dr_tf_inboard"): + check_process(None, data) + + +def test_explicit_tf_thickness_is_accepted(): + data = DataStructure() + data.tfcoil.i_tf_sup = TFConductorModel.SUPERCONDUCTING + data.build.dr_tf_inboard = 0.5 + + assert "dr_tf_inboard" not in _validation_error_message(data) + + +def test_wp_thickness_iteration_variable_is_accepted(): + """With ixc = 140 active, dr_tf_inboard is derived in the build model, + so a zero input value is legitimate. + """ + data = DataStructure() + data.tfcoil.i_tf_sup = TFConductorModel.SUPERCONDUCTING + data.build.dr_tf_inboard = 0.0 + data.numerics.n_iteration_variables = 1 + data.numerics.ixc[0] = 140 + + assert "dr_tf_inboard" not in _validation_error_message(data) + + +def test_zero_thickness_resistive_tf_is_rejected(): + """Resistive TF coils use dr_tf_inboard through the same radial-build + path as superconducting ones, so a zero thickness is equally invalid. + """ + data = DataStructure() + data.tfcoil.i_tf_sup = TFConductorModel.WATER_COOLED_COPPER + data.build.dr_tf_inboard = 0.0 + + with pytest.raises(ProcessValidationError, match="dr_tf_inboard"): + check_process(None, data) + + +def test_explicit_thickness_resistive_tf_is_accepted(): + data = DataStructure() + data.tfcoil.i_tf_sup = TFConductorModel.WATER_COOLED_COPPER + data.build.dr_tf_inboard = 0.5 + + assert "dr_tf_inboard" not in _validation_error_message(data) + + +@pytest.mark.parametrize("bad_value", [0.0, -0.5]) +def test_thickness_iteration_variable_does_not_exempt(bad_value): + """ixc = 13 does not exempt a non-positive input value: it seeds the + first model evaluation, and an exactly-zero value is only rejected + later by the generic iteration-variable check. A negative value + cannot come from an input file (the parser bounds dr_tf_inboard to + [0, 10]), but check_process guards the data structure however it was + populated. + """ + data = DataStructure() + data.tfcoil.i_tf_sup = TFConductorModel.SUPERCONDUCTING + data.build.dr_tf_inboard = bad_value + data.numerics.n_iteration_variables = 1 + data.numerics.ixc[0] = 13 + + with pytest.raises(ProcessValidationError, match="dr_tf_inboard"): + check_process(None, data) + + +def test_stellarator_is_not_checked(): + """Stellarators calculate dr_tf_inboard during the model run.""" + data = DataStructure() + data.stellarator.istell = 1 + data.tfcoil.i_tf_sup = TFConductorModel.SUPERCONDUCTING + data.build.dr_tf_inboard = 0.0 + + assert "dr_tf_inboard" not in _validation_error_message(data) diff --git a/tests/unit/core/test_input.py b/tests/unit/core/test_input.py index 65cb364b80..4af9b60a11 100644 --- a/tests/unit/core/test_input.py +++ b/tests/unit/core/test_input.py @@ -10,6 +10,12 @@ from process.data_structure.numerics import PROCESSRunMode +@pytest.fixture(autouse=True) +def turn_off_check_process(monkeypatch): + """These are parser tests; configuration validation is not a part of test.""" + monkeypatch.setattr(init, "check_process", lambda *_: None) + + @pytest.fixture def data_structure_obj(): return DataStructure()