Skip to content

Commit

Permalink
Merge branch 'main' into follow_crop_navigation
Browse files Browse the repository at this point in the history
  • Loading branch information
LukasBaecker committed Jul 17, 2024
2 parents b2d57b0 + 40146e8 commit f2cea04
Show file tree
Hide file tree
Showing 12 changed files with 139 additions and 51 deletions.
1 change: 1 addition & 0 deletions field_friend/automations/implements/tornado.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ async def start_workflow(self) -> None:
self.system.detector.simulated_objects = [obj for obj in self.system.detector.simulated_objects
if not (inner_radius <= obj.position.projection().distance(punch_position) <= outer_radius)]
self.log.info(f'simulated_objects2: {len(self.system.detector.simulated_objects)}')
return True
except PuncherException:
self.log.error('Error in Tornado Workflow')
except Exception as e:
Expand Down
4 changes: 2 additions & 2 deletions field_friend/automations/implements/weeding_screw.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ async def start_workflow(self) -> None:
self.system.detector.simulated_objects = [
obj for obj in self.system.detector.simulated_objects
if obj.position.projection().distance(punch_position) > self.system.field_friend.DRILL_RADIUS]
# NOTE no weeds to work on at this position -> advance robot
return True # NOTE no weeds to work on at this position -> advance robot
except Exception as e:
raise ImplementException(f'Error in Weed Screw Workflow: {e}') from e

Expand All @@ -49,7 +49,7 @@ async def get_stretch(self, max_distance: float) -> float:
if not weeds_in_range:
self.log.info('No weeds in range')
return self.WORKING_DISTANCE
# self.log.info(f'Found {len(weeds_in_range)} weeds in range: {weeds_in_range}')
self.log.info(f'Found {len(weeds_in_range)} weeds in range: {weeds_in_range}')
for next_weed_id, next_weed_position in weeds_in_range.items():
# next_weed_position.x += 0.01 # NOTE somehow this helps to mitigate an offset we experienced in the tests
weed_world_position = self.system.odometer.prediction.transform(next_weed_position)
Expand Down
26 changes: 9 additions & 17 deletions field_friend/automations/navigation/follow_crops_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,14 @@ async def _drive(self, distance: float):
if np.abs(yaw - self.odometer.prediction.yaw) > math.pi / 2:
yaw = yaw + math.pi

# Calculate a point 0.3 meters in front of the robot along the line
x_front = self.odometer.prediction.point.x + 0.3 * np.cos(yaw)
y_front = m * x_front + c
point_front = np.array([x_front, y_front])

# Calculate the desired yaw angle from the robot's current position to the front point
delta_x = point_front[0] - self.odometer.prediction.point.x
delta_y = point_front[1] - self.odometer.prediction.point.y
yaw_of_row = np.arctan2(delta_y, delta_x)
fitted_line = rosys.geometry.Line(a=m, b=-1, c=c)
closest_point = fitted_line.foot_point(self.odometer.prediction.point)
front_point = closest_point.polar(0.3, yaw)
target_yaw = self.odometer.prediction.point.direction(front_point)
else:
yaw_of_row = self.odometer.prediction.yaw
target_yaw = self.combine_angles(yaw_of_row, self.crop_attraction, self.odometer.prediction.yaw)
# self.log.info(f'following crops with target yaw {target_yaw}')
target = self.odometer.prediction.point.polar(distance, target_yaw)
# self.log.info(f'Current world position: {self.odometer.prediction} Target next crop at {target}')
with self.driver.parameters.set(linear_speed_limit=0.125, angular_speed_limit=0.1):
await self.driver.drive_to(target)
target_yaw = self.odometer.prediction.yaw
target_yaw = self.combine_angles(target_yaw, self.crop_attraction, self.odometer.prediction.yaw)
await self._drive_to_yaw(distance, target_yaw)

def combine_angles(self, angle1: float, influence: float, angle2: float) -> float:
weight1 = influence
Expand All @@ -84,7 +75,8 @@ def combine_angles(self, angle1: float, influence: float, angle2: float) -> floa
def create_simulation(self):
for i in range(100):
x = i/10.0
p = rosys.geometry.Point3d(x=x, y=np.sin(x/2), z=0)
p = rosys.geometry.Point3d(x=x, y=(x/4) ** 3, z=0)
p = self.odometer.prediction.transform3d(p)
self.detector.simulated_objects.append(rosys.vision.SimulatedObject(category_name='maize', position=p))

def _should_finish(self) -> bool:
Expand Down
23 changes: 18 additions & 5 deletions field_friend/automations/navigation/navigation.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import abc
import asyncio
import logging
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -31,6 +32,8 @@ def __init__(self, system: 'System', implement: Implement) -> None:
self.detector = system.detector
self.name = 'Unknown'
self.start_position = self.odometer.prediction.point
self.linear_speed_limit = 0.125
self.angular_speed_limit = 0.1

async def start(self) -> None:
try:
Expand Down Expand Up @@ -75,11 +78,21 @@ async def finish(self) -> None:

@abc.abstractmethod
async def _drive(self, distance: float) -> None:
"""Drives the vehicle forward
This should only advance the robot by a small distance, e.g. 2 cm
to allow for adjustments and observations.
"""
"""Drives the vehicle a short distance forward"""

async def _drive_to_yaw(self, distance: float, yaw: float):
deadline = rosys.time() + 2
start_position = self.odometer.prediction.point
yaw -= self.odometer.prediction.yaw # take current yaw into account and only steer the difference
with self.driver.parameters.set(linear_speed_limit=self.linear_speed_limit, angular_speed_limit=self.angular_speed_limit):
await self.driver.wheels.drive(*self.driver._throttle(1, yaw)) # pylint: disable=protected-access
try:
while self.odometer.prediction.point.distance(start_position) < distance:
if rosys.time() >= deadline:
raise TimeoutError('Driving Timeout')
await rosys.sleep(0.01)
finally:
await self.driver.wheels.stop()

@abc.abstractmethod
def _should_finish(self) -> bool:
Expand Down
20 changes: 12 additions & 8 deletions field_friend/automations/navigation/straight_line_navigation.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
from random import randint
from typing import TYPE_CHECKING, Any

Expand All @@ -20,12 +21,14 @@ def __init__(self, system: 'System', tool: Implement) -> None:
self.detector = system.detector
self.length = 2.0
self.name = 'Straight Line'
self.linear_speed_limit = 0.125
self.angular_speed_limit = 0.1
self.origin: rosys.geometry.Point
self.target: rosys.geometry.Point

async def prepare(self) -> bool:
await super().prepare()
self.log.info(f'Activating {self.implement.name}...')
self.origin = self.odometer.prediction.point
self.target = self.odometer.prediction.transform(rosys.geometry.Point(x=self.length, y=0))
await self.implement.activate()
return True

Expand All @@ -34,14 +37,15 @@ async def finish(self) -> None:
await self.implement.deactivate()

async def _drive(self, distance: float):
target = self.odometer.prediction.transform(rosys.geometry.Point(x=distance, y=0))
# self.log.info(f'driving to {target}')
with self.driver.parameters.set(linear_speed_limit=self.linear_speed_limit, angular_speed_limit=self.angular_speed_limit):
await self.driver.drive_to(target)
start_position = self.odometer.prediction.point
closest_point = rosys.geometry.Line.from_points(self.origin, self.target).foot_point(start_position)
local_target = rosys.geometry.Pose(x=closest_point.x, y=closest_point.y, yaw=start_position.direction(self.target), time=0) \
.transform(rosys.geometry.Point(x=1, y=0))
await self._drive_to_yaw(distance, start_position.direction(local_target))

def _should_finish(self):
distance = self.odometer.prediction.point.distance(self.start_position)
return abs(distance - self.length) < 0.05
end_pose = rosys.geometry.Pose(x=self.target.x, y=self.target.y, yaw=self.origin.direction(self.target), time=0)
return end_pose.relative_point(self.odometer.prediction.point).x > 0

def create_simulation(self):
crop_distance = 0.2
Expand Down
2 changes: 1 addition & 1 deletion field_friend/hardware/field_friend_simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def __init__(self, robot_id) -> None:
self.CHOP_RADIUS = config_params['chop_radius']
else:
raise NotImplementedError(f'Unknown FieldFriend tool: {tool}')
wheels = rosys.hardware.WheelsSimulation()
wheels = rosys.hardware.WheelsSimulation(self.WHEEL_DISTANCE)

y_axis: YAxisSimulation | ChainAxisSimulation | None
if config_hardware['y_axis']['version'] == 'chain_axis':
Expand Down
5 changes: 3 additions & 2 deletions field_friend/localization/gnss_simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@

class GnssSimulation(Gnss):

def __init__(self, odometer: rosys.driving.Odometer) -> None:
def __init__(self, odometer: rosys.driving.Odometer, wheels: rosys.hardware.WheelsSimulation) -> None:
super().__init__(odometer, 0.0)
self.wheels = wheels
self.allow_connection = True
self.gps_quality = 4

Expand All @@ -18,7 +19,7 @@ async def try_connection(self) -> None:
self.device = 'simulation'

async def _create_new_record(self) -> Optional[GNSSRecord]:
pose = self.odometer.prediction
pose = self.wheels.pose
reference = self.reference if self.reference else GeoPoint(lat=51.983159, long=7.434212)
new_position = reference.shifted(pose.point)
record = GNSSRecord(timestamp=pose.time, location=new_position)
Expand Down
8 changes: 6 additions & 2 deletions field_friend/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ def __init__(self) -> None:
assert isinstance(self.field_friend, FieldFriendHardware)
self.gnss = GnssHardware(self.odometer, self.field_friend.ANTENNA_OFFSET)
else:
self.gnss = GnssSimulation(self.odometer)
assert isinstance(self.field_friend.wheels, rosys.hardware.WheelsSimulation)
self.gnss = GnssSimulation(self.odometer, self.field_friend.wheels)
self.gnss.ROBOT_POSE_LOCATED.register(self.odometer.handle_detection)
self.driver = rosys.driving.Driver(self.field_friend.wheels, self.odometer)
self.driver.parameters.linear_speed_limit = 0.3
Expand Down Expand Up @@ -243,5 +244,8 @@ def get_jetson_cpu_temperature(self):
return float(temp) / 1000.0 # Convert from milli °C to °C

def log_status(self):
msg = f'cpu: {psutil.cpu_percent():.0f}% mem: {psutil.virtual_memory().percent:.0f}% temp: {self.get_jetson_cpu_temperature():.1f}°C'
msg = f'cpu: {psutil.cpu_percent():.0f}% '
msg += f'mem: {psutil.virtual_memory().percent:.0f}% '
msg += f'temp: {self.get_jetson_cpu_temperature():.1f}°C '
msg += f'battery: {self.field_friend.bms.state.short_string}'
self.log.info(msg)
3 changes: 3 additions & 0 deletions field_friend/vision/simulated_cam_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,6 @@ def add_cameras(self, num_cameras: int) -> None:
new_id = f'cam{len(self._cameras)}'
print(f'adding simulated camera: {new_id}')
self.add_camera(SimulatedCam(id=new_id, width=640, height=480))

async def update_device_list(self) -> None:
return None
3 changes: 2 additions & 1 deletion tests/test_field_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@


def test_loading_from_old_persistence():
field_provider = FieldProvider(GnssSimulation(rosys.driving.Odometer(rosys.hardware.WheelsSimulation())))
wheels = rosys.hardware.WheelsSimulation()
field_provider = FieldProvider(GnssSimulation(rosys.driving.Odometer(wheels), wheels))
field_provider.restore(json.loads(Path('tests/old_field_provider_persistence.json').read_text()))
assert len(field_provider.fields) == 3
field = field_provider.fields[1]
Expand Down
3 changes: 2 additions & 1 deletion tests/test_gnss.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

import pytest
from copy import deepcopy

import pytest
import rosys
from conftest import ROBOT_GEO_START_POSITION
from rosys.testing import assert_point, forward
Expand Down Expand Up @@ -76,6 +76,7 @@ async def empty():
assert_point(gnss_driving.odometer.prediction.point, rosys.geometry.Point(x=2, y=0))


@pytest.mark.skip('does not work anymore due to gps using wheels.pose instead of odometry.pose')
async def test_changing_reference(gnss_driving: System):
assert gnss_driving.gnss.current is not None
assert gnss_driving.gnss.reference is not None
Expand Down
92 changes: 80 additions & 12 deletions tests/test_navigation.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import random

import numpy as np
import pytest
from conftest import ROBOT_GEO_START_POSITION

import rosys
from field_friend import System
from field_friend.automations import Field
from field_friend.automations.implements import Recorder
from field_friend.automations.implements import Implement, Recorder
from field_friend.automations.navigation import StraightLineNavigation
from rosys.testing import forward

Expand All @@ -15,9 +17,8 @@ async def test_straight_line(system: System):
assert isinstance(system.current_navigation, StraightLineNavigation)
assert isinstance(system.current_navigation.implement, Recorder)
system.automator.start()
await forward(2)
assert system.automator.is_running
await forward(55)
await forward(until=lambda: system.automator.is_running)
await forward(until=lambda: system.automator.is_stopped)
assert not system.automator.is_running, 'automation should stop after default length'
assert system.odometer.prediction.point.x == pytest.approx(system.straight_line_navigation.length, abs=0.1)

Expand Down Expand Up @@ -52,21 +53,68 @@ async def test_follow_crops_straight(system: System, detector: rosys.vision.Dete
assert system.odometer.prediction.yaw_deg == pytest.approx(0, abs=1.0)


async def test_driving_to_exact_positions(system: System):
class Stopper(Implement):
def __init__(self, system: System) -> None:
super().__init__('Stopper')
self.system = system
self.current_stretch = 0.0
self.workflow_started = False

async def get_stretch(self, max_distance: float) -> float:
self.current_stretch = random.uniform(0.02, max_distance)
return self.current_stretch

async def start_workflow(self) -> bool:
self.workflow_started = True
deadline = rosys.time() + 1
while self.workflow_started and rosys.time() < deadline:
await rosys.sleep(0.1)
self.workflow_started = False
return True

system.current_implement = stopper = Stopper(system)
assert isinstance(system.current_navigation, StraightLineNavigation)
system.current_navigation.linear_speed_limit = 0.05 # drive really slow so we can archive the accuracy tested below
system.automator.start()

await forward(until=lambda: system.automator.is_running, dt=0.01)
for _ in range(20):
old_position = system.odometer.prediction.point
await forward(until=lambda: stopper.workflow_started and system.automator.is_running, dt=0.01)
distance = old_position.distance(system.odometer.prediction.point)
assert distance == pytest.approx(stopper.current_stretch, abs=0.001)
stopper.workflow_started = False
await forward(0.1) # give robot time to update position


async def test_driving_straight_line_with_slippage(system: System):
assert isinstance(system.field_friend.wheels, rosys.hardware.WheelsSimulation)
assert isinstance(system.current_navigation, StraightLineNavigation)
system.current_navigation.length = 1.0
system.field_friend.wheels.slip_factor_right = 0.05
system.automator.start()
await forward(until=lambda: system.automator.is_running)
await forward(until=lambda: system.automator.is_stopped)
assert system.odometer.prediction.point.x == pytest.approx(1.0, abs=0.1)
assert system.odometer.prediction.point.y == pytest.approx(0.0, abs=0.1)


async def test_follow_crops_sinus(system: System, detector: rosys.vision.DetectorSimulation):
for i in range(10):
x = i/10.0
p = rosys.geometry.Point3d(x=x, y=np.sin(x/2), z=0)
p = rosys.geometry.Point3d(x=x, y=(x/2) ** 3, z=0)
p = system.odometer.prediction.transform3d(p)
detector.simulated_objects.append(rosys.vision.SimulatedObject(category_name='maize', position=p))
system.current_navigation = system.follow_crops_navigation
assert isinstance(system.current_navigation.implement, Recorder)
system.automator.start()
await forward(2)
assert system.automator.is_running
await forward(until=lambda: not system.automator.is_running, timeout=300)
assert isinstance(system.current_navigation.implement, Recorder)
await forward(until=lambda: system.automator.is_running)
await forward(until=lambda: system.automator.is_stopped)
assert not system.automator.is_running, 'automation should stop if no crops are detected anymore'
assert system.odometer.prediction.point.x == pytest.approx(1.4, abs=0.1)
assert system.odometer.prediction.point.y == pytest.approx(0.6, abs=0.1)
assert system.odometer.prediction.yaw_deg == pytest.approx(25.0, abs=5.0)
assert system.odometer.prediction.point.x == pytest.approx(2.2, abs=0.1)
assert system.odometer.prediction.point.y == pytest.approx(0.45, abs=0.1)
assert system.odometer.prediction.yaw_deg == pytest.approx(40.0, abs=5.0)


async def test_follow_crops_no_direction(system: System, detector: rosys.vision.DetectorSimulation):
Expand Down Expand Up @@ -151,6 +199,25 @@ async def test_follow_crops_outlier_last(system: System, detector: rosys.vision.
assert 0 >= system.odometer.prediction.yaw_deg >= -45


async def test_follow_crops_with_slippage(system: System, detector: rosys.vision.DetectorSimulation):
for i in range(20):
x = i/10.0
p = rosys.geometry.Point3d(x=x, y=(x/3) ** 3, z=0)
p = system.odometer.prediction.transform3d(p)
detector.simulated_objects.append(rosys.vision.SimulatedObject(category_name='maize', position=p))
print(p)
assert isinstance(system.field_friend.wheels, rosys.hardware.WheelsSimulation)
system.current_navigation = system.follow_crops_navigation
system.field_friend.wheels.slip_factor_right = 0.05
system.automator.start()
await forward(until=lambda: system.automator.is_running)
await forward(until=lambda: system.automator.is_stopped)
assert system.odometer.prediction.point.x == pytest.approx(2.3, abs=0.1)
assert system.odometer.prediction.point.y == pytest.approx(0, abs=0.1)
assert system.odometer.prediction.yaw_deg == pytest.approx(25.0, abs=2.0)


@pytest.mark.skip('does not work anymore due to gps using wheels.pose instead of odometry.pose')
async def test_approaching_first_row(system: System, field: Field):
system.field_navigation.field = field
system.current_navigation = system.field_navigation
Expand Down Expand Up @@ -184,6 +251,7 @@ async def drive_away():
assert not system.automator.is_running, 'should have been stopped because robot is outside of field boundaries'


@pytest.mark.skip('does not work anymore due to gps using wheels.pose instead of odometry.pose')
async def test_resuming_field_navigation_after_automation_stop(system: System, field: Field):
system.field_navigation.field = field
system.current_navigation = system.field_navigation
Expand Down

0 comments on commit f2cea04

Please sign in to comment.