Repository: oemof/oemof-solph

Issue:
Chained sequence yields unexpected result
Casting to sequences can give unexpected results, as it sometimes returns a (fixed length) array, sometimes a (flexible) _Sequence:

```python
# previous implementation
s = sequence(sequence([5]))
s[0] = 5
s[5] = 5

# current implementation
s = sequence(sequence([5]))
s[0] = 5
s[5] -> out of range
```

The suggested solution is to not track `_Sequence.highest_index` in `_Sequence.__get_item__`. This way, the `_Sequence` does not have a defined length if it not explicitly given.

PS: We learned this in #1089.

Discussion:

Patch:
diff --git a/docs/whatsnew/v0-5-4.rst b/docs/whatsnew/v0-5-4.rst
index 94263513..74ce1207 100644
--- a/docs/whatsnew/v0-5-4.rst
+++ b/docs/whatsnew/v0-5-4.rst
@@ -21,6 +21,8 @@ Bug fixes
 Other changes
 #############
 
+* Refined internal sequence generation. (For e.g. constraint formulations,
+  Scalars are internally mapped to fake sequences without a defined length.)
 
 Known issues
 ############
diff --git a/src/oemof/solph/_plumbing.py b/src/oemof/solph/_plumbing.py
index bf18dbda..bb6ed5b8 100644
--- a/src/oemof/solph/_plumbing.py
+++ b/src/oemof/solph/_plumbing.py
@@ -11,7 +11,6 @@ SPDX-License-Identifier: MIT
 
 """
 
-from collections import UserList
 from collections import abc
 from itertools import repeat
 
@@ -19,7 +18,7 @@ import numpy as np
 
 
 def sequence(iterable_or_scalar):
-    """Tests if an object is iterable (except string) or scalar and returns
+    """Checks if an object is iterable (except string) or scalar and returns
     the original sequence if object is an iterable and an 'emulated'
     sequence object of class _Sequence if object is a scalar or string.
 
@@ -47,64 +46,81 @@ def sequence(iterable_or_scalar):
 
     >>> x[10]
     10
-    >>> print(x)
-    [10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10]
 
     """
-    if isinstance(iterable_or_scalar, abc.Iterable) and not isinstance(
-        iterable_or_scalar, str
-    ):
+    if isinstance(iterable_or_scalar, str):
+        return iterable_or_scalar
+    elif isinstance(iterable_or_scalar, abc.Iterable):
         return np.array(iterable_or_scalar)
     else:
-        return _Sequence(default=iterable_or_scalar)
+        return _FakeSequence(value=iterable_or_scalar)
 
 
-class _Sequence(UserList):
+class _FakeSequence:
     """Emulates a list whose length is not known in advance.
 
     Parameters
     ----------
-    source:
-    default:
+    value : scalar
+    length : integer
 
 
     Examples
     --------
-    >>> s = _Sequence(default=42)
-    >>> len(s)
-    1
-    >>> s[1]
-    42
-    >>> s[2]
-    42
-    >>> len(s)
-    3
+    >>> s = _FakeSequence(value=42, length=5)
     >>> s
-    [42, 42, 42]
-    >>> s[8]
+    [42, 42, 42, 42, 42]
+    >>> s = _FakeSequence(value=42)
+    >>> # undefined lenght, access still works
+    >>> s[1337]
     42
-
-
     """
 
-    def __init__(self, *args, **kwargs):
-        self.default = kwargs["default"]
-        self.default_changed = False
-        self.highest_index = 0
-        super().__init__(*args)
+    def __init__(self, value, length=None):
+        self._value = value
+        self._length = length
+
+    @property
+    def size(self):
+        return self._length
 
-    def __getitem__(self, key):
-        self.highest_index = max(self.highest_index, key)
-        return self.default
+    @size.setter
+    def size(self, value):
+        self._length = value
 
-    def __init_list(self):
-        self.data = [self.default] * (self.highest_index + 1)
+    def __getitem__(self, _):
+        return self._value
 
     def __repr__(self):
-        return str([i for i in self])
+        if self._length is not None:
+            return str([i for i in self])
+        else:
+            return f"[{self._value}, {self._value}, ..., {self._value}]"
 
     def __len__(self):
-        return max(len(self.data), self.highest_index + 1)
+        return self._length
 
     def __iter__(self):
-        return repeat(self.default, self.highest_index + 1)
+        return repeat(self._value, self._length)
+
+    def max(self):
+        return self._value
+
+    def min(self):
+        return self._value
+
+    def sum(self):
+        if self._length is None:
+            return np.inf
+        else:
+            return self._length * self._value
+
+    def to_numpy(self, length=None):
+        if length is not None:
+            return np.full(length, self._value)
+        else:
+            return np.full(len(self), self._value)
+
+    @property
+    def value(self):
+        return self._value
diff --git a/src/oemof/solph/components/_generic_storage.py b/src/oemof/solph/components/_generic_storage.py
index ab814178..a9c5e1c8 100644
--- a/src/oemof/solph/components/_generic_storage.py
+++ b/src/oemof/solph/components/_generic_storage.py
@@ -282,9 +282,9 @@ class GenericStorage(Node):
             raise AttributeError(e2)
         if (
             self.investment
-            and sum(solph_sequence(self.fixed_losses_absolute)) != 0
+            and self.fixed_losses_absolute.max() != 0
             and self.investment.existing == 0
-            and self.investment.minimum[0] == 0
+            and self.investment.minimum.min() == 0
         ):
             e3 = (
                 "With fixed_losses_absolute > 0, either investment.existing "
@@ -1127,7 +1127,7 @@ class GenericInvestmentStorageBlock(ScalarBlock):
                     "For a multi-period investment model, fixed absolute"
                     " losses are not supported. Please remove parameter."
                 )
-                if n.fixed_losses_absolute.default != 0:
+                if n.fixed_losses_absolute[0] != 0:
                     raise ValueError(error_fixed_absolute_losses)
                 error_initial_storage_level = (
                     "For a multi-period model, initial_storage_level is"
diff --git a/src/oemof/solph/components/_offset_converter.py b/src/oemof/solph/components/_offset_converter.py
index d5a09bcb..b078c9ce 100644
--- a/src/oemof/solph/components/_offset_converter.py
+++ b/src/oemof/solph/components/_offset_converter.py
@@ -19,7 +19,6 @@ SPDX-FileCopyrightText: Francesco Witte
 SPDX-License-Identifier: MIT
 
 """
-
 from warnings import warn
 
 from oemof.network import Node
@@ -280,16 +279,14 @@ class OffsetConverter(Node):
         input_bus = list(self.inputs.values())[0].input
         for flow in self.outputs.values():
 
-            max_len = max(
-                len(flow.max),
-                len(flow.min),
-                len(coefficients[0]),
-                len(coefficients[1]),
-            )
+            if flow.max.size is not None:
+                target_len = flow.max.size
+            else:
+                target_len = 1
 
             slope = []
             offset = []
-            for i in range(max_len):
+            for i in range(target_len):
                 eta_at_max = (
                     flow.max[i]
                     * coefficients[1][i]
@@ -307,7 +304,7 @@ class OffsetConverter(Node):
                 slope.append(c0)
                 offset.append(c1)
 
-            if max_len == 1:
+            if target_len == 1:
                 slope = slope[0]
                 offset = offset[0]
 
diff --git a/src/oemof/solph/flows/_flow.py b/src/oemof/solph/flows/_flow.py
index ae9b4e6e..633e2bfe 100644
--- a/src/oemof/solph/flows/_flow.py
+++ b/src/oemof/solph/flows/_flow.py
@@ -293,7 +293,7 @@ class Flow(Edge):
         if (
             self.investment
             and self.nonconvex
-            and not np.isfinite(self.investment.maximum)
+            and not np.isfinite(self.investment.maximum.max())
         ):
             raise AttributeError(
                 "Investment into a non-convex flows needs a maximum "
diff --git a/src/oemof/solph/flows/_investment_flow_block.py b/src/oemof/solph/flows/_investment_flow_block.py
index 02f3c8f3..99efec0d 100644
--- a/src/oemof/solph/flows/_investment_flow_block.py
+++ b/src/oemof/solph/flows/_investment_flow_block.py
@@ -141,11 +141,7 @@ class InvestmentFlowBlock(ScalarBlock):
         )
 
         self.MIN_INVESTFLOWS = Set(
-            initialize=[
-                (g[0], g[1])
-                for g in group
-                if (g[2].min[0] != 0 or len(g[2].min) > 1)
-            ]
+            initialize=[(g[0], g[1]) for g in group if g[2].min.min() != 0]
         )
 
         self.EXISTING_INVESTFLOWS = Set(
diff --git a/src/oemof/solph/flows/_non_convex_flow_block.py b/src/oemof/solph/flows/_non_convex_flow_block.py
index 5034ab17..aa1cf457 100644
--- a/src/oemof/solph/flows/_non_convex_flow_block.py
+++ b/src/oemof/solph/flows/_non_convex_flow_block.py
@@ -230,14 +230,14 @@ class NonConvexFlowBlock(ScalarBlock):
             initialize=[
                 (g[0], g[1])
                 for g in group
-                if max(g[2].nonconvex.minimum_uptime) > 0
+                if g[2].nonconvex.minimum_uptime.max() > 0
             ]
         )
         self.MINDOWNTIMEFLOWS = Set(
             initialize=[
                 (g[0], g[1])
                 for g in group
-                if max(g[2].nonconvex.minimum_downtime) > 0
+                if g[2].nonconvex.minimum_downtime.max() > 0
             ]
         )
         self.NEGATIVE_GRADIENT_FLOWS = Set(
diff --git a/src/oemof/solph/processing.py b/src/oemof/solph/processing.py
index 0ff75e70..6623aa5b 100644
--- a/src/oemof/solph/processing.py
+++ b/src/oemof/solph/processing.py
@@ -17,6 +17,7 @@ SPDX-License-Identifier: MIT
 """
 
 import sys
+from collections import abc
 from itertools import groupby
 
 import numpy as np
@@ -25,6 +26,7 @@ from oemof.network.network import Entity
 from pyomo.core.base.piecewise import IndexedPiecewise
 from pyomo.core.base.var import Var
 
+from ._plumbing import _FakeSequence
 from .helpers import flatten
 
 
@@ -510,7 +512,8 @@ def __separate_attrs(
     """
 
     def detect_scalars_and_sequences(com):
-        com_data = {"scalars": {}, "sequences": {}}
+        scalars = {}
+        sequences = {}
 
         default_exclusions = [
             "__",
@@ -538,13 +541,13 @@ def __separate_attrs(
             # "investment" prefix to component data:
             if attr_value.__class__.__name__ == "Investment":
                 invest_data = detect_scalars_and_sequences(attr_value)
-                com_data["scalars"].update(
+                scalars.update(
                     {
                         "investment_" + str(k): v
                         for k, v in invest_data["scalars"].items()
                     }
                 )
-                com_data["sequences"].update(
+                sequences.update(
                     {
                         "investment_" + str(k): v
                         for k, v in invest_data["sequences"].items()
@@ -553,7 +556,7 @@ def __separate_attrs(
                 continue
 
             if isinstance(attr_value, str):
-                com_data["scalars"][a] = attr_value
+                scalars[a] = attr_value
                 continue
 
             # If the label is a tuple it is iterable, therefore it should be
@@ -561,16 +564,19 @@ def __separate_attrs(
             if a == "label":
                 attr_value = str(attr_value)
 
-            # check if attribute is iterable
-            # see: https://stackoverflow.com/questions/1952464/
-            # in-python-how-do-i-determine-if-an-object-is-iterable
-            try:
-                _ = (e for e in attr_value)
-                com_data["sequences"][a] = attr_value
-            except TypeError:
-                com_data["scalars"][a] = attr_value
+            if isinstance(attr_value, abc.Iterable):
+                sequences[a] = attr_value
+            elif isinstance(attr_value, _FakeSequence):
+                scalars[a] = attr_value.value
+            else:
+                scalars[a] = attr_value
 
-        com_data["sequences"] = flatten(com_data["sequences"])
+        sequences = flatten(sequences)
+
+        com_data = {
+            "scalars": scalars,
+            "sequences": sequences,
+        }
         move_undetected_scalars(com_data)
         if exclude_none:
             remove_nones(com_data)
@@ -586,19 +592,11 @@ def __separate_attrs(
             if isinstance(value, str):
                 com["scalars"][ckey] = value
                 del com["sequences"][ckey]
-                continue
-            try:
-                _ = (e for e in value)
-            except TypeError:
-                com["scalars"][ckey] = value
+            elif isinstance(value, _FakeSequence):
+                com["scalars"][ckey] = value.value
+                del com["sequences"][ckey]
+            elif len(value) == 0:
                 del com["sequences"][ckey]
-            else:
-                try:
-                    if not value.default_changed:
-                        com["scalars"][ckey] = value.default
-                        del com["sequences"][ckey]
-                except AttributeError:
-                    pass
 
     def remove_nones(com):
         for ckey, value in list(com["scalars"].items()):


Test patch:
diff --git a/tests/test_plumbing.py b/tests/test_plumbing.py
index 4ed99c11..b51187be 100644
--- a/tests/test_plumbing.py
+++ b/tests/test_plumbing.py
@@ -6,34 +6,60 @@ SPDX-FileCopyrightText: Patrik Schönfeldt
 
 SPDX-License-Identifier: MIT
 """
+import numpy as np
 import pytest
 
+from oemof.solph._plumbing import _FakeSequence
 from oemof.solph._plumbing import sequence
 
 
-def test_sequence():
-    seq0 = sequence(0)
-    assert seq0[0] == 0
-    assert len(seq0) == 1
+def test_fake_sequence():
+    seq0 = _FakeSequence(42)
+    assert seq0[0] == 42
+    assert seq0.size is None
+
+    assert seq0[10] == 42
+    assert seq0.size is None
+
+    assert seq0.max() == 42
+    assert seq0.min() == 42
+    assert seq0.value == 42
+    assert seq0.sum() == np.inf
+
+    assert str(seq0) == "[42, 42, ..., 42]"
 
-    assert seq0[10] == 0
-    assert len(seq0) == 11
+    with pytest.raises(TypeError):
+        seq0.to_numpy()
+    assert (seq0.to_numpy(length=5) == np.array(5 * [42])).all()
 
-    assert max(seq0) == 0
+    with pytest.raises(TypeError):
+        len(seq0)
 
-    seq10 = sequence(10)
-    assert max(seq10) == 10
+    seq0.size = 2
+    assert seq0.size == 2
+    assert len(seq0) == 2
 
-    assert seq10[0] == 10
-    assert len(seq10) == 1
+    assert seq0.max() == 42
+    assert seq0.min() == 42
+    assert seq0.value == 42
+    assert seq0.sum() == 84
 
-    assert seq10[10] == 10
-    assert len(seq10) == 11
+    assert str(seq0) == "[42, 42]"
+
+    assert (seq0.to_numpy() == np.array(2 * [42])).all()
+    assert (seq0.to_numpy(length=5) == np.array(5 * [42])).all()
+
+
+def test_sequence():
+    seq0 = sequence(0)
+    assert isinstance(seq0, _FakeSequence)
+    assert seq0.value == 0
+    assert seq0.size is None
 
-    seq12 = sequence([1, 3])
-    assert max(seq12) == 3
-    assert seq12[0] == 1
-    assert seq12[1] == 3
+    seq13 = sequence([1, 3])
+    assert isinstance(seq13, np.ndarray)
+    assert (seq13 == np.array([1, 3])).all()
 
-    with pytest.raises(IndexError):
-        _ = seq12[2]
+    seq_ab = sequence("ab")
+    assert isinstance(seq_ab, str)
+    assert seq_ab == "ab"
diff --git a/tests/test_scripts/test_solph/test_variable_chp/test_variable_chp.py b/tests/test_scripts/test_solph/test_variable_chp/test_variable_chp.py
index eec3f726..4b790bd2 100644
--- a/tests/test_scripts/test_solph/test_variable_chp/test_variable_chp.py
+++ b/tests/test_scripts/test_solph/test_variable_chp/test_variable_chp.py
@@ -195,8 +195,7 @@ def test_variable_chp(filename="variable_chp.csv", solver="cbc"):
         parameter[(energysystem.groups["('fixed_chp', 'gas')"], None)][
             "scalars"
         ]["conversion_factors_('electricity', 2)"]
-        == 0.3
-    )
+    ) == pytest.approx(0.3)
 
     # objective function
     assert solph.processing.meta_results(om)["objective"] == pytest.approx(