Skip to content

Commit 333774d

Browse files
committed
Merge branch 'edge' into sg_delay-utils
2 parents add5ccb + 217cdb9 commit 333774d

19 files changed

+264
-119
lines changed

api/src/opentrons/protocol_engine/types/liquid_level_detection.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from dataclasses import dataclass
44
from datetime import datetime
55
from typing import Optional, List
6-
from pydantic import BaseModel, model_serializer
6+
from pydantic import BaseModel, model_serializer, field_validator
77

88

99
class SimulatedProbeResult(BaseModel):
@@ -104,6 +104,23 @@ class ProbedVolumeInfo(BaseModel):
104104
class WellInfoSummary(BaseModel):
105105
"""Payload for a well's liquid info in StateSummary."""
106106

107+
# TODO(cm): 3/21/25: refactor SimulatedLiquidProbe in a way that
108+
# doesn't require models like this one that are just using it to
109+
# need a custom validator
110+
@field_validator("probed_height", "probed_volume", mode="before")
111+
@classmethod
112+
def validate_simulated_probe_result(
113+
cls, input_val: object
114+
) -> LiquidTrackingType | None:
115+
"""Return the appropriate input to WellInfoSummary from json data."""
116+
if input_val is None:
117+
return None
118+
if isinstance(input_val, LiquidTrackingType):
119+
return input_val
120+
if isinstance(input_val, str) and input_val == "SimulatedProbeResult":
121+
return SimulatedProbeResult()
122+
raise ValueError(f"Invalid input value {input_val} to WellInfoSummary")
123+
107124
labware_id: str
108125
well_name: str
109126
loaded_volume: Optional[float] = None

robot-server/robot_server/labware_offsets/fastapi_dependencies.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
import sqlalchemy
1414

1515
from robot_server.persistence.fastapi_dependencies import get_sql_engine
16+
from robot_server.service.notifications.publishers.labware_offsets_publisher import (
17+
LabwareOffsetsPublisher,
18+
get_labware_offsets_publisher,
19+
)
1620
from .store import LabwareOffsetStore
1721

1822

@@ -24,10 +28,13 @@
2428
async def get_labware_offset_store(
2529
app_state: Annotated[AppState, Depends(get_app_state)],
2630
sql_engine: Annotated[sqlalchemy.engine.Engine, Depends(get_sql_engine)],
31+
labware_offsets_publisher: Annotated[
32+
LabwareOffsetsPublisher, Depends(get_labware_offsets_publisher)
33+
],
2734
) -> LabwareOffsetStore:
2835
"""Get the server's singleton LabwareOffsetStore."""
2936
labware_offset_store = _labware_offset_store_accessor.get_from(app_state)
3037
if labware_offset_store is None:
31-
labware_offset_store = LabwareOffsetStore(sql_engine)
38+
labware_offset_store = LabwareOffsetStore(sql_engine, labware_offsets_publisher)
3239
_labware_offset_store_accessor.set_on(app_state, labware_offset_store)
3340
return labware_offset_store

robot-server/robot_server/labware_offsets/store.py

+16-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
labware_offset_table,
1818
labware_offset_location_sequence_components_table,
1919
)
20+
from robot_server.service.notifications.publishers.labware_offsets_publisher import (
21+
LabwareOffsetsPublisher,
22+
)
2023
from .models import (
2124
ANY_LOCATION,
2225
AnyLocation,
@@ -49,14 +52,19 @@ class IncomingStoredLabwareOffset:
4952
class LabwareOffsetStore:
5053
"""A persistent store for labware offsets, to support the `/labwareOffsets` endpoints."""
5154

52-
def __init__(self, sql_engine: sqlalchemy.engine.Engine) -> None:
55+
def __init__(
56+
self,
57+
sql_engine: sqlalchemy.engine.Engine,
58+
labware_offsets_publisher: LabwareOffsetsPublisher | None,
59+
) -> None:
5360
"""Initialize the store.
5461
5562
Params:
5663
sql_engine: The SQL database to use as backing storage. Assumed to already
5764
have all the proper tables set up.
5865
"""
5966
self._sql_engine = sql_engine
67+
self._labware_offsets_publisher = labware_offsets_publisher
6068

6169
def add(
6270
self,
@@ -79,6 +87,7 @@ def add(
7987
),
8088
location_components_to_insert,
8189
)
90+
self._publish_change_notification()
8291

8392
def get_all(self) -> list[StoredLabwareOffset]:
8493
"""Return all offsets from oldest to newest.
@@ -123,7 +132,7 @@ def delete(self, offset_id: str) -> StoredLabwareOffset:
123132
.where(labware_offset_table.c.offset_id == offset_id)
124133
.values(active=False)
125134
)
126-
135+
self._publish_change_notification()
127136
return next(_collate_sql_to_pydantic(offset_rows))
128137

129138
def delete_all(self) -> None:
@@ -132,6 +141,11 @@ def delete_all(self) -> None:
132141
transaction.execute(
133142
sqlalchemy.update(labware_offset_table).values(active=False)
134143
)
144+
self._publish_change_notification()
145+
146+
def _publish_change_notification(self) -> None:
147+
if self._labware_offsets_publisher:
148+
self._labware_offsets_publisher.publish_labware_offsets()
135149

136150

137151
class LabwareOffsetNotFoundError(KeyError):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import Annotated
2+
import fastapi
3+
from robot_server.service.notifications import topics
4+
from robot_server.service.notifications.notification_client import (
5+
NotificationClient,
6+
get_notification_client,
7+
)
8+
9+
10+
class LabwareOffsetsPublisher:
11+
"""Publishes clientData topics."""
12+
13+
def __init__(self, client: NotificationClient) -> None:
14+
self._client = client
15+
16+
def publish_labware_offsets(self) -> None:
17+
"""Publish the equivalent of `GET /labwareOffsets` or `POST /labwareOffsets/searches`."""
18+
self._client.publish_advise_refetch(topics.LABWARE_OFFSETS)
19+
20+
21+
async def get_labware_offsets_publisher(
22+
notification_client: Annotated[
23+
NotificationClient, fastapi.Depends(get_notification_client)
24+
],
25+
) -> LabwareOffsetsPublisher:
26+
"""Return a ClientDataPublisher for use by FastAPI endpoints."""
27+
return LabwareOffsetsPublisher(notification_client)

robot-server/robot_server/service/notifications/topics.py

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def _is_valid_topic_name_level(level: str) -> bool:
3030
RUNS = TopicName(f"{_TOPIC_BASE}/runs")
3131
DECK_CONFIGURATION = TopicName(f"{_TOPIC_BASE}/deck_configuration")
3232
RUNS_PRE_SERIALIZED_COMMANDS = TopicName(f"{_TOPIC_BASE}/runs/pre_serialized_commands")
33+
LABWARE_OFFSETS = TopicName(f"{_TOPIC_BASE}/labwareOffsets")
3334

3435

3536
def client_data(key: str) -> TopicName:

robot-server/tests/labware_offsets/test_store.py

+39-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from datetime import datetime, timezone
44
from typing import Sequence
55

6+
from decoy import Decoy
67
import pytest
78
import sqlalchemy
89

@@ -32,12 +33,24 @@
3233
StoredLabwareOffsetLocationSequenceComponents,
3334
UnknownLabwareOffsetLocationSequenceComponent,
3435
)
36+
from robot_server.service.notifications.publishers.labware_offsets_publisher import (
37+
LabwareOffsetsPublisher,
38+
)
39+
40+
41+
@pytest.fixture
42+
def mock_labware_offsets_publisher(decoy: Decoy) -> LabwareOffsetsPublisher:
43+
"""Return a mock in the shape of a LabwareOffsetsPublisher."""
44+
return decoy.mock(cls=LabwareOffsetsPublisher)
3545

3646

3747
@pytest.fixture
38-
def subject(sql_engine: sqlalchemy.engine.Engine) -> LabwareOffsetStore:
48+
def subject(
49+
sql_engine: sqlalchemy.engine.Engine,
50+
mock_labware_offsets_publisher: LabwareOffsetsPublisher,
51+
) -> LabwareOffsetStore:
3952
"""Return a test subject."""
40-
return LabwareOffsetStore(sql_engine)
53+
return LabwareOffsetStore(sql_engine, mock_labware_offsets_publisher)
4154

4255

4356
def test_empty_search(subject: LabwareOffsetStore) -> None:
@@ -260,7 +273,7 @@ def test_filter_fields(
260273
)
261274
]
262275
)
263-
assert sorted(results, key=lambda o: o.id,) == sorted(
276+
assert sorted(results, key=lambda o: o.id) == sorted(
264277
[
265278
StoredLabwareOffset(
266279
id=offsets[id_].id,
@@ -473,3 +486,26 @@ def test_handle_unknown(
473486
)
474487
)
475488
assert subject.search([SearchFilter(id="id-a")]) == [outgoing_offset]
489+
490+
491+
def test_notifications(
492+
subject: LabwareOffsetStore,
493+
decoy: Decoy,
494+
mock_labware_offsets_publisher: LabwareOffsetsPublisher,
495+
) -> None:
496+
"""It should publish notifications any time the set of labware offsets changes."""
497+
decoy.verify(mock_labware_offsets_publisher.publish_labware_offsets(), times=0)
498+
subject.add(
499+
IncomingStoredLabwareOffset(
500+
id="id",
501+
createdAt=datetime.now(timezone.utc),
502+
definitionUri="definitionUri",
503+
locationSequence=ANY_LOCATION,
504+
vector=LabwareOffsetVector(x=1, y=2, z=3),
505+
)
506+
)
507+
decoy.verify(mock_labware_offsets_publisher.publish_labware_offsets(), times=1)
508+
subject.delete("id")
509+
decoy.verify(mock_labware_offsets_publisher.publish_labware_offsets(), times=2)
510+
subject.delete_all()
511+
decoy.verify(mock_labware_offsets_publisher.publish_labware_offsets(), times=3)

robot-server/tests/labware_offsets/test_store_hypothesis.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def test_round_trip(
121121
)
122122

123123
with TemporaryDirectory() as tmp_dir, make_sql_engine(Path(tmp_dir)) as sql_engine:
124-
subject = LabwareOffsetStore(sql_engine)
124+
subject = LabwareOffsetStore(sql_engine, labware_offsets_publisher=None)
125125
subject.add(offset_to_add)
126126
[offset_retrieved_by_get_all] = subject.get_all()
127127
[offset_retrieved_by_search] = subject.search([SearchFilter(id=id)])
@@ -228,7 +228,7 @@ def __init__(self) -> None:
228228
self._exit_stack = ExitStack()
229229
temp_dir = Path(self._exit_stack.enter_context(TemporaryDirectory()))
230230
sql_engine = self._exit_stack.enter_context(make_sql_engine(temp_dir))
231-
self._subject = LabwareOffsetStore(sql_engine)
231+
self._subject = LabwareOffsetStore(sql_engine, labware_offsets_publisher=None)
232232
self._simulated_model = SimulatedStore()
233233
self._added_ids = set[str]()
234234

step-generation/src/__tests__/airGapInTrash.test.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,18 @@ import type { CutoutId } from '@opentrons/shared-data'
1010
import type { InvariantContext, RobotState } from '../types'
1111

1212
const mockCutout: CutoutId = 'cutoutA3'
13-
const invariantContext: InvariantContext = makeContext()
13+
const mockTrashId = 'mockTrashId'
14+
const invariantContext: InvariantContext = {
15+
...makeContext(),
16+
additionalEquipmentEntities: {
17+
[mockTrashId]: {
18+
id: mockTrashId,
19+
name: 'trashBin',
20+
pythonName: 'mock_trash_bin_1',
21+
location: mockCutout,
22+
},
23+
},
24+
}
1425
const prevRobotState: RobotState = getInitialRobotStateStandard(
1526
invariantContext
1627
)
@@ -22,7 +33,7 @@ describe('airGapInTrash', () => {
2233
pipetteId: DEFAULT_PIPETTE,
2334
volume: 10,
2435
flowRate: 10,
25-
trashLocation: mockCutout,
36+
trashId: mockTrashId,
2637
},
2738
invariantContext,
2839
prevRobotState

step-generation/src/__tests__/airGapInWasteChute.test.ts

+17-15
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,25 @@
11
import { describe, it, expect } from 'vitest'
2+
import { WASTE_CHUTE_CUTOUT } from '@opentrons/shared-data'
23
import {
4+
DEFAULT_PIPETTE,
35
getInitialRobotStateStandard,
46
getSuccessResult,
57
makeContext,
68
} from '../fixtures'
79
import { airGapInWasteChute } from '../commandCreators/compound'
8-
import type { InvariantContext, PipetteEntities, RobotState } from '../types'
9-
10-
const mockId = 'mockId'
11-
const mockPipEntities: PipetteEntities = {
12-
[mockId]: {
13-
name: 'p50_single_flex',
14-
id: mockId,
15-
spec: { channels: 1 },
16-
},
17-
} as any
10+
import type { InvariantContext, RobotState } from '../types'
1811

12+
const wasteChuteId = 'wasteChuteId'
1913
const invariantContext: InvariantContext = {
2014
...makeContext(),
21-
pipetteEntities: mockPipEntities,
15+
additionalEquipmentEntities: {
16+
[wasteChuteId]: {
17+
id: wasteChuteId,
18+
name: 'wasteChute',
19+
pythonName: 'mock_waste_chute_1',
20+
location: WASTE_CHUTE_CUTOUT,
21+
},
22+
},
2223
}
2324
const prevRobotState: RobotState = getInitialRobotStateStandard(
2425
invariantContext
@@ -28,9 +29,10 @@ describe('airGapInWasteChute', () => {
2829
it('returns correct commands for air gap in waste chute', () => {
2930
const result = airGapInWasteChute(
3031
{
31-
pipetteId: mockId,
32+
pipetteId: DEFAULT_PIPETTE,
3233
volume: 10,
3334
flowRate: 10,
35+
wasteChuteId,
3436
},
3537
invariantContext,
3638
prevRobotState
@@ -40,7 +42,7 @@ describe('airGapInWasteChute', () => {
4042
commandType: 'moveToAddressableArea',
4143
key: expect.any(String),
4244
params: {
43-
pipetteId: mockId,
45+
pipetteId: DEFAULT_PIPETTE,
4446
addressableAreaName: '1ChannelWasteChute',
4547
offset: { x: 0, y: 0, z: 0 },
4648
},
@@ -49,14 +51,14 @@ describe('airGapInWasteChute', () => {
4951
commandType: 'prepareToAspirate',
5052
key: expect.any(String),
5153
params: {
52-
pipetteId: mockId,
54+
pipetteId: DEFAULT_PIPETTE,
5355
},
5456
},
5557
{
5658
commandType: 'airGapInPlace',
5759
key: expect.any(String),
5860
params: {
59-
pipetteId: mockId,
61+
pipetteId: DEFAULT_PIPETTE,
6062
flowRate: 10,
6163
volume: 10,
6264
},

0 commit comments

Comments
 (0)