|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +# |
| 3 | +# test_ticket_686_positive_parameters.py |
| 4 | +# |
| 5 | +# This file is part of NEST. |
| 6 | +# |
| 7 | +# Copyright (C) 2004 The NEST Initiative |
| 8 | +# |
| 9 | +# NEST is free software: you can redistribute it and/or modify |
| 10 | +# it under the terms of the GNU General Public License as published by |
| 11 | +# the Free Software Foundation, either version 2 of the License, or |
| 12 | +# (at your option) any later version. |
| 13 | +# |
| 14 | +# NEST is distributed in the hope that it will be useful, |
| 15 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 16 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 17 | +# GNU General Public License for more details. |
| 18 | +# |
| 19 | +# You should have received a copy of the GNU General Public License |
| 20 | +# along with NEST. If not, see <http://www.gnu.org/licenses/>. |
| 21 | + |
| 22 | +import math |
| 23 | + |
| 24 | +import nest |
| 25 | +import numpy as np |
| 26 | + |
| 27 | +""" |
| 28 | +Regression test for Ticket #686. |
| 29 | +
|
| 30 | +Test ported from SLI regression test. |
| 31 | +Ensure models with positive-only parameters reject non-positive values and accept larger positive ones. |
| 32 | +
|
| 33 | +Author: Hans Ekkehard Plesser, 2013-04-18 |
| 34 | +""" |
| 35 | + |
| 36 | + |
| 37 | +SKIPPED_MODELS = { |
| 38 | + "correlation_detector", |
| 39 | + "correlomatrix_detector", |
| 40 | + "correlospinmatrix_detector", |
| 41 | + "siegert_neuron", |
| 42 | +} |
| 43 | + |
| 44 | +POSITIVE_KEYS_CACHE: dict[str, tuple[str, ...]] = {} |
| 45 | + |
| 46 | +# Parameters that must be updated together to maintain matching dimensions |
| 47 | +DIMENSION_PAIRS = { |
| 48 | + "tau_sfa": ("q_sfa",), |
| 49 | + "tau_stc": ("q_stc",), |
| 50 | +} |
| 51 | + |
| 52 | + |
| 53 | +def _models_to_check(): |
| 54 | + return [model for model in nest.node_models if model not in SKIPPED_MODELS] |
| 55 | + |
| 56 | + |
| 57 | +def _positive_keys(model: str) -> tuple[str, ...]: |
| 58 | + if model not in POSITIVE_KEYS_CACHE: |
| 59 | + defaults = nest.GetDefaults(model) |
| 60 | + keys = tuple(key for key in defaults if key == "C_m" or key.startswith("tau_")) |
| 61 | + POSITIVE_KEYS_CACHE[model] = keys |
| 62 | + return POSITIVE_KEYS_CACHE[model] |
| 63 | + |
| 64 | + |
| 65 | +def _as_numeric_tuple(value) -> tuple[float, ...]: |
| 66 | + """Convert value to tuple of floats, handling scalars, lists, and arrays.""" |
| 67 | + if isinstance(value, np.ndarray): |
| 68 | + return tuple(float(v) for v in value.tolist()) |
| 69 | + if isinstance(value, (list, tuple)): |
| 70 | + return tuple(float(v) for v in value) |
| 71 | + return (float(value),) |
| 72 | + |
| 73 | + |
| 74 | +def _cast_like(reference, numeric_values: tuple[float, ...]): |
| 75 | + """Cast numeric values to match the type of reference value.""" |
| 76 | + if isinstance(reference, np.ndarray): |
| 77 | + return np.array(numeric_values, dtype=reference.dtype) |
| 78 | + if isinstance(reference, tuple): |
| 79 | + return tuple(numeric_values) |
| 80 | + if isinstance(reference, list): |
| 81 | + return list(numeric_values) |
| 82 | + return numeric_values[0] if len(numeric_values) == 1 else numeric_values |
| 83 | + |
| 84 | + |
| 85 | +def _allclose(values_a: tuple[float, ...], values_b: tuple[float, ...], *, abs_tol: float = 1e-12) -> bool: |
| 86 | + return len(values_a) == len(values_b) and all( |
| 87 | + math.isclose(a, b, rel_tol=0.0, abs_tol=abs_tol) for a, b in zip(values_a, values_b) |
| 88 | + ) |
| 89 | + |
| 90 | + |
| 91 | +def _get_dimension_pairs(neuron, key): |
| 92 | + """Get dimension-paired parameter values to include in updates.""" |
| 93 | + return {pair: neuron.get(pair) for pair in DIMENSION_PAIRS.get(key, ())} |
| 94 | + |
| 95 | + |
| 96 | +def _test_parameter_update(neuron, key, raw_value, new_values): |
| 97 | + """Test updating a parameter with new values, including dimension-paired parameters.""" |
| 98 | + dimension_pairs = _get_dimension_pairs(neuron, key) |
| 99 | + try: |
| 100 | + update = {key: _cast_like(raw_value, new_values)} |
| 101 | + update.update(dimension_pairs) |
| 102 | + nest.SetStatus(neuron, update) |
| 103 | + return True, None |
| 104 | + except nest.kernel.NESTError as err: |
| 105 | + return False, str(err) |
| 106 | + |
| 107 | + |
| 108 | +def test_ticket_686_rejects_non_positive_values(): |
| 109 | + """ |
| 110 | + Ensure parameters that must remain positive reject zero and negative assignments. |
| 111 | + """ |
| 112 | + |
| 113 | + models = _models_to_check() |
| 114 | + assert models, "No models available to test (all models may be skipped)" |
| 115 | + failing_cases = [] |
| 116 | + |
| 117 | + for model in models: |
| 118 | + positive_keys = _positive_keys(model) |
| 119 | + if not positive_keys: |
| 120 | + continue |
| 121 | + |
| 122 | + nest.ResetKernel() |
| 123 | + nest.SetKernelStatus({"dict_miss_is_error": False}) |
| 124 | + |
| 125 | + for key in positive_keys: |
| 126 | + for candidate in (0.0, -1.0): |
| 127 | + if model == "iaf_tum_2000" and candidate == 0.0: |
| 128 | + continue |
| 129 | + neuron = nest.Create(model) |
| 130 | + raw_value = neuron.get(key) |
| 131 | + original_values = _as_numeric_tuple(raw_value) |
| 132 | + if not original_values: |
| 133 | + continue |
| 134 | + invalid_values = tuple(float(candidate) for _ in original_values) |
| 135 | + |
| 136 | + success, _ = _test_parameter_update(neuron, key, raw_value, invalid_values) |
| 137 | + if success: |
| 138 | + current_values = _as_numeric_tuple(neuron.get(key)) |
| 139 | + if not _allclose(current_values, original_values): |
| 140 | + failing_cases.append((model, key, f"accepted_non_positive_{candidate}")) |
| 141 | + else: |
| 142 | + failing_cases.append((model, key, f"silently_ignored_non_positive_{candidate}")) |
| 143 | + else: |
| 144 | + current_values = _as_numeric_tuple(neuron.get(key)) |
| 145 | + if not _allclose(current_values, original_values): |
| 146 | + failing_cases.append((model, key, "value_changed_after_exception")) |
| 147 | + |
| 148 | + assert not failing_cases, f"Models not rejecting non-positive values: {failing_cases}" |
| 149 | + |
| 150 | + |
| 151 | +def test_ticket_686_accepts_positive_assignments(): |
| 152 | + """ |
| 153 | + Ensure the same parameters accept updated positive values. |
| 154 | + """ |
| 155 | + |
| 156 | + models = _models_to_check() |
| 157 | + assert models, "No models available to test (all models may be skipped)" |
| 158 | + failing_cases = [] |
| 159 | + |
| 160 | + for model in models: |
| 161 | + if model == "iaf_psc_exp_ps_lossless": |
| 162 | + continue |
| 163 | + |
| 164 | + positive_keys = _positive_keys(model) |
| 165 | + if not positive_keys: |
| 166 | + continue |
| 167 | + |
| 168 | + nest.ResetKernel() |
| 169 | + nest.SetKernelStatus({"dict_miss_is_error": False}) |
| 170 | + |
| 171 | + for key in positive_keys: |
| 172 | + neuron = nest.Create(model) |
| 173 | + raw_value = neuron.get(key) |
| 174 | + original_values = _as_numeric_tuple(raw_value) |
| 175 | + if not original_values: |
| 176 | + continue |
| 177 | + new_values = tuple(v + 1.0 for v in original_values) |
| 178 | + |
| 179 | + success, error_msg = _test_parameter_update(neuron, key, raw_value, new_values) |
| 180 | + if success: |
| 181 | + updated_values = _as_numeric_tuple(neuron.get(key)) |
| 182 | + if not _allclose(updated_values, new_values): |
| 183 | + failing_cases.append((model, key, "value_not_updated")) |
| 184 | + else: |
| 185 | + failing_cases.append((model, key, f"raised_exception:{error_msg}")) |
| 186 | + |
| 187 | + assert not failing_cases, f"Models not accepting positive updates: {failing_cases}" |
0 commit comments