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
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.
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.
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).
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)
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