Repository: PyFstat/PyFstat

Issue:
segmentation fault from BSGL grid test with recent LALSuite nightlies
**Describe the bug**
see https://github.com/PyFstat/PyFstat/actions/runs/3360239212/jobs/5569188206#step:7:104

The first failing version seems to be `7.10.1.dev20221025`.

Note that the non-BSGL `TestGridSearch::test_semicoherent_grid_search` does **not** segfault.

**Expected behavior**
no segfault

**Environment (please complete the following information):**
- see action setup, but can aso reproduce locally

**To Reproduce**
```
pip install --upgrade --pre lalsuite==7.10.1.dev20221025
pytest tests/test_grid_based_searches.py::TestGridSearchBSGL::test_semicoherent_grid_search
```

**Additional context**
---


Comments:

Patch:
diff --git a/docs/source/pyfstat.utils.rst b/docs/source/pyfstat.utils.rst
index 260c2a7..1465894 100644
--- a/docs/source/pyfstat.utils.rst
+++ b/docs/source/pyfstat.utils.rst
@@ -7,7 +7,7 @@ Most of these are used internally by other parts of the package
 and are of interest mostly only for developers,
 but others can also be helpful for end users.
 
-Functions in these modules can be directly acessed via ``pyfstat.utils``
+Functions in these modules can be directly accessed via ``pyfstat.utils``
 without explicitly mentioning the specific module in where they reside.
 (E.g. just call ``pyfstat.utils.some_function``,
 not ``pyfstat.utils.some_topic.some_function``.)
@@ -15,6 +15,14 @@ not ``pyfstat.utils.some_topic.some_function``.)
 Submodules
 ----------
 
+pyfstat.utils.atoms module
+--------------------------
+
+.. automodule:: pyfstat.utils.atoms
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 pyfstat.utils.cli module
 ------------------------
 
diff --git a/pyfstat/core.py b/pyfstat/core.py
index 9f0b3f1..023ec12 100644
--- a/pyfstat/core.py
+++ b/pyfstat/core.py
@@ -1317,19 +1317,10 @@ class ComputeFstat(BaseSearchClass):
         # and return the maximum of that?
         idx_maxTwoF = self.FstatMap.get_maxF_idx()
         for X in range(self.FstatResults.numDetectors):
-            # For each detector, we need to build a MultiFstatAtomVector
-            # because that's what the Fstat map function expects.
-            singleIFOmultiFatoms = lalpulsar.CreateMultiFstatAtomVector(1)
-            # The first [0] index on the multiFatoms here is over frequency bins;
+            # The [0] index on the multiFatoms here is over frequency bins;
             # we always operate on a single bin.
-            singleIFOmultiFatoms.data[0] = lalpulsar.CreateFstatAtomVector(
-                self.FstatResults.multiFatoms[0].data[X].length
-            )
-            singleIFOmultiFatoms.data[0].TAtom = (
-                self.FstatResults.multiFatoms[0].data[X].TAtom
-            )
-            singleIFOmultiFatoms.data[0].data = (
-                self.FstatResults.multiFatoms[0].data[X].data
+            singleIFOmultiFatoms = utils.extract_singleIFOmultiFatoms_from_multiAtoms(
+                self.FstatResults.multiFatoms[0], X
             )
             FXstatMap, timingFXstatMap = tcw.call_compute_transient_fstat_map(
                 self.tCWFstatMapVersion,
@@ -1987,19 +1978,10 @@ class SemiCoherentSearch(ComputeFstat):
                 "This function is available only if singleFstats or BSGL options were set."
             )
         for X in range(self.FstatResults.numDetectors):
-            # For each detector, we need to build a MultiFstatAtomVector
-            # because that's what the Fstat map function expects.
-            singleIFOmultiFatoms = lalpulsar.CreateMultiFstatAtomVector(1)
-            # The first [0] index on the multiFatoms here is over frequency bins;
+            # The [0] index on the multiFatoms here is over frequency bins;
             # we always operate on a single bin.
-            singleIFOmultiFatoms.data[0] = lalpulsar.CreateFstatAtomVector(
-                self.FstatResults.multiFatoms[0].data[X].length
-            )
-            singleIFOmultiFatoms.data[0].TAtom = (
-                self.FstatResults.multiFatoms[0].data[X].TAtom
-            )
-            singleIFOmultiFatoms.data[0].data = (
-                self.FstatResults.multiFatoms[0].data[X].data
+            singleIFOmultiFatoms = utils.extract_singleIFOmultiFatoms_from_multiAtoms(
+                self.FstatResults.multiFatoms[0], X
             )
             FXstatMap = lalpulsar.ComputeTransientFstatMap(
                 multiFstatAtoms=singleIFOmultiFatoms,
diff --git a/pyfstat/utils/__init__.py b/pyfstat/utils/__init__.py
index 7230235..2161e53 100644
--- a/pyfstat/utils/__init__.py
+++ b/pyfstat/utils/__init__.py
@@ -6,6 +6,7 @@ and are of interest mostly only for developers,
 but others can also be helpful for end users.
 """
 
+from .atoms import copy_FstatAtomVector, extract_singleIFOmultiFatoms_from_multiAtoms
 from .cli import match_commandlines, run_commandline
 from .converting import (
     convert_aPlus_aCross_to_h0_cosi,
diff --git a/pyfstat/utils/atoms.py b/pyfstat/utils/atoms.py
new file mode 100644
index 0000000..39eb2f8
--- /dev/null
+++ b/pyfstat/utils/atoms.py
@@ -0,0 +1,69 @@
+import logging
+
+import lalpulsar
+
+logger = logging.getLogger(__name__)
+
+
+def extract_singleIFOmultiFatoms_from_multiAtoms(
+    multiAtoms: lalpulsar.MultiFstatAtomVector, X: int
+) -> lalpulsar.MultiFstatAtomVector:
+    """Extract a length-1 MultiFstatAtomVector from a larger MultiFstatAtomVector.
+
+    The result is needed as input to ``lalpulsar.ComputeTransientFstatMap`` in some places.
+
+    The new object is freshly allocated,
+    and we do a deep copy of the actual per-timestamp atoms.
+
+    Parameters
+    -------
+    multiAtoms:
+        Fully allocated multi-detector struct of `length > X`.
+    X:
+        The detector index for which to extract atoms.
+    Returns
+    -------
+    singleIFOmultiFatoms:
+        Length-1 MultiFstatAtomVector with only the data for detector `X`.
+    """
+    if X >= multiAtoms.length:
+        raise ValueError(
+            f"Detector index {X} is out of range for multiAtoms of length {multiAtoms.length}."
+        )
+    singleIFOmultiFatoms = lalpulsar.CreateMultiFstatAtomVector(1)
+    singleIFOmultiFatoms.data[0] = lalpulsar.CreateFstatAtomVector(
+        multiAtoms.data[X].length
+    )
+    # we deep-copy the entries of the atoms vector,
+    # since just assigning the whole array can cause a segfault
+    # from memory cleanup in looping over this function
+    copy_FstatAtomVector(singleIFOmultiFatoms.data[0], multiAtoms.data[X])
+    return singleIFOmultiFatoms
+
+
+def copy_FstatAtomVector(
+    dest: lalpulsar.FstatAtomVector, src: lalpulsar.FstatAtomVector
+):
+    """Deep-copy an FstatAtomVector with all its per-SFT FstatAtoms.
+
+    The two vectors must have the same length,
+    and the destination vector must already be allocated.
+
+    Parameters
+    -------
+    dest:
+        The destination vector to copy to.
+        Must already be allocated.
+        Will be modified in-place.
+    src:
+        The source vector to copy from.
+    """
+    if dest.length != src.length:
+        raise ValueError(
+            f"Lengths of destination and source vectors do not match. ({dest.length} != {src.length})"
+        )
+    dest.TAtom = src.TAtom
+    for k in range(dest.length):
+        # this is now copying the actual FstatAtom object,
+        # with its actual data in memory (no more pointers)
+        dest.data[k] = src.data[k]


Test patch:
diff --git a/tests/test_utils/test_atoms.py b/tests/test_utils/test_atoms.py
new file mode 100644
index 0000000..a425fec
--- /dev/null
+++ b/tests/test_utils/test_atoms.py
@@ -0,0 +1,75 @@
+import lalpulsar
+import pytest
+
+from pyfstat.utils import (
+    copy_FstatAtomVector,
+    extract_singleIFOmultiFatoms_from_multiAtoms,
+)
+
+
+@pytest.fixture
+def arbitrary_singleAtoms():
+    single_atoms = lalpulsar.CreateFstatAtomVector(5)
+
+    single_atoms.TAtom = 1800
+
+    for i in range(single_atoms.length):
+
+        for attr in [
+            "timestamp",
+            "a2_alpha",
+            "b2_alpha",
+            "ab_alpha",
+            "Fa_alpha",
+            "Fb_alpha",
+        ]:
+            setattr(single_atoms.data[i], attr, i)
+
+    return single_atoms
+
+
+@pytest.fixture
+def arbitrary_multiAtoms(arbitrary_singleAtoms):
+    ma = lalpulsar.CreateMultiFstatAtomVector(1)
+    ma.data[0] = arbitrary_singleAtoms
+    return ma
+
+
+def compare_FstatAtomVector(vectorA, vectorB):
+
+    for attr in ["TAtom", "length"]:
+        assert getattr(vectorA, attr) == getattr(vectorB, attr)
+
+    for i in range(vectorA.length):
+
+        for attr in [
+            "timestamp",
+            "a2_alpha",
+            "b2_alpha",
+            "ab_alpha",
+            "Fa_alpha",
+            "Fb_alpha",
+        ]:
+            assert getattr(vectorA.data[i], attr) == getattr(vectorB.data[i], attr)
+
+
+def test_extract_singleIFOmultiFatoms_from_multiAtoms(
+    arbitrary_singleAtoms, arbitrary_multiAtoms
+):
+
+    single_atoms = extract_singleIFOmultiFatoms_from_multiAtoms(arbitrary_multiAtoms, 0)
+    compare_FstatAtomVector(single_atoms.data[0], arbitrary_multiAtoms.data[0])
+
+    with pytest.raises(ValueError):
+        extract_singleIFOmultiFatoms_from_multiAtoms(arbitrary_multiAtoms, 1)
+
+
+def test_copy_FstatAtomVector(arbitrary_singleAtoms):
+
+    single_atoms = lalpulsar.CreateFstatAtomVector(arbitrary_singleAtoms.length)
+    copy_FstatAtomVector(single_atoms, arbitrary_singleAtoms)
+    compare_FstatAtomVector(single_atoms, arbitrary_singleAtoms)
+
+    faulty_atoms = lalpulsar.CreateFstatAtomVector(arbitrary_singleAtoms.length + 1)
+    with pytest.raises(ValueError):
+        copy_FstatAtomVector(faulty_atoms, arbitrary_singleAtoms)