Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/isar/apis/models/start_mission_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class StartMissionInspectionDefinition(BaseModel):
inspection_target: InputPosition
inspection_description: str | None = None
duration: float | None = None
analysis_types: list[str] | None = Field(default=None)


class StartMissionTaskDefinition(BaseModel):
Expand Down Expand Up @@ -123,6 +124,7 @@ def to_inspection_task(task_definition: StartMissionTaskDefinition) -> TASKS:
inspection_description=task_definition.inspection.inspection_description,
target=task_definition.inspection.inspection_target.to_alitra_position(),
zoom=task_definition.zoom,
analysis_types=inspection_definition.analysis_types,
)
elif inspection_definition.type == InspectionTypes.video:
if inspection_definition.duration is None:
Expand All @@ -135,6 +137,7 @@ def to_inspection_task(task_definition: StartMissionTaskDefinition) -> TASKS:
target=task_definition.inspection.inspection_target.to_alitra_position(),
duration=inspection_definition.duration,
zoom=task_definition.zoom,
analysis_types=inspection_definition.analysis_types,
)
elif inspection_definition.type == InspectionTypes.thermal_image:
return TakeThermalImage(
Expand All @@ -144,6 +147,7 @@ def to_inspection_task(task_definition: StartMissionTaskDefinition) -> TASKS:
inspection_description=task_definition.inspection.inspection_description,
target=task_definition.inspection.inspection_target.to_alitra_position(),
zoom=task_definition.zoom,
analysis_types=inspection_definition.analysis_types,
)
elif inspection_definition.type == InspectionTypes.thermal_video:
if inspection_definition.duration is None:
Expand All @@ -156,6 +160,7 @@ def to_inspection_task(task_definition: StartMissionTaskDefinition) -> TASKS:
target=task_definition.inspection.inspection_target.to_alitra_position(),
duration=inspection_definition.duration,
zoom=task_definition.zoom,
analysis_types=inspection_definition.analysis_types,
)
elif inspection_definition.type == InspectionTypes.audio:
if inspection_definition.duration is None:
Expand All @@ -167,13 +172,15 @@ def to_inspection_task(task_definition: StartMissionTaskDefinition) -> TASKS:
inspection_description=task_definition.inspection.inspection_description,
target=task_definition.inspection.inspection_target.to_alitra_position(),
duration=inspection_definition.duration,
analysis_types=inspection_definition.analysis_types,
)
elif inspection_definition.type == InspectionTypes.co2_measurement:
return TakeCO2Measurement(
id=task_definition.id if task_definition.id else str(uuid4()),
robot_pose=task_definition.pose.to_alitra_pose(),
tag_id=task_definition.tag,
inspection_description=task_definition.inspection.inspection_description,
analysis_types=inspection_definition.analysis_types,
)
else:
raise ValueError(
Expand Down
1 change: 1 addition & 0 deletions src/isar/robot/robot_inspection_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def robot_upload_inspection(
)

inspection.metadata.tag_id = task.tag_id
inspection.metadata.analysis_types = task.analysis_types

message: Tuple[Inspection, Mission] = (
inspection,
Expand Down
4 changes: 1 addition & 3 deletions src/isar/storage/uploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,6 @@ def _publish_inspection_result(
inspection: InspectionBlob,
inspection_paths: StoragePaths[BlobStoragePath],
) -> None:
"""Publishes the reference of the inspection result to the MQTT Broker
along with the analysis type
"""
if not self.mqtt_publisher:
return

Expand All @@ -275,6 +272,7 @@ def _publish_inspection_result(
tag_id=inspection.metadata.tag_id,
inspection_type=type(inspection).__name__,
inspection_description=inspection.metadata.inspection_description,
required_analysis=inspection.metadata.analysis_types,
timestamp=inspection.metadata.start_time,
)
self.mqtt_publisher.publish(
Expand Down
1 change: 1 addition & 0 deletions src/isar/storage/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def construct_metadata_file(
"robot_name": settings.ROBOT_NAME,
"inspection_description": inspection.metadata.inspection_description,
"tag": inspection.metadata.tag_id,
"analysis_types": inspection.metadata.analysis_types,
"robot_pose": {
"position": {
"x": inspection.metadata.robot_pose.position.x,
Expand Down
1 change: 1 addition & 0 deletions src/robot_interface/models/inspection/inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class InspectionMetadata(BaseModel):
file_type: str
tag_id: str | None = None
inspection_description: str | None = None
analysis_types: list[str] | None = None


class ImageMetadata(InspectionMetadata):
Expand Down
1 change: 1 addition & 0 deletions src/robot_interface/models/mission/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class InspectionTask(Task):
robot_pose: Pose = Field()
inspection_description: str | None = Field(default=None)
zoom: ZoomDescription | None = Field(default=None)
analysis_types: list[str] | None = Field(default=None)

@staticmethod
def get_inspection_type() -> Type[Inspection]:
Expand Down
1 change: 1 addition & 0 deletions src/robot_interface/telemetry/payloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ class InspectionResultPayload(BaseModel):
tag_id: str | None = None
inspection_type: str | None = None
inspection_description: str | None = None
required_analysis: list[str] | None = None
timestamp: datetime


Expand Down
1 change: 1 addition & 0 deletions tests/isar/apis/models/example_mission_definition.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"z": 0,
"frame_name": "robot"
},
"analysis_types": ["anonymize"],
"id": "generated_inspection_id"
},
"tag": "MY-TAG-123",
Expand Down
41 changes: 40 additions & 1 deletion tests/isar/apis/models/test_start_mission_definition.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import os

import pytest
from alitra import Frame, Orientation, Pose, Position

from isar.apis.models.models import InputOrientation, InputPose, InputPosition
Expand All @@ -13,7 +14,7 @@
to_isar_mission,
)
from robot_interface.models.mission.mission import Mission
from robot_interface.models.mission.task import TakeImage
from robot_interface.models.mission.task import InspectionTask, TakeImage


def test_to_isar_mission() -> None:
Expand Down Expand Up @@ -79,3 +80,41 @@ def test_mission_definition_from_json_to_isar_mission() -> None:
)
assert task.type == "take_image"
assert task.target == Position(0.0, 0.0, 0.0, frame=Frame("robot"))
assert task.analysis_types == ["anonymize"]


def _build_mission_with_inspection_payload(inspection_payload: dict) -> Mission:
payload = {
"name": "mission",
"tasks": [
{
"type": "inspection",
"pose": {
"position": {"x": 0, "y": 0, "z": 0},
"orientation": {"x": 0, "y": 0, "z": 0, "w": 1},
},
"inspection": inspection_payload,
}
],
}
return to_isar_mission(StartMissionDefinition.model_validate(payload))


@pytest.mark.parametrize(
"inspection_type",
[t.value for t in InspectionTypes],
)
def test_analysis_types_defaults_to_none_for_all_inspection_types(
inspection_type: str,
) -> None:
payload: dict = {
"type": inspection_type,
"inspection_target": {"x": 0, "y": 0, "z": 0},
}
if inspection_type in {"Video", "ThermalVideo", "Audio"}:
payload["duration"] = 1.0

mission = _build_mission_with_inspection_payload(payload)
task = mission.tasks[0]
assert isinstance(task, InspectionTask)
assert task.analysis_types is None
83 changes: 72 additions & 11 deletions tests/isar/storage/test_uploader.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import time
from datetime import datetime
from typing import Tuple
from typing import Callable, Tuple
from uuid import uuid4

from alitra import Frame, Orientation, Pose, Position
Expand Down Expand Up @@ -31,6 +32,17 @@
)


def _wait_until(
predicate: Callable[[], bool], timeout: float = 5.0, interval: float = 0.01
) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(interval)
raise AssertionError(f"Timed out after {timeout}s waiting for predicate")


def test_should_upload_from_queue(
container: ApplicationContainer, uploader_thread: UploaderThreadMock
) -> None:
Expand Down Expand Up @@ -65,12 +77,10 @@ def test_should_upload_from_queue(
)

uploader: Uploader = container.uploader()
storage_handler: StorageFake = uploader.storage_handlers[0] # type: ignore

uploader.upload_queue.put(message)
time.sleep(0.01)

storage_handler: StorageFake = uploader.storage_handlers[0] # type: ignore
assert inspection in storage_handler.stored_inspections
_wait_until(lambda: inspection in storage_handler.stored_inspections)


def test_should_retry_failed_upload_from_queue(
Expand All @@ -93,15 +103,15 @@ def test_should_retry_failed_upload_from_queue(
# Need it to fail so that it retries
storage_handler.will_fail = True
uploader.upload_queue.put(message)
# Fixed wait: asserting absence of upload requires a real elapsed window
time.sleep(1)

# Should not upload, instead raise StorageException
assert not storage_handler.blob_exists(inspection)
storage_handler.will_fail = False
time.sleep(3)

# After some time, it should have retried and now it should be successful
assert storage_handler.blob_exists(inspection)
# Retry succeeds once exponential backoff elapses
_wait_until(lambda: storage_handler.blob_exists(inspection), timeout=5.0)


def test_should_not_publish_when_blob_paths_are_empty(
Expand All @@ -127,8 +137,59 @@ def test_should_not_publish_when_blob_paths_are_empty(
mission,
)
uploader.upload_queue.put(message)
time.sleep(1)

assert inspection in storage_handler.stored
_wait_until(lambda: inspection in storage_handler.stored)

# Brief quiet period to confirm no MQTT publish follows the store
time.sleep(0.1)
assert len(mqtt_fake.published) == 0


def _put_inspection_with_analysis_types(
container: ApplicationContainer,
analysis_types: list[str] | None,
) -> MqttPublisherFake:
metadata = ImageMetadata(
start_time=datetime.now(),
robot_pose=Pose(
Position(0, 0, 0, Frame("asset")),
Orientation(x=0, y=0, z=0, w=1, frame=Frame("asset")),
Frame("asset"),
),
target_position=Position(0, 0, 0, Frame("asset")),
file_type="jpg",
analysis_types=analysis_types,
)
inspection = InspectionBlob(metadata=metadata, id=str(uuid4()))
mission = Mission(name="m")

uploader: Uploader = container.uploader()
mqtt_fake = MqttPublisherFake()
uploader.mqtt_publisher = mqtt_fake

message: Tuple[Inspection, Mission] = (inspection, mission)
uploader.upload_queue.put(message)
return mqtt_fake


def test_publishes_required_analysis_when_present(
container: ApplicationContainer, uploader_thread: UploaderThreadMock
) -> None:
uploader_thread.start()
mqtt_fake = _put_inspection_with_analysis_types(
container, ["anonymize", "thermal-reading"]
)
_wait_until(lambda: mqtt_fake.count() == 1)

payload = json.loads(mqtt_fake.last()["payload"])
assert payload["required_analysis"] == ["anonymize", "thermal-reading"]


def test_publishes_null_required_analysis_when_absent(
container: ApplicationContainer, uploader_thread: UploaderThreadMock
) -> None:
uploader_thread.start()
mqtt_fake = _put_inspection_with_analysis_types(container, None)
_wait_until(lambda: mqtt_fake.count() == 1)

payload = json.loads(mqtt_fake.last()["payload"])
assert payload["required_analysis"] is None
8 changes: 7 additions & 1 deletion tests/test_mocks/blob_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ class StorageFake(StorageInterface):
def __init__(self) -> None:
self.stored_inspections: List[Inspection] = []

def store(self, inspection: Inspection, mission: Mission) -> None:
def store(
self, inspection: InspectionBlob, mission: Mission
) -> StoragePaths[BlobStoragePath]:
if self.will_fail:
raise StorageException("Fake failed on purpose")
self.stored_inspections.append(inspection)
path = BlobStoragePath(
storage_account="acct", blob_container="cont", blob_name="blob"
)
return StoragePaths(data_path=path, metadata_path=path)

def blob_exists(self, inspection: Inspection) -> bool:
return inspection in self.stored_inspections
Expand Down
Loading