diff --git a/docs/source/src.ch07.rst b/docs/source/src.ch07.rst new file mode 100644 index 0000000..6885a5b --- /dev/null +++ b/docs/source/src.ch07.rst @@ -0,0 +1,22 @@ +src.ch07 package +================ + +Submodules +---------- + +src.ch07.c1\_breed\_rats module +------------------------------- + +.. automodule:: src.ch07.c1_breed_rats + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: src.ch07 + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/src.rst b/docs/source/src.rst index c328fea..c27558a 100644 --- a/docs/source/src.rst +++ b/docs/source/src.rst @@ -12,6 +12,7 @@ Subpackages src.ch04 src.ch05 src.ch06 + src.ch07 Module contents --------------- diff --git a/src/ch07/__init__.py b/src/ch07/__init__.py new file mode 100644 index 0000000..a16958a --- /dev/null +++ b/src/ch07/__init__.py @@ -0,0 +1 @@ +"""Chapter 7.""" diff --git a/src/ch07/c1_breed_rats.py b/src/ch07/c1_breed_rats.py new file mode 100644 index 0000000..6b2715f --- /dev/null +++ b/src/ch07/c1_breed_rats.py @@ -0,0 +1,463 @@ +"""Efficiently breed rats to an average weight of 50000 grams. + +Use genetic algorithm on a mixed population of male and female rats. + +""" +import time +import random +import statistics + + +class BreedRats: + """Efficiently breed rats to an average weight of **target_wt**. + + Use genetic algorithm on a mixed population of male and female rats. + + Weights and number of each gender vary and can be set by modifying the + following: + + Args: + num_males (int): Number of male rats in population. + Default is ``4``. + num_females (int): Number of female rats in population. + Default is ``16``. + target_wt (int): Target weight in grams. Default is ``50000``. + gen_limit (int): Generational cutoff to stop breeding program. + Default is ``500``. + + """ + + # pylint: disable=too-many-instance-attributes,too-many-public-methods + # Limit is for instance attributes and public methods are 7 and 20, + # but analysis like this requires many constants. + # I am opting to make them modifiable in something that is isn't a + # dictionary. + # If there is a better way, please submit an issue for discussion. + + def __init__(self, num_males: int = 4, num_females: int = 16, + target_wt: int = 50000, gen_limit: int = 500): + """Initialize class.""" + self._min_wt = 200 + self._max_wt = 600 + self._male_mode_wt = 300 + self._female_mode_wt = 250 + self._mut_odds = 0.01 + self._mut_min = 0.5 + self._mut_max = 1.2 + self._litter_sz = 8 + self._litters_per_yr = 10 + + self._num_males = num_males + self._num_females = num_females + self._target_wt = target_wt + self._gen_limit = gen_limit + + @property + def min_wt(self): + """int: Minimum weight of adult rat in initial population. + + Default is ``200``. + """ + return self._min_wt + + @min_wt.setter + def min_wt(self, value: int): + self._min_wt = value + + @property + def max_wt(self): + """int: Maximum weight of adult rat in initial population. + + Default is ``600``. + """ + return self._max_wt + + @max_wt.setter + def max_wt(self, value: int): + self._max_wt = value + + @property + def male_mode_wt(self): + """int: Most common adult male rat weight in initial population. + + Default is ``300``. + """ + return self._male_mode_wt + + @male_mode_wt.setter + def male_mode_wt(self, value: int): + self._male_mode_wt = value + + @property + def female_mode_wt(self): + """int: Most common adult female rat weight in initial population. + + Default is ``250``. + """ + return self._female_mode_wt + + @female_mode_wt.setter + def female_mode_wt(self, value: int): + self._female_mode_wt = value + + @property + def mut_odds(self): + """float: Probability of a mutation occurring in a pup. + + Default is ``0.01``. + """ + return self._mut_odds + + @mut_odds.setter + def mut_odds(self, value: float): + self._mut_odds = value + + @property + def mut_min(self): + """float: Scalar on rat weight of least beneficial mutation. + + Default is ``0.5``. + """ + return self._mut_min + + @mut_min.setter + def mut_min(self, value: float): + self._mut_min = value + + @property + def mut_max(self): + """float: Scalar on rat weight of most beneficial mutation. + + Default is ``1.2``. + """ + return self._mut_max + + @mut_max.setter + def mut_max(self, value: float): + self._mut_max = value + + @property + def litter_sz(self): + """int: Number of pups per pair of breeding rats. + + Default is ``8``. + """ + return self._litter_sz + + @litter_sz.setter + def litter_sz(self, value: int): + self._litter_sz = value + + @property + def litters_per_yr(self): + """int: Number of litters per year per pair of breeding rats. + + Default is ``10``. + """ + return self._litters_per_yr + + @litters_per_yr.setter + def litters_per_yr(self, value: int): + self._litters_per_yr = value + + @property + def num_males(self): + """int: Number of male rats in population. + + Default is ``4``. + """ + return self._num_males + + @num_males.setter + def num_males(self, value: int): + self._num_males = value + + @property + def num_females(self): + """int: Number of female rats in population. + + Default is ``16``. + """ + return self._num_females + + @num_females.setter + def num_females(self, value: int): + self._num_females = value + + @property + def target_wt(self): + """int: Target weight in grams. + + Default is ``50000``. + """ + return self._target_wt + + @target_wt.setter + def target_wt(self, value: int): + self._target_wt = value + + @property + def gen_limit(self): + """int: Generational cutoff to stop breeding program. + + Default is ``500``. + """ + return self._gen_limit + + @gen_limit.setter + def gen_limit(self, value: int): + self._gen_limit = value + + def populate(self, pop_total: int, mode_wt: int) -> list: + """Generate population with a triangular distribution of weights. + + Use :py:mod:`~random.triangular` to generate a population with a + triangular distribution of weights based on **mode_wt**. + + Args: + pop_total (int): Total number of rats in population. + mode_wt (int): Most common adult rat weight in initial population. + + Returns: + List of triangularly distributed weights of a given rat population. + + """ + return [int(random.triangular(self._min_wt, self._max_wt, mode_wt)) + for _ in range(pop_total)] + + def get_population(self, num_males: int = None, + num_females: int = None) -> dict: + """Generate random population of rats. + + Wraps :func:`populate` using **num_males** and **num_females**. + + Args: + num_males (int): Number of males in population. + If :obj:`None`, defaults to instance value. + num_females (int): Number of females in population. + If :obj:`None`, defaults to instance value. + + Returns: + Dictionary of lists with ``males`` and ``females`` as keys and + specimen weight in grams as values. + + """ + if num_males is None: + num_males = self._num_males + if num_females is None: + num_females = self._num_females + population = { + 'males': self.populate(num_males, self._male_mode_wt), + 'females': self.populate(num_females, self._female_mode_wt) + } + return population + + @staticmethod + def combine_values(dictionary: dict) -> list: + """Combine dictionary values. + + Combine values in a dictionary of lists into one list. + + Args: + dictionary (dict): Dictionary of lists. + + Returns: + List containing all values that were in **dictionary**. + + """ + values = [] + for value in dictionary.values(): + values.extend(value) + return values + + def measure(self, population: dict) -> float: + """Measure average weight of population against target. + + Calculate mean weight of **population** and divide by **target_wt** to + determine if goal has been met. + + Args: + population (dict): Dictionary of lists with ``males`` and + ``females`` as keys and specimen weight in grams as values. + + Returns: + :py:obj:`float` representing decimal percentage of completion + where a value of ``1`` is ``100%``, or complete. + + """ + mean = statistics.mean(self.combine_values(population)) + return mean / self._target_wt + + def select(self, population: dict) -> dict: + """Select largest members of population. + + Sort members in descending order, and then keep largest members up to + instance values for **num_males** and **num_females**. + + Args: + population (dict): Dictionary of lists with ``males`` and + ``females`` as keys and specimen weight in grams as values. + + Returns: + Dictionary of lists of specified length of largest members of + **population**. + + Examples: + >>> from src.ch07.c1_breed_rats import BreedRats + >>> sample_one = BreedRats(num_males = 4, num_females = 4) + >>> s1_population = sample_one.get_population(num_males = 5, + ... num_females = 10) + >>> selected_population = sample_one.select(s1_population) + >>> print(selected_population) + {'males': [555, 444, 333, 222], 'females': [999, 888, 777, 666]} + + """ + new_population = {'males': [], 'females': []} + for gender in population: + if gender == 'males': + new_population[gender].extend( + sorted(population[gender], + reverse=True)[:self._num_males]) + else: + new_population[gender].extend( + sorted(population[gender], + reverse=True)[:self._num_females]) + return new_population + + def crossover(self, population: dict) -> dict: + """Crossover genes among members (weights) of a population. + + Breed **population** where each breeding pair produces a litter + of instance value for **litter_sz** pups. Pup's gender is assigned + randomly. + + To accommodate mismatched pairs, breeding pairs are selected randomly, + and once paired, females are removed from the breeding pool while + males remain. + + Args: + population (dict): Dictionary of lists with ``males`` and + ``females`` as keys and specimen weight in grams as values. + + Returns: + Dictionary of lists with ``males`` and ``females`` as keys and + pup weight in grams as values. + + """ + males = population['males'] + females = population['females'].copy() + litter = {'males': [], 'females': []} + while females: + male = random.choice(males) + female = random.choice(females) + for pup in range(self._litter_sz): + larger, smaller = male, female + if female > male: + larger, smaller = female, male + pup = random.randint(smaller, larger) + if random.choice([0, 1]): + litter['males'].append(pup) + else: + litter['females'].append(pup) + females.remove(female) + # Sort output for test consistency. + for value in litter.values(): + value.sort() + return litter + + def mutate(self, litter: dict) -> dict: + """Randomly alter pup weights applying input odds as a scalar. + + For each pup in **litter**, randomly decide if a floating point number + between instance values for **mut_min** and **mut_max** from + :py:mod:`~random.uniform` will be used as a scalar to modified their + weight. + + Args: + litter (dict): Dictionary of lists with ``males`` and ``females`` + as keys and specimen weight in grams as values. + + Returns: + Same dictionary of lists with weights potentially modified. + + """ + for gender in litter: + pups = litter[gender] + for index, pup in enumerate(pups): + if self._mut_odds >= random.random(): + pups[index] = round(pup * + random.uniform(self._mut_min, + self._mut_max)) + return litter + + def simulate(self, population: dict) -> tuple: + """Simulate genetic algorithm by breeding rats. + + Using **population**, repeat cycle of measure, select, crossover, + and mutate until either **target_wt** or **gen_limit** are met. + + Args: + population (dict): Dictionary of lists with ``males`` and + ``females`` as keys and specimen weight in grams as values. + + Returns: + Tuple containing list of average weights of generations and number + of generations. + + Examples: + >>> from src.ch07.c1_breed_rats import BreedRats + >>> sample_one = BreedRats() + >>> s1_population = sample_one.get_population() + >>> ave_wt, generations = sample_one.simulate(s1_population) + >>> print(generations) + 248 + + """ + generations = 0 + ave_wt = [] + match = self.measure(population) + + while match < 1 and generations < self._gen_limit: + population = self.select(population) + litter = self.crossover(population) + litter = self.mutate(litter) + for gender in litter: + population[gender].extend(litter[gender]) + match = self.measure(population) + print(f'Generation {generations} match: {match * 100:.4f}%') + + ave_wt.append(int(statistics.mean( + self.combine_values(population)))) + generations += 1 + return ave_wt, generations + + +def main(): + """Demonstrate BreedRats class. + + Use default values to run a demonstration simulation and display time + (in seconds) it took to run. + + """ + start_time = time.time() + experiment = BreedRats() + + population = experiment.get_population() + match = experiment.measure(population) + print(f'Initial population: {population}') + print(f'Initial population match: {match * 100}%') + print(f'Number of males, females to keep: {experiment.num_males}, ' + f'{experiment.num_females}') + ave_wt, generations = experiment.simulate(population) + + print(f'Average weight per generation: {ave_wt}') + print(f'\nNumber of generations: {generations}') + print(f'Number of years: {int(generations/experiment.litters_per_yr)}') + + end_time = time.time() + duration = end_time - start_time + print(f'Runtime for this program was {duration} seconds.') + + +if __name__ == '__main__': + main() diff --git a/tests/data/ch07/__init__.py b/tests/data/ch07/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/ch07/breed_rats.txt b/tests/data/ch07/breed_rats.txt new file mode 100644 index 0000000..6e2b8c2 --- /dev/null +++ b/tests/data/ch07/breed_rats.txt @@ -0,0 +1,12 @@ +Generation 0 match: 1.7358% +Generation 1 match: 2.8215% +Generation 2 match: 4.3073% +Generation 3 match: 5.9099% +Generation 4 match: 8.1822% +Generation 5 match: 11.7231% +Generation 6 match: 16.5973% +Generation 7 match: 24.7527% +Generation 8 match: 36.1726% +Generation 9 match: 52.3236% +Generation 10 match: 75.5783% +Generation 11 match: 108.5198% diff --git a/tests/data/ch07/main/breed_rats.txt b/tests/data/ch07/main/breed_rats.txt new file mode 100644 index 0000000..1911645 --- /dev/null +++ b/tests/data/ch07/main/breed_rats.txt @@ -0,0 +1,212 @@ +Initial population: {'males': [453, 301, 253, 339], 'females': [535, 255, 349, 344, 271, 291, 288, 410, 368, 405, 394, 284, 456, 355, 365, 278]} +Initial population match: 0.6993999999999999% +Number of males, females to keep: 4, 16 +Generation 0 match: 0.6763% +Generation 1 match: 0.8846% +Generation 2 match: 0.9594% +Generation 3 match: 1.0266% +Generation 4 match: 1.0600% +Generation 5 match: 1.0689% +Generation 6 match: 1.0786% +Generation 7 match: 1.1509% +Generation 8 match: 1.1816% +Generation 9 match: 1.1840% +Generation 10 match: 1.1860% +Generation 11 match: 1.1860% +Generation 12 match: 1.1876% +Generation 13 match: 1.1920% +Generation 14 match: 1.2738% +Generation 15 match: 1.3727% +Generation 16 match: 1.4070% +Generation 17 match: 1.4156% +Generation 18 match: 1.4134% +Generation 19 match: 1.4149% +Generation 20 match: 1.4150% +Generation 21 match: 1.4106% +Generation 22 match: 1.4319% +Generation 23 match: 1.4809% +Generation 24 match: 1.5012% +Generation 25 match: 1.5169% +Generation 26 match: 1.5375% +Generation 27 match: 1.6406% +Generation 28 match: 1.6742% +Generation 29 match: 1.6887% +Generation 30 match: 1.7272% +Generation 31 match: 1.8235% +Generation 32 match: 1.8446% +Generation 33 match: 1.8502% +Generation 34 match: 1.8430% +Generation 35 match: 1.8603% +Generation 36 match: 1.9234% +Generation 37 match: 2.0487% +Generation 38 match: 2.0849% +Generation 39 match: 2.1066% +Generation 40 match: 2.1209% +Generation 41 match: 2.2104% +Generation 42 match: 2.3183% +Generation 43 match: 2.3558% +Generation 44 match: 2.3979% +Generation 45 match: 2.6029% +Generation 46 match: 2.7094% +Generation 47 match: 2.7573% +Generation 48 match: 2.7541% +Generation 49 match: 2.7610% +Generation 50 match: 2.7736% +Generation 51 match: 2.8156% +Generation 52 match: 2.9536% +Generation 53 match: 3.0460% +Generation 54 match: 3.0531% +Generation 55 match: 3.0599% +Generation 56 match: 3.1893% +Generation 57 match: 3.3412% +Generation 58 match: 3.4400% +Generation 59 match: 3.4996% +Generation 60 match: 3.5317% +Generation 61 match: 3.7245% +Generation 62 match: 4.2546% +Generation 63 match: 4.4829% +Generation 64 match: 4.6759% +Generation 65 match: 4.9930% +Generation 66 match: 5.1067% +Generation 67 match: 5.2001% +Generation 68 match: 5.4559% +Generation 69 match: 5.5223% +Generation 70 match: 5.5676% +Generation 71 match: 5.7401% +Generation 72 match: 6.0965% +Generation 73 match: 6.1851% +Generation 74 match: 6.2199% +Generation 75 match: 6.2550% +Generation 76 match: 6.6570% +Generation 77 match: 7.1252% +Generation 78 match: 7.2423% +Generation 79 match: 7.3414% +Generation 80 match: 7.6177% +Generation 81 match: 8.0474% +Generation 82 match: 8.4778% +Generation 83 match: 8.5551% +Generation 84 match: 8.5943% +Generation 85 match: 8.5973% +Generation 86 match: 8.5953% +Generation 87 match: 8.6259% +Generation 88 match: 8.8294% +Generation 89 match: 8.9842% +Generation 90 match: 9.0423% +Generation 91 match: 9.0237% +Generation 92 match: 9.0480% +Generation 93 match: 9.0247% +Generation 94 match: 9.0463% +Generation 95 match: 9.0370% +Generation 96 match: 9.0918% +Generation 97 match: 9.7119% +Generation 98 match: 10.3309% +Generation 99 match: 10.9136% +Generation 100 match: 12.1193% +Generation 101 match: 12.3139% +Generation 102 match: 12.3290% +Generation 103 match: 12.3340% +Generation 104 match: 12.3075% +Generation 105 match: 12.3340% +Generation 106 match: 12.3340% +Generation 107 match: 12.3409% +Generation 108 match: 12.4644% +Generation 109 match: 13.1881% +Generation 110 match: 13.3787% +Generation 111 match: 13.6540% +Generation 112 match: 14.3537% +Generation 113 match: 14.8379% +Generation 114 match: 14.9674% +Generation 115 match: 15.0241% +Generation 116 match: 15.1602% +Generation 117 match: 15.7452% +Generation 118 match: 16.8613% +Generation 119 match: 17.1151% +Generation 120 match: 17.2924% +Generation 121 match: 17.7822% +Generation 122 match: 19.2245% +Generation 123 match: 19.4887% +Generation 124 match: 19.5949% +Generation 125 match: 19.7033% +Generation 126 match: 19.7646% +Generation 127 match: 19.7524% +Generation 128 match: 19.7660% +Generation 129 match: 19.7137% +Generation 130 match: 19.7161% +Generation 131 match: 19.7660% +Generation 132 match: 19.7184% +Generation 133 match: 19.7947% +Generation 134 match: 19.8487% +Generation 135 match: 20.8109% +Generation 136 match: 22.1507% +Generation 137 match: 22.9251% +Generation 138 match: 24.8349% +Generation 139 match: 26.4692% +Generation 140 match: 26.8086% +Generation 141 match: 27.1594% +Generation 142 match: 27.7669% +Generation 143 match: 27.9254% +Generation 144 match: 28.1132% +Generation 145 match: 29.3268% +Generation 146 match: 31.7899% +Generation 147 match: 32.3443% +Generation 148 match: 33.3064% +Generation 149 match: 35.2271% +Generation 150 match: 35.8846% +Generation 151 match: 35.9729% +Generation 152 match: 35.8795% +Generation 153 match: 35.9490% +Generation 154 match: 36.1555% +Generation 155 match: 36.9230% +Generation 156 match: 37.3824% +Generation 157 match: 37.3552% +Generation 158 match: 38.7906% +Generation 159 match: 41.3846% +Generation 160 match: 43.1931% +Generation 161 match: 43.5718% +Generation 162 match: 43.7987% +Generation 163 match: 43.8242% +Generation 164 match: 44.0769% +Generation 165 match: 45.3220% +Generation 166 match: 45.7661% +Generation 167 match: 46.0268% +Generation 168 match: 46.2422% +Generation 169 match: 46.2025% +Generation 170 match: 46.8829% +Generation 171 match: 47.2227% +Generation 172 match: 49.4635% +Generation 173 match: 52.8801% +Generation 174 match: 53.1827% +Generation 175 match: 53.1192% +Generation 176 match: 53.2938% +Generation 177 match: 53.2440% +Generation 178 match: 53.2664% +Generation 179 match: 53.1639% +Generation 180 match: 53.0472% +Generation 181 match: 53.3548% +Generation 182 match: 53.5466% +Generation 183 match: 56.0562% +Generation 184 match: 60.7658% +Generation 185 match: 61.9034% +Generation 186 match: 62.2861% +Generation 187 match: 65.2147% +Generation 188 match: 68.6984% +Generation 189 match: 70.3274% +Generation 190 match: 70.4157% +Generation 191 match: 70.9391% +Generation 192 match: 72.4079% +Generation 193 match: 76.9236% +Generation 194 match: 80.5213% +Generation 195 match: 82.7053% +Generation 196 match: 89.7543% +Generation 197 match: 95.2849% +Generation 198 match: 97.4329% +Generation 199 match: 97.9471% +Generation 200 match: 98.4308% +Generation 201 match: 98.6094% +Generation 202 match: 98.4553% +Generation 203 match: 101.1761% +Average weight per generation: [338, 442, 479, 513, 530, 534, 539, 575, 590, 592, 593, 593, 593, 596, 636, 686, 703, 707, 706, 707, 707, 705, 715, 740, 750, 758, 768, 820, 837, 844, 863, 911, 922, 925, 921, 930, 961, 1024, 1042, 1053, 1060, 1105, 1159, 1177, 1198, 1301, 1354, 1378, 1377, 1380, 1386, 1407, 1476, 1523, 1526, 1529, 1594, 1670, 1720, 1749, 1765, 1862, 2127, 2241, 2337, 2496, 2553, 2600, 2727, 2761, 2783, 2870, 3048, 3092, 3109, 3127, 3328, 3562, 3621, 3670, 3808, 4023, 4238, 4277, 4297, 4298, 4297, 4312, 4414, 4492, 4521, 4511, 4524, 4512, 4523, 4518, 4545, 4855, 5165, 5456, 6059, 6156, 6164, 6167, 6153, 6167, 6167, 6170, 6232, 6594, 6689, 6827, 7176, 7418, 7483, 7512, 7580, 7872, 8430, 8557, 8646, 8891, 9612, 9744, 9797, 9851, 9882, 9876, 9883, 9856, 9858, 9883, 9859, 9897, 9924, 10405, 11075, 11462, 12417, 13234, 13404, 13579, 13883, 13962, 14056, 14663, 15894, 16172, 16653, 17613, 17942, 17986, 17939, 17974, 18077, 18461, 18691, 18677, 19395, 20692, 21596, 21785, 21899, 21912, 22038, 22661, 22883, 23013, 23121, 23101, 23441, 23611, 24731, 26440, 26591, 26559, 26646, 26621, 26633, 26581, 26523, 26677, 26773, 28028, 30382, 30951, 31143, 32607, 34349, 35163, 35207, 35469, 36203, 38461, 40260, 41352, 44877, 47642, 48716, 48973, 49215, 49304, 49227, 50588] + +Number of generations: 204 +Number of years: 20 +Runtime for this program was 55545 seconds. diff --git a/tests/test_chapter07.py b/tests/test_chapter07.py new file mode 100644 index 0000000..ed6466c --- /dev/null +++ b/tests/test_chapter07.py @@ -0,0 +1,325 @@ +"""Test Chapter 7.""" +import unittest.mock +import os +from random import Random +from io import StringIO + +import src.ch07.c1_breed_rats as breed_rats + + +class TestBreedRats(unittest.TestCase): + """Test Breed Rats.""" + + @classmethod + def setUpClass(cls): + """Configure attributes for use in this class only.""" + cls.random = Random() + + def test_properties(self): + """Test properties.""" + experiment = breed_rats.BreedRats() + + # Test default property values. + self.assertEqual(experiment.num_males, 4) + self.assertEqual(experiment.num_females, 16) + self.assertEqual(experiment.target_wt, 50000) + self.assertEqual(experiment.gen_limit, 500) + self.assertEqual(experiment.min_wt, 200) + self.assertEqual(experiment.max_wt, 600) + self.assertEqual(experiment.male_mode_wt, 300) + self.assertEqual(experiment.female_mode_wt, 250) + self.assertEqual(experiment.mut_odds, 0.01) + self.assertEqual(experiment.mut_min, 0.5) + self.assertEqual(experiment.mut_max, 1.2) + self.assertEqual(experiment.litters_per_yr, 10) + self.assertEqual(experiment.litter_sz, 8) + + # Test setters. + experiment.num_males = 10 + self.assertEqual(experiment.num_males, 10) + experiment.num_females = 20 + self.assertEqual(experiment.num_females, 20) + experiment.target_wt = 20000 + self.assertEqual(experiment.target_wt, 20000) + experiment.gen_limit = 200 + self.assertEqual(experiment.gen_limit, 200) + experiment.min_wt = 250 + self.assertEqual(experiment.min_wt, 250) + experiment.max_wt = 700 + self.assertEqual(experiment.max_wt, 700) + experiment.male_mode_wt = 400 + self.assertEqual(experiment.male_mode_wt, 400) + experiment.female_mode_wt = 300 + self.assertEqual(experiment.female_mode_wt, 300) + experiment.mut_odds = 0.93 + self.assertEqual(experiment.mut_odds, 0.93) + experiment.mut_min = 2.5 + self.assertEqual(experiment.mut_min, 2.5) + experiment.mut_max = 3.0 + self.assertEqual(experiment.mut_max, 3.0) + experiment.litters_per_yr = 8 + self.assertEqual(experiment.litters_per_yr, 8) + experiment.litter_sz = 3 + self.assertEqual(experiment.litter_sz, 3) + + @unittest.mock.patch('src.ch07.c1_breed_rats.random') + def test_populate(self, mock_random): + """Test populate.""" + # Patch random.triangular to use non-random seed. + self.random.seed(512) + mock_random.triangular._mock_side_effect = self.random.triangular + experiment = breed_rats.BreedRats() + experiment.min_wt = 100 + experiment.max_wt = 300 + test_pop = experiment.populate(10, 200) + expected_pop = [119, 193, 181, 190, 261, 190, 158, 169, 109, 229] + self.assertListEqual(test_pop, expected_pop) + + def test_combine_values(self): + """Test combine_values.""" + dictionary = { + 'first': [1, 2, 3, 4, 5], + 'second': [6, 7, 8, 9, 0] + } + experiment = breed_rats.BreedRats() + combined = experiment.combine_values(dictionary) + expected = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] + self.assertListEqual(combined, expected) + + def test_measure(self): + """Test measure.""" + population = { + 'males': [219, 293, 281, 290, 361, 290, 258, 269, 309, 329], + 'females': [119, 193, 181, 190, 261, 190, 158, 169, 109, 229] + } + experiment = breed_rats.BreedRats(target_wt=500) + completion = experiment.measure(population) + self.assertEqual(completion, 0.4698) + + def test_select(self): + """Test select.""" + population = { + 'males': [219, 293, 281, 290, 361, 290, 258, 269, 309, 329], + 'females': [119, 193, 181, 190, 261, 190, 158, 169, 209, 229] + } + experiment = breed_rats.BreedRats() + + # Test even numbered populations. + experiment.num_males = 2 + experiment.num_females = 2 + test_population = experiment.select(population) + expected_population = { + 'males': [361, 329], 'females': [261, 229] + } + self.assertDictEqual(test_population, expected_population) + experiment.num_males = 4 + experiment.num_females = 4 + test_population = experiment.select(population) + expected_population = { + 'males': [361, 329, 309, 293], 'females': [261, 229, 209, 193] + } + self.assertDictEqual(test_population, expected_population) + + # Test odd numbered populations. + experiment.num_males = 3 + experiment.num_females = 3 + test_population = experiment.select(population) + expected_population = { + 'males': [361, 329, 309], 'females': [261, 229, 209] + } + self.assertDictEqual(test_population, expected_population) + experiment.num_males = 5 + experiment.num_females = 5 + test_population = experiment.select(population) + expected_population = { + 'males': [361, 329, 309, 293, 290], + 'females': [261, 229, 209, 193, 190] + } + self.assertDictEqual(test_population, expected_population) + + @unittest.mock.patch('src.ch07.c1_breed_rats.random') + def test_crossover(self, mock_random): + """Test crossover.""" + # Patch random to use non-random seed. + self.random.seed(411) + mock_random.choice._mock_side_effect = self.random.choice + mock_random.randint._mock_side_effect = self.random.randint + + # Test equal males and females. + population = { + 'males': [219, 293, 281], + 'females': [119, 193, 181] + } + experiment = breed_rats.BreedRats() + experiment.litter_sz = 8 + litter = experiment.crossover(population) + expected_litter = { + 'males': [128, 148, 196, 197, 201, 206, 213, 214, 256, 269], + 'females': [120, 160, 170, 182, 187, 193, 196, 197, 203, + 212, 215, 250, 251, 256] + } + self.assertDictEqual(litter, expected_litter) + litter_total = sum([len(value) for value in litter.values()]) + self.assertEqual(litter_total, + experiment.litter_sz * len(population['females'])) + + # Test fewer males than females. + population = { + 'males': [219, 293], + 'females': [119, 193, 181] + } + litter = experiment.crossover(population) + expected_litter = { + 'males': [165, 190, 208, 210, 245, 257, 280, 287], + 'females': [128, 140, 179, 181, 182, 182, 184, 187, + 187, 194, 201, 206, 216, 241, 281, 290] + } + self.assertDictEqual(litter, expected_litter) + litter_total = sum([len(value) for value in litter.values()]) + self.assertEqual(litter_total, + experiment.litter_sz * len(population['females'])) + + # Test fewer females than males. + population = { + 'males': [219, 293], + 'females': [119] + } + litter = experiment.crossover(population) + expected_litter = { + 'males': [162, 201, 265], + 'females': [205, 228, 254, 261, 282] + } + self.assertDictEqual(litter, expected_litter) + litter_total = sum([len(value) for value in litter.values()]) + self.assertEqual(litter_total, + experiment.litter_sz * len(population['females'])) + + # Test different litter size. + population = { + 'males': [219, 293], + 'females': [119] + } + experiment.litter_sz = 3 + litter = experiment.crossover(population) + expected_litter = { + 'males': [167, 181], + 'females': [291] + } + self.assertDictEqual(litter, expected_litter) + litter_total = sum([len(value) for value in litter.values()]) + self.assertEqual(litter_total, + experiment.litter_sz * len(population['females'])) + + # Test larger female than males. + population = { + 'males': [119, 193], + 'females': [219] + } + litter = experiment.crossover(population) + expected_litter = { + 'males': [139, 150], + 'females': [119] + } + self.assertDictEqual(litter, expected_litter) + litter_total = sum([len(value) for value in litter.values()]) + self.assertEqual(litter_total, + experiment.litter_sz * len(population['females'])) + + @unittest.mock.patch('src.ch07.c1_breed_rats.random') + def test_mutate(self, mock_random): + """Test mutate.""" + # Patch random to use non-random seed. + self.random.seed(311) + mock_random.random._mock_side_effect = self.random.random + mock_random.uniform._mock_side_effect = self.random.uniform + + experiment = breed_rats.BreedRats() + + # Test large litter with low mutation chance. + litter = { + 'males': [165, 190, 208, 210, 245, 257, 280, 287], + 'females': [128, 140, 179, 181, 182, 182, 184, 187, + 187, 194, 201, 206, 216, 241, 281, 290] + } + mutated_litter = experiment.mutate(litter) + expected = { + 'males': [165, 190, 208, 210, 245, 257, 280, 287], + 'females': [128, 140, 179, 181, 182, 182, 184, 187, + 187, 194, 201, 206, 216, 241, 281, 290] + } + self.assertDictEqual(mutated_litter, expected) + + # Test small litter with large mutation chance. + litter = { + 'males': [162, 201, 265], + 'females': [205, 228, 254, 261, 282] + } + experiment.mut_odds = 0.90 + mutated_litter = experiment.mutate(litter) + expected = { + 'males': [95, 201, 265], + 'females': [179, 130, 267, 211, 261] + } + self.assertDictEqual(mutated_litter, expected) + + # Test small litter with large mutation chance and scale factor. + litter = { + 'males': [162, 201, 265], + 'females': [205, 228, 254, 261, 282] + } + experiment.mut_min = 2.0 + experiment.mut_max = 3.0 + mutated_litter = experiment.mutate(litter) + expected = { + 'males': [338, 442, 655], + 'females': [469, 666, 254, 612, 789] + } + self.assertDictEqual(mutated_litter, expected) + + @unittest.mock.patch('src.ch07.c1_breed_rats.random', new_callable=Random) + @unittest.mock.patch('sys.stdout', new_callable=StringIO) + def test_simulate(self, mock_stdout, mock_random): + """Test simulate.""" + # Patch random to use non-random seed. + mock_random.seed(311) + + population = { + 'males': [450, 320, 510], + 'females': [250, 300, 220, 160] + } + experiment = breed_rats.BreedRats(num_males=3, num_females=10, + target_wt=20000, gen_limit=500) + experiment.mut_odds = 0.75 + experiment.mut_min = 0.75 + experiment.mut_max = 1.5 + ave, generations = experiment.simulate(population) + self.assertEqual(generations, 12) + self.assertEqual(ave, [347, 564, 861, 1181, 1636, 2344, 3319, 4950, + 7234, 10464, 15115, 21703]) + + # Test sys.stdout output. + with open(os.path.normpath('tests/data/ch07/breed_rats.txt'), + 'r') as file: + file_data = ''.join(file.readlines()) + self.assertEqual(mock_stdout.getvalue(), file_data) + + @unittest.mock.patch('src.ch07.c1_breed_rats.time') + @unittest.mock.patch('src.ch07.c1_breed_rats.random', new_callable=Random) + @unittest.mock.patch('sys.stdout', new_callable=StringIO) + def test_main(self, mock_stdout, mock_random, mock_time): + """Test main.""" + # Patch out variances. + mock_random.seed(311) + mock_time.time.side_effect = [12345, 67890] + + breed_rats.main() + + # Test sys.stdout output. + with open(os.path.normpath('tests/data/ch07/main/breed_rats.txt'), + 'r') as file: + file_data = ''.join(file.readlines()) + self.assertEqual(mock_stdout.getvalue(), file_data) + + +if __name__ == '__main__': + unittest.main()