Skip to content

Commit

Permalink
Crop-Type for Beds Handling and Selected Bed Fixing (#230)
Browse files Browse the repository at this point in the history
This PR adds the functionality to add a crop-type for a field or its
individual beds. Also, it adapts the field_navigation along with these
crop types.
Furthermore, the ability to deselect beds for navigation (added with PR
#219) needs to be fixed, due to small issues. Along with this the
field_provider will be cleaned-up, due to triggering events multiple
times at some points.

- [x] adding crops to field and row
- [x] add crop-setup (changing) to field_creator, operation and
field_provider
- [x] fixing selected beds
- [x] cleaning field_provider
- [x] write tests
- [x] test on a robot

---------

Co-authored-by: Pascal Schade <[email protected]>
  • Loading branch information
LukasBaecker and pascalzauberzeug authored Dec 5, 2024
1 parent 189327f commit 8eac021
Show file tree
Hide file tree
Showing 15 changed files with 488 additions and 122 deletions.
2 changes: 1 addition & 1 deletion field_friend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from .system import System

__all__ = [
'System',
'interface',
'log_configuration',
'System',
]
2 changes: 1 addition & 1 deletion field_friend/automations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
'KpiProvider',
'Path',
'PathProvider',
'Plant',
'PlantLocator',
'PlantProvider',
'Plant',
'Puncher',
'Row',
'RowSupportPoint',
Expand Down
17 changes: 12 additions & 5 deletions field_friend/automations/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@
@dataclass(slots=True, kw_only=True)
class Row(GeoPointCollection):
reverse: bool = False
crop: str | None = None

def reversed(self):
return Row(
id=self.id,
name=self.name,
points=list(reversed(self.points)),
crop=self.crop
)

def line_segment(self) -> rosys.geometry.LineSegment:
Expand Down Expand Up @@ -49,7 +51,8 @@ def __init__(self, *,
outline_buffer_width: float = 2,
row_support_points: list[RowSupportPoint] | None = None,
bed_count: int = 1,
bed_spacing: float = 0.5) -> None:
bed_spacing: float = 0.5,
bed_crops: dict[str, str | None] | None = None) -> None:
self.id: str = id
self.name: str = name
self.first_row_start: GeoPoint = first_row_start
Expand All @@ -64,6 +67,7 @@ def __init__(self, *,
self.visualized: bool = False
self.rows: list[Row] = []
self.outline: list[GeoPoint] = []
self.bed_crops: dict[str, str | None] = bed_crops or {str(i): None for i in range(bed_count)}
self.refresh()

@property
Expand Down Expand Up @@ -135,13 +139,14 @@ def _generate_rows(self) -> list[Row]:
offset_row_coordinated = offset_curve(ab_line_cartesian, -offset).coords
row_points: list[GeoPoint] = [localization.reference.shifted(
Point(x=p[0], y=p[1])) for p in offset_row_coordinated]
row = Row(id=f'field_{self.id}_row_{i + 1!s}', name=f'row_{i + 1}', points=row_points)
row = Row(id=f'field_{self.id}_row_{i + 1!s}', name=f'row_{i + 1}',
points=row_points, crop=self.bed_crops[str(bed_index)])
rows.append(row)
return rows

def _generate_outline(self) -> list[GeoPoint]:
assert len(self.rows) > 0
return self.get_buffered_area(self.rows, self.outline_buffer_width)
return self.get_buffered_area()

def to_dict(self) -> dict:
return {
Expand All @@ -155,6 +160,7 @@ def to_dict(self) -> dict:
'row_support_points': [rosys.persistence.to_dict(sp) for sp in self.row_support_points],
'bed_count': self.bed_count,
'bed_spacing': self.bed_spacing,
'bed_crops': self.bed_crops,
}

def shapely_polygon(self) -> shapely.geometry.Polygon:
Expand All @@ -173,7 +179,8 @@ def args_from_dict(cls, data: dict[str, Any]) -> dict:
'outline_buffer_width': 1,
'row_support_points': [],
'bed_count': 1,
'bed_spacing': 1
'bed_spacing': 1,
'bed_crops': {}
}
for key in defaults:
if key in data:
Expand All @@ -194,7 +201,7 @@ def from_dict(cls, data: dict[str, Any]) -> Self:
field_data.id = str(uuid.uuid4())
return field_data

def get_buffered_area(self, rows: list[Row], buffer_width: float) -> list[GeoPoint]:
def get_buffered_area(self) -> list[GeoPoint]:
outline_unbuffered: list[Point] = [
self.first_row_end.cartesian(),
self.first_row_start.cartesian()
Expand Down
39 changes: 30 additions & 9 deletions field_friend/automations/field_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@ def __init__(self) -> None:
self.FIELD_SELECTED = rosys.event.Event()
"""A field has been selected."""

self.FIELDS_CHANGED.register(self.refresh_fields)
self.FIELD_SELECTED.register(self.clear_selected_beds)

self._only_specific_beds: bool = False
self._selected_beds: list[int] = []

Expand All @@ -37,7 +34,7 @@ def selected_beds(self, value: list[int]) -> None:
def backup(self) -> dict:
return {
'fields': {f.id: f.to_dict() for f in self.fields},
'selected_field': self.selected_field.id if self.selected_field else None,
'selected_field': self.selected_field.id if self.selected_field else None
}

def restore(self, data: dict[str, Any]) -> None:
Expand All @@ -47,17 +44,18 @@ def restore(self, data: dict[str, Any]) -> None:
self.fields.append(new_field)
selected_field_id: str | None = data.get('selected_field')
if selected_field_id:
self.selected_field = self.get_field(selected_field_id)
self.FIELD_SELECTED.emit()
self.select_field(selected_field_id)
self.refresh_fields()
self.FIELDS_CHANGED.emit()

def invalidate(self) -> None:
self.request_backup()
self.refresh_fields()
self.FIELDS_CHANGED.emit()
if self.selected_field and self.selected_field not in self.fields:
self.selected_field = None
self._only_specific_beds = False
self.selected_beds = []
self.clear_selected_beds()
self.FIELD_SELECTED.emit()

def get_field(self, id_: str | None) -> Field | None:
Expand Down Expand Up @@ -106,7 +104,9 @@ def refresh_fields(self) -> None:

def select_field(self, id_: str | None) -> None:
self.selected_field = self.get_field(id_)
self.clear_selected_beds()
self.FIELD_SELECTED.emit()
self.request_backup()

def update_field_parameters(self, *,
field_id: str,
Expand All @@ -115,7 +115,8 @@ def update_field_parameters(self, *,
row_spacing: float,
outline_buffer_width: float,
bed_count: int,
bed_spacing: float) -> None:
bed_spacing: float,
bed_crops: dict[str, str | None]) -> None:
field = self.get_field(field_id)
if not field:
self.log.warning('Field with id %s not found. Cannot update parameters.', field_id)
Expand All @@ -126,6 +127,17 @@ def update_field_parameters(self, *,
field.bed_count = bed_count
field.bed_spacing = bed_spacing
field.outline_buffer_width = outline_buffer_width
bed_crops = bed_crops.copy()
if len(bed_crops) < bed_count:
for i in range(bed_count - len(bed_crops)):
bed_crops[str(i+len(field.bed_crops))] = None
field.bed_crops = bed_crops
elif len(bed_crops) > bed_count:
for i in range(len(bed_crops) - bed_count):
bed_crops.pop(str(bed_count + i))
field.bed_crops = bed_crops
else:
field.bed_crops = bed_crops
self.log.info('Updated parameters for field %s: row number = %d, row spacing = %f',
field.name, row_count, row_spacing)
self.invalidate()
Expand All @@ -149,4 +161,13 @@ def get_rows_to_work_on(self) -> list[Row]:
for bed in self.selected_beds:
for row_index in range(self.selected_field.row_count):
row_indices.append((bed - 1) * self.selected_field.row_count + row_index)
return [row for i, row in enumerate(self.selected_field.rows) if i in row_indices]
rows_to_work_on = [row for i, row in enumerate(self.selected_field.rows) if i in row_indices]
return rows_to_work_on

def is_row_in_selected_beds(self, row_index: int) -> bool:
if not self._only_specific_beds:
return True
if self.selected_field is None:
return False
bed_index = row_index // self.selected_field.row_count + 1
return bed_index in self.selected_beds
8 changes: 4 additions & 4 deletions field_friend/automations/implements/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
from .weeding_screw import WeedingScrew

__all__ = [
'ExternalMower',
'Implement',
'WeedingImplement',
'ImplementException',
'Recorder',
'WeedingScrew',
'Tornado',
'ExternalMower',
'ImplementException',
'WeedingImplement',
'WeedingScrew',
]
8 changes: 4 additions & 4 deletions field_friend/automations/navigation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
from .straight_line_navigation import StraightLineNavigation

__all__ = [
'CrossglideDemoNavigation',
'FieldNavigation',
'FollowCropsNavigation',
'Navigation',
'WorkflowException',
'StraightLineNavigation',
'FollowCropsNavigation',
'FieldNavigation',
'CrossglideDemoNavigation',
'WorkflowException',
]
29 changes: 22 additions & 7 deletions field_friend/automations/navigation/field_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from ..field import Field, Row
from ..implements.implement import Implement
from ..implements.weeding_implement import WeedingImplement
from .straight_line_navigation import StraightLineNavigation

if TYPE_CHECKING:
Expand Down Expand Up @@ -77,12 +78,12 @@ async def prepare(self) -> bool:
if not len(row.points) >= 2:
rosys.notify(f'Row {idx} on field {self.field.name} has not enough points', 'negative')
return False
self.row_index = self.field.rows.index(self.get_nearest_row())
nearest_row = self.get_nearest_row()
if nearest_row is None:
return False
self._state = State.APPROACH_START_ROW
self.plant_provider.clear()

self.automation_watcher.start_field_watch(self.field.outline)

self.log.info(f'Activating {self.implement.name}...')
await self.implement.activate()
return True
Expand All @@ -95,14 +96,17 @@ async def finish(self) -> None:
self.automation_watcher.stop_field_watch()
await self.implement.deactivate()

def get_nearest_row(self) -> Row:
def get_nearest_row(self) -> Row | None:
assert self.field is not None
assert self.gnss.device is not None
row = min(self.field.rows, key=lambda r: r.line_segment().line.foot_point(
self.odometer.prediction.point).distance(self.odometer.prediction.point))
self.log.info(f'Nearest row is {row.name}')
self.row_index = self.field.rows.index(row)
return row
if row not in self.rows_to_work_on:
rosys.notify('Please place the robot in front of a selected bed\'s row', 'negative')
return None
self.row_index = self.rows_to_work_on.index(row)
return self.rows_to_work_on[self.row_index]

def set_start_and_end_points(self):
assert self.field is not None
Expand Down Expand Up @@ -153,6 +157,7 @@ async def _drive(self, distance: float) -> None:

async def _run_approach_start_row(self) -> State:
self.robot_in_working_area = False
rosys.notify(f'Approaching row {self.current_row.name}')
self.set_start_and_end_points()
if self.start_point is None or self.end_point is None:
return State.ERROR
Expand All @@ -177,6 +182,7 @@ async def _run_approach_start_row(self) -> State:
assert self.end_point is not None
driving_yaw = self.odometer.prediction.direction(self.end_point)
await self.turn_in_steps(driving_yaw)
self._set_cultivated_crop()
return State.FOLLOW_ROW

async def _run_change_row(self) -> State:
Expand All @@ -189,7 +195,6 @@ async def _run_change_row(self) -> State:
self.create_simulation()
else:
self.plant_provider.clear()

await self.gnss.ROBOT_POSE_LOCATED.emitted(self._max_gnss_waiting_time)
# turn towards row start
assert self.start_point is not None
Expand All @@ -202,6 +207,7 @@ async def _run_change_row(self) -> State:
assert self.end_point is not None
driving_yaw = self.odometer.prediction.direction(self.end_point)
await self.turn_in_steps(driving_yaw)
self._set_cultivated_crop()
return State.FOLLOW_ROW

async def drive_in_steps(self, target: Pose) -> None:
Expand Down Expand Up @@ -272,6 +278,15 @@ async def _run_row_completed(self) -> State:
next_state = State.FIELD_COMPLETED
return next_state

def _set_cultivated_crop(self) -> None:
if not isinstance(self.implement, WeedingImplement):
return
if self.implement.cultivated_crop == self.current_row.crop:
return
rosys.notify(f'Setting crop {self.current_row.crop} for {self.implement.name}')
self.implement.cultivated_crop = self.current_row.crop
self.implement.request_backup()

def _is_in_working_area(self, start_point: Point, end_point: Point) -> bool:
# TODO: check if in working rectangle, current just checks if between start and stop
relative_start = self.odometer.prediction.relative_point(start_point)
Expand Down
29 changes: 26 additions & 3 deletions field_friend/automations/plant_locator.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
from .plant import Plant

WEED_CATEGORY_NAME = ['coin', 'weed', 'weedy_area', ]
CROP_CATEGORY_NAME = ['coin_with_hole', 'borrietsch', 'estragon', 'feldsalat', 'garlic', 'jasione', 'kohlrabi', 'liebstoeckel', 'maize', 'minze', 'onion',
'oregano_majoran', 'pastinake', 'petersilie', 'pimpinelle', 'red_beet', 'salatkopf', 'schnittlauch', 'sugar_beet', 'thymian_bohnenkraut', 'zitronenmelisse', ]
CROP_CATEGORY_NAME: dict[str, str] = {}
MINIMUM_CROP_CONFIDENCE = 0.3
MINIMUM_WEED_CONFIDENCE = 0.3

Expand Down Expand Up @@ -39,7 +38,7 @@ def __init__(self, system: 'System') -> None:
self.autoupload: Autoupload = Autoupload.DISABLED
self.upload_images: bool = False
self.weed_category_names: list[str] = WEED_CATEGORY_NAME
self.crop_category_names: list[str] = CROP_CATEGORY_NAME
self.crop_category_names: dict[str, str] = CROP_CATEGORY_NAME
self.minimum_crop_confidence: float = MINIMUM_CROP_CONFIDENCE
self.minimum_weed_confidence: float = MINIMUM_WEED_CONFIDENCE
rosys.on_repeat(self._detect_plants, 0.01) # as fast as possible, function will sleep if necessary
Expand All @@ -50,6 +49,7 @@ def __init__(self, system: 'System') -> None:
self.teltonika_router = system.teltonika_router
self.teltonika_router.CONNECTION_CHANGED.register(self.set_upload_images)
self.teltonika_router.MOBILE_UPLOAD_PERMISSION_CHANGED.register(self.set_upload_images)
rosys.on_startup(self.get_crop_names)

def backup(self) -> dict:
self.log.info(f'backup: autoupload: {self.autoupload}')
Expand Down Expand Up @@ -216,3 +216,26 @@ def set_upload_images(self):
self.upload_images = True
else:
self.upload_images = False

async def get_crop_names(self) -> dict[str, str]:
if isinstance(self.detector, rosys.vision.DetectorSimulation):
simulated_crop_names: list[str] = ['coin_with_hole', 'borrietsch', 'estragon', 'feldsalat', 'garlic', 'jasione', 'kohlrabi', 'liebstoeckel', 'maize', 'minze', 'onion',
'oregano_majoran', 'pastinake', 'petersilie', 'pimpinelle', 'red_beet', 'salatkopf', 'schnittlauch', 'sugar_beet', 'thymian_bohnenkraut', 'zitronenmelisse', ]

CROP_CATEGORY_NAME.update({name: name.replace('_', ' ').title() for name in simulated_crop_names})
self.crop_category_names = CROP_CATEGORY_NAME
return CROP_CATEGORY_NAME
port = self.detector.port
url = f'http://localhost:{port}/about'
async with aiohttp.request('GET', url) as response:
if response.status != 200:
self.log.error(f'Could not get crop names on port {port} - status code: {response.status}')
return {}
response_text = await response.json()
crop_names: list[str] = [category['name'] for category in response_text['model_info']['categories']]
weeds = ['weed', 'weedy_area', 'coin', 'danger', 'big_weed']
for weed in weeds:
crop_names.remove(weed)
CROP_CATEGORY_NAME.update({name: name.replace('_', ' ').title() for name in crop_names})
self.crop_category_names = CROP_CATEGORY_NAME
return CROP_CATEGORY_NAME
6 changes: 3 additions & 3 deletions field_friend/interface/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@

__all__ = [
'CameraCard',
'create_development_ui',
'create_header',
'create_status_drawer',
'LeafletMap',
'Monitoring',
'Operation',
'RobotScene',
'create_development_ui',
'create_header',
'create_status_drawer',
]
Loading

0 comments on commit 8eac021

Please sign in to comment.