11
\$\begingroup\$

(This is all available in a github repo if that's easier: https://github.com/paulnicholsen27/SpeciesSimulator)

I am submitting this as a code sample for a possible job, so I'm just looking for any major red flags or simple ways to fancify it a bit.

I was given a YAML file of a variety of animals and habitats--here is one of each:

species:
  - name: kangaroo 
    attributes:
      monthly_food_consumption: 3 #arbitrary unit
      monthly_water_consumption: 4 #arbitrary unit
      life_span: 30 #years 
      minimum_breeding_age: 5 #years
      maximum_breeding_age: 20 #years
      gestation_period: 9 #months
      minimum_temperature: 30 #F
      maximum_temperature: 110 #F
  - name: bear
    attributes:
      monthly_food_consumption: 4
      monthly_water_consumption: 4
      life_span: 50
      minimum_breeding_age: 10
      maximum_breeding_age: 35
      gestation_period: 12
      minimum_temperature: 0
      maximum_temperature: 95
habitats:
    - name: plains
      monthly_food: 100 #arbitrary unit
      monthly_water: 150 #arbitrary unit
      average_temperature:
        summer: 85 #F
        spring: 60 #F
        fall: 50 #F
        winter: 30 #F

Instructions

SpeciesSimulator

This was a coding challenge I was given which I had some fun with and learned a bit on.

The given parameters made it somewhat impossible for kangaroos to survive, but no one really likes kangaroos anyway.

Task: create an application that imports the provided YAML config file (which contains Control, Species and Habitat parameters) and then simulates the passage of time for each species in each habitat.

Result: At the end of each run, provide the following data-

 1. Species
 2. Habitat:
  A. Average Population: x
  B. Max Population: x
  C. Mortality Rate: x%  #overall death percentage
  D. Causes of Death:
      - x% starvation
      - x% age
      - x% cold_weather
      - x% hot_weather

Rules/Notes:

  • Passage of Time:
    • The passage of time should be in months. Animals only eat/drink/mate/die at 1 month intervals & habitats only refresh their food/water/temperature at 1 month intervals.
    • The Years value in the config should control how many years the simulation should run.
    • The Iterations value in the config should control how many times you run the full simulation from beginning to end. When running multiple iterations, final stats should represent stats from all iterations combined.
  • Species:
    • Each month individual animals should consume food & water, age, and survive temperature conditions
    • You only need to run one species at a time inside of a habitat (no need to run species side-by-side)
    • Assume animals are not monogamous
    • There is no need to keep track of parent/child relationships
  • Death (causes):
    • Starvation: 3 consecutive full months without food
    • Thirst: 1 full month without food
    • Old Age: age life_span
    • Extreme Temperature: 1 full month above or below threshold for species
  • Breeding:
    • When a species starts in a new habitat it should begin with exactly 1 male and 1 female
    • When a female gives birth, the sex of the offspring should be chosen 50:50
    • Breeding is controlled by
      • available females (not pregnant and within the breeding age range)
      • a supportive habitat
      • there should be more food/water currently available in habitat than is required to support the current population
      • HOWEVER, even when there is not enough food, allow breeding to occur at a 0.5% rate.
      • gestation period (# of months a female is pregnant before giving birth)
  • Habitat:
    • The Habitat should refresh its food/water supply every month.
    • Seasons/Temperature
      • Use this Season/Month mapping 12,1,2=Winter 3,4,5=Spring 6,7,8=Summer 9,10,11=Fall
      • The temperature should be updated for every new month and should fluctuate above/below the average by up to 5 degrees, with a 0.5% chance of having up to a 15 degree fluctuation.

And here is my code:

import random
import yaml


def yaml_parser(yaml):
    with open('config.txt') as f:
        data = f.read()
        output = yaml.load(data)
    return output


def dice_roller(percentage):
    '''
        given the probability of an event occuring, returns True if
        event is successful
    '''
    chance = random.random()
    percentage = percentage / 100.0
    if chance <= percentage:
        return True
    else:
        return False


class Animal:
    def __init__(self, species, monthly_food_consumption, monthly_water_consumption,
                 life_span, minimum_breeding_age, maximum_breeding_age, gestation_period,
                 minimum_temperature, maximum_temperature, gender=None):
        self.species = species
        self.monthly_food_consumption = monthly_food_consumption
        self.monthly_water_consumption = monthly_water_consumption
        self.life_span_years = life_span
        self.minimum_breeding_age_years = minimum_breeding_age
        self.maximum_breeding_age_years = maximum_breeding_age
        self.life_span = self.life_span_years * 12  # all time units converted to months
        self.minimum_breeding_age = self.minimum_breeding_age_years * 12
        self.maximum_breeding_age = self.maximum_breeding_age_years * 12
        self.gestation_period = gestation_period
        self.minimum_temperature = minimum_temperature
        self.maximum_temperature = maximum_temperature
        self.age = 0
        self.living = True
        if gender:  # 1 is female, 2 is male
            self.gender = gender
        else:
            self.gender = random.randint(1, 2)
        self.pregnant = {'pregnant': False, 'months': 0}
        self.cause_of_death = None
        self.months_without_water = 0
        self.months_without_food = 0
        self.months_of_extreme_temperature = 0
        self.fertility_rate = 80  # TODO: this value is made up for now


class Habitat:
    def __init__(self, name, monthly_food, monthly_water, summer_temp, spring_temp,
                 fall_temp, winter_temp):
        self.name = name
        self.monthly_food = monthly_food
        self.monthly_water = monthly_water
        self.summer_temp = summer_temp
        self.spring_temp = spring_temp
        self.fall_temp = fall_temp
        self.winter_temp = winter_temp
        self.food_supply = 0
        self.water_supply = 0
        self.population = []
        self.population_record = []

    def set_temperature(self, season):
        '''
        sets temperature which fluctuates by up to 5 degrees, with a
        1/200 chance of fluctuating by up to 15 degrees
        '''
        multiplier = 1
        if dice_roller(.5):
            multiplier = 3
        fluctuation = random.randint(-5, 5) * multiplier
        if season == 'summer':
            self.temperature = self.summer_temp + fluctuation
        elif season == 'fall':
            self.temperature = self.fall_temp + fluctuation
        elif season == 'winter':
            self.temperature = self.winter_temp + fluctuation
        elif season == 'spring':
            self.temperature = self.spring_temp + fluctuation

    def refresh_food_and_water(self):
        self.food_supply += self.monthly_food
        self.water_supply += self.monthly_water

    def consume_food_and_water(self):
        '''
            for each living animal in population: if food and water supply is
            adequate, decreases supply by animal's consumption.  Otherwise,
            increases months without food/water by one
        '''
        for animal in self.population:
            if animal.living:
                if self.food_supply >= animal.monthly_food_consumption:
                    self.food_supply -= animal.monthly_food_consumption
                    animal.months_without_food = 0
                else:
                    animal.months_without_food += 1
                if self.water_supply >= animal.monthly_water_consumption:
                    self.water_supply -= animal.monthly_water_consumption
                    animal.months_without_water = 0
                else:
                    animal.months_without_water += 1

    def age_animals(self):
        '''
            increments age of each living animal by one month, along with
            months pregnant if applicable
        '''
        for animal in self.population:
            if animal.living:
                animal.age += 1
                if animal.pregnant['pregnant']:
                    animal.pregnant['months'] += 1

    def breed_animals(self):
        babies = []
        male_available = False
        for animal in self.population:
            if animal.gender == 2 and animal.age >= animal.minimum_breeding_age:
                #check for at least one male of age
                male_available = True
                break
        for animal in self.population:
            if animal.gender == 1 and animal.living:
                if animal.pregnant['pregnant'] and (animal.pregnant['months'] >= animal.gestation_period):
                    animal.pregnant = {'pregnant': False, 'months': 0}
                    new_animal = Animal(
                        animal.species,
                        animal.monthly_food_consumption,
                        animal.monthly_water_consumption,
                        animal.life_span_years,
                        animal.minimum_breeding_age_years,
                        animal.maximum_breeding_age_years,
                        animal.gestation_period,
                        animal.minimum_temperature,
                        animal.maximum_temperature
                    )
                    babies.append(new_animal)
                elif (not animal.pregnant['pregnant'] and
                        animal.minimum_breeding_age <= animal.age < animal.maximum_breeding_age):
                    fertility = animal.fertility_rate
                    if (self.food_supply < animal.monthly_food_consumption or
                            self.water_supply < animal.monthly_water_consumption):
                        fertility *= .005  # reduces fertility rate if insuff. resources
                    if dice_roller(fertility):
                        animal.pregnant['pregnant'] = True
        self.population += babies

    def kill_the_weak(self):
        '''
        sets living to False if any fatal conditions are met and stores
        cause of death.  Also tracks remaining living population.
        '''
        living_count = 0
        for animal in self.population:
            if animal.living:
                living_count += 1
                if animal.age > animal.life_span:
                    animal.living = False
                    animal.cause_of_death = 'age'
                elif animal.months_without_water > 1:
                    animal.living = False
                    animal.cause_of_death = 'thirst'
                elif animal.months_without_food > 3:
                    animal.living = False
                    animal.cause_of_death = 'starvation'
                elif self.temperature > animal.maximum_temperature:
                    animal.months_of_extreme_temperature += 1
                    if animal.months_of_extreme_temperature > 1:
                        animal.living = False
                        animal.cause_of_death = 'hot_weather'
                elif self.temperature < animal.minimum_temperature:
                    animal.months_of_extreme_temperature += 1
                    if animal.months_of_extreme_temperature > 1:
                        animal.living = False
                        animal.cause_of_death = 'cold_weather'
                else:
                    animal.months_of_extreme_temperature = 0

        self.population_record.append(living_count)


def current_season(month):
    '''
        given month number, returns season
    '''
    month_of_year = month % 12
    if month_of_year == 0:
        month_of_year = 12
    seasons = {
        1: 'winter',
        2: 'winter',
        3: 'spring',
        4: 'spring',
        5: 'spring',
        6: 'summer',
        7: 'summer',
        8: 'summer',
        9: 'fall',
        10: 'fall',
        11: 'fall',
        12: 'winter'
    }

    season = seasons[month_of_year]
    return season


def monthly_tasks(month, environment):
    season = current_season(month)
    environment.refresh_food_and_water()
    environment.set_temperature(season)
    environment.kill_the_weak()
    environment.consume_food_and_water()
    environment.breed_animals()
    environment.age_animals()


def percentage_converter(part, whole):
    '''
    converts to a percentage to two decimal places
    '''
    percentage = round(part/float(whole) * 100.0, 2)
    return percentage


def results_generator(species, habitat, iteration_results, months, iterations):
    '''
        iteration_results should consist of a list of completed habitats, returns dictionary of results
    '''
    animal_type = species['name']
    habitat_type = habitat.name
    total_population = 0
    max_population = max([max(environment.population_record) for environment in iteration_results])
    for environment in iteration_results:
        total_population += sum(environment.population_record)
    average_population = total_population / (months * iterations)

    number_of_dead = 0
    death_by_age = 0
    death_by_starvation = 0
    death_by_thirst = 0
    death_by_cold = 0
    death_by_heat = 0
    total_animals = 0
    for environment in iteration_results:
        total_animals += len(environment.population)
        for animal in environment.population:
            if not animal.living:
                number_of_dead += 1
                if animal.cause_of_death == 'age':
                    death_by_age += 1
                elif animal.cause_of_death == 'starvation':
                    death_by_starvation += 1
                elif animal.cause_of_death == 'thirst':
                    death_by_thirst += 1
                elif animal.cause_of_death == 'cold_weather':
                    death_by_cold += 1
                elif animal.cause_of_death == 'hot_weather':
                    death_by_heat += 1
        for cause_of_death in ([death_by_heat, death_by_cold, death_by_thirst,
                                death_by_starvation, death_by_age]):
            cause_of_death = percentage_converter(cause_of_death, number_of_dead)

    mortality_rate = str(round(number_of_dead / float(total_animals) * 100, 2)) + '%'
    causes_of_death = {'age': death_by_age,
                       'starvation': death_by_starvation,
                       'thirst': death_by_thirst,
                       'hot_weather': death_by_heat,
                       'cold_weather': death_by_cold
                       }
    for cause, count in causes_of_death.iteritems():
        causes_of_death[cause] = str(percentage_converter(count, number_of_dead)) + '%'
    results = {habitat_type: {
        'Average Population': average_population,
        'Max Population': max_population,
        'Mortality Rate': mortality_rate,
        'Cause of Death': causes_of_death}
    }
    return results


def simulation_runner():
    '''
    Main function of the simulator, calls functions to parse and run data
    '''
    data = yaml_parser(yaml)
    months = data['years'] * 12
    iterations = data['iterations']
    results = {}
    results['Conditions'] = "Simulation ran for {0} iterations at {1} years per iteration".format(iterations, data['years'])
    for species in data['species']:
        name = species['name']
        animal_results = []
        habitat_population_tracker = []  # will keep track of populations over iterations
        for habitat in data['habitats']:
            iteration_results = []
            for i, iteration in enumerate(range(iterations)):
                environment = Habitat(habitat['name'],
                                      habitat['monthly_food'],
                                      habitat['monthly_water'],
                                      habitat['average_temperature']['summer'],
                                      habitat['average_temperature']['spring'],
                                      habitat['average_temperature']['fall'],
                                      habitat['average_temperature']['winter'],
                                      )
                for gender_code in [1, 2]:  # create initial male and female
                    new_animal = Animal(species['name'],
                                        species['attributes']['monthly_food_consumption'],
                                        species['attributes']['monthly_water_consumption'],
                                        species['attributes']['life_span'],
                                        species['attributes']['minimum_breeding_age'],
                                        species['attributes']['maximum_breeding_age'],
                                        species['attributes']['gestation_period'],
                                        species['attributes']['minimum_temperature'],
                                        species['attributes']['maximum_temperature'],
                                        gender=gender_code
                                        )
                    environment.population.append(new_animal)
                for month in range(months):
                    monthly_tasks(month, environment)
                iteration_results.append(environment)
            animal_results.append(results_generator(species, environment, iteration_results, months, iterations))
        results[name] = animal_results
    return yaml.dump(results, default_flow_style=False)

if __name__ == '__main__':
    print simulation_runner()
\$\endgroup\$
6
\$\begingroup\$

Will drop some general comments that arise after checking your code

Naming

First, you could use a bit better naming for functions and methods. Although there is no silver bullet regarding naming, usually you want to put a verb that explains what functions are doing. Small examples

percentage_converter is easier to understand if you name it convert_percentage_to_decimal

results_generator sounds better as generate_results

monthly_tasks sounds like an enum. run_monthly_tasks or execute_monthly_tasks give an idea that the function has some effect on your program

Last and not least, your choice for genders as 1 and 2 could be better. Why not male and female? Otherwise you have to compensate with comments in every part of the code

What reads better?

if animal.gender == 2

Or

if animal.is_female

Documentation

The documentation in the code is arbitrary, some functions contain a docstring, some don't. Specially the __init__ method of classes could use a better description, putting attention on the many parameters they receive.

Another example, look how this function can improve with better docstrings (I chose one with a docstring)

def current_season(month):
    '''
        given month number, returns season
    '''

What if we changed it to something like this?

def get_season(month):
    '''
    Returns the season of the year, depending on the month provided

    :param month: month of the year in numeric format (1-12)
    :returns: string with season of the year
    '''

I don't even have to look at the code to understand what's going on inside. Happiness :)

Best practises

Usually we want to avoid having extremely long functions, or many nested statements, since the flow is hard to follow that way

In this code, functions like simulation_runner with for loops nested several levels in, is hard to track where we stand exactly and what is each loop responsible of

Either we can reduce the number of loops changing the logic, or we can extract some of this looping to list comprehensions

A very simple example, through your code you use this structure a lot:

for animal in self.population:
    if animal.living:

You can reduce a nesting level introducing a function to return living animals:

def _living_animals():
    for animal in self.population:
        if animal.living:
            yield animal

Then use it sparely (and yield helps with memory consumption!)

for animal in self._living_animals()

Try and check which other loops in the code you may reuse this pattern, you may find a couple more

Exceptions

Your code is not handling any exceptions, and this is something tricky dealing with files.

You should assume that sometimes files will not have a flawless format, and at least you want to watch out the dictionary keys you are accessing on the simulate_runner function.

Programming shenanigans

Some parts of the code can be greatly improved using the power of python, bare examples follow

if chance <= percentage:
    return True
else:
    return False

Can be converted to:

return chance <= percentage

Ternary operator also helps on assignments

multiplier = 1
if dice_roller(.5):
    multiplier = 3

Will look like this

multiplier = 3 if dice_roller(.5) else 1

The method current_season creates a dictionary every time you check for the season. One improvement can be creating this dictionary only once outside the function. Second is creating it using dictionary comprehensions

Testing

Did you include testing on this assignment?

Would be a very nice addition ;)

\$\endgroup\$
3
\$\begingroup\$

In addition to @A.Romeu's answer, I'd like to point out this code:

def breed_animals(self):
    ...
    self.population += babies

def consume_food_and_water(self):
    ...
    for animal in self.population:
        ...
                animal.months_without_food = 0

You are maintaining population as a queue rather than as an unordered collection. All new animals go on the end of the list, and food is assigned from beginning to end. This means that you are disproportionately killing babies.

While this might be correct, in a simulation sense, it does not appear to be explicit - that is, I don't see anything that indicates you intended to kill babies first.

So I'd suggest that you either (1) make your intentions explicit, or (2) randomize the allocation of food.

\$\endgroup\$
2
\$\begingroup\$

a few tips on the Animal

Instead of specifying the behaviour of aging for each animal in the Habitat, I would abstract that to the Animal class

Something like this:

class Animal:
    def __init__(self, species, monthly_food_consumption, monthly_water_consumption,
                 life_span, minimum_breeding_age, maximum_breeding_age, gestation_period,
                 minimum_temperature, maximum_temperature):
        self.species = species
        self.monthly_food_consumption = monthly_food_consumption
        self.monthly_water_consumption = monthly_water_consumption
        self.life_span_years = life_span
        self.minimum_breeding_age_years = minimum_breeding_age
        self.maximum_breeding_age_years = maximum_breeding_age
        self.life_span = self.life_span_years * 12  # all time units converted to months
        self.minimum_breeding_age = self.minimum_breeding_age_years * 12
        self.maximum_breeding_age = self.maximum_breeding_age_years * 12
        self.gestation_period = gestation_period
        self.minimum_temperature = minimum_temperature
        self.maximum_temperature = maximum_temperature
        self.age = 0
        self.living = True
        self.pregnant = None
        self.cause_of_death = None
        self.months_without_water = 0
        self.months_without_food = 0
        self.months_of_extreme_temperature = 0
        self.fertility_rate = 80 

    def get_older(self):
        self.age += 1
        if self.age > self.life_span:
            self.living = False
            self.cause_of_death = 'old age'
        if self.pregnant:
            self.pregnant += 1

    def eat(self, reserve):
        succes = reserve >= self.monthly_food_consumption
        if success:
            reserve -= self.monthly_food_consumption
            self.months_without_food = 0
        else:
            self.months_without_food += 1
            if self.months_without_food > 3:
                self.living = False
                self.cause_of_death = 'hunger'
        return reserve

in Habitat

def age_animals(self): for animal in self.living_animals(): animal.get_older()

def self.consume_food():

reserve = self.food_supply
for animal in self.living_animals():
    reserve = animal.eat(reserve)

and so on

Factory

Instead of having to pass the default attributes for the species to each instance, you can work with a Factory (borrowed from this SO answer)

def animal_factory(species, monthly_food_consumption, monthly_water_consumption,
                 life_span, minimum_breeding_age, maximum_breeding_age, gestation_period,
                 minimum_temperature, maximum_temperature):
    def __init__(self, gender=None):
        genders = ['male', 'female']
        if gender not in genders:
            gender = genders[random.randint(0, 1)] 

        setattr(self, 'gender', gender)
        Animal.__init__(self, species, monthly_food_consumption, monthly_water_consumption,
                 life_span, minimum_breeding_age, maximum_breeding_age, gestation_period,
                 minimum_temperature, maximum_temperature)
    return type(species, (Animal,), {'__init__': __init__})

That you can use it as

Kangoroo = animal_factory('kangaroo', 
            monthly_food_consumption=3,
            monthly_water_consumption=4,
            life_span=30, 
            minimum_breeding_age=5, 
            maximum_breeding_age=20, 
            gestation_period=9,
            minimum_temperature=30, 
            maximum_temperature=100,
                         )

and use animal = Kangoroo() to make a new kangoroo

Pregnant

instead of coding the pregnant state of the animal as a dict, you can just use an int, and use None for animals that are not pregnant

then line 160 changes from if animal.pregnant['pregnant'] and (animal.pregnant['months'] >= animal.gestation_period): to if animal.pregnant and animal.pregnant > animal.gestation_period):

\$\endgroup\$

Your Answer

By clicking "Post Your Answer", you acknowledge that you have read our updated terms of service, privacy policy and cookie policy, and that your continued use of the website is subject to these policies.

Not the answer you're looking for? Browse other questions tagged or ask your own question.