Skip to content

Commit

Permalink
Merge pull request #115 from zauberzeug/punch-dialog
Browse files Browse the repository at this point in the history
Fix punch dialog for the refactorings which have been done on #102
  • Loading branch information
rodja committed Jul 11, 2024
2 parents d0cea3a + 3e01030 commit b24ca73
Show file tree
Hide file tree
Showing 8 changed files with 59 additions and 71 deletions.
2 changes: 1 addition & 1 deletion field_friend/automations/implements/implement.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ async def deactivate(self):
"""Deactivate the implement (for example to stop weeding at the row's end)"""
self.is_active = False

def get_stretch(self) -> float:
async def get_stretch(self, max_distance: float) -> float:
"""Return the stretch which the implement thinks is safe to drive forward."""
return 0.02

Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
from typing import Optional
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional

import rosys
from nicegui import ui
from rosys.automation import Automator, automation_controls
from rosys.driving import Odometer
from rosys.geometry import Point3d
from rosys.vision import Image
from rosys.automation import Automator, automation_controls

from ...automations import PlantLocator
from ...automations.plant import Plant
from ...vision import CalibratableUsbCameraProvider, SimulatedCamProvider
from ..plant import Plant

if TYPE_CHECKING:
from field_friend.system import System

from ...automations import PlantLocator


class PunchDialog(ui.dialog):
def __init__(self, system: 'System', shrink_factor: int = 1, timeout: float = 20.0, ui_update_rate: float = 0.2) -> None:
super().__init__()
self.camera_provider: CalibratableUsbCameraProvider | SimulatedCamProvider = system.usb_camera_provider
self.plant_locator: PlantLocator = system.plant_locator
self.plant_locator: 'PlantLocator' = system.plant_locator
self.odometer: Odometer = system.odometer
self.automator: Automator = system.automator
self.shrink_factor: int = shrink_factor
Expand Down Expand Up @@ -57,6 +57,8 @@ def open(self) -> None:
assert self.target_plant is not None
assert self.camera is not None
detection_image = self.camera.latest_detected_image if self.target_plant.detection_image is None else self.target_plant.detection_image
assert detection_image is not None
assert self.static_image_view is not None
self.update_content(self.static_image_view, detection_image, draw_target=True)
self.timer.activate()
super().open()
Expand All @@ -68,16 +70,8 @@ def close(self) -> None:
def setup_camera(self) -> None:
cameras = list(self.camera_provider.cameras.values())
active_camera = next((camera for camera in cameras if camera.is_connected), None)
if not active_camera:
if self.camera:
self.camera = None
self.camera_card.clear()
with self.camera_card:
ui.label('no camera available').classes('text-center')
ui.image('assets/field_friend.webp').classes('w-full')
return
if self.camera is None or self.camera != active_camera:
self.camera = active_camera
assert active_camera is not None
self.camera = active_camera

def update_content(self, image_view: ui.interactive_image, image: Image, draw_target: bool = False) -> None:
self._duration_left -= self.ui_update_rate
Expand All @@ -90,15 +84,21 @@ def update_content(self, image_view: ui.interactive_image, image: Image, draw_ta
if image and image.detections:
target_point = None
confidence = None
assert self.camera.calibration is not None
if self.target_plant and draw_target:
confidence = self.target_plant.confidence
relative_point = self.odometer.prediction.relative_point(self.target_plant.position)
# TODO simplify this when https://github.com/zauberzeug/rosys/discussions/130 is available and integrated into the field friend code
if isinstance(self.camera_provider, SimulatedCamProvider):
point = self.target_plant.position
else:
point = self.odometer.prediction.relative_point(self.target_plant.position)
target_point = self.camera.calibration.project_to_image(
Point3d(x=relative_point.x, y=relative_point.y, z=0))
Point3d(x=point.x, y=point.y, z=0))
image_view.set_content(self.to_svg(image.detections, target_point, confidence))

def update_live_view(self) -> None:
if self.camera is None:
self.live_image_view.set_source('assets/field_friend.webp')
return
self.update_content(self.live_image_view, self.camera.latest_detected_image)

Expand Down
14 changes: 7 additions & 7 deletions field_friend/automations/implements/tornado.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,7 @@ async def start_workflow(self) -> bool:
open_drill = False
if self.drill_with_open_tornado:
open_drill = True
await self.system.puncher.punch(y=self.next_punch_y_position,
angle=self.tornado_angle,
with_open_tornado=open_drill,
with_punch_check=self.with_punch_check)
await self.system.puncher.punch(y=self.next_punch_y_position, angle=self.tornado_angle, with_open_tornado=open_drill)
# TODO remove weeds from plant_provider and increment kpis (like in Weeding Screw)
if isinstance(self.system.detector, rosys.vision.DetectorSimulation):
# remove the simulated weeds
Expand All @@ -55,7 +52,8 @@ async def start_workflow(self) -> bool:
except Exception as e:
raise ImplementException('Error while tornado Workflow') from e

def get_stretch(self) -> float:
async def get_stretch(self, max_distance: float) -> float:
await super().get_stretch(max_distance)
super()._has_plants_to_handle()
if len(self.crops_to_handle) == 0:
return self.WORKING_DISTANCE
Expand Down Expand Up @@ -85,8 +83,10 @@ def get_stretch(self) -> float:
stretch = 0
self.log.info(f'Targeting crop {closest_crop_id} which is {stretch} away at world: '
f'{closest_crop_world_position}, local: {closest_crop_position}')
self.next_punch_y_position = closest_crop_position.y
return stretch
if stretch < max_distance and await self.ask_for_punch(closest_crop_id):
self.next_punch_y_position = closest_crop_position.y
return stretch
return self.WORKING_DISTANCE

def _crops_in_drill_range(self, crop_id: str, crop_position: rosys.geometry.Point, angle: float) -> bool:
inner_diameter, outer_diameter = self.system.field_friend.tornado_diameters(angle)
Expand Down
19 changes: 19 additions & 0 deletions field_friend/automations/implements/weeding_implement.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import logging
from collections import deque
from typing import TYPE_CHECKING, Any, Optional
Expand All @@ -8,6 +9,7 @@

from ...hardware import ChainAxis
from .implement import Implement
from .punch_dialog import PunchDialog

if TYPE_CHECKING:
from system import System
Expand Down Expand Up @@ -48,6 +50,7 @@ def __init__(self, name: str, system: 'System', persistence_key: str = 'weeding
self.last_punches: deque[rosys.geometry.Point] = deque(maxlen=5)
self.next_punch_y_position: float = 0

self.punch_dialog: PunchDialog | None = None
rosys.on_repeat(self._update_time_and_distance, 0.1)

async def prepare(self) -> bool:
Expand Down Expand Up @@ -216,3 +219,19 @@ def settings_ui(self):
ui.checkbox('With punch check', value=True) \
.bind_value(self, 'with_punch_check') \
.tooltip('Set the weeding automation to check for punch')
self.punch_dialog = PunchDialog(self.system)

async def ask_for_punch(self, plant_id: str | None = None) -> bool:
if not self.with_punch_check or plant_id is None or self.punch_dialog is None:
return True
self.punch_dialog.target_plant = self.system.plant_provider.get_plant_by_id(plant_id)
result: str | None = None
try:
result = await asyncio.wait_for(self.punch_dialog, timeout=self.punch_dialog.timeout)
if result == 'Yes':
self.log.info('punching was allowed')
return True
except asyncio.TimeoutError:
self.punch_dialog.close()
self.log.warning('punch was not allowed')
return False
15 changes: 8 additions & 7 deletions field_friend/automations/implements/weeding_screw.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,7 @@ async def start_workflow(self) -> bool:
punch_position = self.system.odometer.prediction.transform(
rosys.geometry.Point(x=self.system.field_friend.WORK_X, y=self.next_punch_y_position))
self.last_punches.append(punch_position)
await self.system.puncher.punch(y=self.next_punch_y_position,
depth=self.weed_screw_depth,
plant_id=self.next_punch_y_position,
with_punch_check=self.with_punch_check)
await self.system.puncher.punch(y=self.next_punch_y_position, depth=self.weed_screw_depth)
punched_weeds = [weed.id for weed in self.system.plant_provider.get_relevant_weeds(self.system.odometer.prediction.point)
if weed.position.distance(punch_position) <= self.system.field_friend.DRILL_RADIUS]
for weed_id in punched_weeds:
Expand All @@ -44,7 +41,8 @@ async def start_workflow(self) -> bool:
except Exception as e:
raise ImplementException(f'Error in Weed Screw Workflow: {e}') from e

def get_stretch(self) -> float:
async def get_stretch(self, max_distance: float) -> float:
await super().get_stretch(max_distance)
super()._has_plants_to_handle()
weeds_in_range = {weed_id: position for weed_id, position in self.weeds_to_handle.items()
if self.system.field_friend.can_reach(position)}
Expand All @@ -70,8 +68,11 @@ def get_stretch(self) -> float:
stretch = 0
self.log.info(f'Targeting weed {next_weed_id} which is {stretch} away at world: '
f'{weed_world_position}, local: {next_weed_position}')
self.next_punch_y_position = next_weed_position.y
return stretch
if stretch < max_distance and await self.ask_for_punch(next_weed_id):
self.next_punch_y_position = next_weed_position.y
return stretch
else:
break
return self.WORKING_DISTANCE

def settings_ui(self):
Expand Down
8 changes: 5 additions & 3 deletions field_friend/automations/navigation/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class WorkflowException(Exception):


class Navigation(rosys.persistence.PersistentModule):
MAX_STRETCH_DISTANCE = 0.05
DEFAULT_DRIVE_DISTANCE = 0.02

def __init__(self, system: 'System', implement: Implement) -> None:
super().__init__()
Expand All @@ -42,9 +44,9 @@ async def start(self) -> None:
if isinstance(self.driver.wheels, rosys.hardware.WheelsSimulation) and not rosys.is_test:
self.create_simulation()
while not self._should_finish():
distance = self.implement.get_stretch()
if distance > 0.05: # we do not want to drive to long without observing
await self._drive(0.02)
distance = await self.implement.get_stretch(self.MAX_STRETCH_DISTANCE)
if distance > self.MAX_STRETCH_DISTANCE: # we do not want to drive to long without observing
await self._drive(self.DEFAULT_DRIVE_DISTANCE)
continue
await self._drive(distance)
await self.implement.start_workflow()
Expand Down
16 changes: 1 addition & 15 deletions field_friend/automations/puncher.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ class PuncherException(Exception):

class Puncher:
def __init__(self, field_friend: FieldFriend, driver: Driver, kpi_provider: KpiProvider) -> None:
self.POSSIBLE_PUNCH = rosys.event.Event()
'''Event that is emitted when a punch is possible.'''
self.punch_allowed: str = 'waiting'
self.field_friend = field_friend
self.driver = driver
Expand Down Expand Up @@ -64,9 +62,7 @@ async def punch(self,
depth: float = 0.01,
angle: float = 180,
turns: float = 2.0,
plant_id: Optional[str] = None,
with_open_tornado: bool = False,
with_punch_check: bool = False,
) -> None:
self.log.info(f'Punching at {y} with depth {depth}...')
rest_position = 'reference'
Expand All @@ -92,16 +88,6 @@ async def punch(self,
if not self.field_friend.y_axis.min_position <= y <= self.field_friend.y_axis.max_position:
rosys.notify('y position out of range', type='negative')
raise PuncherException('y position out of range')

if with_punch_check and plant_id is not None:
self.punch_allowed = 'waiting'
self.POSSIBLE_PUNCH.emit(plant_id)
while self.punch_allowed == 'waiting':
await rosys.sleep(0.1)
if self.punch_allowed == 'not_allowed':
self.log.warning('punch was not allowed')
return
self.log.info('punching was allowed')

if isinstance(self.field_friend.z_axis, Tornado):
await self.field_friend.y_axis.move_to(y)
Expand All @@ -119,7 +105,7 @@ async def punch(self,
self.log.info(f'punched at {y:.2f} with depth {depth}, now back to rest position "{rest_position}"')
self.kpi_provider.increment_weeding_kpi('punches')
except Exception as e:
raise PuncherException(f'punching failed because: {e}') from e
raise PuncherException('punching failed') from e
finally:
await self.field_friend.y_axis.stop()
await self.field_friend.z_axis.stop()
Expand Down
20 changes: 0 additions & 20 deletions field_friend/interface/components/operation.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@

import asyncio
import logging
from typing import TYPE_CHECKING

from nicegui import app, events, ui

from .field_creator import FieldCreator
from .key_controls import KeyControls
from .punch_dialog import PunchDialog

if TYPE_CHECKING:
from field_friend.system import System
Expand Down Expand Up @@ -56,22 +52,6 @@ def __init__(self, system: 'System') -> None:
.bind_value_from(self.system, 'current_implement', lambda i: i.name)
self.implement_selection.value = self.system.current_implement.name

self.system.puncher.POSSIBLE_PUNCH.register_ui(self.can_punch)
self.punch_dialog = PunchDialog(self.system)

async def can_punch(self, plant_id: str) -> None:
self.punch_dialog.target_plant = self.system.plant_provider.get_plant_by_id(plant_id)
result: str | None = None
try:
result = await asyncio.wait_for(self.punch_dialog, timeout=self.punch_dialog.timeout)
except asyncio.TimeoutError:
self.punch_dialog.close()
result = None
if result == 'Yes':
self.system.puncher.punch_allowed = 'allowed'
elif result is None or result == 'No' or result == 'Cancel':
self.system.puncher.punch_allowed = 'not_allowed'

def handle_implement_changed(self, e: events.ValueChangeEventArguments) -> None:
if self.system.current_implement.name != e.value:
self.system.current_implement = self.system.implements[e.value]
Expand Down

0 comments on commit b24ca73

Please sign in to comment.