The Abstraction-Presentation framework is now complete except for one crucial feature, a mechanism for data persistence. The imaginative strategy we adopted at the outset has worked well up to this point, but one cannot think about persistence, without thinking about the persistence of something. In other words, it is difficult to think about persistence in the abstract, without having something specific and concrete to make persistent. Previously the unit tests themselves were sufficiently concrete to keep us headed in the right direction, but for this part of the Thought Experiment it will be helpful, if not essential, to fashion a more tangible example using the framework as it exists now and then evolve a persistence mechanism that will work with this example (and the underlying framework as well).
What we want to do is create a minimal application that nonetheless illustrates the most pervasive issues in regard to persistence. This example will be clinical in nature, allowing us to store and retrieve basic information about some variable number of patients, and to store and retrieve variable amounts of information about each of these patients. For the more business minded, think of it as a list of customers with a variable number of orders for each customer. In newborn intensive care, a basic piece of information collected daily is the patient weight. Our sample application, therefore, will allow us to store and retrieve a weight value for each day (for each patient) and provide certain calculated values based on these stored (persistent) values.
We may choose to begin with the Abstraction layer or with the Presentation layer, and in either case we can design each layer from the top down or construct it from the bottom up. For this investigation it seems most natural to start with the Abstraction layer, working from the bottom up.
As always, this investigation takes the form of unit testing, so the unittest module is imported first, and the copy module is imported as well. Then we import the AP framework by importing the abstraction and presentation modules as ab and pr.
import unittest, copy from donnal.thought import abstraction as ab from donnal.thought import presentation as pr
The primary purpose of the sample application is to store and retrieve interval weights for several different patients. The reason for creating a custom application to do this, however, instead of using a generic database is in order to add specific functionality not otherwise available. In this toy application the required additional features take the form of two specific calculations.
The first calculation is a measure of weight gain over some extended interval of time. The difference (in grams) between two weights is divided by the interval in days (gm/da), and then divided by the average weight (in kg) for that period to give a value scaled for size (gm/kg/da). (A larger baby should gain twice as much weight per day as one half its size for example.)
The second is a comparison (ratio) of the present weight to birthweight. The greater the age of the patient, the less helpful this calculated value is, but especially during the early weight loss phase, this value is important as well.
We begin by coding these calculations as Python functions. The input values for the weight gain (gm/kg/da) calculation are two weights (w1 and w2) and their respective "time stamps" (t1 and t2). For the purpose of this study the time stamps are merely the "day of life", such that t2 minus t1 gives the interval in days. Eventually, of course, these parameters would be replaced with absolute values of finer grain (date and time). The ratio, obviously, is obtained by dividing the current weight (w2) by the birthweight (bw).
def _GmPerKgPerDay(t1, t2, w1, w2): """Return weight gain in grams per kg per day""" ave = (w1 + w2) / 2 dif = (w2 - w1) * 1000 return dif / ave / (t2 - t1) def _BirthweightRatio(bw, w2): """Return the ratio of weight (w2) to birthweight (bw)""" return w2 / bw
class Test000(unittest.TestCase): """Functions for mathematical calculations""" def test001(self): """_GmPerKgPerDay: calculated from inputs""" result = _GmPerKgPerDay(0, 7, 1.395, 1.605) self.failUnless(19.99 < result < 20.01) result = _GmPerKgPerDay(w1=1.369, w2=1.631, t2=8, t1=1) self.failUnless(24.94 < result < 24.96) def test002(self): """_BirthweightRatio: calculated from inputs""" result = _BirthweightRatio(1.395, 1.605) self.failUnless( 1.150 < result < 1.151 )
Functions return a value based on values passed to them as input parameters, but functions do not hold values. To be used as components in the AP framework (and thereby made available to the GUI), these functions need to be wrapped inside a scalar model or cell. By virtue of the Constrained metaclass for Cell, a function that is bound to the name 'Calculate' is converted into a static method of the same name. If a previously defined function is available, it can simply be assigned to the name 'Calculate' in the class definition like so:
class GmPerKgPerDay_(ab.NumberCell): """The average daily weight gain scaled to size""" Calculate = _GmPerKgPerDay class BirthweightRatio_(ab.NumberCell): """The ratio of weight (w2) to birthweight (bw)""" Calculate = _BirthweightRatio
Then the unit tests call the static Calculate method with appropriate arguments.
class Test001(unittest.TestCase): """Dependent values""" def test001(self): """GmPerKgPerDay: calculated from inputs""" z = GmPerKgPerDay_() result = z.Calculate(0, 7, 1.395, 1.605) self.failUnless(19.999 < result < 20.001) result = z.Calculate(w1=1.369, w2=1.631, t2=8, t1=1) self.failUnless(24.94 < result < 24.96) def test002(self): """BirthweightRatio: calculated from inputs""" z = BirthweightRatio_() result = z.Calculate(1.405, 1.605) self.failUnless(1.142 < result < 1.143)
More often, however, a previously defined function will not be available, but instead will be defined along with the dependent cell. In this case, one simply defines the Calculate method as a static method (no self parameter), except that because of the metaclass, calling the staticmethod function is not required.
class GmPerKgPerDay(ab.NumberCell): """The average daily weight gain scaled to size""" def Calculate(t1, t2, w1, w2): ave = (w1 + w2) / 2 dif = (w2 - w1) * 1000 return dif / ave / (t2 - t1) class BirthweightRatio(ab.NumberCell): """The ratio of weight (w2) to birthweight (bw)""" def Calculate(bw, w2): return w2 / bw
The unit tests for these dependent cells are the same as test001 and test002 above.
In the same manner, we define two additional dependent cells, but this time derived from the ab.StringCell class.
class Joiner(ab.StringCell): """A dependent Cell that joins strings separated by spaces""" def Calculate(*args): return ' '.join([i or '' for i in args]) class Inverter(ab.StringCell): """A dep Cell that inverts two names separated by a comma""" def Calculate(first, last): return ', '.join([last, first])
# class Test001 (continued)
def test003(self):
"""Joiner: computed from inputs"""
z = Joiner()
result = z.Calculate('Martin', 'Luther', 'King')
self.assertEqual(result, 'Martin Luther King')
def test004(self):
"""Inverter: computed from inputs"""
z = Inverter()
result = z.Calculate('Bilbo', 'Baggins')
self.assertEqual(result, 'Baggins, Bilbo')
Another way to perform unit tests is to instantiate the dependent cell with appropriate independent cells as references. Then when the independent cells are given specific values, the value of the dependent cell is automatically changed (and may be tested for accuracy).
# class Test001 (continued)
def test005(self):
"""GmPerKgPerDay: calculated from independent cells"""
a = ab.NumberCell()
b = ab.NumberCell()
c = ab.NumberCell()
d = ab.NumberCell()
z = GmPerKgPerDay(None, '', a, b, c, d)
a.value = 0
b.value = 7
c.value = 1.395
d.value = 1.605
result = z.value
self.failUnless(19.999 < result < 20.001)
a.value = 1
b.value = 8
c.value = 1.369
d.value = 1.631
self.failUnless(24.94 < z.value < 24.96)
def test006(self):
"""BirthweightRatio: calculated from independent cells"""
a = ab.NumberCell()
b = ab.NumberCell()
z = BirthweightRatio(None, '', a, b)
a.value = 1.405
b.value = 1.605
self.failUnless(1.142 < z.value < 1.143)
def test007(self):
"""Joiner: computed from independent cells"""
a = ab.StringCell()
b = ab.StringCell()
c = ab.StringCell()
z = Joiner(None, '', a, b, c)
a.value = 'Martin'
b.value = 'Luther'
c.value = 'King'
self.assertEqual(z.value, 'Martin Luther King')
def test008(self):
"""Inverter: computed from independent cells"""
a = ab.StringCell()
b = ab.StringCell()
z = Inverter(None, '', a, b)
a.value = 'Bilbo'
b.value = 'Baggins'
self.assertEqual(z.value, 'Baggins, Bilbo')
Perhaps the simplest compound data structure is to combine two independent StringCells with two dependent cells forming a structure to store and manage a name. The independent cells hold the first name and last name, and the dependent cells compute the full name (by joining the first and the last) and the inverted form of the full name. These dependent values could have been computed whenever they are needed by using a Python function, of course, but later we shall see how the dependent cells are attached to a view by a presenter in the AP framework.
class Name(ab.Structure): def Assemble(self): ln = self.Add('last', ab.StringCell) fn = self.Add('first', ab.StringCell) self.Add('full', Joiner, fn, ln) self.Add('inverse', Inverter, fn, ln)
class Test002(unittest.TestCase): """Compound data structures""" def test001(self): """Name: full, inverse""" x = Name() x.last.value = 'Baggins' x.first.value = 'Frodo' self.assertEqual(x.full.value, 'Frodo Baggins') self.assertEqual(x.inverse.value, 'Baggins, Frodo')
The WeightChange class is an example of a more complex data structure. It consists of two independent structures (both TimedWeight instances) and two dependent cells (Birthweight Ratio and GmPerKgPerDay). The TimedWeight class, in turn, is a simple structure that consists of two number cells, which are labeled 'age' and 'wt'. In this case, the age is simply an integer number of days since birth (relative and coarse), but in a more sophisticated application, these cells would be replaced by true date-and-time cells (absolute and fine-grained).
class TimedWeight(ab.Structure): def Assemble(self): self.Add('age', ab.NumberCell) self.Add('wt', ab.NumberCell) class WeightChange(ab.Structure): def Assemble(self, bw=ab.NumberCell()): tw1 = self.Add('before', TimedWeight) tw2 = self.Add('after', TimedWeight) self.Add('ratio', BirthweightRatio, bw, tw2.wt) self.Add('gain', GmPerKgPerDay, tw1.age, tw2.age, tw1.wt, tw2.wt)
Notice that the 'ratio' attribute requires a connection to an independent cell not present in the WeightChange structure. A reference to an external cell (x, for example) can be given as (bw=x) as in test003 below, but if such an instance is not available, a default cell is provided by the parameter list for Assemble above. This is useful: (1) for documenting the cell type expected by the Assemble method, and (2) for instantiating the structure for testing purposes without specifying an external reference, as in test002 below.
# class Test002 (continued)
def test002(self):
"""WeightChange: gain calculated from timed weights"""
z = WeightChange() # no external reference given
z.before.age.value = 0
z.before.wt.value = 1.395
z.after.age.value = 7
z.after.wt.value = 1.605
self.failUnless(19.999 < z.gain.value < 20.001)
def test003(self):
"""WeightChange: ratio calculated from timed weights"""
x = ab.NumberCell()
x.value = 1.405
z = WeightChange(bw=x) # reference to external cell 'x'
z.after.age.value = 7
z.after.wt.value = 1.605
self.failUnless(1.142 < z.ratio.value < 1.143)
Now take another look at test002. Values are assigned to the independent cells directly (using the value property). But what if the relevant values were already stored in other TimedWeight instances? It would be nice to be able to plug these instances into the WeightChange structure in place of those created when the structure itself is generated. Unfortunately the observer connections between dependent and independent cells are hardwired at the time of instantiation, and the cost of breaking these connections and making new ones is prohibitive. Fortunately, though, there is a mechanism in place to automate the transfer of values from one structure to its counterpart. This is the Receive method. For example, z.before can pull (receive) values from x, and z.after can pull (receive) values from y.
# class Test002 (continued)
def def test004(self):
"""WeightChange: gain derived from Received values"""
x = TimedWeight()
x.age.value = 0
x.wt.value = 1.395
y = TimedWeight()
y.age.value = 7
y.wt.value = 1.605
z = WeightChange()
z.before.Receive(x)
z.after.Receive(y)
self.failUnless(19.999 < z.gain.value < 20.001)
def test005(self):
"""WeightChange: ratio derived from Received values"""
a = ab.NumberCell()
a.value = 1.405
y = TimedWeight()
y.age.value = 7
y.wt.value = 1.605
z = WeightChange(bw=a)
z.after.Receive(y)
self.failUnless(1.142 < z.ratio.value < 1.143)
There is also available a push mechanism called the Send method. In this case, x pushes (sends) its value to z.before and y pushes (sends) its value to z.after.
# class Test002 (continued)
def test006(self):
"""WeightChange: gain derived from Sent values"""
x = TimedWeight()
x.age.value = 0
x.wt.value = 1.395
y = TimedWeight()
y.age.value = 7
y.wt.value = 1.605
z = WeightChange()
x.Send(z.before)
y.Send(z.after)
self.failUnless(19.999 < z.gain.value < 20.001)
def test007(self):
"""WeightChange: ratio derived from Sent values"""
a = ab.NumberCell()
a.value = 1.405
y = TimedWeight()
y.age.value = 7
y.wt.value = 1.605
z = WeightChange(bw=a)
y.Send(z.after)
self.failUnless(1.142 < z.ratio.value < 1.143)
To finish up the Abstraction layer for this sample application, we need: (1) a way to store multiple instances of the TimedWeight structure, (2) a way to combine these data objects with other patient-specific data in a larger structure, and (3) a way to store multiple instances of this larger structure, one for each patient.
For the first requirement, then, we subclass ab.Table specifying TimedWeight as the class to use for item instances. The result is is the WeightTable class.
For the second requirement, we define another structure, PatientData, that incorporates an instance of the newly defined WeightTable class along with several independent cells and an instance of the Name class custom defined above. Note, however, that the WeightChange structure is not included, as this structure is used only for computation, not persistence.
For the third requirement, we define PatientTable, again subclassing ab.Table, but this time giving PatientData as the class to use for item instances.
class WeightTable(ab.Table): item = TimedWeight class PatientData(ab.Structure): def Assemble(self): self.Add('ptid', ab.StringCell) self.Add('bed', ab.StringCell) self.Add('name', Name) self.Add('bw', ab.NumberCell) self.Add('weight', WeightTable) class PatientTable(ab.Table): item = PatientData
It would be feasible to write a whole series of low-level unit tests for these components of the application's Abstraction layer, but it makes more sense to do the testing in the context of interaction with the Presentation layer, which we shall do later. For now, we will simply write one unit test to make sure the data tables are instantiated and that they yield appropriate items when the .Add method is called.
class Test003(unittest.TestCase): """Compound data structures""" def test001(self): """PatientTable: simple test of instantiation""" x = PatientTable() y = x.Add() # make item: PatientData instance z = y.weight.Add() # make item: TimedWeight instance z.age.value = 3 self.assertEqual(z.age.value, 3)
On second thought, it might also be helpful to be able to populate an instance of the PatientTable class outside of the application itself (i.e., without using the Presentation layer). This is the task taken up in the following section.
If, as asserted in the introduction, it is important to be able to think about the persistence of concrete data items, sooner or later we will need to produce specific values to be used in testing. Why not now? We are not yet ready to tackle persistence per se, but we are ready to test how real values can be placed in an empty model within the Abstraction layer.
The first step is to fabricate a set of values expressed in whatever format is convenient. The format used here is a set of nested native Python dictionaries (dicts) and lists.
data = [{'ptid': '123456',
'bed': '56',
'name': {'first': 'Sam',
'last': 'Jones'},
'bw': 1.155,
'weight': [(0, 1.155),
(1, 1.130),
(2, 1.125),
(5, 1.165),
(7, 1.200)]
},
{'ptid': '123457',
'bed': '23',
'name': {'first': 'Bilbo',
'last': 'Baggins'},
'bw': 2.155,
'weight': [(0, 2.155),
(1, 2.130),
(2, 2.125)]
},
{'ptid': '123458',
'bed': '46',
'name': {'first': 'Mary',
'last': 'Smith'},
'bw': 1.255,
'weight': [(0, 1.255),
(1, 1.230),
(2, 1.225)]
}
]
Despite the close similarity of the above data object to the PatientTable class, we reiterate that the format adopted is strictly one of convenience. This format makes it easy to write a function to populate an instance of PatientTable with these values.
In the following function, most of the actual data transfers are simple assignment statements, but notice especially the two Add calls, one for the PatientTable instance and one for the 'weight' (WeightTable) instances. In the first case, a specific key (the patient identifier) is taken from the data, and in the second, a random key is generated by the Table instance.
def PopulatedTable(): x = PatientTable() for i in data: y = x.Add(i['ptid']) y.ptid.value = i['ptid'] y.bed.value = i['bed'] y.name.last.value = i['name']['last'] y.name.first.value = i['name']['first'] y.bw.value = i['bw'] for t, w in i['weight']: z = y.weight.Add() z.age.value = t z.wt.value = w return x
The PopulatedTable function returns an instance of PatientTable populated with values from the data object defined immediately above. The following unit test, then, checks several representative components to make sure they are populated correctly.
# class Test003 (continued)
def test002(self):
"""PatientTable: populating with realtime data"""
x = PopulatedTable()
self.assertEqual(x['123456'].name.last.value, 'Jones')
y = x.GetSortedList('ptid')
self.assertEqual(y[0][2].name.last.value, 'Jones')
self.assertEqual(y[1][2].name.last.value, 'Baggins')
self.assertEqual(y[2][2].name.last.value, 'Smith')
z = y[0][2].weight.GetSortedList('age')
self.assertEqual(z[0][2].wt.value, 1.155)
self.assertEqual(z[1][2].wt.value, 1.130)
self.assertEqual(z[2][2].wt.value, 1.125)
self.assertEqual(z[3][2].wt.value, 1.165)
self.assertEqual(z[4][2].wt.value, 1.200)
At this point we are now ready to begin designing and implementing the user interface via the Presentation layer. For this layer we shall adopt a top-down approach, starting with the big picture and filling in the details later.
The first step is to define a minimal application, in which the OnInit method is overridden to instantiate and set up the top frame (MyTopFrame). This class, in turn, is defined by overriding the Assemble method to add two panels named 'select' and 'ptdata'. These are instances of SelectionPanel and DataPanel respectively, two classes which at this point are mere stubs to be fleshed out later.
class SelectionPanel(pr.Panel): pass class DataPanel(pr.Panel): pass class MyTopFrame(pr.Frame): """The main frame of the application contains two panels: one for selecting a patient from a list of patients or otherwise managing the patient list, and the other for displaying or otherwise manipulating information specific to the selected patient. """ def Assemble(self): self.Add('select', SelectionPanel) self.Add('ptdata', DataPanel) class MySampleApp(pr.App): """A custom application with OnInit overridden""" def OnInit(self): self.SetTopFrame(MyTopFrame())
Since the minimal application doesn't actually do anything yet, the unit test merely checks to make sure the application can be instantiated and that doing so creates two (different) panels within the top frame.
class Test004(unittest.TestCase): """Tests of custom sample application""" def test001(self): """Custom application, bare minimum""" x = MySampleApp() f = x.GetTopFrame() self.assertEqual(f.select.__class__, SelectionPanel) self.assertEqual(f.ptdata.__class__, DataPanel)
PatientListOf the two panels, the SelectionPanel must be considered the more top-level (broader in scope), since the DataPanel is more narrowly focused on the data for the patient specified. The SelectionPanel may evenually have a number of components, but at this point we give it only one: a PatientList presenter derived from pr.ListBox, and for the moment it is merely a stub.
class PatientList(pr.ListBox): pass class SelectionPanel(pr.Panel): def Assemble(self): self.Add('ptList', PatientList)
# class Test004 (continued)
# def test001 (continued)
self.assertEqual(f.select.ptList.__class__, PatientList)
PatientTableThe first step in fleshing out the PatientList presenter is to specify a class for the default model, which is PatientTable. This is easily accomplished, simply by assigning this class to the class variable model.
class PatientList(pr.ListBox): model = PatientTable
What this assignment provides is means of creating an appropriate model when none is given as an argument during instantiation of the presenter. In test002 below, the presenter is created directly, while in test003, it is created as part of creating an instance of MySampleApp.
# class Test004 (continued)
def test002(self):
"""PatientList: with model"""
x = PatientList()
m = x.GetModel()
y = m.Add()
self.assertEqual(m.__class__, PatientTable)
self.assertEqual(y.__class__, PatientData)
def test003(self):
"""Custom application: check model for ptList"""
x = MySampleApp()
f = x.GetTopFrame()
m = f.select.ptList.GetModel()
y = m.Add()
self.assertEqual(m.__class__, PatientTable)
self.assertEqual(y.__class__, PatientData)
Unit test test004 below demonstrates that a pre-existing model instance can be passed as an argument to the presenter as it is being instantiated. Here an item is created in the model (a Table) prior to the model being passed to the presenter constructor (model=mi).
# class Test004 (continued)
def test004(self):
"""PatientList: model instance passed to presenter"""
mi = PatientTable()
mi.Add()
x = PatientList(model=mi)
m = x.GetModel()
self.assertEqual(m.__class__, PatientTable)
y = m.values()[0]
self.assertEqual(y.__class__, PatientData)
If a PatientTable model populated with data is passed to the PatientList presenter, the established connection is apparent by the fact that the view (ui.ListBox widget) has automatically been populated with the corresponding client data items. The GetClientData method returns the appropriate data object for each index value.
# class Test004 (continued)
def test005(self):
"""PatientList: populated model instance (test data)"""
mi = PopulatedTable()
x = PatientList(model=mi)
d = x.GetClientData(0)
self.assertEqual(d.__class__, PatientData)
self.assertEqual(d.name.last.value, 'Jones')
d = x.GetClientData(1)
self.assertEqual(d.name.last.value, 'Baggins')
d = x.GetClientData(2)
self.assertEqual(d.name.last.value, 'Smith')
Unfortunately, however, the ui.ListBox widget has not been populated with helpful string values. In fact all three strings in the list box are empty strings.
# class Test004 (continued)
def test006(self):
"""PatientList: populated model instance (test string)"""
mi = PopulatedTable()
x = PatientList(model=mi)
s = x.GetString(0)
self.assertEqual(s, '')
s = x.GetString(1)
self.assertEqual(s, '')
s = x.GetString(2)
self.assertEqual(s, '')
This happens because: (1) no sort key has been specified for the ListBox presenter, and (2) no special Get method has been defined for the compound data structure. Let's try the latter remedy first, overriding the Get method of PatientData to return the full name of the patient.
class PatientData(ab.Structure): def Assemble(self): self.Add('ptid', ab.StringCell) self.Add('bed', ab.StringCell) self.Add('name', Name) self.Add('bw', ab.NumberCell) self.Add('weight', WeightTable) def Get(self): return self.name.full.value
Notice first (test006) that now the ui.ListBox widget does have strings, and second that the items are sorted according to those strings (full names in this case). This means, of course, that the client data objects will have changed order as well (test005).
# class Test004 (continued)
def test005(self):
"""PatientList: populated model instance (test data)"""
mi = PopulatedTable()
x = PatientList(model=mi)
d = x.GetClientData(0)
self.assertEqual(d.__class__, PatientData)
self.assertEqual(d.name.last.value, 'Baggins')
d = x.GetClientData(1)
self.assertEqual(d.name.last.value, 'Smith')
d = x.GetClientData(2)
self.assertEqual(d.name.last.value, 'Jones')
def test006(self):
"""PatientList: populated model instance (test string)"""
mi = PopulatedTable()
x = PatientList(model=mi)
s = x.GetString(0)
self.assertEqual(s, 'Bilbo Baggins')
s = x.GetString(1)
self.assertEqual(s, 'Mary Smith')
s = x.GetString(2)
self.assertEqual(s, 'Sam Jones')
If one wishes to present the list of items in some order other than that sorted by the values returned by the overridden Get method, this can be done by assigning the desired key (in dot notation) to the class attribute sortkey. For example, the following presenter definition sorts the items according to the inverted name strings.
class PatientList(pr.ListBox): model = PatientTable sortkey = 'name.inverse'
# class Test004 (continued)
def test005(self):
"""PatientList: populated model instance (test data)"""
mi = PopulatedTable()
x = PatientList(model=mi)
d = x.GetClientData(0)
self.assertEqual(d.__class__, PatientData)
self.assertEqual(d.name.last.value, 'Baggins')
d = x.GetClientData(1)
self.assertEqual(d.name.last.value, 'Jones')
d = x.GetClientData(2)
self.assertEqual(d.name.last.value, 'Smith')
def test006(self):
"""PatientList: populated model instance (test string)"""
mi = PopulatedTable()
x = PatientList(model=mi)
s = x.GetString(0)
self.assertEqual(s, 'Baggins, Bilbo')
s = x.GetString(1)
self.assertEqual(s, 'Jones, Sam')
s = x.GetString(2)
self.assertEqual(s, 'Smith, Mary')
...
|
XHTML 1.0
© 2003-2004 Donnal C. Walter,
This page updated 2004-01-16. See About this document for information on suggesting changes. |