diff --git a/scripts/courseGenerator/Center.lua b/scripts/courseGenerator/Center.lua index 9de863c8f..56eb5dfcc 100644 --- a/scripts/courseGenerator/Center.lua +++ b/scripts/courseGenerator/Center.lua @@ -340,8 +340,9 @@ function Center:_calculateRowDistribution(fieldWidth, overlapLast) local centerWorkingWidth = self.context:getCenterRowSpacing() -- only use the overlap-corrected headland width if we have headlands, otherwise, must use the -- nominal working width to avoid generating rows extending outside of the field - local headlandWorkingWidth = self.mayOverlapHeadland and self.context:getHeadlandWorkingWidth() or - self.context.workingWidth + local headlandWorkingWidth = self.mayOverlapHeadland and + self.context:getHeadlandWorkingWidth() * (1 - self.context:getHeadlandOverlap()) or + self.context:getHeadlandWorkingWidth() -- making the field width 1 cm less to avoid generating the last row exactly on the headland if -- the field width is an exact multiple of the working width local nRows = math.floor((fieldWidth - headlandWorkingWidth - 0.01) / centerWorkingWidth) + 1 @@ -353,14 +354,15 @@ function Center:_calculateRowDistribution(fieldWidth, overlapLast) return { fieldWidth - centerWorkingWidth / 2 } end else - if self.context.evenRowDistribution then - -- #1 - centerWorkingWidth = (fieldWidth - headlandWorkingWidth) / nRows - end local firstRowOffset local rowOffsets = {} -- the first/last row's offset from the surrounding headland centerline local outermostRowOffset = headlandWorkingWidth / 2 + centerWorkingWidth / 2 + if self.context.evenRowDistribution then + -- #1, calculate this after the outermost row offset, so that one uses the real working + -- width for the first and last row to not go outside of the field + centerWorkingWidth = (fieldWidth - headlandWorkingWidth) / nRows + end if self.mayOverlapHeadland then -- #3 we have headlands if overlapLast then diff --git a/scripts/courseGenerator/FieldworkContext.lua b/scripts/courseGenerator/FieldworkContext.lua index 913ebe88d..60941964a 100644 --- a/scripts/courseGenerator/FieldworkContext.lua +++ b/scripts/courseGenerator/FieldworkContext.lua @@ -52,8 +52,8 @@ function FieldworkContext:log() self.fieldCornerRadius, self.sharpenCorners, self.bypassIslands, self.nIslandHeadlands, self.islandHeadlandClockwise) self.logger:debug('row pattern: %s, row angle auto: %s, %.1fÂș, even row distribution: %s, use baseline edge: %s, small overlaps: %s', self.rowPattern, self.autoRowAngle, math.deg(self.rowAngle), self.evenRowDistribution, self.useBaselineEdge, self.enableSmallOverlapsWithHeadland) - self.logger:debug('start location %s, baseline edge %s, vehicles %d, same turn width %s', - self.startLocation, self.baselineEdge, self.nVehicles, self.useSameTurnWidth) + self.logger:debug('start location %s, baseline edge %s, vehicles %d, same turn width %s, headland overlap %.1f', + self.startLocation, self.baselineEdge, self.nVehicles, self.useSameTurnWidth, self.overlap * 100) end function FieldworkContext:addError(logger, ...) @@ -225,15 +225,20 @@ function FieldworkContext:setFieldMargin(margin) return self end ---- Override the working width for headland passes +--- Override the working width for headland passes (if not the same as the working width) ---@param w number function FieldworkContext:setHeadlandWorkingWidth(w) self.headlandWorkingWidth = w end ----@return number width of a headland pass in meters. Default is the working width less the overlap. +---@return number width of a headland pass in meters function FieldworkContext:getHeadlandWorkingWidth() - return self.headlandWorkingWidth or self.workingWidth * (1 - self.overlap) + return self.headlandWorkingWidth or self.workingWidth +end + +---@return number headland overlap +function FieldworkContext:getHeadlandOverlap() + return self.overlap end --- Disable sequencing of blocks, just generate them, with the rows and then stop. diff --git a/scripts/courseGenerator/FieldworkCourse.lua b/scripts/courseGenerator/FieldworkCourse.lua index 2de6870fb..5192b7834 100644 --- a/scripts/courseGenerator/FieldworkCourse.lua +++ b/scripts/courseGenerator/FieldworkCourse.lua @@ -29,7 +29,7 @@ function FieldworkCourse:init(context) -- 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.context:getHeadlandWorkingWidth(), self.context.turningRadius) + context.startLocation, self:_getHeadlandWorkingWidth(), self.context.turningRadius) self:routeHeadlandsAroundSmallIslands(self.headlandPath) self.logger:debug('### Generating up/down rows ###') self:generateCenter() @@ -39,7 +39,7 @@ function FieldworkCourse:init(context) 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.context:getHeadlandWorkingWidth(), self.context.turningRadius) + endOfLastRow, self:_getHeadlandWorkingWidth(), self.context.turningRadius) self:routeHeadlandsAroundSmallIslands(self.headlandPath) end @@ -132,17 +132,17 @@ end --- Generate the headlands based on the current context function FieldworkCourse:generateHeadlands() self.headlands = {} - self.logger:debug('generating %d headlands with round corners, then %d with sharp corners', + 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.nHeadlandsWithRoundCorners + 0.5) * self.context:getHeadlandWorkingWidth(), + self:_getHeadlandOffset(self.nHeadlandsWithRoundCorners + 1), #self.headlands + 1) end elseif self.nHeadlands > 0 then - self:generateHeadlandsFromOutside(self.boundary, self.context:getHeadlandWorkingWidth() / 2, 1) + self:generateHeadlandsFromOutside(self.boundary, self:_getHeadlandOffset(1), 1) end end @@ -153,8 +153,8 @@ end ---@param startIx number index of the first headland to generate function FieldworkCourse:generateHeadlandsFromOutside(boundary, firstHeadlandWidth, startIx) - self.logger:debug('generating %d sharp headlands from the outside, min radius %.1f', - self.nHeadlands - startIx + 1, self.context.turningRadius) + 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, nil) if not self.headlands[startIx]:isValid() then @@ -166,7 +166,7 @@ function FieldworkCourse:generateHeadlandsFromOutside(boundary, firstHeadlandWid end for i = startIx + 1, self.nHeadlands do self.headlands[i] = CourseGenerator.Headland(self.headlands[i - 1]:getPolygon(), self.context.headlandClockwise, i, - self.context:getHeadlandWorkingWidth(), false, self.headlands[1]:getPolygon()) + 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) @@ -189,7 +189,7 @@ function FieldworkCourse:generateHeadlandsFromInside() -- 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.nHeadlandsWithRoundCorners - 0.5) * self.context:getHeadlandWorkingWidth(), + self.nHeadlandsWithRoundCorners, self:_getHeadlandOffset(self.nHeadlandsWithRoundCorners), false, self.boundary) if self.headlands[self.nHeadlandsWithRoundCorners]:isValid() then self.headlands[self.nHeadlandsWithRoundCorners]:roundCorners(self.context.turningRadius) @@ -202,7 +202,7 @@ function FieldworkCourse:generateHeadlandsFromInside() end for i = self.nHeadlandsWithRoundCorners - 1, 1, -1 do self.headlands[i] = CourseGenerator.Headland(self.headlands[i + 1]:getPolygon(), self.context.headlandClockwise, i, - self.context:getHeadlandWorkingWidth(), true, self.boundary) + self:_getHeadlandWorkingWidth(i), true, self.boundary) self.headlands[i]:roundCorners(self.context.turningRadius) end end @@ -314,7 +314,7 @@ function FieldworkCourse:circleBigIslands(path, vehicle) -- 'inside' since with islands, everything is backwards local headlandPath = CourseGenerator.HeadlandConnector.connectHeadlandsFromInside(islandHeadlands, - slider.ix, self.context:getHeadlandWorkingWidth(), self.context.turningRadius) + 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. @@ -366,9 +366,9 @@ end --- 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.context:getHeadlandWorkingWidth() + local headlandWidth = #headlands * self:_getHeadlandWorkingWidth() local usableHeadlandWidth = headlandWidth - (minDistanceFromRowEnd or 0) - local headlandPassNumber = CourseGenerator.clamp(math.floor(usableHeadlandWidth / self.context:getHeadlandWorkingWidth()), 1, #headlands) + local headlandPassNumber = CourseGenerator.clamp(math.floor(usableHeadlandWidth / self:_getHeadlandWorkingWidth()), 1, #headlands) local headland = headlands[headlandPassNumber] if headland == nil then return Polyline() @@ -437,6 +437,28 @@ function FieldworkCourse:_getCachedHeadlands(boundaryId) 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 + print(n) + return self:_getHeadlandWorkingWidth(1) + (n - 1 - 0.5) * self:_getHeadlandWorkingWidth(n) + end +end + function FieldworkCourse:__tostring() return string.format('%d/%d headland/center waypoints', #self:getHeadlandPath(), #self:getCenterPath()) end diff --git a/scripts/courseGenerator/FieldworkCourseMultiVehicle.lua b/scripts/courseGenerator/FieldworkCourseMultiVehicle.lua index 41635a02d..5a634db45 100644 --- a/scripts/courseGenerator/FieldworkCourseMultiVehicle.lua +++ b/scripts/courseGenerator/FieldworkCourseMultiVehicle.lua @@ -79,7 +79,7 @@ function FieldworkCourseMultiVehicle:init(context) -- create a headland path for each vehicle self.headlandPaths[v] = CourseGenerator.HeadlandConnector.connectHeadlandsFromOutside(self.headlandsForVehicle[v], -- TODO is this really the headland working width? Not the combined width? - self.context.startLocation, self.context:getHeadlandWorkingWidth(), self.context.turningRadius) + self.context.startLocation, self:_getHeadlandWorkingWidth(), self.context.turningRadius) self:routeHeadlandsAroundSmallIslands(self.headlandPaths[v]) end self.logger:debug('### Generating up/down rows ###') @@ -93,7 +93,7 @@ function FieldworkCourseMultiVehicle:init(context) -- create a headland path for each vehicle self.headlandPaths[v] = CourseGenerator.HeadlandConnector.connectHeadlandsFromInside(self.headlandsForVehicle[v], -- TODO is this really the headland working width? Not the combined width? - endOfLastRow, self.context:getHeadlandWorkingWidth(), self.context.turningRadius) + endOfLastRow, self:_getHeadlandWorkingWidth(), self.context.turningRadius) self:routeHeadlandsAroundSmallIslands(self.headlandPaths[v]) end end @@ -249,7 +249,7 @@ function FieldworkCourseMultiVehicle:generateCenter() centerBoundary = referenceHeadland else centerBoundary = CourseGenerator.Headland(referenceHeadland:getPolygon(), self.context.headlandClockwise, - #self.headlands - 1, self.context:getHeadlandWorkingWidth() / 2, false) + #self.headlands - 1, self:_getHeadlandWorkingWidth() / 2, false) end CourseGenerator.addDebugPolyline(centerBoundary:getPolygon()) local innerMostHeadlandPolygon = self.headlands[#self.headlands]:getPolygon() diff --git a/scripts/courseGenerator/Island.lua b/scripts/courseGenerator/Island.lua index 8b8620900..d34b1f7ac 100644 --- a/scripts/courseGenerator/Island.lua +++ b/scripts/courseGenerator/Island.lua @@ -116,14 +116,16 @@ function Island:generateHeadlands(context, mustNotCross) local headlands = {} self.boundary = CourseGenerator.FieldworkCourseHelper.createUsableBoundary(self.boundary, self.context.islandHeadlandClockwise) -- innermost headland is offset from the island by half width - headlands[1] = CourseGenerator.IslandHeadland(self, self.boundary, self.context.islandHeadlandClockwise, 1, self.context:getHeadlandWorkingWidth() / 2) + headlands[1] = CourseGenerator.IslandHeadland(self, self.boundary, self.context.islandHeadlandClockwise, 1, + self.context:getHeadlandWorkingWidth() / 2) for i = 2, self.context.nIslandHeadlands do if not headlands[i - 1]:isValid() then self.logger:warning('headland %d is invalid, removing', i - 1) headlands[i - 1] = nil break end - headlands[i] = CourseGenerator.IslandHeadland(self, headlands[i - 1]:getPolygon(), self.context.islandHeadlandClockwise, i, self.context:getHeadlandWorkingWidth()) + headlands[i] = CourseGenerator.IslandHeadland(self, headlands[i - 1]:getPolygon(), self.context.islandHeadlandClockwise, + i, self.context:getHeadlandWorkingWidth() * (1 - self.context:getHeadlandOverlap())) end if headlands[1]:getPolygon():intersects(mustNotCross) then self.logger:error('First headland intersects field boundary!') diff --git a/scripts/courseGenerator/test/CenterTest.lua b/scripts/courseGenerator/test/CenterTest.lua index 2db529029..274276a2f 100644 --- a/scripts/courseGenerator/test/CenterTest.lua +++ b/scripts/courseGenerator/test/CenterTest.lua @@ -10,7 +10,7 @@ local function printRowOffsets(rowOffsets) end end -local function createContext(headlandWidth, centerRowSpacing, evenRowDistribution) +local function createContext(headlandWidth, centerRowSpacing, evenRowDistribution, overlap) local mockContext = { evenRowDistribution = evenRowDistribution, workingWidth = headlandWidth, @@ -19,6 +19,9 @@ local function createContext(headlandWidth, centerRowSpacing, evenRowDistributio end, getCenterRowSpacing = function() return centerRowSpacing + end, + getHeadlandOverlap = function() + return overlap or 0 end } return mockContext @@ -137,13 +140,41 @@ function testRowDistributionMultiVehicleNoHeadland() lu.assertAlmostEquals(rowOffsets[#rowOffsets], 10) end -function testEvenRowDistribution() +function testEvenRowDistributionWithHeadland() local rowOffsets local center = {context = createContext(5, 5, true), mayOverlapHeadland = true} + rowOffsets = CourseGenerator.Center._calculateRowDistribution(center, 49, true) + lu.assertEquals(#rowOffsets, 9) + lu.assertAlmostEquals(rowOffsets[1], 5) + lu.assertAlmostEquals(rowOffsets[#rowOffsets], 4.88) + rowOffsets = CourseGenerator.Center._calculateRowDistribution(center, 49, false) + lu.assertEquals(#rowOffsets, 9) + lu.assertAlmostEquals(rowOffsets[1], 4.88) + lu.assertAlmostEquals(rowOffsets[#rowOffsets], 4.88) + + rowOffsets = CourseGenerator.Center._calculateRowDistribution(center, 50, false) + lu.assertEquals(#rowOffsets, 9) + lu.assertAlmostEquals(rowOffsets[1], 5) + lu.assertAlmostEquals(rowOffsets[#rowOffsets], 5) + center.mayOverlapHeadland = false +end + +function testEvenRowDistributionWithNoHeadland() + local rowOffsets + local center = {context = createContext(5, 5, true), mayOverlapHeadland = false} + rowOffsets = CourseGenerator.Center._calculateRowDistribution(center, 49, true) + lu.assertEquals(#rowOffsets, 9) + lu.assertAlmostEquals(rowOffsets[1], 5) + lu.assertAlmostEquals(rowOffsets[2], 4.88) + -- this should also be 4.88, no idea what we are missing, but is minimal, we'll address it when it becomes a problem + lu.assertAlmostEquals(rowOffsets[#rowOffsets], 4.77) rowOffsets = CourseGenerator.Center._calculateRowDistribution(center, 49, false) lu.assertEquals(#rowOffsets, 9) - lu.assertAlmostEquals(rowOffsets[1], 4.94) + lu.assertAlmostEquals(rowOffsets[1], 5) + -- this should also be 4.88, no idea what we are missing, but is minimal, we'll address it when it becomes a problem + lu.assertAlmostEquals(rowOffsets[2], 4.77) lu.assertAlmostEquals(rowOffsets[#rowOffsets], 4.88) + rowOffsets = CourseGenerator.Center._calculateRowDistribution(center, 50, false) lu.assertEquals(#rowOffsets, 9) lu.assertAlmostEquals(rowOffsets[1], 5) @@ -151,4 +182,6 @@ function testEvenRowDistribution() center.mayOverlapHeadland = false end + + os.exit(lu.LuaUnit.run())