Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Points for Specific Rows #200

Merged
merged 16 commits into from
Oct 22, 2024
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
2 changes: 1 addition & 1 deletion field_friend/automations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from .automation_watcher import AutomationWatcher
from .battery_watcher import BatteryWatcher
from .coverage_planer import CoveragePlanner
from .field import Field, Row
from .field import Field, Row, RowSupportPoint
from .field_provider import FieldProvider
from .implements.implement import Implement
from .kpi_provider import KpiProvider
Expand Down
48 changes: 37 additions & 11 deletions field_friend/automations/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ def line_segment(self) -> rosys.geometry.LineSegment:
point2=self.points[-1].cartesian())


@dataclass(slots=True, kw_only=True)
class RowSupportPoint(GeoPoint):
row_index: int

@classmethod
def from_geopoint(cls, geopoint: GeoPoint, row_index: int) -> Self:
return cls(lat=geopoint.lat, long=geopoint.long, row_index=row_index)


class Field:
def __init__(self,
id: str, # pylint: disable=redefined-builtin
Expand All @@ -39,14 +48,17 @@ def __init__(self,
first_row_end: GeoPoint,
row_spacing: float = 0.5,
row_number: int = 10,
outline_buffer_width: float = 2) -> None:
outline_buffer_width: float = 2,
row_support_points: list[RowSupportPoint] | None = None) -> None:
self.id: str = id
self.name: str = name
self.first_row_start: GeoPoint = first_row_start
self.first_row_end: GeoPoint = first_row_end
self.row_spacing: float = row_spacing
self.row_number: int = row_number
self.outline_buffer_width: float = outline_buffer_width
self.row_support_points: list[RowSupportPoint] = row_support_points or []
self.row_support_points.sort(key=lambda sp: sp.row_index)
self.visualized: bool = False
self.rows: list[Row] = []
self.outline: list[GeoPoint] = []
Expand Down Expand Up @@ -78,32 +90,43 @@ def worked_area(self, worked_rows: int) -> float:
return worked_area

def refresh(self):
self.outline = self._generate_outline()
self.rows = self._generate_rows()
self.outline = self._generate_outline()

def _generate_rows(self) -> list[Row]:
assert self.first_row_start is not None
assert self.first_row_end is not None
ab_line_cartesian = LineString([self.first_row_start.cartesian().tuple, self.first_row_end.cartesian().tuple])
rows: list[Row] = []

last_support_point = None
last_support_point_offset = 0

for i in range(int(self.row_number)):
offset = i * self.row_spacing
support_point = next((sp for sp in self.row_support_points if sp.row_index == i), None)
if support_point:
support_point_cartesian = support_point.cartesian()
offset = ab_line_cartesian.distance(shapely.geometry.Point(
[support_point_cartesian.x, support_point_cartesian.y]))
last_support_point = support_point
last_support_point_offset = offset
else:
if last_support_point:
offset = last_support_point_offset + (i - last_support_point.row_index) * self.row_spacing
else:
offset = i * self.row_spacing
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=str(uuid4()), name=f'{i + 1}', points=row_points)
row = Row(id=f'field_{self.id}_row_{str(i + 1)}', name=f'row_{i + 1}', points=row_points)
rows.append(row)
return rows

def _generate_outline(self) -> list[GeoPoint]:
assert self.first_row_start is not None
assert self.first_row_end is not None
ab_line_cartesian = LineString([self.first_row_start.cartesian().tuple, self.first_row_end.cartesian().tuple])
last_row_linestring = offset_curve(ab_line_cartesian, - self.row_spacing * self.row_number + self.row_spacing)
end_row_points: list[Point] = [Point(x=p[0], y=p[1]) for p in last_row_linestring.coords]
assert len(self.rows) > 0
outline_unbuffered: list[Point] = []
for p in end_row_points:
outline_unbuffered.append(p)
for p in self.rows[-1].points:
outline_unbuffered.append(p.cartesian())
outline_unbuffered.append(self.first_row_end.cartesian())
outline_unbuffered.append(self.first_row_start.cartesian())
outline_polygon = Polygon([p.tuple for p in outline_unbuffered])
Expand All @@ -123,6 +146,7 @@ def to_dict(self) -> dict:
'row_spacing': self.row_spacing,
'row_number': self.row_number,
'outline_buffer_width': self.outline_buffer_width,
'row_support_points': [rosys.persistence.to_dict(sp) for sp in self.row_support_points],
}

def shapely_polygon(self) -> shapely.geometry.Polygon:
Expand All @@ -136,5 +160,7 @@ def args_from_dict(cls, data: dict[str, Any]) -> dict:
def from_dict(cls, data: dict[str, Any]) -> Self:
data['first_row_start'] = GeoPoint(lat=data['first_row_start']['lat'], long=data['first_row_start']['long'])
data['first_row_end'] = GeoPoint(lat=data['first_row_end']['lat'], long=data['first_row_end']['long'])
data['row_support_points'] = [rosys.persistence.from_dict(
RowSupportPoint, sp) for sp in data['row_support_points']] if 'row_support_points' in data else []
field_data = cls(**cls.args_from_dict(data))
return field_data
13 changes: 12 additions & 1 deletion field_friend/automations/field_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import rosys

from ..localization import Gnss
from . import Field
from . import Field, RowSupportPoint


class FieldProvider(rosys.persistence.PersistentModule):
Expand Down Expand Up @@ -62,6 +62,17 @@ def is_polygon(self, field: Field) -> bool:
except Exception:
return False

def add_row_support_point(self, field_id: str, row_support_point: RowSupportPoint) -> None:
field = self.get_field(field_id)
if not field:
return
existing_point = next((sp for sp in field.row_support_points if sp.row_index ==
row_support_point.row_index), None)
if existing_point:
field.row_support_points.remove(existing_point)
field.row_support_points.append(row_support_point)
self.invalidate()

def refresh_fields(self) -> None:
for field in self.fields:
field.refresh()
1 change: 1 addition & 0 deletions field_friend/interface/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .status_bulb import StatusBulb as status_bulb
from .status_dev import status_dev_page
from .status_drawer import status_drawer
from .support_point_dialog import SupportPointDialog
from .system_bar import system_bar
from .test import test
from .visualizer_object import visualizer_object
6 changes: 4 additions & 2 deletions field_friend/interface/components/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
from typing import TYPE_CHECKING

from nicegui import app, events, ui

from .support_point_dialog import SupportPointDialog
from .field_creator import FieldCreator
from .key_controls import KeyControls

if TYPE_CHECKING:
from field_friend.system import System

Expand All @@ -29,6 +28,9 @@ def __init__(self, system: 'System') -> None:
with ui.row().style('width:100%;'):
ui.button("Create Field" if len(self.system.field_provider.fields) < 1 else "Overwrite Field", on_click=lambda: FieldCreator(self.system)).tooltip("Build a field with AB-line in a few simple steps") \
.classes("ml-auto").style("display: block; margin-top:auto; margin-bottom: auto; width: 100%;").tooltip("Build a field with AB-line in a few simple steps. Currently only one field will be saved.")
with ui.row().style('width:100%;'):
ui.button("Add Support Point", on_click=lambda: SupportPointDialog(self.system)).tooltip(
"Add a support point for a row").classes("w-full")
self.navigation_settings = ui.row().classes('items-center')
with ui.expansion('Implement').classes('w-full').bind_value(app.storage.user, 'show_implement_settings'):
self.implement_settings = ui.row().classes('items-center')
Expand Down
89 changes: 89 additions & 0 deletions field_friend/interface/components/support_point_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from typing import TYPE_CHECKING, Callable
from uuid import uuid4

import rosys
import shapely
from nicegui import ui

from field_friend.automations.field import RowSupportPoint
from field_friend.interface.components.monitoring import CameraPosition
from field_friend.localization import GeoPoint

if TYPE_CHECKING:
from field_friend.system import System


class SupportPointDialog:

def __init__(self, system: 'System'):
self.front_cam = next((value for key, value in system.mjpeg_camera_provider.cameras.items()
if CameraPosition.FRONT in key), None) if hasattr(system, 'mjpeg_camera_provider') else None
self.steerer = system.steerer
self.gnss = system.gnss
self.field_provider = system.field_provider
self.row_name: int = 1
self.support_point_coordinates: GeoPoint | None = None
self.next: Callable = self.find_support_point

with ui.dialog() as self.dialog, ui.card().style('width: 900px; max-width: none'):
with ui.row().classes('w-full no-wrap no-gap'):
self.row_sight = ui.interactive_image().classes('w-3/5')
with ui.column().classes('items-center w-2/5 p-8'):
self.headline = ui.label().classes('text-lg font-bold')
self.content = ui.column().classes('items-center')
# NOTE: the next function is replaced, hence we need the lambda
ui.button('Next', on_click=lambda: self.next())
ui.timer(0.1, self.update_front_cam)
self.open()

def open(self) -> None:
self.next()
self.dialog.open()

def find_support_point(self) -> None:
self.headline.text = 'Drive to Row'
self.row_sight.content = '<line x1="50%" y1="0" x2="50%" y2="100%" stroke="#6E93D6" stroke-width="6"/>'
with self.content:
rosys.driving.joystick(self.steerer, size=50, color='#6E93D6')
ui.label('1. Drive the robot on the row you want to give a fixed support point.').classes(
'text-lg')
ui.label('2. Enter the row number for the support point:').classes('text-lg')
ui.number(
label='Row Number', min=1, max=self.field_provider.fields[0].row_number if self.field_provider.fields else 1, step=1, value=1) \
.props('dense outlined').classes('w-40') \
.tooltip('Choose the row number you would like to give a fixed support point to.') \
.bind_value(self, 'row_name')
self.next = self.confirm_support_point

def confirm_support_point(self) -> None:
assert self.gnss.current is not None
if not ("R" in self.gnss.current.mode or self.gnss.current.mode == "SSSS"):
with self.content:
ui.label('No RTK fix available.').classes('text-red')
self.support_point_coordinates = self.gnss.current.location
self.headline.text = 'Confirm Values'
self.content.clear()
with self.content:
with ui.row().classes('items-center'):
ui.label(f'Support Point Coordinates: {self.support_point_coordinates}').classes('text-lg')
ui.label(f'Row Number: {round(self.row_name)}').classes('text-lg')
with ui.row().classes('items-center'):
ui.button('Cancel', on_click=self.dialog.close).props('color=red')
self.next = self._apply

def _apply(self) -> None:
self.dialog.close()
if self.support_point_coordinates is None:
ui.notify('No valid point coordinates.')
return
row_index = self.row_name - 1
field = self.field_provider.fields[0]
row_support_point = RowSupportPoint.from_geopoint(self.support_point_coordinates, row_index)
self.field_provider.add_row_support_point(field.id, row_support_point)
ui.notify('Support point added.')

def update_front_cam(self) -> None:
if self.front_cam is None:
return
self.front_cam.streaming = True
self.row_sight.set_source(self.front_cam.get_latest_image_url())
58 changes: 53 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@

from field_friend.automations import Field, RowSupportPoint
import logging
from typing import AsyncGenerator, Generator
from uuid import uuid4
Expand All @@ -9,7 +10,7 @@
from rosys.testing import forward, helpers

from field_friend import localization
from field_friend.automations import Field
from field_friend.automations import Field, Row
from field_friend.interface.components.field_creator import FieldCreator
from field_friend.localization import GeoPoint, GnssSimulation
from field_friend.system import System
Expand Down Expand Up @@ -46,11 +47,58 @@ def gnss(system: System) -> GnssSimulation:
return system.gnss


class TestField():
def __init__(self):
self.id = "test_field_id"
self.name = "Test Field"
self.first_row_start = FIELD_FIRST_ROW_START
self.first_row_end = FIELD_FIRST_ROW_END
self.row_spacing = 0.45
self.row_number = 4
self.outline_buffer_width = 2
self.row_support_points = []
self.rows = [
Row(id=f"field_{self.id}_row_1", name="row_1", points=[
self.first_row_start,
self.first_row_end
]),
Row(id=f"field_{self.id}_row_2", name="row_2", points=[
self.first_row_start.shifted(Point(x=0, y=-0.45)),
self.first_row_end.shifted(Point(x=0, y=-0.45))
]),
Row(id=f"field_{self.id}_row_3", name="row_3", points=[
self.first_row_start.shifted(Point(x=0, y=-0.9)),
self.first_row_end.shifted(Point(x=0, y=-0.9))
]),
Row(id=f"field_{self.id}_row_4", name="row_4", points=[
self.first_row_start.shifted(Point(x=0, y=-1.35)),
self.first_row_end.shifted(Point(x=0, y=-1.35))
])
]
self.outline = [
self.first_row_start.shifted(Point(x=-self.outline_buffer_width, y=self.outline_buffer_width)),
self.first_row_end.shifted(Point(x=self.outline_buffer_width, y=self.outline_buffer_width)),
self.first_row_end.shifted(Point(x=self.outline_buffer_width, y=-
self.outline_buffer_width - (self.row_number - 1) * self.row_spacing)),
self.first_row_start.shifted(Point(x=-self.outline_buffer_width, y=-
self.outline_buffer_width - (self.row_number - 1) * self.row_spacing)),
self.first_row_start.shifted(Point(x=-self.outline_buffer_width, y=self.outline_buffer_width))
]


@pytest.fixture
async def field(system: System) -> AsyncGenerator[Field, None]:
f = system.field_provider.create_field(Field(id=str(uuid4()), name='Field 1', first_row_start=FIELD_FIRST_ROW_START,
first_row_end=FIELD_FIRST_ROW_END, row_spacing=0.45, row_number=10))
yield f
async def field(system: System) -> AsyncGenerator[TestField, None]:
test_field = TestField()
system.field_provider.create_field(Field(
id=test_field.id,
name="Test Field",
first_row_start=test_field.first_row_start,
first_row_end=test_field.first_row_end,
row_spacing=test_field.row_spacing,
row_number=test_field.row_number,
row_support_points=[]
))
yield test_field


@pytest.fixture
Expand Down
3 changes: 2 additions & 1 deletion tests/old_field_provider_persistence.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"first_row_end": { "lat": 51.98334192260392, "long": 7.434293309874038 },
"row_spacing": 0.5,
"row_number": 10,
"outline_buffer_width": 2
"outline_buffer_width": 2,
"row_support_points": []
}
}
}
22 changes: 21 additions & 1 deletion tests/test_field_creator.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import pytest
from conftest import FIELD_FIRST_ROW_END, FIELD_FIRST_ROW_START
from rosys.geometry import Point

from field_friend import System
from field_friend import System, localization
from field_friend.automations.field import Field
from field_friend.interface.components.field_creator import FieldCreator
from field_friend.interface.components.support_point_dialog import SupportPointDialog


def test_field_creation(system: System, field_creator: FieldCreator):
Expand All @@ -22,3 +26,19 @@ def test_field_creation_wrong_row_spacing(system: System, field_creator: FieldCr
field_creator.next()
assert len(system.field_provider.fields) == 1
assert len(system.field_provider.fields[0].rows) == 10, 'the row should still be there even if the spacing is wrong'


def test_support_point_dialog(system: System, field: Field):
dialog = SupportPointDialog(system)
row_index = 2
dialog.row_name = row_index+1
test_location = FIELD_FIRST_ROW_START.shifted(point=Point(x=0, y=-1.5))
system.gnss.current.location = test_location
dialog.next()
dialog.next()
assert system.field_provider.fields[0].row_support_points[0].cartesian(
).x - FIELD_FIRST_ROW_START.cartesian().x == pytest.approx(0, abs=0.001)
assert system.field_provider.fields[0].row_support_points[0].cartesian(
).y - FIELD_FIRST_ROW_START.cartesian().y == pytest.approx(-1.5, abs=0.001)
assert system.field_provider.fields[0].row_support_points[0].row_index == row_index
assert dialog.support_point_coordinates == test_location
Loading
Loading