-
-
Notifications
You must be signed in to change notification settings - Fork 69
Expand file tree
/
Copy pathFieldworkCourse.lua
More file actions
470 lines (432 loc) · 23.8 KB
/
FieldworkCourse.lua
File metadata and controls
470 lines (432 loc) · 23.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
--- A complete fieldwork course. This contains all main parts of the course in a structured form:
--- headlands, the center with blocks, each block with a set of rows.
--- The constructor FieldworkCourse() generates the course based on the parameters passed in the context.
--- FieldworkCourse:getPath() then returns a continuous Polyline covering the entire field. This is the
--- path a vehicle would follow to complete work on the field.
--- The vertices of the path contain WaypointAttributes which provide additional navigation information
--- for the vehicle.
---@class FieldworkCourse
local FieldworkCourse = CpObject()
---@param context CourseGenerator.FieldworkContext
function FieldworkCourse:init(context)
self.logger = Logger('FieldworkCourse', nil, CpDebug.DBG_COURSES)
self:_setContext(context)
self.headlandPath = Polyline()
self.circledIslands = {}
self.headlandCache = CourseGenerator.CacheMap()
self.logger:debug('### Generating headlands around the field perimeter ###')
self:generateHeadlands()
self.logger:debug('### Setting up islands ###')
self:setupAndSortIslands()
if self.context.bypassIslands then
self:routeHeadlandsAroundBigIslands()
end
if self.context.headlandFirst then
-- connect the headlands first as the center needs to start where the headlands finish
self.logger:debug('### Connecting headlands (%d) from the outside towards the inside ###', #self.headlands)
self.headlandPath = CourseGenerator.HeadlandConnector.connectHeadlandsFromOutside(self.headlands,
context.startLocation, self:_getHeadlandWorkingWidth(), self.context.turningRadius)
self:routeHeadlandsAroundSmallIslands(self.headlandPath)
self.logger:debug('### Generating up/down rows ###')
self:generateCenter()
else
-- here, make the center first as we want to start on the headlands where the center was finished
self.logger:debug('### Generating up/down rows ###')
local endOfLastRow = self:generateCenter()
self.logger:debug('### Connecting headlands (%d) from the inside towards the outside ###', #self.headlands)
self.headlandPath = CourseGenerator.HeadlandConnector.connectHeadlandsFromInside(self.headlands,
endOfLastRow, self:_getHeadlandWorkingWidth(), self.context.turningRadius)
self:routeHeadlandsAroundSmallIslands(self.headlandPath)
end
if self.context.bypassIslands then
self:bypassSmallIslandsInCenter()
self.logger:debug('### Bypassing big islands in the center: create path around them ###')
self:circleBigIslands(self:getPath())
end
end
--- Returns a continuous Polyline covering the entire field. This is the
--- path a vehicle would follow to complete work on the field.
--- The vertices of the path contain WaypointAttributes which provide additional navigation information
--- for the vehicle.
---@return Polyline
function FieldworkCourse:getPath()
if not self.path then
self.path = Polyline()
if self.context.headlandFirst then
self.path:appendMany(self:getHeadlandPath())
self.path:appendMany(self:getCenterPath())
else
self.path:appendMany(self:getCenterPath())
self.path:appendMany(self:getHeadlandPath())
end
self.path:calculateProperties()
end
return self.path
end
--- Iterates through the paths of all vehicles in the group. For compatibility with the FieldworkCourseMultiVehicle,
--- primarily for testing.
---@return number, number, Polyline[] index, position and path for each vehicle
function FieldworkCourse:pathIterator()
local position = - 1
return function()
-- we iterate exactly over one element, the only path this course has, which belongs to position 0
if position < 0 then
position = position + 1
return position, position, self:getPath()
end
end
end
--- Reverse the course, so the vehicle drives it in the opposite direction. The only changes made
--- during reversing is flipping the attributes where applicable, for instance, row ends become row
--- starts.
--- This is for cases where someone wants to drive the exact same course, for instance baling from
--- starting on the headland and finishing on the center, and then collecting the bales starting
--- from the center towards the headland.
--- Note that reverse() guarantees it is the exact same course just backwards, whereas generating
--- a course with starting in the center instead of the headland may result in slightly different path.
function FieldworkCourse:reverse()
-- make sure we have the forward path
self:getPath()
self.path:reverse()
for _, v in ipairs(self.path) do
v:getAttributes():_reverse()
end
end
---@return Polyline
function FieldworkCourse:getHeadlandPath()
return self.headlandPath
end
---@return CourseGenerator.Headland[]
function FieldworkCourse:getHeadlands()
return self.headlands
end
---@return number number of actually generated headlands (may be less than requested)
function FieldworkCourse:getNumberOfHeadlands()
return self.nHeadlands
end
---@return CourseGenerator.Center
function FieldworkCourse:getCenter()
return self.center
end
---@return Polyline
function FieldworkCourse:getCenterPath()
return self.center and self.center:getPath() or Polyline()
end
------------------------------------------------------------------------------------------------------------------------
--- Headlands
------------------------------------------------------------------------------------------------------------------------
--- Generate the headlands based on the current context
function FieldworkCourse:generateHeadlands()
self.headlands = {}
self.logger:debug('generating %d headland(s) with round corners, then %d with sharp corners',
self.nHeadlandsWithRoundCorners, self.nHeadlands - self.nHeadlandsWithRoundCorners)
if self.nHeadlandsWithRoundCorners > 0 then
self:generateHeadlandsFromInside()
if self.nHeadlands > self.nHeadlandsWithRoundCorners and #self.headlands < self.nHeadlands then
self:generateHeadlandsFromOutside(self.boundary,
self:_getHeadlandOffset(self.nHeadlandsWithRoundCorners + 1),
#self.headlands + 1, self.headlands[1] and self.headlands[1]:getPolygon())
end
elseif self.nHeadlands > 0 then
self:generateHeadlandsFromOutside(self.boundary, self:_getHeadlandOffset(1), 1, self.boundary)
end
end
--- Generate headlands around the field, starting with the outermost one.
---@param boundary Polygon field boundary or other headland to start the generation from
---@param firstHeadlandWidth number width of the outermost headland to generate, if the boundary is the field boundary,
--- it will usually be the half working width, if the boundary is another headland, the full working width
---@param startIx number index of the first headland to generate
---@param mustNotCross Polygon|nil polygon which the outermost generated headland must not cross, usually the innermost
--- headland generated with rounded corners, or the field boundary itself when there are no rounded headlands.
function FieldworkCourse:generateHeadlandsFromOutside(boundary, firstHeadlandWidth, startIx, mustNotCross)
self.logger:debug('generating %d sharp headlands from the outside, first width %.1f, start at %d, min radius %.1f',
self.nHeadlands - startIx + 1, firstHeadlandWidth, startIx, self.context.turningRadius)
-- outermost headland is offset from the field boundary by half width
self.headlands[startIx] = CourseGenerator.Headland(boundary, self.context.headlandClockwise, startIx,
firstHeadlandWidth, false, mustNotCross)
if not self.headlands[startIx]:isValid() then
self:_removeHeadland(startIx)
return
end
if self.context.sharpenCorners then
self.headlands[startIx]:sharpenCorners(self.context.turningRadius)
end
for i = startIx + 1, self.nHeadlands do
self.headlands[i] = CourseGenerator.Headland(self.headlands[i - 1]:getPolygon(), self.context.headlandClockwise, i,
self:_getHeadlandWorkingWidth(i), false, self.headlands[1]:getPolygon())
if self.headlands[i]:isValid() then
if self.context.sharpenCorners then
self.headlands[i]:sharpenCorners(self.context.turningRadius)
end
else
self:_removeHeadland(i)
break
end
end
end
--- Generate headlands around the field, starting with the innermost one. Generating from the inside
--- is needed when we needed a headland with corners rounded to the vehicle's turn radius, everything
--- outside of such a headland should be generated based on the innermost one with a rounded corner to
--- guarantee that none will have a corner sharper than the turn radius.
function FieldworkCourse:generateHeadlandsFromInside()
self.logger:debug('generating %d headlands with round corners, min radius %.1f',
self.nHeadlandsWithRoundCorners, self.context.turningRadius)
-- start with the innermost headland, try until it can fit in the field (as the required number of
-- headlands may be more than what actually fits into the field)
while self.nHeadlandsWithRoundCorners > 0 do
self.headlands[self.nHeadlandsWithRoundCorners] = CourseGenerator.Headland(self.boundary, self.context.headlandClockwise,
self.nHeadlandsWithRoundCorners, self:_getHeadlandOffset(self.nHeadlandsWithRoundCorners),
false, self.boundary)
if self.headlands[self.nHeadlandsWithRoundCorners]:isValid() then
self.headlands[self.nHeadlandsWithRoundCorners]:roundCorners(self.context.turningRadius)
break
else
self:_removeHeadland(self.nHeadlandsWithRoundCorners)
self.logger:warning('no room for innermost headland, reducing headlands to %d, rounded %d',
self.nHeadlands, self.nHeadlandsWithRoundCorners)
end
end
for i = self.nHeadlandsWithRoundCorners - 1, 1, -1 do
self.headlands[i] = CourseGenerator.Headland(self.headlands[i + 1]:getPolygon(), self.context.headlandClockwise, i,
self:_getHeadlandWorkingWidth(i + 1), true, self.boundary)
self.headlands[i]:roundCorners(self.context.turningRadius)
end
end
------------------------------------------------------------------------------------------------------------------------
--- Up/down rows
------------------------------------------------------------------------------------------------------------------------
function FieldworkCourse:generateCenter()
-- if there are no headlands, or there are, but we start working in the middle, then use the
-- designated start location, otherwise the point where the innermost headland ends.
if #self.headlands == 0 then
self.center = CourseGenerator.Center(self.context, self.boundary, nil, self.context.startLocation, self.bigIslands)
else
local innerMostHeadlandPolygon = self.headlands[#self.headlands]:getPolygon()
self.center = CourseGenerator.Center(self.context, self.boundary, self.headlands[#self.headlands],
self.context.headlandFirst and
innerMostHeadlandPolygon[#innerMostHeadlandPolygon] or
self.context.startLocation,
self.bigIslands)
end
return self.center:generate()
end
------------------------------------------------------------------------------------------------------------------------
--- Islands
------------------------------------------------------------------------------------------------------------------------
function FieldworkCourse:setupAndSortIslands()
self.bigIslands, self.smallIslands = {}, {}
if not self.context.bypassIslands then
return
end
for _, island in pairs(self.context.field:getIslands()) do
island:generateHeadlands(self.context, (self.nHeadlands > 0 and self.headlands[1]) and
self.headlands[1]:getPolygon() or self.boundary)
-- for some weird cases we may not have been able to generate island headlands, so ignore those islands
if island:getInnermostHeadland() then
if island:isTooBigToBypass(self.context.workingWidth) then
table.insert(self.bigIslands, island)
else
table.insert(self.smallIslands, island)
end
else
self.logger:warning('Could not generate headlands for island %d', island:getId())
end
end
end
function FieldworkCourse:routeHeadlandsAroundBigIslands()
self.logger:debug('### Bypassing big islands: headlands ###')
for _, headland in ipairs(self.headlands) do
headland:bypassBigIslands(self.bigIslands)
end
end
--- We do this after we have connected the individual headlands so the links between the headlands
--- are also routed around the islands.
function FieldworkCourse:routeHeadlandsAroundSmallIslands(headlandPath)
self.logger:debug('### Bypassing small islands on the headland ###')
for _, island in pairs(self.smallIslands) do
local startIx, circled = 1, false
while startIx ~= nil do
self.logger:debug('Bypassing island %d on the headland, at %d', island:getId(), startIx)
--- Remember the islands we circled already, as even if multiple tracks cross it, we only want to
--- circle once, subsequent bypasses just pick the shortest way around it.
circled, startIx = headlandPath:goAround(
island:getHeadlands()[1]:getPolygon(), startIx, not self.circledIslands[island])
self.circledIslands[island] = circled or self.circledIslands[island]
end
end
end
function FieldworkCourse:bypassSmallIslandsInCenter()
self.logger:debug('### Bypassing small islands in the center ###')
for _, island in pairs(self.smallIslands) do
self.logger:debug('Bypassing small island %d on the center', island:getId())
self.center:bypassSmallIsland(island:getInnermostHeadland():getPolygon(), not self.circledIslands[island])
end
end
-- Once we have the whole course laid out, we add the headland passes around the big islands
---@param path Polyline the complete path
---@param vehicle number|nil the vehicle index, if nil, we assume there is only one vehicle
function FieldworkCourse:circleBigIslands(path, vehicle)
for _, i in ipairs(self.context.field:getIslands()) do
self.logger:debug('Island %d: circled %s, big %s',
i:getId(), self.circledIslands[i], i:isTooBigToBypass(self.context.workingWidth))
end
-- if we are harvesting (headlandFirst = true) we want to take care of the island headlands
-- when we first get to them. For other field works it is the opposite, we want all the up/down rows
-- done before working on the island headlands.
local first = self.context.headlandFirst and 1 or #path
local step = self.context.headlandFirst and 1 or -1
local last = self.context.headlandFirst and #path or 1
local i = first
local found = false
while i ~= last and not found do
local island = path[i]:getAttributes():_getAtIsland()
if island and not self:isBigIslandCircled(island, vehicle) and path[i]:getAttributes():isRowEnd() then
self.logger:debug('Found island %s at %d', island:getId(), i)
-- we bumped upon an island which the path does not circle yet and we are at the end of a row.
-- so now work on the island's headlands and then continue with the next row.
local islandHeadlands = self:getIslandHeadlands(island, vehicle)
local outermostHeadlandPolygon = islandHeadlands[#islandHeadlands]:getPolygon()
-- find a vertex on the outermost headland to start working on the island headlands,
-- far enough that we can generate a Dubins path to it
local slider = CourseGenerator.Slider(outermostHeadlandPolygon,
outermostHeadlandPolygon:findClosestVertexToPoint(path[i]).ix, 3 * self.context.turningRadius)
-- 'inside' since with islands, everything is backwards
local headlandPath = CourseGenerator.HeadlandConnector.connectHeadlandsFromInside(islandHeadlands,
slider.ix, self:_getHeadlandWorkingWidth(), self.context.turningRadius)
-- from the row end to the start of the headland, we instruct the driver to use
-- the pathfinder.
path:setAttribute(i, CourseGenerator.WaypointAttributes.setUsePathfinderToNextWaypoint)
headlandPath:setAttribute(#headlandPath, CourseGenerator.WaypointAttributes.setUsePathfinderToNextWaypoint)
headlandPath:setAttribute(nil, CourseGenerator.WaypointAttributes.setIslandHeadland)
self.logger:debug('Added headland path around island %d with %d points', island:getId(), #headlandPath)
for j = #headlandPath, 1, -1 do
table.insert(path, i + 1, headlandPath[j])
end
path:calculateProperties()
self:setBigIslandCircled(island, vehicle)
-- if we are iterating backwards, we still want to stop at the first vertex.
last = self.context.headlandFirst and last + #headlandPath or 1
end
i = i + step
end
end
------------------------------------------------------------------------------------------------------------------------
--- Helper functions for circleBigIsland(), to override in derived classes, like in FieldworkCourseMultiVehicle
--- where the logic of getting the headlands is different.
function FieldworkCourse:getIslandHeadlands(island, vehicle)
return island:getHeadlands()
end
--- Here we only have one vehicle, so we only need to circle an island once and ignore the vehicle.
function FieldworkCourse:isBigIslandCircled(island, vehicle)
return self.circledIslands[island]
end
function FieldworkCourse:setBigIslandCircled(island, vehicle)
self.circledIslands[island] = true
end
------------------------------------------------------------------------------------------------------------------------
--- Find the path to the next row on the headland.
---@param boundaryId string the boundary ID, telling if this is a boundary around the field or around an island. Will
--- only return a path when the next row can be reached while staying on the same boundary.
---@param rowEnd Vector Last waypoint of the row
---@param rowStart Vector First waypoint of the next row
---@param minDistanceFromRowEnd number|nil minimum distance of the headland (default 0)we choose for the path,
--- from the row end, this should be set so that the vehicle can make the turn from the position where it ended the
--- work on the row into the headland. In case of a headland perpendicular to the rows, this is approximately the turn
--- radius, at other angles it could be bigger or smaller, which we currently do not take into account
---@return Polyline The path on the headland to the next row. Users should consider shortening both ends of the
--- path with the turning radius to leave enough room for the vehicle to cleanly make the turn from the row end into
--- the headland path and from the headland into the next row
function FieldworkCourse:findPathToNextRow(boundaryId, rowEnd, rowStart, minDistanceFromRowEnd)
local headlands = self:_getCachedHeadlands(boundaryId)
local headlandWidth = #headlands * self:_getHeadlandWorkingWidth()
local usableHeadlandWidth = headlandWidth - (minDistanceFromRowEnd or 0)
local headlandPassNumber = CpMathUtil.clamp(math.floor(usableHeadlandWidth / self:_getHeadlandWorkingWidth()), 1, #headlands)
local headland = headlands[headlandPassNumber]
if headland == nil then
return Polyline()
end
local vx1 = headland:findClosestVertexToPoint(rowEnd)
local vx2 = headland:findClosestVertexToPoint(rowStart)
--self.logger:debug('Found shortest path to next row on boundary %s, headland %d, %d->%d',
-- boundaryId, headlandPassNumber, vx1.ix, vx2.ix)
return headland:getShortestPathBetween(vx1.ix, vx2.ix)
end
------------------------------------------------------------------------------------------------------------------------
--- Private functions
------------------------------------------------------------------------------------------------------------------------
function FieldworkCourse:_setContext(context)
self.context = context
self.context:log()
self.nHeadlands = self.context.nHeadlands
self.nHeadlandsWithRoundCorners = math.min(self.context.nHeadlands, self.context.nHeadlandsWithRoundCorners)
---@type Polygon
self.boundary = CourseGenerator.FieldworkCourseHelper.createUsableBoundary(context.field:getBoundary(), self.context.headlandClockwise)
if self.context.fieldMargin ~= 0 then
self.logger:debug('Applying field margin %.1f', self.context.fieldMargin)
self.boundary = CourseGenerator.Headland(self.boundary, self.boundary:isClockwise(), 0,
math.abs(self.context.fieldMargin), self.context.fieldMargin < 0):getPolygon()
end
if self.context.fieldCornerRadius > 0 then
self.logger:debug('sharpening field boundary corners')
self.boundary:ensureMinimumRadius(self.context.fieldCornerRadius, true)
end
CourseGenerator.addDebugPolyline(self.boundary, {0, 1, 1, 0.3})
end
function FieldworkCourse:_removeHeadland(n)
-- If this is invalid, all above it (generated from this) must be invalid, remove them all so
-- #self.headlands is not confused.
for i = n, #self.headlands do
self.headlands[i] = nil
end
self.nHeadlands = n - 1
self.nHeadlandsWithRoundCorners = math.min(self.nHeadlands, self.nHeadlandsWithRoundCorners)
self.logger:error('could not generate headland %d, course has %d headlands, %d rounded',
n, self.nHeadlands, self.nHeadlandsWithRoundCorners)
end
function FieldworkCourse:_getCachedHeadlands(boundaryId)
local headlands = self.headlandCache:get(boundaryId)
if not headlands then
headlands = {}
for _, v in ipairs(self:getPath()) do
local a = v:getAttributes()
if a:getBoundaryId() == boundaryId and not a:isIslandBypass() and not a:isHeadlandTransition() and
not a:isOnConnectingPath() then
local pass = a:getHeadlandPassNumber()
if headlands[pass] == nil then
headlands[pass] = Polygon()
end
headlands[pass]:append(v)
end
end
for i, h in ipairs(headlands) do
self.logger:debug('cached boundary %s, headland %d with %d vertices', boundaryId, i, #h)
h:calculateProperties()
end
self.headlandCache:put(boundaryId, headlands)
end
return headlands
end
---@param n number|nil index of the headland, 1 being the outermost one. If nil, it always returns the working width
--- corrected with the overlap.
function FieldworkCourse:_getHeadlandWorkingWidth(n)
if n == nil or n > 1 then
return self.context:getHeadlandWorkingWidth() * (1 - self.context:getHeadlandOverlap())
else
-- working width of the first headland has no overlap otherwise implements won't remain on the field
return self.context:getHeadlandWorkingWidth()
end
end
---@return number the offset of the nth headland from the field boundary, taking into account the overlap.
function FieldworkCourse:_getHeadlandOffset(n)
if n == 1 then
return self:_getHeadlandWorkingWidth(1) / 2
else
-- for n > 1, the headland width is with the overlap
return self:_getHeadlandWorkingWidth(1) / 2 + (n - 1) * self:_getHeadlandWorkingWidth(n)
end
end
function FieldworkCourse:__tostring()
return string.format('%d/%d headland/center waypoints', #self:getHeadlandPath(), #self:getCenterPath())
end
---@class CourseGenerator.FieldworkCourse
CourseGenerator.FieldworkCourse = FieldworkCourse