Skip to content

population_builder Module

High-level population construction utilities.

Overview

The population_builder module provides tools for setting up complex population models and initial states.

Complete Module Reference

natal.population_builder

Builder for constructing population instances with fluent API.

This module provides PopulationBuilder classes for streamlined, chainable population construction. It separates configuration management from object instantiation, preventing parameter bloat and enabling clear, readable code.

PopulationConfigBuilder

Internal builder for constructing PopulationConfig.

Handles all low-level configuration details and array initialization. It encapsulating the complexity of converting builder parameters.

build staticmethod
build(species: Species, n_ages: int, new_adult_age: int, is_stochastic: bool, use_continuous_sampling: bool, female_age_based_survival_rates: Optional[Any], male_age_based_survival_rates: Optional[Any], female_age_based_mating_rates: Optional[ArrayF64], male_age_based_mating_rates: Optional[ArrayF64], female_age_based_reproduction_rates: Optional[ArrayF64], female_age_based_relative_fertility: Optional[ArrayF64], expected_eggs_per_female: float, use_fixed_egg_count: bool, sex_ratio: float, use_sperm_storage: bool, sperm_displacement_rate: float, relative_competition_factor: float, juvenile_growth_mode: Union[int, str], low_density_growth_rate: float, age_1_carrying_capacity: Optional[float], old_juvenile_carrying_capacity: Optional[float], expected_num_adult_females: Optional[float], equilibrium_individual_distribution: Optional[ArrayF64], gamete_modifiers: Optional[List[ModifierSpec]], zygote_modifiers: Optional[List[ModifierSpec]], generation_time: Optional[int], initial_individual_count: Optional[NDArray[float64]] = None, initial_sperm_storage: Optional[NDArray[float64]] = None) -> PopulationConfig

Construct a complete PopulationConfig from builder parameters.

Parameters:

Name Type Description Default
species Species

Genetic architecture.

required
n_ages int

Number of age classes.

required
new_adult_age int

Minimum age for adults.

required
is_stochastic bool

Whether to use stochastic sampling.

required
use_continuous_sampling bool

Whether to use Dirichlet sampling.

required
female_age_based_survival_rates Any

Survival rates for females.

required
male_age_based_survival_rates Any

Survival rates for males.

required
female_age_based_mating_rates NDArray

Mating rates for females.

required
male_age_based_mating_rates NDArray

Mating rates for males.

required
female_age_based_reproduction_rates NDArray

Reproduction participation rates for females.

required
female_age_based_relative_fertility NDArray

Fertility weights for females.

required
expected_eggs_per_female float

Average egg production.

required
use_fixed_egg_count bool

Whether egg count is deterministic.

required
sex_ratio float

Offspring sex ratio.

required
use_sperm_storage bool

Whether to enable sperm storage.

required
sperm_displacement_rate float

Rate of sperm displacement.

required
relative_competition_factor float

Competition intensity.

required
juvenile_growth_mode Union[int, str]

Growth model type.

required
low_density_growth_rate float

Intrinsic growth rate.

required
age_1_carrying_capacity Optional[float]

Population carrying capacity at age=1.

required
old_juvenile_carrying_capacity Optional[float]

Alias for age_1_carrying_capacity (deprecated).

required
expected_num_adult_females Optional[float]

Target adult female count.

required
equilibrium_individual_distribution Optional[NDArray]

Expected distribution.

required
gamete_modifiers List[Tuple]

Custom gamete modifiers.

required
zygote_modifiers List[Tuple]

Custom zygote modifiers.

required
generation_time Optional[int]

Calculated generation time.

required
initial_individual_count Optional[NDArray[float64]]

Initial counts array.

None
initial_sperm_storage Optional[NDArray[float64]]

Initial sperm storage array.

None

Returns:

Name Type Description
PopulationConfig PopulationConfig

A fully initialized PopulationConfig instance.

Raises:

Type Description
ValueError

If n_ages, new_adult_age or other parameters are invalid.

TypeError

If input types are incorrect.

Source code in src/natal/population_builder.py
@staticmethod
def build(
    species: Species,
    # Basic settings
    n_ages: int,
    new_adult_age: int,
    is_stochastic: bool,
    use_continuous_sampling: bool,
    # Survival & Mating
    female_age_based_survival_rates: Optional[Any],
    male_age_based_survival_rates: Optional[Any],
    female_age_based_mating_rates: Optional[ArrayF64],
    male_age_based_mating_rates: Optional[ArrayF64],
    female_age_based_reproduction_rates: Optional[ArrayF64],
    female_age_based_relative_fertility: Optional[ArrayF64],
    # Reproduction
    expected_eggs_per_female: float,
    use_fixed_egg_count: bool,
    sex_ratio: float,
    use_sperm_storage: bool,  # TODO
    sperm_displacement_rate: float,
    # Competition
    relative_competition_factor: float,
    juvenile_growth_mode: Union[int, str],
    low_density_growth_rate: float,
    age_1_carrying_capacity: Optional[float],
    old_juvenile_carrying_capacity: Optional[float],
    expected_num_adult_females: Optional[float],
    equilibrium_individual_distribution: Optional[ArrayF64],
    # Modifiers
    gamete_modifiers: Optional[List[ModifierSpec]],
    zygote_modifiers: Optional[List[ModifierSpec]],
    # Generation time
    generation_time: Optional[int],
    # Initial state arrays (already parsed by builder)
    initial_individual_count: Optional[NDArray[np.float64]] = None,
    initial_sperm_storage: Optional[NDArray[np.float64]] = None,
) -> PopulationConfig:
    """Construct a complete PopulationConfig from builder parameters.

    Args:
        species (Species): Genetic architecture.
        n_ages (int): Number of age classes.
        new_adult_age (int): Minimum age for adults.
        is_stochastic (bool): Whether to use stochastic sampling.
        use_continuous_sampling (bool): Whether to use Dirichlet sampling.
        female_age_based_survival_rates (Any): Survival rates for females.
        male_age_based_survival_rates (Any): Survival rates for males.
        female_age_based_mating_rates (NDArray): Mating rates for females.
        male_age_based_mating_rates (NDArray): Mating rates for males.
        female_age_based_reproduction_rates (NDArray): Reproduction participation
            rates for females.
        female_age_based_relative_fertility (NDArray): Fertility weights for females.
        expected_eggs_per_female (float): Average egg production.
        use_fixed_egg_count (bool): Whether egg count is deterministic.
        sex_ratio (float): Offspring sex ratio.
        use_sperm_storage (bool): Whether to enable sperm storage.
        sperm_displacement_rate (float): Rate of sperm displacement.
        relative_competition_factor (float): Competition intensity.
        juvenile_growth_mode (Union[int, str]): Growth model type.
        low_density_growth_rate (float): Intrinsic growth rate.
        age_1_carrying_capacity (Optional[float]): Population carrying capacity at age=1.
        old_juvenile_carrying_capacity (Optional[float]): Alias for age_1_carrying_capacity (deprecated).
        expected_num_adult_females (Optional[float]): Target adult female count.
        equilibrium_individual_distribution (Optional[NDArray]): Expected distribution.
        gamete_modifiers (List[Tuple]): Custom gamete modifiers.
        zygote_modifiers (List[Tuple]): Custom zygote modifiers.
        generation_time (Optional[int]): Calculated generation time.
        initial_individual_count (Optional[NDArray[np.float64]]): Initial counts array.
        initial_sperm_storage (Optional[NDArray[np.float64]]): Initial sperm storage array.

    Returns:
        PopulationConfig: A fully initialized PopulationConfig instance.

    Raises:
        ValueError: If n_ages, new_adult_age or other parameters are invalid.
        TypeError: If input types are incorrect.

    """
    # print("⏳ Building population config...")

    # ===== Validation =====
    if n_ages <= 1:
        raise ValueError(f"n_ages must be at least 2, got {n_ages}")
    if new_adult_age < 0 or new_adult_age >= n_ages:
        raise ValueError(f"new_adult_age must be in [0, {n_ages}), got {new_adult_age}")

    # ===== Extract genotypes =====
    raw_gamete_labels = cast(Optional[List[str]], getattr(species, "gamete_labels", None))
    gamete_labels = raw_gamete_labels or ["default"]
    genotypes = species.get_all_genotypes()
    haploid_genotypes = species.get_all_haploid_genotypes()

    n_genotypes = len(genotypes)
    n_haplogenotypes = len(haploid_genotypes)
    n_glabs = len(gamete_labels)

    gamete_tensor_mods, zygote_tensor_mods = PopulationConfigBuilder._setup_modifiers(gamete_modifiers, zygote_modifiers)

    # ===== Build genotype/gamete maps =====
    gamete_map = initialize_gamete_map(
        diploid_genotypes=genotypes,
        haploid_genotypes=haploid_genotypes,
        n_glabs=n_glabs,
        gamete_modifiers=gamete_tensor_mods
    )

    zygote_map = initialize_zygote_map(
        haploid_genotypes=haploid_genotypes,
        diploid_genotypes=genotypes,
        n_glabs=n_glabs,
        zygote_modifiers=zygote_tensor_mods
    )

    # ===== Resolve survival rates =====
    _default_female = [1.0, 1.0, 5/6, 4/5, 3/4, 2/3, 1/2, 0.0]
    _default_male = [1.0, 1.0, 2/3, 1/2, 0.0, 0.0, 0.0, 0.0]

    female_survival = PopulationConfigBuilder._resolve_survival_param(
        female_age_based_survival_rates, n_ages, _default_female
    )
    male_survival = PopulationConfigBuilder._resolve_survival_param(
        male_age_based_survival_rates, n_ages, _default_male
    )

    age_based_survival_rates = np.array([female_survival, male_survival], dtype=np.float64)

    # ===== Mating rates =====
    if female_age_based_mating_rates is not None:
        if len(female_age_based_mating_rates) != n_ages:
            raise ValueError(
                f"female_age_based_mating_rates length {len(female_age_based_mating_rates)} != n_ages {n_ages}"
            )
        female_mating = np.array(female_age_based_mating_rates, dtype=np.float64)
    else:
        female_mating = np.zeros(n_ages, dtype=np.float64)
        female_mating[new_adult_age:] = 1.0

    if male_age_based_mating_rates is not None:
        if len(male_age_based_mating_rates) != n_ages:
            raise ValueError(
                f"male_age_based_mating_rates length {len(male_age_based_mating_rates)} != n_ages {n_ages}"
            )
        male_mating = np.array(male_age_based_mating_rates, dtype=np.float64)
    else:
        male_mating = np.zeros(n_ages, dtype=np.float64)
        male_mating[new_adult_age:] = 1.0

    age_based_mating_rates = np.array([female_mating, male_mating], dtype=np.float64)

    # ===== Female reproduction participation rates =====
    if female_age_based_reproduction_rates is not None:
        if len(female_age_based_reproduction_rates) != n_ages:
            raise ValueError(
                f"female_age_based_reproduction_rates length {len(female_age_based_reproduction_rates)} != n_ages {n_ages}"
            )
        female_reproduction = np.array(female_age_based_reproduction_rates, dtype=np.float64)
    else:
        # Backward compatible default: reuse female mating rates.
        female_reproduction = female_mating.copy()

    # ===== Female fertility =====
    if female_age_based_relative_fertility is not None:
        if len(female_age_based_relative_fertility) != n_ages:
            raise ValueError(
                f"female_age_based_relative_fertility length {len(female_age_based_relative_fertility)} != n_ages {n_ages}"
            )
        female_fertility = np.array(female_age_based_relative_fertility, dtype=np.float64)
    else:
        female_fertility = np.ones(n_ages, dtype=np.float64)

    # ===== Fitness tensors (default) =====
    viability_fitness = np.ones((2, n_ages, n_genotypes), dtype=np.float64)
    fecundity_fitness = np.ones((2, n_genotypes), dtype=np.float64)
    sexual_selection_fitness = np.ones((n_genotypes, n_genotypes), dtype=np.float64)
    zygote_viability_fitness = np.ones((2, n_genotypes), dtype=np.float64)

    # ===== Competition strength =====
    age_based_relative_competition_strength = np.ones(n_ages, dtype=np.float64)
    if relative_competition_factor > 0 and n_ages > 1:
        # Keep new larvae (age 0) at baseline competition strength 1.0,
        # and scale only old larvae (age 1), matching the SLiM formula:
        # new_larvae + old_larvae * OLD_LARVA_COMPETITION_FACTOR.
        age_based_relative_competition_strength[1] = relative_competition_factor

    # ===== Parse juvenile growth mode =====
    juvenile_growth_mode_int = PopulationConfigBuilder._resolve_growth_mode(juvenile_growth_mode)

    # ===== Resolve carrying capacity K (age-1 total at equilibrium) =====
    K = PopulationConfigBuilder._resolve_carrying_capacity(
        age_1_carrying_capacity=age_1_carrying_capacity,
        old_juvenile_carrying_capacity=old_juvenile_carrying_capacity,
        initial_individual_count=initial_individual_count,
    )

    # ===== Build equilibrium distribution =====
    if equilibrium_individual_distribution is not None:
        eq_dist = equilibrium_individual_distribution
    else:
        eq_dist = PopulationConfigBuilder._build_equilibrium_distribution(
            K=K,
            sex_ratio=sex_ratio,
            age_based_survival_rates=age_based_survival_rates,
            n_ages=n_ages,
        )

    # ===== Compute expected egg production =====
    # expected_num_adult_females independently determines expected eggs;
    # otherwise fall back to the equilibrium distribution's adult females.
    if expected_num_adult_females is not None:
        external_eggs = PopulationConfigBuilder._compute_expected_eggs_from_females(
            expected_num_adult_females=expected_num_adult_females,
            expected_eggs_per_female=expected_eggs_per_female,
            age_based_survival_rates=age_based_survival_rates,
            age_based_reproduction_rates=female_reproduction,
            female_age_based_relative_fertility=female_fertility,
            sex_ratio=sex_ratio,
            new_adult_age=new_adult_age,
            n_ages=n_ages,
        )
    else:
        external_eggs = None

    # ===== Create and return PopulationConfig =====
    cfg = build_population_config(
        n_genotypes=n_genotypes,
        n_haploid_genotypes=n_haplogenotypes,
        n_sexes=2,
        n_ages=n_ages,
        n_glabs=n_glabs,
        is_stochastic=is_stochastic,
        use_continuous_sampling=use_continuous_sampling,
        age_based_survival_rates=age_based_survival_rates,
        age_based_mating_rates=age_based_mating_rates,
        age_based_reproduction_rates=female_reproduction,
        female_age_based_relative_fertility=female_fertility,
        viability_fitness=viability_fitness,
        fecundity_fitness=fecundity_fitness,
        sexual_selection_fitness=sexual_selection_fitness,
        zygote_viability_fitness=zygote_viability_fitness,
        age_based_relative_competition_strength=age_based_relative_competition_strength,
        new_adult_age=new_adult_age,
        sperm_displacement_rate=sperm_displacement_rate,
        expected_eggs_per_female=expected_eggs_per_female,
        use_fixed_egg_count=use_fixed_egg_count,
        carrying_capacity=K,
        sex_ratio=sex_ratio,
        low_density_growth_rate=low_density_growth_rate,
        juvenile_growth_mode=juvenile_growth_mode_int,
        age_1_carrying_capacity=age_1_carrying_capacity or old_juvenile_carrying_capacity,
        old_juvenile_carrying_capacity=None,
        expected_num_adult_females=expected_num_adult_females,
        equilibrium_individual_distribution=eq_dist,
        external_expected_eggs=external_eggs,
        genotype_to_gametes_map=gamete_map,
        gametes_to_zygote_map=zygote_map,
        generation_time=generation_time,
        initial_individual_count=initial_individual_count,
    )

    if initial_sperm_storage is not None:
        cfg = cfg._replace(initial_sperm_storage=initial_sperm_storage.copy())

    return cfg
resolve_age_structured_initial_individual_count staticmethod
resolve_age_structured_initial_individual_count(species: Species, distribution: InitialIndividualCountInput, n_ages: int, new_adult_age: int) -> NDArray[np.float64]

Resolve initial individual counts for age-structured models.

Parameters:

Name Type Description Default
species Species

The bound Species object.

required
distribution Dict

User-provided distribution mapping.

required
n_ages int

Total number of age classes.

required
new_adult_age int

Minimum age for adults.

required

Returns:

Type Description
NDArray[float64]

NDArray[np.float64]: A 3D array [sex, age, genotype].

Source code in src/natal/population_builder.py
@staticmethod
def resolve_age_structured_initial_individual_count(
    species: Species,
    distribution: InitialIndividualCountInput,
    n_ages: int,
    new_adult_age: int,
) -> NDArray[np.float64]:
    """Resolve initial individual counts for age-structured models.

    Args:
        species (Species): The bound Species object.
        distribution (Dict): User-provided distribution mapping.
        n_ages (int): Total number of age classes.
        new_adult_age (int): Minimum age for adults.

    Returns:
        NDArray[np.float64]: A 3D array [sex, age, genotype].
    """
    genotypes = species.get_all_genotypes()
    genotype_to_index = {gt: idx for idx, gt in enumerate(genotypes)}
    out = np.zeros((2, n_ages, len(genotypes)), dtype=np.float64)

    for sex_key, genotype_dist in distribution.items():
        sex_idx = PopulationConfigBuilder._resolve_sex_index(sex_key)
        for genotype_key, age_data in genotype_dist.items():
            genotype_idx = PopulationConfigBuilder._resolve_genotype_index(
                species, genotype_key, genotype_to_index
            )
            age_counts = PopulationConfigBuilder._resolve_age_counts_age_structured(
                age_data=age_data, n_ages=n_ages, new_adult_age=new_adult_age
            )
            for age, count in age_counts.items():
                out[sex_idx, age, genotype_idx] += float(count)
    return out
resolve_age_structured_initial_sperm_storage staticmethod
resolve_age_structured_initial_sperm_storage(species: Species, sperm_storage: InitialSpermStorageInput, n_ages: int, new_adult_age: int) -> NDArray[np.float64]

Resolve initial sperm storage for age-structured models.

Parameters:

Name Type Description Default
species Species

The bound Species object.

required
sperm_storage Dict

User-provided sperm storage mapping.

required
n_ages int

Total number of age classes.

required
new_adult_age int

Minimum age for adults.

required

Returns:

Type Description
NDArray[float64]

NDArray[np.float64]: A 3D array [age, female_genotype, male_genotype].

Raises:

Type Description
TypeError

If storage value is not a dictionary.

Source code in src/natal/population_builder.py
@staticmethod
def resolve_age_structured_initial_sperm_storage(
    species: Species,
    sperm_storage: InitialSpermStorageInput,
    n_ages: int,
    new_adult_age: int,
) -> NDArray[np.float64]:
    """Resolve initial sperm storage for age-structured models.

    Args:
        species (Species): The bound Species object.
        sperm_storage (Dict): User-provided sperm storage mapping.
        n_ages (int): Total number of age classes.
        new_adult_age (int): Minimum age for adults.

    Returns:
        NDArray[np.float64]: A 3D array [age, female_genotype, male_genotype].

    Raises:
        TypeError: If storage value is not a dictionary.
    """
    genotypes = species.get_all_genotypes()
    genotype_to_index = {gt: idx for idx, gt in enumerate(genotypes)}
    out = np.zeros((n_ages, len(genotypes), len(genotypes)), dtype=np.float64)

    for female_key, male_dict in sperm_storage.items():
        female_idx = PopulationConfigBuilder._resolve_genotype_index(
            species, female_key, genotype_to_index
        )
        for male_key, age_data in male_dict.items():
            male_idx = PopulationConfigBuilder._resolve_genotype_index(
                species, male_key, genotype_to_index
            )
            age_counts = PopulationConfigBuilder._resolve_age_counts_age_structured(
                age_data=age_data, n_ages=n_ages, new_adult_age=new_adult_age
            )
            for age, count in age_counts.items():
                out[age, female_idx, male_idx] += float(count)
    return out
resolve_discrete_initial_individual_count staticmethod
resolve_discrete_initial_individual_count(species: Species, distribution: InitialIndividualCountInput) -> NDArray[np.float64]

Resolve initial individual counts for discrete generation models.

Parameters:

Name Type Description Default
species Species

The bound Species object.

required
distribution Dict

User-provided distribution mapping.

required

Returns:

Type Description
NDArray[float64]

NDArray[np.float64]: A 3D array [sex, age, genotype] with age max 2.

Source code in src/natal/population_builder.py
@staticmethod
def resolve_discrete_initial_individual_count(
    species: Species,
    distribution: InitialIndividualCountInput,
) -> NDArray[np.float64]:
    """Resolve initial individual counts for discrete generation models.

    Args:
        species (Species): The bound Species object.
        distribution (Dict): User-provided distribution mapping.

    Returns:
        NDArray[np.float64]: A 3D array [sex, age, genotype] with age max 2.
    """
    genotypes = species.get_all_genotypes()
    genotype_to_index = {gt: idx for idx, gt in enumerate(genotypes)}
    out = np.zeros((2, 2, len(genotypes)), dtype=np.float64)

    for sex_key, genotype_dist in distribution.items():
        sex_idx = PopulationConfigBuilder._resolve_sex_index(sex_key)
        for genotype_key, age_data in genotype_dist.items():
            genotype_idx = PopulationConfigBuilder._resolve_genotype_index(
                species, genotype_key, genotype_to_index
            )
            age0, age1 = PopulationConfigBuilder._resolve_discrete_age_distribution(age_data)
            out[sex_idx, 0, genotype_idx] += age0
            out[sex_idx, 1, genotype_idx] += age1
    return out

PopulationBuilderBase

PopulationBuilderBase(species: Species)

Abstract base builder with common chainable methods.

Attributes:

Name Type Description
species Species

Genetic architecture for the population.

Initialize builder with required species.

Parameters:

Name Type Description Default
species Species

Genetic architecture for the population.

required
Source code in src/natal/population_builder.py
def __init__(self, species: Species):
    """Initialize builder with required species.

    Args:
        species (Species): Genetic architecture for the population.
    """
    self.species = species
    self._presets: List[Any] = []
    self._observation_groups: Optional[GroupsInput] = None
    self._observation_collapse_age: bool = False
with_observation
with_observation(groups: GroupsInput, *, collapse_age: bool = False) -> PopulationBuilderBase

Register observation groups for compressed history recording.

The groups are compiled into a binary mask and passed to the simulation kernel on each run() call. Once set, history records store aggregated observation data instead of raw flattened state.

Parameters:

Name Type Description Default
groups GroupsInput

Observation groups (dict of name -> spec, list of specs, or None for one-group-per-genotype).

required
collapse_age bool

Whether to collapse the age axis in exports.

False

Returns:

Name Type Description
PopulationBuilderBase PopulationBuilderBase

Self for chaining.

Source code in src/natal/population_builder.py
def with_observation(
    self,
    groups: GroupsInput,
    *,
    collapse_age: bool = False,
) -> 'PopulationBuilderBase':
    """Register observation groups for compressed history recording.

    The groups are compiled into a binary mask and passed to the
    simulation kernel on each ``run()`` call. Once set, history records
    store aggregated observation data instead of raw flattened state.

    Args:
        groups: Observation groups (dict of name -> spec, list of specs,
            or None for one-group-per-genotype).
        collapse_age: Whether to collapse the age axis in exports.

    Returns:
        PopulationBuilderBase: Self for chaining.
    """
    self._observation_groups = groups
    self._observation_collapse_age = collapse_age
    return self
add_preset
add_preset(preset: Any) -> PopulationBuilderBase

Add a gene drive preset to apply during build.

Presets are applied in the order they are added.

Parameters:

Name Type Description Default
preset Any

A GeneDrivePreset or similar modification system.

required

Returns:

Name Type Description
PopulationBuilderBase PopulationBuilderBase

Self for chaining.

Source code in src/natal/population_builder.py
def add_preset(self, preset: Any) -> 'PopulationBuilderBase':
    """Add a gene drive preset to apply during build.

    Presets are applied in the order they are added.

    Args:
        preset (Any): A GeneDrivePreset or similar modification system.

    Returns:
        PopulationBuilderBase: Self for chaining.
    """
    self._presets.append(preset)
    return self
build
build() -> Any

Build and return the configured Population.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in src/natal/population_builder.py
def build(self) -> Any:
    """Build and return the configured Population.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError

AgeStructuredPopulationBuilder

AgeStructuredPopulationBuilder(species: Species)

Bases: PopulationBuilderBase

Builder for AgeStructuredPopulation with organized group methods.

Note

Fitness and modifiers are applied AFTER presets during build(). This allows presets to set base values, which can then be overridden.

Initialize builder.

Parameters:

Name Type Description Default
species Species

Genetic architecture for the population.

required
Source code in src/natal/population_builder.py
def __init__(self, species: Species):
    """Initialize builder.

    Args:
        species (Species): Genetic architecture for the population.
    """
    super().__init__(species)
    # Store builder parameters directly
    self.name: str = "AgeStructuredPop"
    self.is_stochastic: bool = True
    self.use_continuous_sampling: bool = False
    self.use_fixed_egg_count: bool = False

    # Age structure
    self.n_ages: int = 8
    self.new_adult_age: int = 2
    self.generation_time: Optional[int] = None
    self.equilibrium_individual_distribution: Optional[ArrayF64] = None

    # Initial state (required)
    self.initial_individual_count: Optional[InitialIndividualCountInput] = None
    self.initial_sperm_storage: Optional[InitialSpermStorageInput] = None

    # Survival and mating
    self.female_age_based_survival_rates: Optional[Any] = None
    self.male_age_based_survival_rates: Optional[Any] = None
    self.female_age_based_mating_rates: Optional[ArrayF64] = None
    self.male_age_based_mating_rates: Optional[ArrayF64] = None
    self.female_age_based_reproduction_rates: Optional[ArrayF64] = None
    self.female_age_based_relative_fertility: Optional[ArrayF64] = None

    # Reproduction
    self.expected_eggs_per_female: float = 50.0
    self.sex_ratio: float = 0.5
    self.use_sperm_storage: bool = False
    self.sperm_displacement_rate: float = 0.0

    # Competition
    self.relative_competition_factor: float = 1.0
    self.juvenile_growth_mode: Union[int, str] = LOGISTIC
    self.low_density_growth_rate: float = 1.0
    self.age_1_carrying_capacity: Optional[float] = None
    self.old_juvenile_carrying_capacity: Optional[float] = None
    self.expected_num_adult_females: Optional[float] = None

    # Fitness and modifiers (delayed until build)
    self._fitness_operations: List[FitnessOperation] = []
    self.gamete_modifiers: Optional[List[ModifierSpec]] = None
    self.zygote_modifiers: Optional[List[ModifierSpec]] = None

    # Hooks
    self._hooks: HookMap = {}
setup
setup(name: str = 'AgeStructuredPop', stochastic: bool = True, use_continuous_sampling: bool = False, use_fixed_egg_count: bool = False) -> AgeStructuredPopulationBuilder

Configure basic population settings.

Parameters:

Name Type Description Default
name str

Human-readable population name.

'AgeStructuredPop'
stochastic bool

Whether to use stochastic sampling.

True
use_continuous_sampling bool

If True, use Dirichlet; else standard sampling.

False
use_fixed_egg_count bool

If True, egg count is fixed; if False, Poisson.

False

Returns:

Name Type Description
AgeStructuredPopulationBuilder AgeStructuredPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def setup(
    self,
    name: str = "AgeStructuredPop",
    stochastic: bool = True,
    use_continuous_sampling: bool = False,
    use_fixed_egg_count: bool = False
) -> 'AgeStructuredPopulationBuilder':
    """Configure basic population settings.

    Args:
        name (str): Human-readable population name.
        stochastic (bool): Whether to use stochastic sampling.
        use_continuous_sampling (bool): If True, use Dirichlet; else standard sampling.
        use_fixed_egg_count (bool): If True, egg count is fixed; if False, Poisson.

    Returns:
        AgeStructuredPopulationBuilder: Self for chaining.
    """
    self.name = name
    self.is_stochastic = stochastic
    self.use_continuous_sampling = use_continuous_sampling
    self.use_fixed_egg_count = use_fixed_egg_count
    return self
age_structure
age_structure(n_ages: int = 8, new_adult_age: int = 2, generation_time: Optional[int] = None, equilibrium_distribution: Optional[Union[List[float], NDArray[float64]]] = None) -> AgeStructuredPopulationBuilder

Configure age structure and generation time.

Parameters:

Name Type Description Default
n_ages int

Number of age classes.

8
new_adult_age int

Age at which individuals become adults.

2
generation_time Optional[int]

Optional time for one generation.

None
equilibrium_distribution Optional[Union[List, NDArray]]

Scaling distribution.

None

Returns:

Name Type Description
AgeStructuredPopulationBuilder AgeStructuredPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def age_structure(
    self,
    n_ages: int = 8,
    new_adult_age: int = 2,
    generation_time: Optional[int] = None,
    equilibrium_distribution: Optional[Union[List[float], NDArray[np.float64]]] = None
) -> 'AgeStructuredPopulationBuilder':
    """Configure age structure and generation time.

    Args:
        n_ages (int): Number of age classes.
        new_adult_age (int): Age at which individuals become adults.
        generation_time (Optional[int]): Optional time for one generation.
        equilibrium_distribution (Optional[Union[List, NDArray]]): Scaling distribution.

    Returns:
        AgeStructuredPopulationBuilder: Self for chaining.
    """
    self.n_ages = n_ages
    self.new_adult_age = new_adult_age
    if generation_time is not None:
        self.generation_time = generation_time
    if equilibrium_distribution is not None:
        self.equilibrium_individual_distribution = np.array(equilibrium_distribution)
    return self
initial_state
initial_state(individual_count: Mapping[str, Mapping[str, InitialAgeCountValue]], sperm_storage: Optional[Mapping[str, Mapping[str, InitialAgeCountValue]]] = None) -> AgeStructuredPopulationBuilder
initial_state(individual_count: Mapping[str, Mapping[Genotype, InitialAgeCountValue]], sperm_storage: Optional[Mapping[Genotype, Mapping[Genotype, InitialAgeCountValue]]] = None) -> AgeStructuredPopulationBuilder
initial_state(individual_count: Mapping[str, Mapping[Genotype | str, InitialAgeCountValue]], sperm_storage: Optional[Mapping[Genotype | str, Mapping[Genotype | str, InitialAgeCountValue]]] = None) -> AgeStructuredPopulationBuilder
initial_state(individual_count: InitialIndividualCountInput, sperm_storage: Optional[InitialSpermStorageInput] = None) -> AgeStructuredPopulationBuilder

Configure initial population state and sperm storage.

Parameters:

Name Type Description Default
individual_count Dict

Initial population distribution (required). Format: {sex: {genotype: counts_by_age}}

required
sperm_storage Optional[Dict]

Optional initial sperm storage state.

None

Returns:

Name Type Description
AgeStructuredPopulationBuilder AgeStructuredPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def initial_state(
    self,
    individual_count: InitialIndividualCountInput,
    sperm_storage: Optional[InitialSpermStorageInput] = None,
) -> 'AgeStructuredPopulationBuilder':
    """Configure initial population state and sperm storage.

    Args:
        individual_count (Dict): Initial population distribution (required).
            Format: {sex: {genotype: counts_by_age}}
        sperm_storage (Optional[Dict]): Optional initial sperm storage state.

    Returns:
        AgeStructuredPopulationBuilder: Self for chaining.
    """
    self.initial_individual_count = individual_count
    if sperm_storage is not None:
        self.initial_sperm_storage = sperm_storage
    return self
survival
survival(female_age_based_survival_rates: Optional[Any] = None, male_age_based_survival_rates: Optional[Any] = None, generation_time: Optional[int] = None, equilibrium_distribution: Optional[Union[List[float], NDArray[float64]]] = None) -> AgeStructuredPopulationBuilder

Configure survival rates and related parameters.

Parameters:

Name Type Description Default
female_age_based_survival_rates Optional[Any]

Per-age female survival rates.

None
male_age_based_survival_rates Optional[Any]

Per-age male survival rates.

None
generation_time Optional[int]

Optional time scale for generation.

None
equilibrium_distribution Optional[Union[List, NDArray]]

Scaling distribution.

None

Returns:

Name Type Description
AgeStructuredPopulationBuilder AgeStructuredPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def survival(
    self,
    female_age_based_survival_rates: Optional[Any] = None,
    male_age_based_survival_rates: Optional[Any] = None,
    generation_time: Optional[int] = None,
    equilibrium_distribution: Optional[Union[List[float], NDArray[np.float64]]] = None
) -> 'AgeStructuredPopulationBuilder':
    """Configure survival rates and related parameters.

    Args:
        female_age_based_survival_rates (Optional[Any]): Per-age female survival rates.
        male_age_based_survival_rates (Optional[Any]): Per-age male survival rates.
        generation_time (Optional[int]): Optional time scale for generation.
        equilibrium_distribution (Optional[Union[List, NDArray]]): Scaling distribution.

    Returns:
        AgeStructuredPopulationBuilder: Self for chaining.
    """
    if female_age_based_survival_rates is not None:
        self.female_age_based_survival_rates = female_age_based_survival_rates
    if male_age_based_survival_rates is not None:
        self.male_age_based_survival_rates = male_age_based_survival_rates
    if generation_time is not None:
        self.generation_time = generation_time
    if equilibrium_distribution is not None:
        self.equilibrium_individual_distribution = np.array(equilibrium_distribution)
    return self
reproduction
reproduction(female_age_based_mating_rates: Optional[Union[List[float], NDArray[float64]]] = None, male_age_based_mating_rates: Optional[Union[List[float], NDArray[float64]]] = None, female_age_based_reproduction_rates: Optional[Union[List[float], NDArray[float64]]] = None, female_age_based_relative_fertility: Optional[Union[List[float], NDArray[float64]]] = None, eggs_per_female: float = 50.0, use_fixed_egg_count: bool = False, sex_ratio: float = 0.5, use_sperm_storage: bool = True, sperm_displacement_rate: float = 0.05) -> AgeStructuredPopulationBuilder

Configure reproduction parameters including mating, fertility, and sperm storage.

Parameters:

Name Type Description Default
female_age_based_mating_rates Optional[Union[List, NDArray]]

Female mating rates.

None
male_age_based_mating_rates Optional[Union[List, NDArray]]

Male mating rates.

None
female_age_based_reproduction_rates Optional[Union[List, NDArray]]

Female reproduction participation rates by age.

None
female_age_based_relative_fertility Optional[Union[List, NDArray]]

Fertility weights.

None
eggs_per_female float

Baseline eggs per adult female.

50.0
use_fixed_egg_count bool

If True, egg count is fixed; else Poisson.

False
sex_ratio float

Proportion of offspring that are female (0-1).

0.5
use_sperm_storage bool

Whether to model sperm storage.

True
sperm_displacement_rate float

Rate of sperm displacement (0-1).

0.05

Returns:

Name Type Description
AgeStructuredPopulationBuilder AgeStructuredPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def reproduction(
    self,
    female_age_based_mating_rates: Optional[Union[List[float], NDArray[np.float64]]] = None,
    male_age_based_mating_rates: Optional[Union[List[float], NDArray[np.float64]]] = None,
    female_age_based_reproduction_rates: Optional[Union[List[float], NDArray[np.float64]]] = None,
    female_age_based_relative_fertility: Optional[Union[List[float], NDArray[np.float64]]] = None,
    eggs_per_female: float = 50.0,
    use_fixed_egg_count: bool = False,
    sex_ratio: float = 0.5,
    use_sperm_storage: bool = True,
    sperm_displacement_rate: float = 0.05
) -> 'AgeStructuredPopulationBuilder':
    """Configure reproduction parameters including mating, fertility, and sperm storage.

    Args:
        female_age_based_mating_rates (Optional[Union[List, NDArray]]): Female mating rates.
        male_age_based_mating_rates (Optional[Union[List, NDArray]]): Male mating rates.
        female_age_based_reproduction_rates (Optional[Union[List, NDArray]]): Female
            reproduction participation rates by age.
        female_age_based_relative_fertility (Optional[Union[List, NDArray]]): Fertility weights.
        eggs_per_female (float): Baseline eggs per adult female.
        use_fixed_egg_count (bool): If True, egg count is fixed; else Poisson.
        sex_ratio (float): Proportion of offspring that are female (0-1).
        use_sperm_storage (bool): Whether to model sperm storage.
        sperm_displacement_rate (float): Rate of sperm displacement (0-1).

    Returns:
        AgeStructuredPopulationBuilder: Self for chaining.
    """
    if female_age_based_mating_rates is not None:
        self.female_age_based_mating_rates = np.array(female_age_based_mating_rates)
    if male_age_based_mating_rates is not None:
        self.male_age_based_mating_rates = np.array(male_age_based_mating_rates)
    if female_age_based_reproduction_rates is not None:
        self.female_age_based_reproduction_rates = np.array(female_age_based_reproduction_rates)
    if female_age_based_relative_fertility is not None:
        self.female_age_based_relative_fertility = np.array(female_age_based_relative_fertility)
    self.expected_eggs_per_female = eggs_per_female
    self.use_fixed_egg_count = use_fixed_egg_count
    self.sex_ratio = sex_ratio
    self.use_sperm_storage = use_sperm_storage
    self.sperm_displacement_rate = sperm_displacement_rate
    return self
competition
competition(competition_strength: float = 5.0, juvenile_growth_mode: Union[int, str] = 'logistic', low_density_growth_rate: float = 6.0, age_1_carrying_capacity: Optional[float] = None, old_juvenile_carrying_capacity: Optional[float] = None, expected_num_adult_females: Optional[float] = None, equilibrium_distribution: Optional[Union[List[float], NDArray[float64]]] = None) -> AgeStructuredPopulationBuilder

Configure competition, carrying capacity, and density-dependent parameters.

Parameters:

Name Type Description Default
competition_strength float

Relative competition factor for age-1 juveniles (age-0 remains baseline competition weight 1.0).

5.0
juvenile_growth_mode Union[int, str]

Growth model ("logistic", etc.).

'logistic'
low_density_growth_rate float

Growth rate at low density.

6.0
age_1_carrying_capacity Optional[float]

Population capacity at age=1.

None
old_juvenile_carrying_capacity Optional[float]

Alias for age_1_carrying_capacity (deprecated).

None
expected_num_adult_females Optional[float]

Equilibrium number of adult females.

None
equilibrium_distribution Optional[Union[List, NDArray]]

Scaling distribution.

None

Returns:

Name Type Description
AgeStructuredPopulationBuilder AgeStructuredPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def competition(
    self,
    competition_strength: float = 5.0,
    juvenile_growth_mode: Union[int, str] = "logistic",
    low_density_growth_rate: float = 6.0,
    age_1_carrying_capacity: Optional[float] = None,
    old_juvenile_carrying_capacity: Optional[float] = None,
    expected_num_adult_females: Optional[float] = None,
    equilibrium_distribution: Optional[Union[List[float], NDArray[np.float64]]] = None
) -> 'AgeStructuredPopulationBuilder':
    """Configure competition, carrying capacity, and density-dependent parameters.

    Args:
        competition_strength (float): Relative competition factor for age-1
            juveniles (age-0 remains baseline competition weight 1.0).
        juvenile_growth_mode (Union[int, str]): Growth model ("logistic", etc.).
        low_density_growth_rate (float): Growth rate at low density.
        age_1_carrying_capacity (Optional[float]): Population capacity at age=1.
        old_juvenile_carrying_capacity (Optional[float]): Alias for age_1_carrying_capacity (deprecated).
        expected_num_adult_females (Optional[float]): Equilibrium number of adult females.
        equilibrium_distribution (Optional[Union[List, NDArray]]): Scaling distribution.

    Returns:
        AgeStructuredPopulationBuilder: Self for chaining.
    """
    self.relative_competition_factor = competition_strength
    self.juvenile_growth_mode = juvenile_growth_mode
    self.low_density_growth_rate = low_density_growth_rate
    if age_1_carrying_capacity is not None:
        self.age_1_carrying_capacity = age_1_carrying_capacity
    elif old_juvenile_carrying_capacity is not None:
        self.age_1_carrying_capacity = old_juvenile_carrying_capacity
    if expected_num_adult_females is not None:
        self.expected_num_adult_females = expected_num_adult_females
    if equilibrium_distribution is not None:
        self.equilibrium_individual_distribution = np.array(equilibrium_distribution)
    return self
presets
presets(*preset_list: Any) -> AgeStructuredPopulationBuilder

Add preset preset packages (applied during build).

Presets are preset configurations that may include fitness tensors, modifiers, and other modifications. They are applied first, then overridden by explicit fitness(), modifiers(), and hooks() settings if provided.

Parameters:

Name Type Description Default
*preset_list Any

Variable number of preset objects to apply.

()

Returns:

Name Type Description
AgeStructuredPopulationBuilder AgeStructuredPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def presets(self, *preset_list: Any) -> 'AgeStructuredPopulationBuilder':
    """Add preset preset packages (applied during build).

    Presets are preset configurations that may include fitness tensors,
    modifiers, and other modifications. They are applied first, then
    overridden by explicit fitness(), modifiers(), and hooks() settings
    if provided.

    Args:
        *preset_list (Any): Variable number of preset objects to apply.

    Returns:
        AgeStructuredPopulationBuilder: Self for chaining.
    """
    if preset_list:
        self._presets = list(preset_list)
    return self
fitness
fitness(viability: Optional[ViabilityMap] = None, fecundity: Optional[FecundityMap] = None, sexual_selection: Optional[SexualSelectionMap] = None, zygote_viability: Optional[ViabilityMap] = None, mode: str = 'replace') -> AgeStructuredPopulationBuilder

Configure fitness via population methods (applied after presets).

Fitness is set using the population's set_viability(), set_fecundity(), set_sexual_selection(), and set_zygote_viability() methods AFTER presets are applied. This allows presets to set base fitness values which can then be overridden.

Parameters:

Name Type Description Default
viability Optional[Dict]

Mapping genotype -> {sex: value} or genotype -> value. If value is a dict with 'female'/'male' keys, applies per-sex.

None
fecundity Optional[Dict]

Mapping genotype -> fitness value (float or dict). - Float: Applies to both sexes. - Dict: {sex: value} applies per-sex.

None
sexual_selection Optional[Dict]

Nested mapping of female_selector -> {male_selector: value}.

None
zygote_viability Optional[Dict]

Mapping genotype -> zygote fitness value (float or dict). Zygote fitness represents the probability that a zygote survives to become an individual, applied during reproduction stage before survival and competition. - female_selector can be omitted by passing flat form {male_selector: value}, which applies to all female genotypes.

None
mode str

Scaling mode. 'replace' (default) overwrites existing values. 'multiply' scales existing values by the provided factor.

'replace'

Returns:

Name Type Description
AgeStructuredPopulationBuilder AgeStructuredPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def fitness(
    self,
    viability: Optional[ViabilityMap] = None,
    fecundity: Optional[FecundityMap] = None,
    sexual_selection: Optional[SexualSelectionMap] = None,
    zygote_viability: Optional[ViabilityMap] = None,
    mode: str = "replace",
) -> 'AgeStructuredPopulationBuilder':
    """Configure fitness via population methods (applied after presets).

    Fitness is set using the population's set_viability(), set_fecundity(),
    set_sexual_selection(), and set_zygote_viability() methods AFTER presets are applied.
    This allows presets to set base fitness values which can then be overridden.

    Args:
        viability (Optional[Dict]): Mapping genotype -> {sex: value} or genotype -> value.
            If value is a dict with 'female'/'male' keys, applies per-sex.
        fecundity (Optional[Dict]): Mapping genotype -> fitness value (float or dict).
            - Float: Applies to both sexes.
            - Dict: {sex: value} applies per-sex.
        sexual_selection (Optional[Dict]): Nested mapping of female_selector -> {male_selector: value}.
        zygote_viability (Optional[Dict]): Mapping genotype -> zygote fitness value (float or dict).
            Zygote fitness represents the probability that a zygote survives to become
            an individual, applied during reproduction stage before survival and competition.
            - female_selector can be omitted by passing flat form {male_selector: value},
                which applies to all female genotypes.
        mode (str): Scaling mode. 'replace' (default) overwrites existing values.
            'multiply' scales existing values by the provided factor.

    Returns:
        AgeStructuredPopulationBuilder: Self for chaining.
    """
    if viability is not None:
        self._fitness_operations.append(('viability', (viability,), {'mode': mode}))

    if fecundity is not None:
        self._fitness_operations.append(('fecundity', (fecundity,), {'mode': mode}))

    if sexual_selection is not None:
        self._fitness_operations.append(('sexual_selection', (sexual_selection,), {'mode': mode}))

    if zygote_viability is not None:
        self._fitness_operations.append(('zygote_viability', (zygote_viability,), {'mode': mode}))

    return self
modifiers
modifiers(gamete_modifiers: Optional[List[ModifierSpec]] = None, zygote_modifiers: Optional[List[ModifierSpec]] = None) -> AgeStructuredPopulationBuilder

Configure custom modifier functions (applied after presets).

Modifiers are registered AFTER presets are applied, allowing presets to establish base state which can then be modified.

Parameters:

Name Type Description Default
gamete_modifiers Optional[List]

(hook_id, name, modifier_func) for gametes.

None
zygote_modifiers Optional[List]

(hook_id, name, modifier_func) for zygotes.

None

Returns:

Name Type Description
AgeStructuredPopulationBuilder AgeStructuredPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def modifiers(
    self,
    gamete_modifiers: Optional[List[ModifierSpec]] = None,
    zygote_modifiers: Optional[List[ModifierSpec]] = None,
) -> 'AgeStructuredPopulationBuilder':
    """Configure custom modifier functions (applied after presets).

    Modifiers are registered AFTER presets are applied, allowing presets
    to establish base state which can then be modified.

    Args:
        gamete_modifiers (Optional[List]): (hook_id, name, modifier_func) for gametes.
        zygote_modifiers (Optional[List]): (hook_id, name, modifier_func) for zygotes.

    Returns:
        AgeStructuredPopulationBuilder: Self for chaining.
    """
    if gamete_modifiers is not None:
        self.gamete_modifiers = gamete_modifiers
    if zygote_modifiers is not None:
        self.zygote_modifiers = zygote_modifiers
    return self
hooks
hooks(*hook_items: Union[HookFn, HookMap]) -> AgeStructuredPopulationBuilder

Configure event hook registrations.

Parameters:

Name Type Description Default
*hook_items Union[Callable, Dict]

Functions decorated with @hook or mappings to hook registrations.

()

Returns:

Name Type Description
AgeStructuredPopulationBuilder AgeStructuredPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def hooks(
    self,
    *hook_items: Union[HookFn, HookMap]
) -> 'AgeStructuredPopulationBuilder':
    """Configure event hook registrations.

    Args:
        *hook_items (Union[Callable, Dict]): Functions decorated with @hook or mappings
            to hook registrations.

    Returns:
        AgeStructuredPopulationBuilder: Self for chaining.
    """
    for item in hook_items:
        if isinstance(item, dict):
            hook_map = cast(HookMap, item)
            for event, registrations in hook_map.items():
                if event not in self._hooks:
                    self._hooks[event] = []
                self._hooks[event].extend(registrations)
        elif callable(item):
            meta = getattr(item, 'meta', {})
            event = meta.get('event') or getattr(item, 'event', None)
            if not event:
                raise ValueError(
                    f"Hook '{getattr(item, '__name__', str(item))}' missing event. "
                    "Please specify with @hook(event='...')"
                )

            priority = meta.get('priority', getattr(item, 'priority', 0))
            name = getattr(item, '__name__', None)

            if event not in self._hooks:
                self._hooks[event] = []
            self._hooks[event].append((item, name, priority))
        else:
            raise TypeError(f"Unsupported hook type: {type(item)}")

    return self
build
build() -> AgeStructuredPopulation

Build and return the configured AgeStructuredPopulation.

Note

PopulationConfig is immutable after population creation. Fitness must be set during build phase via this method. Required configuration like initial_individual_count must be set.

Returns:

Name Type Description
AgeStructuredPopulation AgeStructuredPopulation

Initialized AgeStructuredPopulation instance.

Raises:

Type Description
ValueError

If required config like initial_individual_count is missing.

Source code in src/natal/population_builder.py
def build(self) -> 'AgeStructuredPopulation':
    """Build and return the configured AgeStructuredPopulation.

    Note:
        PopulationConfig is immutable after population creation.
        Fitness must be set during build phase via this method.
        Required configuration like initial_individual_count must be set.

    Returns:
        AgeStructuredPopulation: Initialized AgeStructuredPopulation instance.

    Raises:
        ValueError: If required config like initial_individual_count is missing.
    """
    # Import here to avoid circular imports
    from natal.age_structured_population import AgeStructuredPopulation

    # Validate required config
    if self.initial_individual_count is None:
        raise ValueError(
            "initial_individual_count is required. "
            "Use .initial_state() before .build()"
        )

    initial_individual_count = PopulationConfigBuilder.resolve_age_structured_initial_individual_count(
        species=self.species,
        distribution=self.initial_individual_count,
        n_ages=self.n_ages,
        new_adult_age=self.new_adult_age,
    )

    initial_sperm_storage = None
    if self.initial_sperm_storage is not None:
        initial_sperm_storage = PopulationConfigBuilder.resolve_age_structured_initial_sperm_storage(
            species=self.species,
            sperm_storage=self.initial_sperm_storage,
            n_ages=self.n_ages,
            new_adult_age=self.new_adult_age,
        )

    # 1️⃣ Build PopulationConfig via PopulationConfigBuilder
    pop_config = PopulationConfigBuilder.build(
        species=self.species,
        n_ages=self.n_ages,
        new_adult_age=self.new_adult_age,
        is_stochastic=self.is_stochastic,
        use_continuous_sampling=self.use_continuous_sampling,
        female_age_based_survival_rates=self.female_age_based_survival_rates,
        male_age_based_survival_rates=self.male_age_based_survival_rates,
        female_age_based_mating_rates=self.female_age_based_mating_rates,
        male_age_based_mating_rates=self.male_age_based_mating_rates,
        female_age_based_reproduction_rates=self.female_age_based_reproduction_rates,
        female_age_based_relative_fertility=self.female_age_based_relative_fertility,
        expected_eggs_per_female=self.expected_eggs_per_female,
        use_fixed_egg_count=self.use_fixed_egg_count,
        sex_ratio=self.sex_ratio,
        use_sperm_storage=self.use_sperm_storage,
        sperm_displacement_rate=self.sperm_displacement_rate,
        relative_competition_factor=self.relative_competition_factor,
        juvenile_growth_mode=self.juvenile_growth_mode,
        low_density_growth_rate=self.low_density_growth_rate,
        age_1_carrying_capacity=self.age_1_carrying_capacity,
        old_juvenile_carrying_capacity=None,
        expected_num_adult_females=self.expected_num_adult_females,
        equilibrium_individual_distribution=self.equilibrium_individual_distribution,
        gamete_modifiers=self.gamete_modifiers,
        zygote_modifiers=self.zygote_modifiers,
        generation_time=self.generation_time,
        initial_individual_count=initial_individual_count,
        initial_sperm_storage=initial_sperm_storage,
    )

    # 2️⃣ Create population with PopulationConfig and hooks
    pop = AgeStructuredPopulation(
        species=self.species,
        population_config=pop_config,
        name=self.name,
        hooks=self._hooks
    )

    # 3️⃣ Apply all presets in order
    pop_any = cast(Any, pop)
    for preset in self._presets:
        pop_any.apply_preset(preset)

    # 4️⃣ Apply fitness settings directly to PopulationConfig (after presets)
    for operation in self._fitness_operations:
        method_name, args, kwargs = operation
        mode = kwargs.get('mode', 'replace')
        is_multiply = (mode == 'multiply')

        if method_name == 'viability':
            viability_map = cast(ViabilityMap, args[0])
            for genotype_selector, values in viability_map.items():
                matched_genotypes = pop.species.resolve_genotype_selectors(
                    selector=genotype_selector,
                    context='viability',
                )

                for genotype in matched_genotypes:
                    genotype_idx = pop.index_registry.genotype_to_index[genotype]
                    target_age = pop.new_adult_age - 1
                    viability_updates = self._iter_viability_updates(
                        values=values,
                        n_ages=self.n_ages,
                        default_age=target_age,
                    )
                    for sex_idx, age_idx, raw_val in viability_updates:
                        val = raw_val
                        if is_multiply:
                            current = pop.config.viability_fitness[sex_idx, age_idx, genotype_idx]
                            val *= current
                        pop.config.set_viability_fitness(sex_idx, genotype_idx, val, age=age_idx)

        elif method_name == 'fecundity':
            fecundity_map = cast(FecundityMap, args[0])
            for genotype_selector, values in fecundity_map.items():
                matched_genotypes = pop.species.resolve_genotype_selectors(
                    selector=genotype_selector,
                    context='fecundity',
                )

                for genotype in matched_genotypes:
                    genotype_idx = pop.index_registry.genotype_to_index[genotype]

                    if isinstance(values, dict):
                        for sex_label, value in values.items():
                            sex_idx = resolve_sex_label(sex_label)
                            val = float(value)
                            if is_multiply:
                                current = pop.config.fecundity_fitness[sex_idx, genotype_idx]
                                val *= current
                            pop.config.set_fecundity_fitness(sex_idx, genotype_idx, val)
                    else:
                        for sex_idx in (0, 1):
                            val = float(values)
                            if is_multiply:
                                current = pop.config.fecundity_fitness[sex_idx, genotype_idx]
                                val *= current
                            pop.config.set_fecundity_fitness(sex_idx, genotype_idx, val)

        elif method_name == 'sexual_selection':
            preferences = cast(SexualSelectionMap, args[0])
            for f_selector, m_selector, preference in self._iter_sexual_selection_entries(preferences):
                matched_f_genotypes = pop.species.resolve_genotype_selectors(
                    selector=f_selector,
                    context='sexual_selection (female)',
                )
                matched_m_genotypes = pop.species.resolve_genotype_selectors(
                    selector=m_selector,
                    context='sexual_selection (male)',
                )

                for f_genotype in matched_f_genotypes:
                    for m_genotype in matched_m_genotypes:
                        f_idx = pop.index_registry.genotype_to_index[f_genotype]
                        m_idx = pop.index_registry.genotype_to_index[m_genotype]
                        val = float(preference)
                        if is_multiply:
                            current = pop.config.sexual_selection_fitness[f_idx, m_idx]
                            val *= current
                        pop.config.set_sexual_selection_fitness(f_idx, m_idx, val)

        elif method_name == 'zygote_viability':
            zygote_viability_map = cast(ZygoteViabilityMap, args[0])
            for genotype_selector, values in zygote_viability_map.items():
                matched_genotypes = pop.species.resolve_genotype_selectors(
                    selector=genotype_selector,
                    context='zygote_viability',
                )

                for genotype in matched_genotypes:
                    genotype_idx = pop.index_registry.genotype_to_index[genotype]

                    if isinstance(values, dict):
                        for sex_label, value in values.items():
                            sex_idx = resolve_sex_label(sex_label)
                            # For zygote viability fitness, we don't support age-specific values
                            # value should be a float, not AgeScalarMap
                            if isinstance(value, dict):
                                raise TypeError("Zygote viability fitness does not support age-specific values. Use a float value instead.")
                            val = float(value)
                            if is_multiply:
                                current = pop.config.zygote_viability_fitness[sex_idx, genotype_idx]
                                val *= current
                            pop.config.set_zygote_viability_fitness(sex_idx, genotype_idx, val)
                    else:
                        # values is a float, not AgeScalarMap for zygote fitness
                        for sex_idx in (0, 1):
                            val = float(values)
                            if is_multiply:
                                current = pop.config.zygote_viability_fitness[sex_idx, genotype_idx]
                                val *= current
                            pop.config.set_zygote_viability_fitness(sex_idx, genotype_idx, val)

    # 8️⃣ Apply observation groups if set
    if self._observation_groups is not None:
        pop.set_observations(
            self._observation_groups,
            collapse_age=self._observation_collapse_age,
        )

    return pop

DiscreteGenerationPopulationBuilder

DiscreteGenerationPopulationBuilder(species: Species)

Bases: PopulationBuilderBase

Builder for DiscreteGenerationPopulation.

For populations with discrete, non-overlapping generations.

Note

This builder fixes n_ages=2 and new_adult_age=1. In discrete kernels, juvenile competition strength is computed from total age-0 abundance directly.

Source code in src/natal/population_builder.py
def __init__(self, species: Species):
    super().__init__(species)

    self.name: str = "DiscreteGenerationPop"
    self.is_stochastic: bool = True
    self.use_continuous_sampling: bool = False
    self.use_fixed_egg_count: bool = False

    self.initial_individual_count: Optional[InitialIndividualCountInput] = None

    self.expected_eggs_per_female: float = 50.0
    self.sex_ratio: float = 0.5

    self.female_age0_survival: float = 1.0
    self.male_age0_survival: float = 1.0
    self.adult_survival: float = 0.0

    self.female_adult_mating_rate: float = 1.0
    self.male_adult_mating_rate: float = 1.0

    self.juvenile_growth_mode: Union[int, str] = LOGISTIC
    self.low_density_growth_rate: float = 1.0
    self.carrying_capacity: Optional[float] = None
    self.equilibrium_individual_distribution: Optional[ArrayF64] = None

    self.gamete_modifiers: Optional[List[ModifierSpec]] = None
    self.zygote_modifiers: Optional[List[ModifierSpec]] = None
    self._fitness_operations: List[FitnessOperation] = []
    self._hooks: HookMap = {}
presets
presets(*preset_list: Any) -> DiscreteGenerationPopulationBuilder

Add preset preset packages (applied during build).

Parameters:

Name Type Description Default
*preset_list Any

Variable number of preset objects to apply.

()

Returns:

Name Type Description
DiscreteGenerationPopulationBuilder DiscreteGenerationPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def presets(self, *preset_list: Any) -> "DiscreteGenerationPopulationBuilder":
    """Add preset preset packages (applied during build).

    Args:
        *preset_list (Any): Variable number of preset objects to apply.

    Returns:
        DiscreteGenerationPopulationBuilder: Self for chaining.
    """
    if preset_list:
        self._presets = list(preset_list)
    return self
fitness
fitness(viability: Optional[ViabilityMap] = None, fecundity: Optional[FecundityMap] = None, sexual_selection: Optional[SexualSelectionMap] = None, zygote_viability: Optional[ViabilityMap] = None, mode: str = 'replace') -> DiscreteGenerationPopulationBuilder

Configure fitness via population methods (applied after presets).

Parameters:

Name Type Description Default
viability Optional[Dict]

Genotype selectors to scalar or per-sex values.

None
fecundity Optional[Dict]

Genotype selectors to fecundity values.

None
sexual_selection Optional[Dict]

Flat or nested mating preference mapping.

None
zygote_viability Optional[Dict]

Genotype selectors to zygote viability fitness values. Zygote viability fitness represents the probability that a zygote survives to become an individual, applied during reproduction stage before survival and competition.

None
mode str

'replace' or 'multiply'.

'replace'

Returns:

Name Type Description
DiscreteGenerationPopulationBuilder DiscreteGenerationPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def fitness(
    self,
    viability: Optional[ViabilityMap] = None,
    fecundity: Optional[FecundityMap] = None,
    sexual_selection: Optional[SexualSelectionMap] = None,
    zygote_viability: Optional[ViabilityMap] = None,
    mode: str = "replace",
) -> "DiscreteGenerationPopulationBuilder":
    """Configure fitness via population methods (applied after presets).

    Args:
        viability (Optional[Dict]): Genotype selectors to scalar or per-sex values.
        fecundity (Optional[Dict]): Genotype selectors to fecundity values.
        sexual_selection (Optional[Dict]): Flat or nested mating preference mapping.
        zygote_viability (Optional[Dict]): Genotype selectors to zygote viability fitness values.
            Zygote viability fitness represents the probability that a zygote survives to become
            an individual, applied during reproduction stage before survival and competition.
        mode (str): 'replace' or 'multiply'.

    Returns:
        DiscreteGenerationPopulationBuilder: Self for chaining.
    """
    if viability is not None:
        self._fitness_operations.append(("viability", (viability,), {'mode': mode}))

    if fecundity is not None:
        self._fitness_operations.append(("fecundity", (fecundity,), {'mode': mode}))

    if sexual_selection is not None:
        self._fitness_operations.append(("sexual_selection", (sexual_selection,), {'mode': mode}))

    if zygote_viability is not None:
        self._fitness_operations.append(("zygote_viability", (zygote_viability,), {'mode': mode}))

    return self
modifiers
modifiers(gamete_modifiers: Optional[List[ModifierSpec]] = None, zygote_modifiers: Optional[List[ModifierSpec]] = None) -> DiscreteGenerationPopulationBuilder

Configure custom modifier functions.

Parameters:

Name Type Description Default
gamete_modifiers Optional[List]

(hook_id, name, modifier_func) for gametes.

None
zygote_modifiers Optional[List]

(hook_id, name, modifier_func) for zygotes.

None

Returns:

Name Type Description
DiscreteGenerationPopulationBuilder DiscreteGenerationPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def modifiers(
    self,
    gamete_modifiers: Optional[List[ModifierSpec]] = None,
    zygote_modifiers: Optional[List[ModifierSpec]] = None,
) -> "DiscreteGenerationPopulationBuilder":
    """Configure custom modifier functions.

    Args:
        gamete_modifiers (Optional[List]): (hook_id, name, modifier_func) for gametes.
        zygote_modifiers (Optional[List]): (hook_id, name, modifier_func) for zygotes.

    Returns:
        DiscreteGenerationPopulationBuilder: Self for chaining.
    """
    if gamete_modifiers is not None:
        self.gamete_modifiers = gamete_modifiers
    if zygote_modifiers is not None:
        self.zygote_modifiers = zygote_modifiers
    return self
setup
setup(name: str = 'DiscreteGenerationPop', stochastic: bool = True, use_continuous_sampling: bool = False, use_fixed_egg_count: bool = False) -> DiscreteGenerationPopulationBuilder

Configure basic population settings.

Parameters:

Name Type Description Default
name str

Human-readable population name.

'DiscreteGenerationPop'
stochastic bool

Whether to use stochastic sampling.

True
use_continuous_sampling bool

If True, use Dirichlet; else standard sampling.

False
use_fixed_egg_count bool

If True, egg count is fixed; if False, Poisson.

False

Returns:

Name Type Description
DiscreteGenerationPopulationBuilder DiscreteGenerationPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def setup(
    self,
    name: str = "DiscreteGenerationPop",
    stochastic: bool = True,
    use_continuous_sampling: bool = False,
    use_fixed_egg_count: bool = False
) -> 'DiscreteGenerationPopulationBuilder':
    """Configure basic population settings.

    Args:
        name (str): Human-readable population name.
        stochastic (bool): Whether to use stochastic sampling.
        use_continuous_sampling (bool): If True, use Dirichlet; else standard sampling.
        use_fixed_egg_count (bool): If True, egg count is fixed; if False, Poisson.

    Returns:
        DiscreteGenerationPopulationBuilder: Self for chaining.
    """
    self.name = name
    self.is_stochastic = stochastic
    self.use_continuous_sampling = use_continuous_sampling
    self.use_fixed_egg_count = use_fixed_egg_count
    return self
initial_state
initial_state(individual_count: Mapping[str, Mapping[str, InitialAgeCountValue]]) -> DiscreteGenerationPopulationBuilder
initial_state(individual_count: Mapping[str, Mapping[Genotype, InitialAgeCountValue]]) -> DiscreteGenerationPopulationBuilder
initial_state(individual_count: Mapping[str, Mapping[Genotype | str, InitialAgeCountValue]]) -> DiscreteGenerationPopulationBuilder
initial_state(individual_count: InitialIndividualCountInput) -> DiscreteGenerationPopulationBuilder

Configure the initial population state.

Parameters:

Name Type Description Default
individual_count Dict

Initial abundance mapping grouped by sex and genotype. Value can be an age-indexed sequence/map or a scalar.

required

Returns:

Name Type Description
DiscreteGenerationPopulationBuilder DiscreteGenerationPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def initial_state(
    self,
    individual_count: InitialIndividualCountInput,
) -> "DiscreteGenerationPopulationBuilder":
    """Configure the initial population state.

    Args:
        individual_count (Dict): Initial abundance mapping grouped by sex and genotype.
            Value can be an age-indexed sequence/map or a scalar.

    Returns:
        DiscreteGenerationPopulationBuilder: Self for chaining.
    """
    self.initial_individual_count = individual_count
    return self
reproduction
reproduction(eggs_per_female: float = 50.0, sex_ratio: float = 0.5, female_adult_mating_rate: float = 1.0, male_adult_mating_rate: float = 1.0) -> DiscreteGenerationPopulationBuilder

Configure reproduction and mating parameters.

Parameters:

Name Type Description Default
eggs_per_female float

Expected offspring produced per adult female.

50.0
sex_ratio float

Proportion of female offspring in [0, 1].

0.5
female_adult_mating_rate float

Adult female mating rate.

1.0
male_adult_mating_rate float

Adult male mating rate.

1.0

Returns:

Name Type Description
DiscreteGenerationPopulationBuilder DiscreteGenerationPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def reproduction(
    self,
    eggs_per_female: float = 50.0,
    sex_ratio: float = 0.5,
    female_adult_mating_rate: float = 1.0,
    male_adult_mating_rate: float = 1.0,
) -> "DiscreteGenerationPopulationBuilder":
    """Configure reproduction and mating parameters.

    Args:
        eggs_per_female (float): Expected offspring produced per adult female.
        sex_ratio (float): Proportion of female offspring in [0, 1].
        female_adult_mating_rate (float): Adult female mating rate.
        male_adult_mating_rate (float): Adult male mating rate.

    Returns:
        DiscreteGenerationPopulationBuilder: Self for chaining.
    """
    self.expected_eggs_per_female = eggs_per_female
    self.sex_ratio = sex_ratio
    self.female_adult_mating_rate = female_adult_mating_rate
    self.male_adult_mating_rate = male_adult_mating_rate
    return self
survival
survival(female_age0_survival: float = 1.0, male_age0_survival: float = 1.0, adult_survival: float = 0.0) -> DiscreteGenerationPopulationBuilder

Configure survival probabilities for juvenile and adult stages.

Parameters:

Name Type Description Default
female_age0_survival float

Female survival probability from age-0 stage.

1.0
male_age0_survival float

Male survival probability from age-0 stage.

1.0
adult_survival float

Adult survival probability to the next step.

0.0

Returns:

Name Type Description
DiscreteGenerationPopulationBuilder DiscreteGenerationPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def survival(
    self,
    female_age0_survival: float = 1.0,
    male_age0_survival: float = 1.0,
    adult_survival: float = 0.0,
) -> "DiscreteGenerationPopulationBuilder":
    """Configure survival probabilities for juvenile and adult stages.

    Args:
        female_age0_survival (float): Female survival probability from age-0 stage.
        male_age0_survival (float): Male survival probability from age-0 stage.
        adult_survival (float): Adult survival probability to the next step.

    Returns:
        DiscreteGenerationPopulationBuilder: Self for chaining.
    """
    self.female_age0_survival = female_age0_survival
    self.male_age0_survival = male_age0_survival
    self.adult_survival = adult_survival
    return self
competition
competition(juvenile_growth_mode: Union[int, str] = 'logistic', low_density_growth_rate: float = 1.0, carrying_capacity: Optional[float] = None) -> DiscreteGenerationPopulationBuilder

Configure juvenile growth mode and density-dependence parameters.

Parameters:

Name Type Description Default
juvenile_growth_mode Union[int, str]

Growth model identifier.

'logistic'
low_density_growth_rate float

Per-step growth factor at low density.

1.0
carrying_capacity Optional[float]

Optional carrying capacity.

None

Returns:

Name Type Description
DiscreteGenerationPopulationBuilder DiscreteGenerationPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def competition(
    self,
    juvenile_growth_mode: Union[int, str] = "logistic",
    low_density_growth_rate: float = 1.0,
    carrying_capacity: Optional[float] = None,
) -> "DiscreteGenerationPopulationBuilder":
    """Configure juvenile growth mode and density-dependence parameters.

    Args:
        juvenile_growth_mode (Union[int, str]): Growth model identifier.
        low_density_growth_rate (float): Per-step growth factor at low density.
        carrying_capacity (Optional[float]): Optional carrying capacity.

    Returns:
        DiscreteGenerationPopulationBuilder: Self for chaining.
    """
    self.juvenile_growth_mode = juvenile_growth_mode
    self.low_density_growth_rate = low_density_growth_rate
    self.carrying_capacity = carrying_capacity
    return self
hooks
hooks(*hook_items: Union[HookFn, HookMap]) -> DiscreteGenerationPopulationBuilder

Register lifecycle hooks for simulation events.

Parameters:

Name Type Description Default
*hook_items Union[Callable, Dict]

Functions decorated with @hook or mappings to hook registrations.

()

Returns:

Name Type Description
DiscreteGenerationPopulationBuilder DiscreteGenerationPopulationBuilder

Self for chaining.

Source code in src/natal/population_builder.py
def hooks(
    self,
    *hook_items: Union[HookFn, HookMap]
) -> "DiscreteGenerationPopulationBuilder":
    """Register lifecycle hooks for simulation events.

    Args:
        *hook_items (Union[Callable, Dict]): Functions decorated with @hook or mappings
            to hook registrations.

    Returns:
        DiscreteGenerationPopulationBuilder: Self for chaining.
    """
    for item in hook_items:
        if isinstance(item, dict):
            hook_map = cast(HookMap, item)
            for event, registrations in hook_map.items():
                if event not in self._hooks:
                    self._hooks[event] = []
                self._hooks[event].extend(registrations)
        elif callable(item):
            meta = getattr(item, 'meta', {})
            event = meta.get('event') or getattr(item, 'event', None)
            if not event:
                raise ValueError(
                    f"Hook '{getattr(item, '__name__', str(item))}' missing event. "
                    "Please specify with @hook(event='...')"
                )

            priority = meta.get('priority', getattr(item, 'priority', 0))
            name = getattr(item, '__name__', None)

            if event not in self._hooks:
                self._hooks[event] = []
            self._hooks[event].append((item, name, priority))
        else:
            if item is not None:
                raise TypeError(f"Unsupported hook type: {type(item)}")
    return self
build
build() -> DiscreteGenerationPopulation

Build and return the configured DiscreteGenerationPopulation.

Returns:

Name Type Description
DiscreteGenerationPopulation DiscreteGenerationPopulation

A fully configured population instance.

Raises:

Type Description
ValueError

If initial_individual_count is not set.

Source code in src/natal/population_builder.py
def build(self) -> "DiscreteGenerationPopulation":
    """Build and return the configured DiscreteGenerationPopulation.

    Returns:
        DiscreteGenerationPopulation: A fully configured population instance.

    Raises:
        ValueError: If initial_individual_count is not set.
    """
    from natal.discrete_generation_population import DiscreteGenerationPopulation

    if self.initial_individual_count is None:
        raise ValueError(
            "initial_individual_count is required. "
            "Use .initial_state() before .build()"
        )

    initial_individual_count = PopulationConfigBuilder.resolve_discrete_initial_individual_count(
        species=self.species,
        distribution=self.initial_individual_count,
    )

    female_survival = [self.female_age0_survival, self.adult_survival]
    male_survival = [self.male_age0_survival, self.adult_survival]

    female_mating = np.array([0.0, self.female_adult_mating_rate], dtype=np.float64)
    male_mating = np.array([0.0, self.male_adult_mating_rate], dtype=np.float64)

    female_relative_fertility = np.array([0.0, 1.0], dtype=np.float64)

    pop_config = PopulationConfigBuilder.build(
        species=self.species,
        n_ages=2,
        new_adult_age=1,
        is_stochastic=self.is_stochastic,
        use_continuous_sampling=self.use_continuous_sampling,
        female_age_based_survival_rates=female_survival,
        male_age_based_survival_rates=male_survival,
        female_age_based_mating_rates=female_mating,
        male_age_based_mating_rates=male_mating,
        female_age_based_reproduction_rates=female_mating,
        female_age_based_relative_fertility=female_relative_fertility,
        expected_eggs_per_female=self.expected_eggs_per_female,
        use_fixed_egg_count=self.use_fixed_egg_count,
        sex_ratio=self.sex_ratio,
        use_sperm_storage=False,
        sperm_displacement_rate=0.0,
        relative_competition_factor=1.0,
        juvenile_growth_mode=self.juvenile_growth_mode,
        low_density_growth_rate=self.low_density_growth_rate,
        age_1_carrying_capacity=self.carrying_capacity,
        old_juvenile_carrying_capacity=None,
        expected_num_adult_females=(
            self.carrying_capacity * self.sex_ratio
            if self.carrying_capacity is not None
            else None
        ),
        equilibrium_individual_distribution=self.equilibrium_individual_distribution,
        gamete_modifiers=self.gamete_modifiers,
        zygote_modifiers=self.zygote_modifiers,
        generation_time=1,
        initial_individual_count=initial_individual_count,
    )

    pop = DiscreteGenerationPopulation(
        species=self.species,
        population_config=pop_config,
        name=self.name,
        hooks=self._hooks,
    )

    pop_any = cast(Any, pop)
    for preset in self._presets:
        pop_any.apply_preset(preset)

    for operation in self._fitness_operations:
        method_name, args, kwargs = operation
        mode = kwargs.get('mode', 'replace')
        is_multiply = (mode == 'multiply')

        if method_name == 'viability':
            viability_map = cast(ViabilityMap, args[0])
            for genotype_selector, values in viability_map.items():
                matched_genotypes = pop.species.resolve_genotype_selectors(
                    selector=genotype_selector,
                    context='viability',
                )

                for genotype in matched_genotypes:
                    genotype_idx = pop.index_registry.genotype_to_index[genotype]
                    new_adult_age = 1
                    target_age = new_adult_age - 1
                    viability_updates = self._iter_viability_updates(
                        values=values,
                        n_ages=2,
                        default_age=target_age,
                    )
                    for sex_idx, age_idx, raw_val in viability_updates:
                        val = raw_val
                        if is_multiply:
                            current = pop.config.viability_fitness[sex_idx, age_idx, genotype_idx]
                            val *= current
                        pop.config.set_viability_fitness(sex_idx, genotype_idx, val, age=age_idx)

        elif method_name == 'fecundity':
            fecundity_map = cast(FecundityMap, args[0])
            for genotype_selector, values in fecundity_map.items():
                matched_genotypes = pop.species.resolve_genotype_selectors(
                    selector=genotype_selector,
                    context='fecundity',
                )

                for genotype in matched_genotypes:
                    genotype_idx = pop.index_registry.genotype_to_index[genotype]
                    if isinstance(values, dict):
                        for sex_label, value in values.items():
                            sex_idx = resolve_sex_label(sex_label)
                            val = float(value)
                            if is_multiply:
                                current = pop.config.fecundity_fitness[sex_idx, genotype_idx]
                                val *= current
                            pop.config.set_fecundity_fitness(sex_idx, genotype_idx, val)
                    else:
                        for sex_idx in (0, 1):
                            val = float(values)
                            if is_multiply:
                                current = pop.config.fecundity_fitness[sex_idx, genotype_idx]
                                val *= current
                            pop.config.set_fecundity_fitness(sex_idx, genotype_idx, val)

        elif method_name == 'sexual_selection':
            preferences = cast(SexualSelectionMap, args[0])
            for f_selector, m_selector, preference in self._iter_sexual_selection_entries(preferences):
                matched_f_genotypes = pop.species.resolve_genotype_selectors(
                    selector=f_selector,
                    context='sexual_selection (female)',
                )
                matched_m_genotypes = pop.species.resolve_genotype_selectors(
                    selector=m_selector,
                    context='sexual_selection (male)',
                )

                for f_genotype in matched_f_genotypes:
                    for m_genotype in matched_m_genotypes:
                        f_idx = pop.index_registry.genotype_to_index[f_genotype]
                        m_idx = pop.index_registry.genotype_to_index[m_genotype]
                        val = float(preference)
                        if is_multiply:
                            current = pop.config.sexual_selection_fitness[f_idx, m_idx]
                            val *= current
                        pop.config.set_sexual_selection_fitness(f_idx, m_idx, val)

        elif method_name == 'zygote_viability':
            zygote_viability_map = cast(ZygoteViabilityMap, args[0])
            for genotype_selector, values in zygote_viability_map.items():
                matched_genotypes = pop.species.resolve_genotype_selectors(
                    selector=genotype_selector,
                    context='zygote',
                )

                for genotype in matched_genotypes:
                    genotype_idx = pop.index_registry.genotype_to_index[genotype]

                    if isinstance(values, dict):
                        for sex_label, value in values.items():
                            sex_idx = resolve_sex_label(sex_label)
                            # For zygote fitness, we don't support age-specific values
                            # value should be a float, not AgeScalarMap
                            if isinstance(value, dict):
                                raise TypeError("Zygote fitness does not support age-specific values. Use a float value instead.")
                            val = float(value)
                            if is_multiply:
                                current = pop.config.zygote_viability_fitness[sex_idx, genotype_idx]
                                val *= current
                            pop.config.set_zygote_viability_fitness(sex_idx, genotype_idx, val)
                    else:
                        # values is a float, not AgeScalarMap for zygote fitness
                        for sex_idx in (0, 1):
                            val = float(values)
                            if is_multiply:
                                current = pop.config.zygote_viability_fitness[sex_idx, genotype_idx]
                                val *= current
                            pop.config.set_zygote_viability_fitness(sex_idx, genotype_idx, val)

    # Apply observation groups if set
    if self._observation_groups is not None:
        pop.set_observations(
            self._observation_groups,
            collapse_age=self._observation_collapse_age,
        )

    return pop