(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()
3 Answers 3
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 ;)
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.
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):
Explore related questions
See similar questions with these tags.