Skip to content

Basic multi-boom support; collapse identical/overlapping sections - #131

Open
gunicsba wants to merge 7 commits into
developfrom
feature/per-boom-section-control
Open

gunicsba wants to merge 7 commits into
developfrom
feature/per-boom-section-control

Conversation

@gunicsba

@gunicsba gunicsba commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Closes #115. Part of #132 (follow-ups: #133, #134, #136). Replaces #130: allSectionsFollowFirst is dropped, because grouping by geometry covers it.

The branch history contains #130's commits and their removal (7c02ace). The diff against develop has none of it. Please squash-merge.

Summary

Two changes to how the TC maps an implement's sections:

  1. Basic multi-boom support (bug fix, A DDOP with two booms: the condensed setpoint goes to the second boom's element only, the first boom never moves #115). Each boom is controlled on its own. Some implements put the condensed section DDIs on several elements, one per boom, and each of those booms numbers its sections from 1. The TC kept one element per DDI (ddiToElementNumber, where the last one wins), so every setpoint and DDI 160 went to the last boom, with all sections packed into it. The other booms were never put in automatic.
  2. Collapse identical/overlapping sections (new, opt-in). Sections that cover the same strip share one AOG section. This is the groupOverlappingSections setting, default false.

Examples

Implement Layout AOG sees
RDS Artemis, 3 dispensers on one 6 m width, unlinked 3 Functions, 1 Section each, all Y = 0, 6000 mm 1 section
RDS Artemis, linked 1 Function, 3 Sections, Y = −5999 / 1 / 6001 mm 3 sections (the reported geometry is side by side)
3 + 3 m seeder, 3 products per half (6 motors) 6 sections on 2 strips 2 sections
Amazone Precea 4500 (#35 DDOP) 1 Function, 6 rows, products as Bins 6 sections, unchanged from develop

Before this PR, the unlinked Artemis had DDI 160 and every setpoint sent to Function 3 only, as a 3-section value, and dispensers 1 and 2 were never switched to automatic.

Changes

Multi-boom support

  • New section_layout.hpp. For each section, resolve_condensed_owners() finds the nearest element (the section itself or an ancestor) that links DDI 290, or a settable DDI 161, and groups consecutive sections by that owner.
  • With two or more owners:
    • each owner gets its own condensed setpoint, with sections numbered within it;
    • DDI 160 goes to every element that declares it;
    • DDI 161 and DDI 160 reports are read per owner;
    • the implement counts as automatic while any boom is. A boom that hasn't reported DDI 160 yet counts as in the last commanded mode.
  • With zero or one owner, the existing path is unchanged. That covers every one of the 24 sample DDOPs in the fixture collection, including the multi-boom Amazone and Kverneland demo pools.

Collapsing sections

  • After activation, the TC requests each section's DDI 135 (Y offset) and its width (DDI 67, otherwise 70, otherwise 68), when the DDOP only declares them as process data. The replies are logged. The RDS Artemis answers within 16–34 ms.
  • group_overlapping_sections(): sections with the same Y offset and width (within 10 mm) share an AOG section.
    • AOG sections are numbered in first-appearance order, so implements without overlaps keep the identity mapping.
    • X offset is ignored.
    • If any section has an unknown offset or a zero width, the identity mapping is kept.
    • The comparison is done in 64 bits.
  • With groupOverlappingSections on:
    • PGN 229 is mapped onto the grouped sections;
    • the heartbeat reports AOG's sections (on while any member section is on);
    • the "working without demand" check reads each section's AOG bit.
  • The grouping the geometry gives is logged even while the setting is off, e.g. geometry gives 1 AOG section(s) for 3 implement section(s) (1->1, 2->1, 3->1), not applied: groupOverlappingSections is off. That way its behaviour on other machines can be checked before the default changes.

Tests

  • test_section_layout covers owner resolution (including read-only DDI 161) and grouping (including int32 extremes).
  • Run with DDOP file paths as arguments, it prints the owners for captured pools.

Notes

Testing

  • Release build passes on Windows (MSVC). ctest: 8/8 pass.
  • Field test with an RDS Artemis (sw 003.07), 2026-11-11, before the Sourcery fixes and the removal of allSectionsFollowFirst:
    • Unlinked with groupOverlappingSections: reporting 1 section(s) to AOG (1->1, 2->1, 3->1). All three Functions acknowledged automatic, and dispensers 2 and 3 followed 3 on/off cycles. Dispenser 1 has a hardware fault on that machine.
  • To do: re-run the unlinked + grouping test on the current head.

🤖 Generated with Claude Code

gunicsba and others added 3 commits November 11, 2026 13:24
…ction 1

For implements that declare several sections that really are one, such
as an RDS Artemis with three dispensers on one working width. With the
setting on, every implement section follows AOG's section 1, and the
heartbeat reports a single section to AOG (on while any implement
section is on). Default off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nto one AOG section

Booms with their own condensed DDIs (e.g. an RDS Artemis with one
Function per dispenser) number their sections from 1 each. The TC kept
one element per DDI, so every setpoint and DDI 160 went to the last
boom, with all sections packed into it; the other booms were never put
in automatic.

- Resolve, per section, the nearest element carrying DDI 290 (or
  settable 161). With two or more such owners, each gets its own
  setpoint (sections numbered within it) and its own DDI 160; DDI 161
  and 160 reports are read per owner; automatic while any boom is.
  Implements with one owner keep the existing path (checked against the
  24 sample DDOPs: none has more than one).
- Request each section's Y offset and width when the DDOP only declares
  them as process data, and log them.
- groupOverlappingSections (default off): sections with the same Y offset
  and width (10 mm) share one AOG section; the heartbeat reports AOG's
  sections. The mapping is logged even while the setting is off.
- section_layout.hpp holds both decisions as pure functions, with unit
  tests; test_section_layout also describes captured DDOP files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns is off

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Data structures

Define the condensed-owner and section-geometry mappings, plus the per-client state that stores owners, DDI 160 reports, and implement-to-AOG section mappings.

  • include/section_layout.hpp L1-193
  • include/task_controller.hpp L17, L70-101, L119-126

API

Expose the setting and server methods for applying optional overlap grouping and the client-state accessors needed by section control.

  • include/settings.hpp L101-105, L170, L179
  • include/task_controller.hpp L164-166, L173-176, L182

Uses of the API

Apply the configured grouping to the server, remove the former follow-first overrides, and report grouped section states and mapped demand to AOG.

  • src/app.cpp L744-749 (+6 small edits)

Implementation

Resolve per-boom condensed owners, route each boom’s setpoints and DDI reports independently, and aggregate its automatic state while retaining the single-owner path.

  • src/task_controller.cpp L343-484, L899-921, L1331-1411 (+9 small edits)

Request missing section geometry, compute and log overlap mappings, map AOG section demand and reports, and send owner-specific setpoints and section-control commands.

  • src/task_controller.cpp L1104-1148, L1330

Configuration

Load, save, expose, and default the new groupOverlappingSections setting.

  • CMakeLists.txt L210-222
  • src/settings.cpp L73, L147, L266-274

Documentation

Document the opt-in overlap-grouping setting, its geometry requirements, and the required AOG section count.

  • readme.md L67

Tests

Test condensed-owner resolution and overlap grouping, and provide a mode for inspecting captured DDOP layouts.

  • tools/test_section_layout.cpp L1-262

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 8 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/task_controller.cpp" line_range="735-737" />
<code_context>
+		// sections from 1 each; give every one of them its own setpoint and DDI 160.
+		if (hasCondensedSetpoint || hasSettableCondensedActual)
+		{
+			const auto firstDdi = static_cast<std::uint16_t>(hasCondensedSetpoint ? isobus::DataDescriptionIndex::SetpointCondensedWorkState1_16 : isobus::DataDescriptionIndex::ActualCondensedWorkState1_16);
+			const auto lastDdi = static_cast<std::uint16_t>(hasCondensedSetpoint ? isobus::DataDescriptionIndex::SetpointCondensedWorkState241_256 : isobus::DataDescriptionIndex::ActualCondensedWorkState241_256);
+			auto owners = section_layout::resolve_condensed_owners(state.get_pool(), element_hierarchy::build_parent_map(state.get_pool()), sectionElementNumbers, firstDdi, lastDdi);
+			if (owners.size() > 1)
+			{
</code_context>
<issue_to_address>
**Some booms miss setpoints**

When a DDOP uses DDI 290 on one boom and only settable DDI 161 on another, `hasCondensedSetpoint` selects one range for the whole pool, so `resolve_condensed_owners()` omits the boom using the other range; `owners.size()` then misses the per-boom path and some booms receive no setpoints.

Resolve owners across both condensed-DDI ranges per element rather than selecting one range for the whole pool.
</issue_to_address>

### Comment 2
<location path="include/section_layout.hpp" line_range="40-42" />
<code_context>
+		{
+			auto child = pool.get_object_by_id(childId);
+			if (child && (child->get_object_type() == ObjectTypes::DeviceProcessData))
+			{
+				const auto ddi = std::static_pointer_cast<DeviceProcessDataObject>(child)->get_ddi();
+				if ((ddi >= firstDdi) && (ddi <= lastDdi))
+				{
+					return true;
</code_context>
<issue_to_address>
**Read-only boom misses control**

When a multi-owner DDOP has a settable DDI 161 on one boom and a read-only DDI 161 on another, `resolve_condensed_owners()` treats the linked read-only DDI 161 as an owner, so `send_owner_section_setpoint_states()` sends it commands it cannot follow; that boom does not follow AOG section commands.

Only resolve a linked DDI as an owner when its process-data object is settable.

Also at `src/task_controller.cpp:1379`.
</issue_to_address>

### Comment 3
<location path="src/task_controller.cpp" line_range="373-379" />
<code_context>
+
+void ClientState::set_element_section_control_state(std::uint16_t elementNumber, bool enabled)
+{
+	elementSectionControlStates[elementNumber] = enabled;
+	bool anyEnabled = false;
+	for (const auto &entry : elementSectionControlStates)
+	{
+		anyEnabled = anyEnabled || entry.second;
+	}
+	isSectionControlEnabled = anyEnabled;
+}
+
</code_context>
<issue_to_address>
**Automatic boom stops receiving setpoints**

When one boom reports manual before another already-automatic boom's first DDI 160 report, `set_element_section_control_state()` aggregates only elements that have reported DDI 160, so it sets the overall state to false and `update_section_states` stops sending setpoints even though a boom is automatic.

Include every registered section-control element in the aggregate so an unreported element is not treated as manual.
</issue_to_address>

### Comment 4
<location path="src/task_controller.cpp" line_range="892-893" />
<code_context>
-			clients[partner].set_section_control_enabled(processDataValue == 1);
+			if (clients[partner].has_multiple_condensed_owners())
+			{
+				clients[partner].set_element_section_control_state(elementNumber, processDataValue == 1);
+			}
+			else
+			{
</code_context>
<issue_to_address>
**Old replies alter the new layout**

When an old report is still in flight when the same client reactivates a pool that reuses an element number, `on_value_command()` identifies reports only by client, DDI, and element number, so a delayed DDI 160 or geometry reply from a previous activation updates the replacement `ClientState` when element numbers are reused. It can mark a boom automatic or change the new layout’s AOG grouping, and the caller sees control or section mapping based on stale data.

Associate reports with the active pool generation or otherwise discard replies from prior activations.

Also at `src/task_controller.cpp:906-911`.
</issue_to_address>

### Comment 5
<location path="src/task_controller.cpp" line_range="379" />
<code_context>
+	{
+		anyEnabled = anyEnabled || entry.second;
+	}
+	isSectionControlEnabled = anyEnabled;
+}
+
</code_context>
<issue_to_address>
**Delayed report re-enables section control**

When an older enabled DDI 160 report arrives after the element has reported disabled, `set_element_section_control_state()` overwrites the element’s remembered DDI 160 value and recomputes the aggregate with OR. A delayed pre-disable `enabled` report arriving after a newer disable report therefore makes the aggregate enabled again, and the TC continues sending section setpoints after the caller requested manual mode.

Prevent reports predating a newer control command from overwriting its state, or otherwise reconcile per-element reports with the latest commanded mode.
</issue_to_address>

### Comment 6
<location path="src/task_controller.cpp" line_range="1183" />
<code_context>
+				if (sectionStates[aogSection] != (state.get_section_setpoint_state(i) == SectionState::ON))
 				{
-					state.set_section_setpoint_state(i, sectionStates[i] ? SectionState::ON : SectionState::OFF);
+					state.set_section_setpoint_state(i, sectionStates[aogSection] ? SectionState::ON : SectionState::OFF);
 					requiresUpdate = true;
+					anyUpdate = true;
</code_context>
<issue_to_address>
**Dropped boom setpoint is not retried**

When a per-owner setpoint command is not delivered while the others are, the section setpoint state is updated before `send_owner_section_setpoint_states()` sends the per-owner commands. If a send for one owner fails or is dropped, a repeated identical AOG demand produces no `anyUpdate`, so that boom remains at the old setpoint until its demand changes; the caller sees inconsistent boom control.

Track per-owner commands that still need delivery and retry them until acknowledged or otherwise confirmed.
</issue_to_address>

### Comment 7
<location path="include/section_layout.hpp" line_range="168-169" />
<code_context>
+			for (std::size_t g = 0; g < representative.size(); g++)
+			{
+				const auto &first = sections[representative[g]];
+				if ((std::abs(*first.yOffset_mm - *sections[i].yOffset_mm) <= toleranceMm) &&
+				    (std::abs(*first.width_mm - *sections[i].width_mm) <= toleranceMm))
+				{
+					aogSection = static_cast<std::uint8_t>(g);
</code_context>
<issue_to_address>
**Distant sections merge**

When reported section offsets or widths have a difference outside the signed 32-bit range, or a difference is INT32_MIN, `group_overlapping_sections()` subtracts signed 32-bit geometry values before calling `std::abs`; the subtraction overflows or `std::abs` cannot represent the result, causing undefined behavior and allowing distant sections to map to the same AOG section.

Widen the geometry values before subtracting and take the absolute difference in a type that can represent it.
</issue_to_address>

### Comment 8
<location path="src/app.cpp" line_range="762-774" />
<code_context>
 			}

-			std::vector<uint8_t> data = { state.is_section_control_enabled(), state.get_number_of_sections() };
+			// AOG's sections: overlapping implement sections are reported as one (identity mapping otherwise)
+			const std::uint8_t aogSections = state.get_number_of_aog_sections();
+			std::vector<uint8_t> data = { state.is_section_control_enabled(), aogSections };

 			std::uint8_t sectionIndex = 0;
-			while (sectionIndex < state.get_number_of_sections())
+			while (sectionIndex < aogSections)
 			{
 				std::uint8_t byte = 0;
 				for (std::uint8_t i = 0; i < 8; i++)
 				{
-					if (sectionIndex < state.get_number_of_sections())
+					if (sectionIndex < aogSections)
 					{
-						byte |= (state.get_section_actual_state(sectionIndex) == SectionState::ON) << i;
+						byte |= state.is_aog_section_on(sectionIndex) << i;
 						sectionIndex++;
 					}
</code_context>
<issue_to_address>
**Grouped sections confuse demand diagnostics**

When `groupOverlappingSections` is enabled and PGN 229 has a set bit in an index beyond the reported AOG section count, `check_section_demand()` still compares PGN 229 demand bits by implement-section index and scans up to the implement section count, while this heartbeat reports the smaller AOG section count when grouping is applied. Padding or otherwise unused bits can therefore make the TC conclude that AOG demands work when it does not, suppressing the warning and hardware alert for an implement working without demand.

Evaluate demand only over the AOG section count (or map each implement section to its AOG demand index) before deciding whether demand exists.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread src/task_controller.cpp Resolved
Comment thread include/section_layout.hpp Resolved
Comment thread src/task_controller.cpp Resolved
Comment thread src/task_controller.cpp Resolved
Comment thread src/task_controller.cpp Resolved
Comment thread src/task_controller.cpp Resolved
Comment thread include/section_layout.hpp Resolved
Comment thread src/app.cpp Resolved
gunicsba and others added 3 commits November 11, 2026 19:28
…t, demand from bit 1 only

PGN 229 has 64 bits, so sections 65-255 kept their old setpoint; the
follow-first vector now covers every section a DDOP can declare.

With several section-controlled implements the heartbeat sent one
one-section report per implement. It now sends a single report: auto
while any implement is in auto, on while any implement section is on.

check_section_demand looked at AOG's bit for each implement section; in
follow-first mode only AOG's section 1 is demand, so a stray bit can no
longer hide the working-without-demand warning.

Addresses Sourcery's review of #130.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o feature/per-boom-section-control

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g math, mapped demand

- A read-only DDI 161 only reports, so it no longer makes an element a
  condensed owner (only applies when the pool has no DDI 290).
- Booms that have not reported DDI 160 yet take the last commanded mode,
  so one boom reporting manual first no longer switches section control
  off for booms that are still automatic.
- group_overlapping_sections compares in 64 bits: offsets at the ends of
  the int32 range could overflow and merge distant sections.
- check_section_demand reads AOG's bit for the AOG section each implement
  section maps to, not the implement section's own index.

Addresses Sourcery's review of #131.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
allSectionsFollowFirst was a quick override to validate the RDS Artemis
on one width. Grouping by geometry does the same for that machine and
also handles implements it cannot, such as a 3 + 3 m seeder with three
products per half (two AOG sections), so the override is removed.

The demand check keeps using each implement section's AOG section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gunicsba
gunicsba changed the base branch from feature/all-sections-follow-first to develop November 11, 2026 19:02
@sourcery-ai

sourcery-ai Bot commented Nov 11, 2026

Copy link
Copy Markdown

Linked issues

Checked at commit 7c02ace

#115 A DDOP with two booms: the condensed setpoint goes to the second boom's element only, the first boom never moves · 1 of 1 met

# Requirement Status Notes
1 For multiple booms with condensed DDIs, send each boom its own condensed value, split by its sections; alternatively refuse or warn about the layout, and log which approach is taken. ✅ Met The code resolves a condensed-DDI owner for each section, sends each owner a separate setpoint, and maps actual-state reports back from the reporting element. It also logs when sections are split across multiple owners.

@gunicsba gunicsba changed the title Control each boom on its own; optionally merge overlapping sections into one AOG section Basic multi-boom support; collapse identical/overlapping sections Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A DDOP with two booms: the condensed setpoint goes to the second boom's element only, the first boom never moves

1 participant