Skip to content

Commit b15af5e

Browse files
authored
feat(test-data-generation): first pass at generating deck configurations (#14962)
# Overview With the large code change in #14684 we want to test it as thoroughly as possible. This PR adds generation of test cases with [hypothesis](https://hypothesis.readthedocs.io/en/latest/index.html) for deck configuration The idea is that hypothesis will generate DeckConfiguration objects that represent what a user defines in their protocol or their deck configuration in the UI. These objects will then end up being used to auto-generate Python protocols to pipe through analysis to exercise our deck configuration validation logic # Test Plan - I went through some of the generated deck configurations to verify they were being created correctly # Changelog - Create datashapes.py - This defines a simplified deck configuration model and all of its contents - Create helper_strategies.py - This file provides the building block strategies that are utilized to make a final strategy that makes a DeckConfiguration object - Create final_strategies.py - This contains the logic for generating the final DeckConfiguration objects # Review requests - Should I add some tests to confirm that DeckConfiguration objects are being generated as expected? # Risk assessment None.... yet
1 parent 6e3c0d1 commit b15af5e

File tree

7 files changed

+546
-1
lines changed

7 files changed

+546
-1
lines changed

test-data-generation/Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,9 @@ wheel:
2929

3030
.PHONY: test
3131
test:
32-
$(pytest) tests -vvv
32+
$(pytest) tests \
33+
-s \
34+
--hypothesis-show-statistics \
35+
--hypothesis-verbosity=normal \
36+
--hypothesis-explain \
37+
-vvv
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Test data generation."""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Test data generation for deck configuration tests."""
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
"""Data shapes for the deck configuration of a Flex."""
2+
3+
import enum
4+
import dataclasses
5+
import typing
6+
7+
ColumnName = typing.Literal["1", "2", "3"]
8+
RowName = typing.Literal["a", "b", "c", "d"]
9+
SlotName = typing.Literal[
10+
"a1", "a2", "a3", "b1", "b2", "b3", "c1", "c2", "c3", "d1", "d2", "d3"
11+
]
12+
13+
14+
class PossibleSlotContents(enum.Enum):
15+
"""Possible contents of a slot on a Flex."""
16+
17+
# Implicitly defined fixtures
18+
THERMOCYCLER_MODULE = enum.auto()
19+
WASTE_CHUTE = enum.auto()
20+
WASTE_CHUTE_NO_COVER = enum.auto()
21+
STAGING_AREA = enum.auto()
22+
STAGING_AREA_WITH_WASTE_CHUTE = enum.auto()
23+
STAGING_AREA_WITH_WASTE_CHUTE_NO_COVER = enum.auto()
24+
STAGING_AREA_WITH_MAGNETIC_BLOCK = enum.auto()
25+
26+
# Explicitly defined fixtures
27+
MAGNETIC_BLOCK_MODULE = enum.auto()
28+
TEMPERATURE_MODULE = enum.auto()
29+
HEATER_SHAKER_MODULE = enum.auto()
30+
TRASH_BIN = enum.auto()
31+
32+
# Other
33+
LABWARE_SLOT = enum.auto()
34+
35+
@classmethod
36+
def longest_string(cls) -> int:
37+
"""Return the longest string representation of the slot content."""
38+
length = max([len(e.name) for e in PossibleSlotContents])
39+
return length if length % 2 == 0 else length + 1
40+
41+
def __str__(self) -> str:
42+
"""Return a string representation of the slot content."""
43+
return f"{self.name.replace('_', ' ')}"
44+
45+
@classmethod
46+
def all(cls) -> typing.List["PossibleSlotContents"]:
47+
"""Return all possible slot contents."""
48+
return list(cls)
49+
50+
@property
51+
def modules(self) -> typing.List["PossibleSlotContents"]:
52+
"""Return the modules."""
53+
return [
54+
PossibleSlotContents.THERMOCYCLER_MODULE,
55+
PossibleSlotContents.MAGNETIC_BLOCK_MODULE,
56+
PossibleSlotContents.TEMPERATURE_MODULE,
57+
PossibleSlotContents.HEATER_SHAKER_MODULE,
58+
]
59+
60+
@property
61+
def staging_areas(self) -> typing.List["PossibleSlotContents"]:
62+
"""Return the staging areas."""
63+
return [
64+
PossibleSlotContents.STAGING_AREA,
65+
PossibleSlotContents.STAGING_AREA_WITH_WASTE_CHUTE,
66+
PossibleSlotContents.STAGING_AREA_WITH_WASTE_CHUTE_NO_COVER,
67+
PossibleSlotContents.STAGING_AREA_WITH_MAGNETIC_BLOCK,
68+
]
69+
70+
@property
71+
def waste_chutes(self) -> typing.List["PossibleSlotContents"]:
72+
"""Return the waste chutes."""
73+
return [
74+
PossibleSlotContents.WASTE_CHUTE,
75+
PossibleSlotContents.WASTE_CHUTE_NO_COVER,
76+
PossibleSlotContents.STAGING_AREA_WITH_WASTE_CHUTE,
77+
PossibleSlotContents.STAGING_AREA_WITH_WASTE_CHUTE_NO_COVER,
78+
]
79+
80+
def is_one_of(self, contents: typing.List["PossibleSlotContents"]) -> bool:
81+
"""Return True if the slot contains one of the contents."""
82+
return any([self is content for content in contents])
83+
84+
def is_a_module(self) -> bool:
85+
"""Return True if the slot contains a module."""
86+
return self.is_one_of(self.modules)
87+
88+
def is_module_or_trash_bin(self) -> bool:
89+
"""Return True if the slot contains a module or trash bin."""
90+
return self.is_one_of(self.modules + [PossibleSlotContents.TRASH_BIN])
91+
92+
def is_a_staging_area(self) -> bool:
93+
"""Return True if the slot contains a staging area."""
94+
return self.is_one_of(self.staging_areas)
95+
96+
def is_a_waste_chute(self) -> bool:
97+
"""Return True if the slot contains a waste chute."""
98+
return self.is_one_of(self.waste_chutes)
99+
100+
101+
@dataclasses.dataclass
102+
class Slot:
103+
"""A slot on a Flex."""
104+
105+
row: RowName
106+
col: ColumnName
107+
contents: PossibleSlotContents
108+
109+
def __str__(self) -> str:
110+
"""Return a string representation of the slot."""
111+
return f"{(self.row + self.col).center(self.contents.longest_string())}{self.contents}"
112+
113+
@property
114+
def __label(self) -> SlotName:
115+
"""Return the slot label."""
116+
return typing.cast(SlotName, f"{self.row}{self.col}")
117+
118+
@property
119+
def slot_label_string(self) -> str:
120+
"""Return the slot label."""
121+
return f"{self.__label.center(self.contents.longest_string())}"
122+
123+
@property
124+
def contents_string(self) -> str:
125+
"""Return the slot contents."""
126+
return f"{str(self.contents).center(self.contents.longest_string())}"
127+
128+
129+
@dataclasses.dataclass
130+
class Row:
131+
"""A row of slots on a Flex."""
132+
133+
row: RowName
134+
135+
col1: Slot
136+
col2: Slot
137+
col3: Slot
138+
139+
def __str__(self) -> str:
140+
"""Return a string representation of the row."""
141+
return f"{self.col1}{self.col2}{self.col3}"
142+
143+
def slot_by_col_number(self, name: ColumnName) -> Slot:
144+
"""Return the slot by name."""
145+
return getattr(self, f"col{name}") # type: ignore
146+
147+
@property
148+
def slots(self) -> typing.List[Slot]:
149+
"""Iterate over the slots in the row."""
150+
return [self.col1, self.col2, self.col3]
151+
152+
def __len__(self) -> int:
153+
"""Return the number of slots in the row."""
154+
return len(self.slots)
155+
156+
def update_slot(self, slot: Slot) -> None:
157+
"""Update the slot in the row."""
158+
setattr(self, f"col{slot.col}", slot)
159+
160+
161+
@dataclasses.dataclass
162+
class Column:
163+
"""A column of slots on a Flex."""
164+
165+
col: ColumnName
166+
167+
a: Slot
168+
b: Slot
169+
c: Slot
170+
d: Slot
171+
172+
def __str__(self) -> str:
173+
"""Return a string representation of the column."""
174+
return f"{self.a}{self.b}{self.c}{self.d}"
175+
176+
@property
177+
def slots(self) -> typing.List[Slot]:
178+
"""Return the slots in the column."""
179+
return [self.a, self.b, self.c, self.d]
180+
181+
def slot_by_row(self, name: RowName) -> Slot:
182+
"""Return the slot by name."""
183+
return getattr(self, f"{name}") # type: ignore
184+
185+
def number_of(self, contents: PossibleSlotContents) -> int:
186+
"""Return the number of slots with the contents."""
187+
return len([True for slot in self.slots if slot.contents is contents])
188+
189+
def slot_above(self, slot: Slot) -> typing.Optional[Slot]:
190+
"""Return the slot above the passed slot."""
191+
index = self.slots.index(slot)
192+
if index == 0:
193+
return None
194+
return self.slots[index - 1]
195+
196+
def slot_below(self, slot: Slot) -> typing.Optional[Slot]:
197+
"""Return the slot below the passed slot."""
198+
index = self.slots.index(slot)
199+
if index == 3:
200+
return None
201+
return self.slots[index + 1]
202+
203+
204+
@dataclasses.dataclass
205+
class DeckConfiguration:
206+
"""The deck on a Flex."""
207+
208+
a: Row
209+
b: Row
210+
c: Row
211+
d: Row
212+
213+
def __str__(self) -> str:
214+
"""Return a string representation of the deck."""
215+
string_list = []
216+
dashed_line = "-" * (PossibleSlotContents.longest_string() * 3)
217+
equal_line = "=" * (PossibleSlotContents.longest_string() * 3)
218+
for row in self.rows:
219+
string_list.append(
220+
" | ".join([slot.slot_label_string for slot in row.slots])
221+
)
222+
string_list.append(" | ".join([slot.contents_string for slot in row.slots]))
223+
if row != self.d:
224+
string_list.append(dashed_line)
225+
joined_string = "\n".join(string_list)
226+
227+
return f"\n{joined_string}\n\n{equal_line}"
228+
229+
def __hash__(self) -> int:
230+
"""Return the hash of the deck."""
231+
return hash(tuple(slot.contents.value for slot in self.slots))
232+
233+
def __eq__(self, other: typing.Any) -> bool:
234+
"""Return True if the deck is equal to the other deck."""
235+
if not isinstance(other, DeckConfiguration):
236+
return False
237+
return all(
238+
slot.contents == other_slot.contents
239+
for slot in self.slots
240+
for other_slot in other.slots
241+
)
242+
243+
@classmethod
244+
def from_cols(cls, col1: Column, col2: Column, col3: Column) -> "DeckConfiguration":
245+
"""Create a deck configuration from columns."""
246+
return cls(
247+
a=Row("a", col1.a, col2.a, col3.a),
248+
b=Row("b", col1.b, col2.b, col3.b),
249+
c=Row("c", col1.c, col2.c, col3.c),
250+
d=Row("d", col1.d, col2.d, col3.d),
251+
)
252+
253+
@property
254+
def rows(self) -> typing.List[Row]:
255+
"""Return the rows of the deck."""
256+
return [self.a, self.b, self.c, self.d]
257+
258+
def row_by_name(self, name: RowName) -> Row:
259+
"""Return the row by name."""
260+
return getattr(self, name) # type: ignore
261+
262+
@property
263+
def slots(self) -> typing.List[Slot]:
264+
"""Return the slots of the deck."""
265+
return [slot for row in self.rows for slot in row.slots]
266+
267+
def slot_above(self, slot: Slot) -> typing.Optional[Slot]:
268+
"""Return the slot above the passed slot."""
269+
row_index = self.rows.index(self.row_by_name(slot.row))
270+
if row_index == 0:
271+
return None
272+
return self.rows[row_index - 1].slot_by_col_number(slot.col)
273+
274+
def slot_below(self, slot: Slot) -> typing.Optional[Slot]:
275+
"""Return the slot below the passed slot."""
276+
row_index = self.rows.index(self.row_by_name(slot.row))
277+
if row_index == 3:
278+
return None
279+
return self.rows[row_index + 1].slot_by_col_number(slot.col)
280+
281+
def number_of(self, contents: PossibleSlotContents) -> int:
282+
"""Return the number of slots with the contents."""
283+
return len([True for slot in self.slots if slot.contents is contents])
284+
285+
def override_with_column(self, column: Column) -> None:
286+
"""Override the deck configuration with the column."""
287+
for row in self.rows:
288+
new_value = column.slot_by_row(row.row)
289+
row.update_slot(new_value)
290+
291+
def column_by_number(self, number: ColumnName) -> Column:
292+
"""Return the column by number."""
293+
return Column(
294+
col=number,
295+
a=self.a.slot_by_col_number(number),
296+
b=self.b.slot_by_col_number(number),
297+
c=self.c.slot_by_col_number(number),
298+
d=self.d.slot_by_col_number(number),
299+
)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Test data generation for deck configuration tests."""
2+
from hypothesis import assume, strategies as st
3+
from test_data_generation.deck_configuration.datashapes import (
4+
Column,
5+
DeckConfiguration,
6+
Slot,
7+
PossibleSlotContents as PSC,
8+
)
9+
10+
from test_data_generation.deck_configuration.strategy.helper_strategies import a_column
11+
12+
13+
def _above_or_below_is_module_or_trash(col: Column, slot: Slot) -> bool:
14+
"""Return True if the deck has a module above or below the specified slot."""
15+
above = col.slot_above(slot)
16+
below = col.slot_below(slot)
17+
18+
return (above is not None and above.contents.is_module_or_trash_bin()) or (
19+
below is not None and below.contents.is_module_or_trash_bin()
20+
)
21+
22+
23+
@st.composite
24+
def a_deck_configuration_with_a_module_or_trash_slot_above_or_below_a_heater_shaker(
25+
draw: st.DrawFn,
26+
) -> DeckConfiguration:
27+
"""Generate a deck with a module or trash bin fixture above or below a heater shaker."""
28+
deck = draw(
29+
st.builds(
30+
DeckConfiguration.from_cols,
31+
col1=a_column("1"),
32+
col2=a_column(
33+
"2", content_options=[PSC.LABWARE_SLOT, PSC.MAGNETIC_BLOCK_MODULE]
34+
),
35+
col3=a_column("3"),
36+
)
37+
)
38+
column = deck.column_by_number(draw(st.sampled_from(["1", "3"])))
39+
40+
assume(column.number_of(PSC.HEATER_SHAKER_MODULE) in [1, 2])
41+
for slot in column.slots:
42+
if slot.contents is PSC.HEATER_SHAKER_MODULE:
43+
assume(_above_or_below_is_module_or_trash(column, slot))
44+
deck.override_with_column(column)
45+
46+
return deck
47+
48+
49+
@st.composite
50+
def a_deck_configuration_with_invalid_fixture_in_col_2(
51+
draw: st.DrawFn,
52+
) -> DeckConfiguration:
53+
"""Generate a deck with an invalid fixture in column 2."""
54+
POSSIBLE_FIXTURES = [
55+
PSC.LABWARE_SLOT,
56+
PSC.TEMPERATURE_MODULE,
57+
PSC.HEATER_SHAKER_MODULE,
58+
PSC.TRASH_BIN,
59+
PSC.MAGNETIC_BLOCK_MODULE,
60+
]
61+
INVALID_FIXTURES = [
62+
PSC.HEATER_SHAKER_MODULE,
63+
PSC.TRASH_BIN,
64+
PSC.TEMPERATURE_MODULE,
65+
]
66+
column2 = draw(a_column("2", content_options=POSSIBLE_FIXTURES))
67+
num_invalid_fixtures = len(
68+
[True for slot in column2.slots if slot.contents.is_one_of(INVALID_FIXTURES)]
69+
)
70+
assume(num_invalid_fixtures > 0)
71+
72+
deck = draw(
73+
st.builds(
74+
DeckConfiguration.from_cols,
75+
col1=a_column("1"),
76+
col2=st.just(column2),
77+
col3=a_column("3"),
78+
)
79+
)
80+
81+
return deck

0 commit comments

Comments
 (0)