Repository: equinor/ert
Issue category: Feature Request

Issue:
Validate that DESIGN_MATRIX is correct upon initialization
The validation should include checking whether the design sheet part handles `NUM_REALIZATIONS` correctly, which means whether the all the design values combined with default sheet will handle the total number of realizations. 

- `REAL` column (if provided) should contain iens; which yield active realizations and thus filling the active realizations edit box. 
- in default sheet if the parameter is already present in the design sheet the default sheet entry is ignored otherwise it is appended as a new column with a singular default value.
- make sure that the parameter names are unique

~~Blocked by: https://github.com/equinor/ert/issues/8902~~


Hints:
Currently design2params will fail if you run more realizations than entries in design matrix. Maybe we should do the same, just ignore `NUM_REALIZATIONS` if you choose design matrix and use number of realizations specified in the design matrix. 
For instance if you only specify realization 1,4,7 in the design matrix, than it is probably expected that only realizations 1,4,7 are actually run?
> Currently design2params will fail if you run more realizations than entries in design matrix. Maybe we should do the same, just ignore `NUM_REALIZATIONS` if you choose design matrix and use number of realizations specified in the design matrix. For instance if you only specify realization 1,4,7 in the design matrix, than it is probably expected that only realizations 1,4,7 are actually run?

This makes sense for `ensemble_experiment`, but not sure if we are to use `DESIGN_MATRIX` in the update step. Let's focus only for the ensemble experiment for now. If we would choose a subset (active realizations) from the list of realizations in the design matrix, why would this not work?
Some of the points of this issue has been covered but are still a lot of validation issues, some of them are:
- Disallow spaces in parameter names
- Decide if we will allow parameter names to be numbers or not
- Handle empty input properly or misconfigured input, might rely on pandas for this. But maybe we should check what kind of error messages we receive and decide if we want to show these or display our own errors.
- Find out what errors are dependent on each others and what are not, so that we can validate as much as possible before raising error?
The semeio forward models accept whitespace in parameter names, so I think we should do the same (and update gen_kw to do so too). It is a very small change, changing `file_line.split()` ->`shlex.split(file_line)`

**EDIT**: after discussing with @oyvindeide, we concluded that we will not be changing this for gen_kw, and we will not support it in design matrix either. We are aware that this makes design_matrix differ from design2params, and if it is crucial for some users, we will add it as a feature down the line.

Patch:
diff --git a/src/ert/config/design_matrix.py b/src/ert/config/design_matrix.py
index 1ecd5995bd8..0df73886271 100644
--- a/src/ert/config/design_matrix.py
+++ b/src/ert/config/design_matrix.py
@@ -191,7 +191,7 @@ def read_design_matrix(
 
         if error_list := DesignMatrix._validate_design_matrix(design_matrix_df):
             error_msg = "\n".join(error_list)
-            raise ValueError(f"Design matrix is not valid, error:\n{error_msg}")
+            raise ValueError(f"Design matrix is not valid, error(s):\n{error_msg}")
 
         defaults_to_use = DesignMatrix._read_defaultssheet(
             self.xls_filename, self.default_sheet, design_matrix_df.columns.to_list()
@@ -259,13 +259,6 @@ def _validate_design_matrix(design_matrix: pd.DataFrame) -> list[str]:
             return []
         errors = []
         column_na_mask = design_matrix.columns.isna()
-        column_indexes_unnamed = [
-            index for index, value in enumerate(column_na_mask) if value
-        ]
-        if len(column_indexes_unnamed) > 0:
-            errors.append(
-                f"Column headers not present in column {column_indexes_unnamed}"
-            )
         if not design_matrix.columns[~column_na_mask].is_unique:
             errors.append("Duplicate parameter names found in design sheet")
         empties = [
@@ -274,6 +267,16 @@ def _validate_design_matrix(design_matrix: pd.DataFrame) -> list[str]:
         ]
         if len(empties) > 0:
             errors.append(f"Design matrix contains empty cells {empties}")
+
+        for column_num, param_name in enumerate(design_matrix.columns):
+            if pd.isna(param_name) or len(param_name.split()) == 0:
+                errors.append(f"Empty parameter name found in column {column_num}.")
+            elif len(param_name.split()) > 1:
+                errors.append(
+                    f"Multiple words in parameter name found in column {column_num} ({param_name})."
+                )
+            elif param_name.isnumeric():
+                errors.append(f"Numeric parameter name found in column {column_num}.")
         return errors
 
     @staticmethod
diff --git a/src/ert/gui/simulation/ensemble_experiment_panel.py b/src/ert/gui/simulation/ensemble_experiment_panel.py
index 342f703d9fc..f17e4847312 100644
--- a/src/ert/gui/simulation/ensemble_experiment_panel.py
+++ b/src/ert/gui/simulation/ensemble_experiment_panel.py
@@ -15,7 +15,7 @@
 from ert.gui.tools.design_matrix.design_matrix_panel import DesignMatrixPanel
 from ert.mode_definitions import ENSEMBLE_EXPERIMENT_MODE
 from ert.run_models import EnsembleExperiment
-from ert.validation import ActiveRange, RangeStringArgument
+from ert.validation import ActiveRange, RangeStringArgument, RangeSubsetStringArgument
 from ert.validation.proper_name_argument import ExperimentValidation, ProperNameArgument
 
 from .experiment_config_panel import ExperimentConfigPanel
@@ -88,6 +88,11 @@ def __init__(
             self._active_realizations_field.setText(
                 ActiveRange(design_matrix.active_realizations).rangestring
             )
+            self._active_realizations_field.setValidator(
+                RangeSubsetStringArgument(
+                    ActiveRange(design_matrix.active_realizations)
+                )
+            )
             show_dm_param_button = QPushButton("Show parameters")
             show_dm_param_button.setObjectName("show-dm-parameters")
             show_dm_param_button.setMinimumWidth(50)
diff --git a/src/ert/validation/__init__.py b/src/ert/validation/__init__.py
index a33dc1d4ffe..f1e4642505a 100644
--- a/src/ert/validation/__init__.py
+++ b/src/ert/validation/__init__.py
@@ -5,7 +5,7 @@
 from .number_list_string_argument import NumberListStringArgument
 from .proper_name_argument import ExperimentValidation, ProperNameArgument
 from .proper_name_format_argument import ProperNameFormatArgument
-from .range_string_argument import RangeStringArgument
+from .range_string_argument import RangeStringArgument, RangeSubsetStringArgument
 from .rangestring import mask_to_rangestring, rangestring_to_list, rangestring_to_mask
 from .runpath_argument import RunPathArgument
 from .string_definition import StringDefinition
@@ -21,6 +21,7 @@
     "ProperNameArgument",
     "ProperNameFormatArgument",
     "RangeStringArgument",
+    "RangeSubsetStringArgument",
     "RunPathArgument",
     "StringDefinition",
     "ValidationStatus",
diff --git a/src/ert/validation/active_range.py b/src/ert/validation/active_range.py
index 517db6acdac..90b0886e532 100644
--- a/src/ert/validation/active_range.py
+++ b/src/ert/validation/active_range.py
@@ -8,6 +8,7 @@ def __init__(
         rangestring: str | None = None,
         length: int | None = None,
     ):
+        self.length = length
         if mask is None and rangestring is None and length is None:
             raise ValueError("Supply mask or rangestring and length to IndexRange.")
         if mask is None:
@@ -74,3 +75,13 @@ def validate_rangestring_vs_length(
                     f"for size {length}"
                 )
         return (rangestring, length)
+
+    def validate_range_is_subset(self, other: "ActiveRange") -> None:
+        if (
+            not [a and b for a, b in zip(self.mask, other.mask, strict=False)]
+            == other.mask
+        ):
+            raise ValueError(
+                f"Specified rangestring ({other.rangestring}) is not a subset "
+                f"of the active realizations ({self.rangestring})"
+            )
diff --git a/src/ert/validation/range_string_argument.py b/src/ert/validation/range_string_argument.py
index 633b756f277..033c26022e4 100644
--- a/src/ert/validation/range_string_argument.py
+++ b/src/ert/validation/range_string_argument.py
@@ -41,3 +41,24 @@ def validate(self, token: str) -> ValidationStatus:
         validation_status.setValue(token)
 
         return validation_status
+
+
+class RangeSubsetStringArgument(RangeStringArgument):
+    def __init__(self, source_active_range: ActiveRange) -> None:
+        super().__init__()
+        self._source_range = source_active_range
+
+    def validate(self, token: str) -> ValidationStatus:
+        validation_status = super().validate(token)
+
+        if not validation_status:
+            return validation_status
+        try:
+            other_range = ActiveRange(rangestring=token, length=len(self._source_range))
+            self._source_range.validate_range_is_subset(other_range)
+        except ValueError as e:
+            validation_status.setFailed()
+            validation_status.addToMessage(str(e))
+
+        validation_status.setValue(token)
+        return validation_status


Test patch:
diff --git a/tests/ert/unit_tests/gui/ide/test_range_string_argument.py b/tests/ert/unit_tests/gui/ide/test_range_string_argument.py
index 0a342924b48..af21dfd0cad 100644
--- a/tests/ert/unit_tests/gui/ide/test_range_string_argument.py
+++ b/tests/ert/unit_tests/gui/ide/test_range_string_argument.py
@@ -1,4 +1,5 @@
-from ert.validation import RangeStringArgument
+from ert.validation import RangeStringArgument, RangeSubsetStringArgument
+from ert.validation.active_range import ActiveRange
 
 
 def test_proper_name_argument():
@@ -23,3 +24,15 @@ def test_proper_name_argument():
 
     assert argument.validate("1-5, 9")
     assert not argument.validate("10")
+
+
+def test_range_subset():
+    source_range = ActiveRange(rangestring="0-3,5,7-9", length=10)
+    argument_range = RangeSubsetStringArgument(source_active_range=source_range)
+
+    assert argument_range.validate("1")
+    assert argument_range.validate("0-3")
+    assert argument_range.validate("0-3,5,7-9")
+    assert not argument_range.validate("10")
+    assert not argument_range.validate("1-10")
+    assert not argument_range.validate("0-4")
diff --git a/tests/ert/unit_tests/sensitivity_analysis/test_design_matrix.py b/tests/ert/unit_tests/sensitivity_analysis/test_design_matrix.py
index 9e9ab76344d..639a96c66dc 100644
--- a/tests/ert/unit_tests/sensitivity_analysis/test_design_matrix.py
+++ b/tests/ert/unit_tests/sensitivity_analysis/test_design_matrix.py
@@ -238,9 +238,25 @@ def test_reading_design_matrix_validate_reals(tmp_path, real_column, error_msg):
         ),
         pytest.param(
             ["a", "b  ", ""],
-            r"Column headers not present in column \[2\]",
+            r"Empty parameter name found in column 2",
             id="missing entries",
         ),
+        pytest.param(
+            ["a", "b", "parameter name with spaces"],
+            "Multiple words in parameter name found in column 2.",
+            id="multiple words in parameter name",
+        ),
+        pytest.param(
+            ["a", "b", " "],
+            "Empty parameter name found in column 2",
+            id="dataframe loads parameter name as whitespace",
+        ),
+        pytest.param(["a", "b", "3"], "Numeric parameter name found in column 2"),
+        pytest.param(
+            ["a", "b c d e", 33],
+            r"Multiple words in parameter name found in column 1 \(b c d e\)\.\nNumeric parameter name found in column 2",
+            id="multiple errors",
+        ),
     ],
 )
 def test_reading_design_matrix_validate_headers(tmp_path, column_names, error_msg):