Wednesday 20 May 2015

Learning Python (4) - Medicine Clash Kata

Another Kata as part of my learning Python project (I do have some other things going on, just not quite done). Another one from Emily Bache, documented on GitHub. The basic premise is that a patient takes a series of medicines. Each medicine may have one or more prescriptions, and so get taken from one date for a number of days. This is all very well, but some medicines combine in strange and interesting ways. Medicine tries to avoid interesting treatments. So we have a program which looks for clashes (in the past). The exercise comes recommended as practice for building an object graph and assigning responsibilities, use of test doubles (I didn't) and use of dates. A bare bones set of three classes are provided: Patient, Medicine and Prescription. The latter two could be combined for this problem, but part of the set-up is that these classes reflect an underlying relational model and we're just ignoring other details so these classes are fixed.

My solution evolved a bit as the development went on, so the tests don't quite document the development linearly. The first behaviour to test was clearly a simple clash of overlapping dates. After a quick think it was clear that the starting point was going to be comparing lists of dates. So my first passing unit tests extracted these without making tests for a clash. I also set up some prescriptions for our user of dangerous medicines as a test fixture, although much of the setUp code followed later.

import unittest
from medicineclash.medicine import Medicine
from medicineclash.patient import Patient
from medicineclash.prescription import Prescription
from datetime import date, timedelta

class Test(unittest.TestCase):


    def setUp(self):
      prescribe_yesterday = Prescription(date.today()-timedelta(days=1))
      prescribe_one_week_ago = Prescription(date.today()-timedelta(days=7), 14)
      prescribe_two_weeks_ago = Prescription(date.today()-timedelta(days=14), 7)
      prescribe_four_weeks_ago = Prescription(date.today()-timedelta(days=28), 30)
      
      self.patient = Patient()
      self.medicines = []
      
      medicine = Medicine("Aconite")
      medicine.add_prescription(prescribe_yesterday)
      self.medicines.append(medicine)
      
      medicine = Medicine("Belladonna")
      medicine.add_prescription(prescribe_two_weeks_ago)
      self.medicines.append(medicine)      

      medicine = Medicine("Celandine")
      medicine.add_prescription(prescribe_one_week_ago)
      self.medicines.append(medicine)      
      
      medicine = Medicine("Duboisia")
      medicine.add_prescription(prescribe_four_weeks_ago)
      self.medicines.append(medicine)      

      medicine = Medicine("Ergot")
      medicine.add_prescription(prescribe_two_weeks_ago)
      medicine.add_prescription(prescribe_yesterday)
      self.medicines.append(medicine)      
      
      medicine = Medicine("Foxglove")
      medicine.add_prescription(prescribe_four_weeks_ago)
      medicine.add_prescription(prescribe_one_week_ago)
      self.medicines.append(medicine)      
      
     
    def test_simpleclash(self):
      self.patient.add_medicine(self.medicines[0])
      self.patient.add_medicine(self.medicines[1])
      self.patient.add_medicine(self.medicines[2])
      result = self.patient.clash(["Aconite","Celandine"])
      self.assertSetEqual(set([date.today()-timedelta(days=1)]), result)

    def test_get_med_date_lists_single(self):
      self.patient.add_medicine(self.medicines[0])
      result = self.patient.get_med_date_lists(["Aconite"])
      self.assertEqual(1, len(result))
      self.assertEqual(1, len(result[0]))
        
    def test_get_med_date_lists_double(self):
      self.patient.add_medicine(self.medicines[0])
      self.patient.add_medicine(self.medicines[1])
      result = self.patient.get_med_date_lists(["Aconite","Belladonna"])
      self.assertEqual(2, len(result))
      self.assertEqual(1, len(result[0]))
      self.assertEqual(7, len(result[1]))


I got these passing, with some quite long code with explicit loops. There was another unit (rather than behaviour in the spec) test by now. I won't reproduce the extra test or the first version code here, but it is worth noting that there was some refactoring at this point and the test of the removed function went with it. The rest of the tests follow, written one at a time.
    def test_nonoverlap_noclash(self):
      self.patient.add_medicine(self.medicines[0])
      self.patient.add_medicine(self.medicines[1])
      result = self.patient.clash(["Aconite","Belladonna"])
      self.assertSetEqual(set(), result)
 
    def test_doubleclash(self):
      self.patient.add_medicine(self.medicines[0])
      self.patient.add_medicine(self.medicines[1])
      self.patient.add_medicine(self.medicines[2])
      self.patient.add_medicine(self.medicines[3])
      self.patient.add_medicine(self.medicines[4])
      result = self.patient.clash(["Duboisia","Ergot"])
      self.assertEqual(8, len(result))
      self.assertTrue(date.today()-timedelta(days=14) in result)
      self.assertTrue(date.today()-timedelta(days=1) in result)
      
    def test_tripleclash(self):
      self.patient.add_medicine(self.medicines[0])
      self.patient.add_medicine(self.medicines[1])
      self.patient.add_medicine(self.medicines[2])
      self.patient.add_medicine(self.medicines[3])
      self.patient.add_medicine(self.medicines[4])
      result = self.patient.clash(["Aconite","Duboisia","Celandine"])
      self.assertEqual(1, len(result))
      self.assertTrue(date.today()-timedelta(days=1) in result)

    def test_prescription_overlap_handled(self):
      result = self.medicines[5].get_date_list_in_window(40)
      self.assertEqual(28, len(result))
      
    def test_nomedicine_noclash(self):
      result = self.patient.clash(["Aconite"])
      self.assertSetEqual(set(), result)
      
    def test_medicine_notinlist_noclash(self):
      self.patient.add_medicine(self.medicines[2])
      result = self.patient.clash(["Aconite","Belladonna"])
      self.assertSetEqual(set(), result)


After completing what I thought was my solution I took a look at Emily Bache's code, tests first. Quickly I saw that there were tests for a couple of conditions that I hadn't read into the spec. These were added and the code tweaked some more.
    def test_singlemed_clash(self):
      self.patient.add_medicine(self.medicines[0])
      self.patient.add_medicine(self.medicines[1])
      self.patient.add_medicine(self.medicines[2])
      result = self.patient.clash(["Aconite"])
      self.assertNotEqual([], result)
      
    def test_doublemedicine_clashself(self):
      self.patient.add_medicine(self.medicines[1])
      self.patient.add_medicine(self.medicines[1])
      result = self.patient.clash(["Belladonna"])
      self.assertNotEqual([], result)


So, tests done - focussed on exploring the possibilities of the spec. As far as possible they aren't tied to the internal structure; although there are also a couple of unit tests written to understand the details mixed in there.
The code has, it is fair to say, evolved over multiple refactoring passes. So what is here is where it ended up, rather than the instinctive first version. (I might add that the first version was written rather early in the morning, the refactoring at a more civilised time!) The process of refining to this was useful, and got me to explore a few language features. I'll show the code next, and then return to discussing the learning.
First the root of the tree, the patient. It enables medicine lookups and identifies clashes using a set intersection. This was also the first time I had used the "splat" operator to pass a list as a sequence of arguments.

'''
Created on 17 May 2015
Starting point: https://github.com/emilybache/KataMedicineClash/blob/master/Python/patient.py

Search for combinations of medicine from a list, which have all been taken at the same time. Report dates of such clashes.

@author: Dan Chalmers 
'''

class Patient(object):
    
    def __init__(self, medicines = None):
        self._medicines = medicines or []
    
    def add_medicine(self, medicine):
        self._medicines.append(medicine)
    
    #A useful step in building the solution, 
    #gives a list of sets of dates, one set per medicine taken that is in search list of meds
    def get_med_date_lists(self, medicine_names, days_back=90):
      return [ med.get_date_list_in_window(days_back) for med in self._medicines if med in medicine_names ] or [set()]
          
    def clash(self, medicine_names, days_back=90):
      med_lists = self.get_med_date_lists(medicine_names, days_back) 
      return set.intersection(*med_lists)
    

Next the medicine. A double-for list comprehension to get date lists and an overriding of equality to enable comparison with the medicine names, which come as a list of strings.

'''
Created on 17 May 2015
Starting point: https://github.com/emilybache/KataMedicineClash/blob/master/Python/medicine.py

A medicine, with a list of prescriptions for that patient.
Equality satisfied on name to simplify search.
Can search on dates it was taken, not stored pre-computed.

@author: Dan Chalmers 
'''

class Medicine(object):
    
    def __init__(self, name):
        self.name = name
        self.prescriptions = []
        
    def add_prescription(self, prescription):
        self.prescriptions.append(prescription)    
        
    def __eq__(self, other):
      return (self.name == other)

    def get_date_list_in_window(self, days_back):
      return set( [ med for p in self.prescriptions for med in p.get_date_list_in_window(days_back) ] )


Finally, the leaves of the tree, the prescription. This provides the list of dates within the search window. Prescription.get_date_list_in_window doesn't have its own test as it started life in with the Medicine.get_date_list_in_window function, and migrated out during refactoring. The existing tests described what I wanted of the function and carried on passing as I restructured.

'''
Created on 17 May 2015
Starting point: https://github.com/emilybache/KataMedicineClash/blob/master/Python/prescription.py

Prescriptions for medicine, a supply date and duration.
Can get a list of dates that overlap a start..today window.

@author: Dan Chalmers 
'''

from datetime import date, timedelta

class Prescription(object):
    
    def __init__(self, dispense_date=None, days_supply=30):
        self.dispense_date = dispense_date or date.today()
        self.days_supply = days_supply
        
    def get_date_list_in_window(self, window_start):
      start_date = max(self.dispense_date, date.today() - timedelta(window_start))
      end_date = min(self.dispense_date + timedelta(self.days_supply), date.today())
      return [ start_date + timedelta(day) for day in range((end_date - start_date).days) ]


As expected, this explored time a little - in particular the timedelta module, which was new to me. However, I practised the refactoring process and use of list comprehensions extensively, as I moved from explicit loops to comprehensions. The syntax is a little different to Haskell, but that remains my reference point despite being a little rusty. This exercise also explored sets more than I had before, and the set / list relationship. The splat operator and __eq__ function were also firsts. So, a lot of learning in this exercise and the result feels bigger than previous Katas. My solution remains a little different to the example solution, but I'm happy with the allocation of functions to classes and that the code feels readable even if I'm missing a few possibilities. There was quite a lot of referring to the API and refactoring in this. I hope the process of writing the functions will become more smooth with experience. I'll return to this Kata at some point, to help these techniques stick and see if they come easier.

No comments:

Post a Comment