aegis_sim.recording.recordingmanager

Data recorder

Records data generated by the simulation.

When thinking about recording additional data, consider that there are three recording methods: I. Snapshots (record data from the population at a specific step) II. Flushes (collect data over time then flush) III. One-time records IV. Other: TE records

  1"""Data recorder
  2
  3Records data generated by the simulation.
  4
  5When thinking about recording additional data, consider that there are three recording methods:
  6    I. Snapshots (record data from the population at a specific step)
  7    II. Flushes (collect data over time then flush)
  8    III. One-time records
  9    IV. Other: TE records
 10"""
 11
 12import pathlib
 13import shutil
 14import logging
 15
 16from aegis_sim import variables
 17
 18from .terecorder import TERecorder
 19from .picklerecorder import PickleRecorder
 20from .popgenstatsrecorder import PopgenStatsRecorder
 21from .intervalrecorder import IntervalRecorder
 22from .flushrecorder import FlushRecorder
 23from .featherrecorder import FeatherRecorder
 24from .phenomaprecorder import PhenomapRecorder
 25from .summaryrecorder import SummaryRecorder
 26from .progressrecorder import ProgressRecorder
 27from .simpleprogressrecorder import SimpleProgressRecorder
 28from .popsizerecorder import PopsizeRecorder
 29from .resourcerecorder import ResourcesRecorder
 30from .ticker import Ticker
 31from .configrecorder import ConfigRecorder
 32from .envdriftmaprecorder import Envdriftmaprecorder
 33from .checkpointrecorder import CheckpointRecorder
 34from .ancestryrecorder import AncestryRecorder
 35from .fastarecorder import FastaRecorder
 36from .vcfrecorder import VCFRecorder
 37from .gvcfrecorder import GVCFRecorder
 38from .lineagerecorder import LineageRecorder
 39from .selectionrecorder import SelectionRecorder
 40from .latticerecorder import LatticeRecorder
 41
 42# TODO write tests
 43
 44
 45class RecordingManager:
 46    """
 47    Container class for various recorders.
 48    Each recorder records a certain type of data.
 49    Most recorders record data as tables, except SummaryRecorder and PickleRecorder which record JSON files and pickles (a binary python format).
 50    Headers and indexes of all tabular files are explicitly recorded.
 51
 52    -----
 53    GUI
 54    AEGIS records a lot of different data.
 55    In brief, AEGIS records
 56    genomic data (population-level allele frequencies and individual-level binary sequences) and
 57    phenotypic data (observed population-level phenotypes and intrinsic individual-level phenotypes),
 58    as well as
 59    derived demographic data (life, death and birth tables),
 60    population genetic data (e.g. effective population size, theta), and
 61    survival analysis data (TE / time-event tables).
 62    Furthermore, it records metadata (e.g. simulation log, processed configuration files) and python pickle files.
 63
 64    Recorded data is distributed in multiple files.
 65    Almost all data are tabular, so each file is a table to which rows are appended as the simulation is running.
 66    The recording rates are frequencies at which rows are added; they are expressed in simulation steps.
 67    """
 68
 69    def init(self, custom_config_path, overwrite):
 70        self.odir = self.make_odir(custom_config_path=custom_config_path, overwrite=overwrite)
 71        self.resuming = False
 72
 73    def init_for_resume(self, custom_config_path):
 74        """Initialize for resume mode — reuse existing output directory, no overwrite."""
 75        output_path = custom_config_path.parent / custom_config_path.stem
 76        if not output_path.exists():
 77            raise FileNotFoundError(
 78                f"Cannot resume: output directory {output_path} does not exist."
 79            )
 80        self.odir = output_path
 81        self.resuming = True
 82
 83    def truncate_for_resume(self, checkpoint_step):
 84        """Truncate output files back to the checkpoint step to avoid duplicate data.
 85
 86        Called after initialize_recorders so self.odir is set. The checkpoint is
 87        saved at the end of run_step for a given step, and all recorders also
 88        write during that same run_step. So the output files already contain data
 89        for checkpoint_step. On resume the sim loop re-executes from checkpoint_step,
 90        so we need to remove data from checkpoint_step onward.
 91
 92        For per-step files (1 line per step, no header), the number of lines to
 93        keep is checkpoint_step - 1 (steps 1 through checkpoint_step-1).
 94
 95        For rate-based files (1 header line + 1 data line every RATE steps),
 96        the number of data lines to keep is (checkpoint_step - 1) // RATE,
 97        plus the header line(s).
 98        """
 99        from aegis_sim.parameterization import parametermanager
100
101        step = checkpoint_step
102
103        # Per-step files: 1 line per step, no header. Keep step-1 lines.
104        per_step_files = [
105            "popsize_before_reproduction.csv",
106            "popsize_after_reproduction.csv",
107            "eggnum_after_reproduction.csv",
108            "resources_before_scavenging.csv",
109            "resources_after_scavenging.csv",
110        ]
111        for fname in per_step_files:
112            self._truncate_file(self.odir / fname, keep_lines=step - 1)
113
114        # Rate-based files with 1 header line.
115        # Data is written when step % RATE == 0 or step == 1.
116        # Number of data lines at step S: 1 (for step 1) + count of multiples of RATE in [2, S-1]
117        # Simpler: lines where skip() returns False for steps 1..S-1
118
119        rate_specs_1header = [
120            ("progress.log", "LOGGING_RATE"),
121        ]
122        for fname, rate_name in rate_specs_1header:
123            rate = getattr(parametermanager.parameters, rate_name)
124            n_data = self._count_recordings(step - 1, rate)
125            self._truncate_file(self.odir / fname, keep_lines=1 + n_data)
126
127        # FlushRecorder spectra: 1 header + data at INTERVAL_RATE
128        spectra_dir = self.odir / "gui" / "spectra"
129        if spectra_dir.exists():
130            rate = parametermanager.parameters.INTERVAL_RATE
131            n_data = self._count_recordings(step - 1, rate)
132            for csv_file in spectra_dir.glob("*.csv"):
133                self._truncate_file(csv_file, keep_lines=1 + n_data)
134
135        # IntervalRecorder: 2 header lines + data at INTERVAL_RATE
136        rate = parametermanager.parameters.INTERVAL_RATE
137        n_data = self._count_recordings(step - 1, rate)
138        for fname in ["gui/genotypes.csv", "gui/phenotypes.csv"]:
139            self._truncate_file(self.odir / fname, keep_lines=2 + n_data)
140
141        # PopgenStatsRecorder: no header, data at POPGENSTATS_RATE
142        popgen_dir = self.odir / "popgen"
143        if popgen_dir.exists():
144            rate = parametermanager.parameters.POPGENSTATS_RATE
145            n_data = self._count_recordings(step - 1, rate)
146            for csv_file in popgen_dir.glob("*.csv"):
147                self._truncate_file(csv_file, keep_lines=n_data)
148
149        # Envdriftmap: no header, writes when step % ENVDRIFT_RATE == 0 (NOT at step 1 unless divisible).
150        # This differs from skip() logic, so we count multiples directly.
151        rate = parametermanager.parameters.ENVDRIFT_RATE
152        if rate > 0:
153            n_data = (step - 1) // rate  # multiples of rate in [1, step-1]
154            self._truncate_file(self.odir / "envdriftmap.csv", keep_lines=n_data)
155
156        # TE files: numbered CSV files in te/ directory.
157        # A new TE file starts at step 1 and every TE_RATE steps.
158        # TE_number increments when a file is flushed (at TE_DURATION offset or final step).
159        # We need to figure out which TE file was in-progress at the checkpoint step
160        # and delete any files started after it.
161        te_rate = parametermanager.parameters.TE_RATE
162        te_duration = parametermanager.parameters.TE_DURATION
163        if te_rate > 0:
164            te_dir = self.odir / "te"
165            if te_dir.exists():
166                self._truncate_te_files(te_dir, step, te_rate, te_duration)
167
168        logging.info(f"Output files truncated to checkpoint step {step}.")
169
170    @staticmethod
171    def _truncate_te_files(te_dir, checkpoint_step, te_rate, te_duration):
172        """Handle TE file truncation on resume.
173
174        TE files are numbered 0.csv, 1.csv, etc. A new collection window opens
175        at step 1 and every TE_RATE steps. The file number increments when the
176        window is flushed. We need to:
177        1. Determine how many complete TE windows finished before checkpoint_step
178        2. Delete any TE files beyond that
179        3. Truncate the in-progress TE file (remove data recorded at/after checkpoint_step)
180        """
181        # Count how many TE windows were fully completed before checkpoint_step.
182        # A window starts at step S where S % te_rate == 0 (or step 1).
183        # It flushes at S + te_duration (or at STEPS_PER_SIMULATION).
184        # Window 0 starts at step 1, flushes at step te_duration (if te_duration < te_rate).
185        # Window i starts at step i*te_rate, flushes at step i*te_rate + te_duration.
186        # A window is "complete" if its flush step < checkpoint_step.
187
188        # Number of complete windows: windows that started AND flushed before checkpoint_step
189        # Window starts: step 1, te_rate, 2*te_rate, 3*te_rate, ...
190        # The window starting at step W flushes at step W + te_duration.
191        # Complete if W + te_duration < checkpoint_step.
192
193        # For simplicity, just count existing TE files and remove those whose
194        # start step >= checkpoint_step.
195        # Window 0 starts at step 1.
196        # Window k starts at step k * te_rate (for k >= 1), or step 1 for k=0.
197
198        existing_files = sorted(te_dir.glob("*.csv"), key=lambda p: int(p.stem))
199        for f in existing_files:
200            file_num = int(f.stem)
201            # Window file_num starts at: step 1 if file_num==0, else file_num * te_rate
202            window_start = 1 if file_num == 0 else file_num * te_rate
203            if window_start >= checkpoint_step:
204                # This window started at or after checkpoint — delete it
205                f.unlink()
206            # If the window started before checkpoint but may contain data from
207            # steps >= checkpoint_step, we need to truncate those lines.
208            # TE files have: 1 header line ("T,E"), then data lines for each
209            # death event recorded during the window. We can't easily map lines
210            # to steps, so we leave partial windows as-is. The resumed sim will
211            # re-open a new window at the appropriate step, and the old partial
212            # data from the interrupted window is acceptable (it's death events
213            # that actually happened).
214
215    @staticmethod
216    def _count_recordings(up_to_step, rate):
217        """Count how many times a recorder with given rate would have written for steps 1..up_to_step.
218
219        Mirrors the skip() logic: always write at step 1, then write at every step divisible by rate.
220        """
221        if rate <= 0 or up_to_step < 1:
222            return 0
223        # Step 1 always records
224        count = 1
225        if up_to_step >= 2:
226            # Multiples of rate in [1, up_to_step] = up_to_step // rate
227            # But step 1 is already counted, so subtract 1 if rate divides 1 (only when rate == 1)
228            count += up_to_step // rate
229            if rate == 1:
230                count -= 1
231        return count
232
233    @staticmethod
234    def _truncate_file(path, keep_lines):
235        """Truncate a file to keep only the first `keep_lines` lines."""
236        if not path.exists():
237            return
238        with open(path, "rb") as f:
239            lines = f.readlines()
240        if len(lines) <= keep_lines:
241            return  # Nothing to truncate
242        with open(path, "wb") as f:
243            f.writelines(lines[:keep_lines])
244
245    def initialize_recorders(self, TICKER_RATE, resuming=False):
246        self.terecorder = TERecorder(odir=self.odir, resuming=resuming)
247        self.picklerecorder = PickleRecorder(odir=self.odir)
248        self.popgenstatsrecorder = PopgenStatsRecorder(odir=self.odir)
249        self.guirecorder = IntervalRecorder(odir=self.odir, resuming=resuming)
250        self.flushrecorder = FlushRecorder(odir=self.odir, resuming=resuming)
251        self.featherrecorder = FeatherRecorder(odir=self.odir)
252        self.phenomaprecorder = PhenomapRecorder(odir=self.odir)
253        self.summaryrecorder = SummaryRecorder(odir=self.odir)
254        self.progressrecorder = ProgressRecorder(odir=self.odir, resuming=resuming)
255        self.simpleprogressrecorder = SimpleProgressRecorder(odir=self.odir)
256        self.ticker = Ticker(odir=self.odir, TICKER_RATE=TICKER_RATE)
257        self.popsizerecorder = PopsizeRecorder(odir=self.odir)
258        self.resourcerecorder = ResourcesRecorder(odir=self.odir)
259        self.configrecorder = ConfigRecorder(odir=self.odir)
260        self.envdriftmaprecorder = Envdriftmaprecorder(odir=self.odir)
261        self.checkpointrecorder = CheckpointRecorder(odir=self.odir)
262        self.ancestryrecorder = AncestryRecorder(odir=self.odir)
263        self.fastarecorder = FastaRecorder(odir=self.odir)
264        self.vcfrecorder = VCFRecorder(odir=self.odir)
265        self.gvcfrecorder = GVCFRecorder(odir=self.odir)
266        self.lineagerecorder = LineageRecorder(odir=self.odir)
267        self.selectionrecorder = SelectionRecorder(odir=self.odir)
268        self.latticerecorder = LatticeRecorder(odir=self.odir)
269
270    #############
271    # UTILITIES #
272    #############
273
274    @staticmethod
275    def make_odir(custom_config_path, overwrite) -> pathlib.Path:
276        output_path = custom_config_path.parent / custom_config_path.stem  # remove .yml
277        is_occupied = output_path.exists() and output_path.is_dir()
278        if is_occupied:
279            if overwrite:
280                shutil.rmtree(output_path)
281            else:
282                raise Exception(f"{output_path} already exists. To overwrite, add flag --overwrite or -o.")
283        return output_path
284
285    @staticmethod
286    def make_subfolders(paths):
287        # TODO relink paths that are now in classes
288        for path in paths:
289            path.mkdir(exist_ok=True, parents=True)
290
291    def is_extinct(self) -> bool:
292        if self.summaryrecorder.extinct:
293            logging.info(f"Population went extinct (at step {variables.steps}).")
294            return True
295        return False
class RecordingManager:
 46class RecordingManager:
 47    """
 48    Container class for various recorders.
 49    Each recorder records a certain type of data.
 50    Most recorders record data as tables, except SummaryRecorder and PickleRecorder which record JSON files and pickles (a binary python format).
 51    Headers and indexes of all tabular files are explicitly recorded.
 52
 53    -----
 54    GUI
 55    AEGIS records a lot of different data.
 56    In brief, AEGIS records
 57    genomic data (population-level allele frequencies and individual-level binary sequences) and
 58    phenotypic data (observed population-level phenotypes and intrinsic individual-level phenotypes),
 59    as well as
 60    derived demographic data (life, death and birth tables),
 61    population genetic data (e.g. effective population size, theta), and
 62    survival analysis data (TE / time-event tables).
 63    Furthermore, it records metadata (e.g. simulation log, processed configuration files) and python pickle files.
 64
 65    Recorded data is distributed in multiple files.
 66    Almost all data are tabular, so each file is a table to which rows are appended as the simulation is running.
 67    The recording rates are frequencies at which rows are added; they are expressed in simulation steps.
 68    """
 69
 70    def init(self, custom_config_path, overwrite):
 71        self.odir = self.make_odir(custom_config_path=custom_config_path, overwrite=overwrite)
 72        self.resuming = False
 73
 74    def init_for_resume(self, custom_config_path):
 75        """Initialize for resume mode — reuse existing output directory, no overwrite."""
 76        output_path = custom_config_path.parent / custom_config_path.stem
 77        if not output_path.exists():
 78            raise FileNotFoundError(
 79                f"Cannot resume: output directory {output_path} does not exist."
 80            )
 81        self.odir = output_path
 82        self.resuming = True
 83
 84    def truncate_for_resume(self, checkpoint_step):
 85        """Truncate output files back to the checkpoint step to avoid duplicate data.
 86
 87        Called after initialize_recorders so self.odir is set. The checkpoint is
 88        saved at the end of run_step for a given step, and all recorders also
 89        write during that same run_step. So the output files already contain data
 90        for checkpoint_step. On resume the sim loop re-executes from checkpoint_step,
 91        so we need to remove data from checkpoint_step onward.
 92
 93        For per-step files (1 line per step, no header), the number of lines to
 94        keep is checkpoint_step - 1 (steps 1 through checkpoint_step-1).
 95
 96        For rate-based files (1 header line + 1 data line every RATE steps),
 97        the number of data lines to keep is (checkpoint_step - 1) // RATE,
 98        plus the header line(s).
 99        """
100        from aegis_sim.parameterization import parametermanager
101
102        step = checkpoint_step
103
104        # Per-step files: 1 line per step, no header. Keep step-1 lines.
105        per_step_files = [
106            "popsize_before_reproduction.csv",
107            "popsize_after_reproduction.csv",
108            "eggnum_after_reproduction.csv",
109            "resources_before_scavenging.csv",
110            "resources_after_scavenging.csv",
111        ]
112        for fname in per_step_files:
113            self._truncate_file(self.odir / fname, keep_lines=step - 1)
114
115        # Rate-based files with 1 header line.
116        # Data is written when step % RATE == 0 or step == 1.
117        # Number of data lines at step S: 1 (for step 1) + count of multiples of RATE in [2, S-1]
118        # Simpler: lines where skip() returns False for steps 1..S-1
119
120        rate_specs_1header = [
121            ("progress.log", "LOGGING_RATE"),
122        ]
123        for fname, rate_name in rate_specs_1header:
124            rate = getattr(parametermanager.parameters, rate_name)
125            n_data = self._count_recordings(step - 1, rate)
126            self._truncate_file(self.odir / fname, keep_lines=1 + n_data)
127
128        # FlushRecorder spectra: 1 header + data at INTERVAL_RATE
129        spectra_dir = self.odir / "gui" / "spectra"
130        if spectra_dir.exists():
131            rate = parametermanager.parameters.INTERVAL_RATE
132            n_data = self._count_recordings(step - 1, rate)
133            for csv_file in spectra_dir.glob("*.csv"):
134                self._truncate_file(csv_file, keep_lines=1 + n_data)
135
136        # IntervalRecorder: 2 header lines + data at INTERVAL_RATE
137        rate = parametermanager.parameters.INTERVAL_RATE
138        n_data = self._count_recordings(step - 1, rate)
139        for fname in ["gui/genotypes.csv", "gui/phenotypes.csv"]:
140            self._truncate_file(self.odir / fname, keep_lines=2 + n_data)
141
142        # PopgenStatsRecorder: no header, data at POPGENSTATS_RATE
143        popgen_dir = self.odir / "popgen"
144        if popgen_dir.exists():
145            rate = parametermanager.parameters.POPGENSTATS_RATE
146            n_data = self._count_recordings(step - 1, rate)
147            for csv_file in popgen_dir.glob("*.csv"):
148                self._truncate_file(csv_file, keep_lines=n_data)
149
150        # Envdriftmap: no header, writes when step % ENVDRIFT_RATE == 0 (NOT at step 1 unless divisible).
151        # This differs from skip() logic, so we count multiples directly.
152        rate = parametermanager.parameters.ENVDRIFT_RATE
153        if rate > 0:
154            n_data = (step - 1) // rate  # multiples of rate in [1, step-1]
155            self._truncate_file(self.odir / "envdriftmap.csv", keep_lines=n_data)
156
157        # TE files: numbered CSV files in te/ directory.
158        # A new TE file starts at step 1 and every TE_RATE steps.
159        # TE_number increments when a file is flushed (at TE_DURATION offset or final step).
160        # We need to figure out which TE file was in-progress at the checkpoint step
161        # and delete any files started after it.
162        te_rate = parametermanager.parameters.TE_RATE
163        te_duration = parametermanager.parameters.TE_DURATION
164        if te_rate > 0:
165            te_dir = self.odir / "te"
166            if te_dir.exists():
167                self._truncate_te_files(te_dir, step, te_rate, te_duration)
168
169        logging.info(f"Output files truncated to checkpoint step {step}.")
170
171    @staticmethod
172    def _truncate_te_files(te_dir, checkpoint_step, te_rate, te_duration):
173        """Handle TE file truncation on resume.
174
175        TE files are numbered 0.csv, 1.csv, etc. A new collection window opens
176        at step 1 and every TE_RATE steps. The file number increments when the
177        window is flushed. We need to:
178        1. Determine how many complete TE windows finished before checkpoint_step
179        2. Delete any TE files beyond that
180        3. Truncate the in-progress TE file (remove data recorded at/after checkpoint_step)
181        """
182        # Count how many TE windows were fully completed before checkpoint_step.
183        # A window starts at step S where S % te_rate == 0 (or step 1).
184        # It flushes at S + te_duration (or at STEPS_PER_SIMULATION).
185        # Window 0 starts at step 1, flushes at step te_duration (if te_duration < te_rate).
186        # Window i starts at step i*te_rate, flushes at step i*te_rate + te_duration.
187        # A window is "complete" if its flush step < checkpoint_step.
188
189        # Number of complete windows: windows that started AND flushed before checkpoint_step
190        # Window starts: step 1, te_rate, 2*te_rate, 3*te_rate, ...
191        # The window starting at step W flushes at step W + te_duration.
192        # Complete if W + te_duration < checkpoint_step.
193
194        # For simplicity, just count existing TE files and remove those whose
195        # start step >= checkpoint_step.
196        # Window 0 starts at step 1.
197        # Window k starts at step k * te_rate (for k >= 1), or step 1 for k=0.
198
199        existing_files = sorted(te_dir.glob("*.csv"), key=lambda p: int(p.stem))
200        for f in existing_files:
201            file_num = int(f.stem)
202            # Window file_num starts at: step 1 if file_num==0, else file_num * te_rate
203            window_start = 1 if file_num == 0 else file_num * te_rate
204            if window_start >= checkpoint_step:
205                # This window started at or after checkpoint — delete it
206                f.unlink()
207            # If the window started before checkpoint but may contain data from
208            # steps >= checkpoint_step, we need to truncate those lines.
209            # TE files have: 1 header line ("T,E"), then data lines for each
210            # death event recorded during the window. We can't easily map lines
211            # to steps, so we leave partial windows as-is. The resumed sim will
212            # re-open a new window at the appropriate step, and the old partial
213            # data from the interrupted window is acceptable (it's death events
214            # that actually happened).
215
216    @staticmethod
217    def _count_recordings(up_to_step, rate):
218        """Count how many times a recorder with given rate would have written for steps 1..up_to_step.
219
220        Mirrors the skip() logic: always write at step 1, then write at every step divisible by rate.
221        """
222        if rate <= 0 or up_to_step < 1:
223            return 0
224        # Step 1 always records
225        count = 1
226        if up_to_step >= 2:
227            # Multiples of rate in [1, up_to_step] = up_to_step // rate
228            # But step 1 is already counted, so subtract 1 if rate divides 1 (only when rate == 1)
229            count += up_to_step // rate
230            if rate == 1:
231                count -= 1
232        return count
233
234    @staticmethod
235    def _truncate_file(path, keep_lines):
236        """Truncate a file to keep only the first `keep_lines` lines."""
237        if not path.exists():
238            return
239        with open(path, "rb") as f:
240            lines = f.readlines()
241        if len(lines) <= keep_lines:
242            return  # Nothing to truncate
243        with open(path, "wb") as f:
244            f.writelines(lines[:keep_lines])
245
246    def initialize_recorders(self, TICKER_RATE, resuming=False):
247        self.terecorder = TERecorder(odir=self.odir, resuming=resuming)
248        self.picklerecorder = PickleRecorder(odir=self.odir)
249        self.popgenstatsrecorder = PopgenStatsRecorder(odir=self.odir)
250        self.guirecorder = IntervalRecorder(odir=self.odir, resuming=resuming)
251        self.flushrecorder = FlushRecorder(odir=self.odir, resuming=resuming)
252        self.featherrecorder = FeatherRecorder(odir=self.odir)
253        self.phenomaprecorder = PhenomapRecorder(odir=self.odir)
254        self.summaryrecorder = SummaryRecorder(odir=self.odir)
255        self.progressrecorder = ProgressRecorder(odir=self.odir, resuming=resuming)
256        self.simpleprogressrecorder = SimpleProgressRecorder(odir=self.odir)
257        self.ticker = Ticker(odir=self.odir, TICKER_RATE=TICKER_RATE)
258        self.popsizerecorder = PopsizeRecorder(odir=self.odir)
259        self.resourcerecorder = ResourcesRecorder(odir=self.odir)
260        self.configrecorder = ConfigRecorder(odir=self.odir)
261        self.envdriftmaprecorder = Envdriftmaprecorder(odir=self.odir)
262        self.checkpointrecorder = CheckpointRecorder(odir=self.odir)
263        self.ancestryrecorder = AncestryRecorder(odir=self.odir)
264        self.fastarecorder = FastaRecorder(odir=self.odir)
265        self.vcfrecorder = VCFRecorder(odir=self.odir)
266        self.gvcfrecorder = GVCFRecorder(odir=self.odir)
267        self.lineagerecorder = LineageRecorder(odir=self.odir)
268        self.selectionrecorder = SelectionRecorder(odir=self.odir)
269        self.latticerecorder = LatticeRecorder(odir=self.odir)
270
271    #############
272    # UTILITIES #
273    #############
274
275    @staticmethod
276    def make_odir(custom_config_path, overwrite) -> pathlib.Path:
277        output_path = custom_config_path.parent / custom_config_path.stem  # remove .yml
278        is_occupied = output_path.exists() and output_path.is_dir()
279        if is_occupied:
280            if overwrite:
281                shutil.rmtree(output_path)
282            else:
283                raise Exception(f"{output_path} already exists. To overwrite, add flag --overwrite or -o.")
284        return output_path
285
286    @staticmethod
287    def make_subfolders(paths):
288        # TODO relink paths that are now in classes
289        for path in paths:
290            path.mkdir(exist_ok=True, parents=True)
291
292    def is_extinct(self) -> bool:
293        if self.summaryrecorder.extinct:
294            logging.info(f"Population went extinct (at step {variables.steps}).")
295            return True
296        return False

Container class for various recorders. Each recorder records a certain type of data. Most recorders record data as tables, except SummaryRecorder and PickleRecorder which record JSON files and pickles (a binary python format). Headers and indexes of all tabular files are explicitly recorded.


GUI AEGIS records a lot of different data. In brief, AEGIS records genomic data (population-level allele frequencies and individual-level binary sequences) and phenotypic data (observed population-level phenotypes and intrinsic individual-level phenotypes), as well as derived demographic data (life, death and birth tables), population genetic data (e.g. effective population size, theta), and survival analysis data (TE / time-event tables). Furthermore, it records metadata (e.g. simulation log, processed configuration files) and python pickle files.

Recorded data is distributed in multiple files. Almost all data are tabular, so each file is a table to which rows are appended as the simulation is running. The recording rates are frequencies at which rows are added; they are expressed in simulation steps.

def init(self, custom_config_path, overwrite):
70    def init(self, custom_config_path, overwrite):
71        self.odir = self.make_odir(custom_config_path=custom_config_path, overwrite=overwrite)
72        self.resuming = False
def init_for_resume(self, custom_config_path):
74    def init_for_resume(self, custom_config_path):
75        """Initialize for resume mode — reuse existing output directory, no overwrite."""
76        output_path = custom_config_path.parent / custom_config_path.stem
77        if not output_path.exists():
78            raise FileNotFoundError(
79                f"Cannot resume: output directory {output_path} does not exist."
80            )
81        self.odir = output_path
82        self.resuming = True

Initialize for resume mode — reuse existing output directory, no overwrite.

def truncate_for_resume(self, checkpoint_step):
 84    def truncate_for_resume(self, checkpoint_step):
 85        """Truncate output files back to the checkpoint step to avoid duplicate data.
 86
 87        Called after initialize_recorders so self.odir is set. The checkpoint is
 88        saved at the end of run_step for a given step, and all recorders also
 89        write during that same run_step. So the output files already contain data
 90        for checkpoint_step. On resume the sim loop re-executes from checkpoint_step,
 91        so we need to remove data from checkpoint_step onward.
 92
 93        For per-step files (1 line per step, no header), the number of lines to
 94        keep is checkpoint_step - 1 (steps 1 through checkpoint_step-1).
 95
 96        For rate-based files (1 header line + 1 data line every RATE steps),
 97        the number of data lines to keep is (checkpoint_step - 1) // RATE,
 98        plus the header line(s).
 99        """
100        from aegis_sim.parameterization import parametermanager
101
102        step = checkpoint_step
103
104        # Per-step files: 1 line per step, no header. Keep step-1 lines.
105        per_step_files = [
106            "popsize_before_reproduction.csv",
107            "popsize_after_reproduction.csv",
108            "eggnum_after_reproduction.csv",
109            "resources_before_scavenging.csv",
110            "resources_after_scavenging.csv",
111        ]
112        for fname in per_step_files:
113            self._truncate_file(self.odir / fname, keep_lines=step - 1)
114
115        # Rate-based files with 1 header line.
116        # Data is written when step % RATE == 0 or step == 1.
117        # Number of data lines at step S: 1 (for step 1) + count of multiples of RATE in [2, S-1]
118        # Simpler: lines where skip() returns False for steps 1..S-1
119
120        rate_specs_1header = [
121            ("progress.log", "LOGGING_RATE"),
122        ]
123        for fname, rate_name in rate_specs_1header:
124            rate = getattr(parametermanager.parameters, rate_name)
125            n_data = self._count_recordings(step - 1, rate)
126            self._truncate_file(self.odir / fname, keep_lines=1 + n_data)
127
128        # FlushRecorder spectra: 1 header + data at INTERVAL_RATE
129        spectra_dir = self.odir / "gui" / "spectra"
130        if spectra_dir.exists():
131            rate = parametermanager.parameters.INTERVAL_RATE
132            n_data = self._count_recordings(step - 1, rate)
133            for csv_file in spectra_dir.glob("*.csv"):
134                self._truncate_file(csv_file, keep_lines=1 + n_data)
135
136        # IntervalRecorder: 2 header lines + data at INTERVAL_RATE
137        rate = parametermanager.parameters.INTERVAL_RATE
138        n_data = self._count_recordings(step - 1, rate)
139        for fname in ["gui/genotypes.csv", "gui/phenotypes.csv"]:
140            self._truncate_file(self.odir / fname, keep_lines=2 + n_data)
141
142        # PopgenStatsRecorder: no header, data at POPGENSTATS_RATE
143        popgen_dir = self.odir / "popgen"
144        if popgen_dir.exists():
145            rate = parametermanager.parameters.POPGENSTATS_RATE
146            n_data = self._count_recordings(step - 1, rate)
147            for csv_file in popgen_dir.glob("*.csv"):
148                self._truncate_file(csv_file, keep_lines=n_data)
149
150        # Envdriftmap: no header, writes when step % ENVDRIFT_RATE == 0 (NOT at step 1 unless divisible).
151        # This differs from skip() logic, so we count multiples directly.
152        rate = parametermanager.parameters.ENVDRIFT_RATE
153        if rate > 0:
154            n_data = (step - 1) // rate  # multiples of rate in [1, step-1]
155            self._truncate_file(self.odir / "envdriftmap.csv", keep_lines=n_data)
156
157        # TE files: numbered CSV files in te/ directory.
158        # A new TE file starts at step 1 and every TE_RATE steps.
159        # TE_number increments when a file is flushed (at TE_DURATION offset or final step).
160        # We need to figure out which TE file was in-progress at the checkpoint step
161        # and delete any files started after it.
162        te_rate = parametermanager.parameters.TE_RATE
163        te_duration = parametermanager.parameters.TE_DURATION
164        if te_rate > 0:
165            te_dir = self.odir / "te"
166            if te_dir.exists():
167                self._truncate_te_files(te_dir, step, te_rate, te_duration)
168
169        logging.info(f"Output files truncated to checkpoint step {step}.")

Truncate output files back to the checkpoint step to avoid duplicate data.

Called after initialize_recorders so self.odir is set. The checkpoint is saved at the end of run_step for a given step, and all recorders also write during that same run_step. So the output files already contain data for checkpoint_step. On resume the sim loop re-executes from checkpoint_step, so we need to remove data from checkpoint_step onward.

For per-step files (1 line per step, no header), the number of lines to keep is checkpoint_step - 1 (steps 1 through checkpoint_step-1).

For rate-based files (1 header line + 1 data line every RATE steps), the number of data lines to keep is (checkpoint_step - 1) // RATE, plus the header line(s).

def initialize_recorders(self, TICKER_RATE, resuming=False):
246    def initialize_recorders(self, TICKER_RATE, resuming=False):
247        self.terecorder = TERecorder(odir=self.odir, resuming=resuming)
248        self.picklerecorder = PickleRecorder(odir=self.odir)
249        self.popgenstatsrecorder = PopgenStatsRecorder(odir=self.odir)
250        self.guirecorder = IntervalRecorder(odir=self.odir, resuming=resuming)
251        self.flushrecorder = FlushRecorder(odir=self.odir, resuming=resuming)
252        self.featherrecorder = FeatherRecorder(odir=self.odir)
253        self.phenomaprecorder = PhenomapRecorder(odir=self.odir)
254        self.summaryrecorder = SummaryRecorder(odir=self.odir)
255        self.progressrecorder = ProgressRecorder(odir=self.odir, resuming=resuming)
256        self.simpleprogressrecorder = SimpleProgressRecorder(odir=self.odir)
257        self.ticker = Ticker(odir=self.odir, TICKER_RATE=TICKER_RATE)
258        self.popsizerecorder = PopsizeRecorder(odir=self.odir)
259        self.resourcerecorder = ResourcesRecorder(odir=self.odir)
260        self.configrecorder = ConfigRecorder(odir=self.odir)
261        self.envdriftmaprecorder = Envdriftmaprecorder(odir=self.odir)
262        self.checkpointrecorder = CheckpointRecorder(odir=self.odir)
263        self.ancestryrecorder = AncestryRecorder(odir=self.odir)
264        self.fastarecorder = FastaRecorder(odir=self.odir)
265        self.vcfrecorder = VCFRecorder(odir=self.odir)
266        self.gvcfrecorder = GVCFRecorder(odir=self.odir)
267        self.lineagerecorder = LineageRecorder(odir=self.odir)
268        self.selectionrecorder = SelectionRecorder(odir=self.odir)
269        self.latticerecorder = LatticeRecorder(odir=self.odir)
@staticmethod
def make_odir(custom_config_path, overwrite) -> pathlib.Path:
275    @staticmethod
276    def make_odir(custom_config_path, overwrite) -> pathlib.Path:
277        output_path = custom_config_path.parent / custom_config_path.stem  # remove .yml
278        is_occupied = output_path.exists() and output_path.is_dir()
279        if is_occupied:
280            if overwrite:
281                shutil.rmtree(output_path)
282            else:
283                raise Exception(f"{output_path} already exists. To overwrite, add flag --overwrite or -o.")
284        return output_path
@staticmethod
def make_subfolders(paths):
286    @staticmethod
287    def make_subfolders(paths):
288        # TODO relink paths that are now in classes
289        for path in paths:
290            path.mkdir(exist_ok=True, parents=True)
def is_extinct(self) -> bool:
292    def is_extinct(self) -> bool:
293        if self.summaryrecorder.extinct:
294            logging.info(f"Population went extinct (at step {variables.steps}).")
295            return True
296        return False