Skip to content

Add support for different focal point hit counts OpenwaterHealth/open…#450

Open
samueljwu wants to merge 1 commit intoOpenwaterHealth:mainfrom
samueljwu:main
Open

Add support for different focal point hit counts OpenwaterHealth/open…#450
samueljwu wants to merge 1 commit intoOpenwaterHealth:mainfrom
samueljwu:main

Conversation

@samueljwu
Copy link
Copy Markdown

Summary

Adds support for non-uniform focal point hit counts and hit_count_optimizer to maximize minimum ISPTA across foci

Closes #449

Changes

  • Added Solution.focal_hit_counts field to store per-focus pulse allocation, validated against number of foci and sequence.pulse_count
  • Extended Protocol.calc_solution with focal_hit_counts (explicit allocation) and optimize (runs the optimizer) arguments
  • Added optimize_hit_counts() in hit_count_optimizer.py: solves a linear program to maximize the minimum ISPTA across foci, rounds to integers with a local improvement pass, and enforces a hard TIC limit from param_constraints
  • Added SolutionAnalysis.per_focus_tic field
  • Weighted TIC and power aggregation in Solution.analyze() and per-focus ISPTA weighting in Solution.get_ispta() when focal_hit_counts is set

Testing

  • Added tests/test_focal_hit_counts.py covering:
    • Validation errors for wrong-length or wrong-sum hit counts
    • Correct hit-count-weighted ISPTA and TIC computation
    • Optimizer output sums to pulse_count and respects TIC constraints
    • JSON round-trip preserves focal_hit_counts
    • Optimizer outperforms random hit count distributions across multiple ISPPA/TIC scenarios
  • All existing tests pass
  • Pre-commit hooks pass

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds support for allocating a non-uniform number of pulses (“hit counts”) across multiple focal points, and introduces an optimizer that chooses per-focus hit counts to maximize the minimum per-focus ISPTA while respecting TIC constraints.

Changes:

  • Added Solution.focal_hit_counts (per-focus pulse allocation) with validation and JSON persistence.
  • Updated solution analysis/aggregation to weight intensity, TIC, and power by focal_hit_counts, and exposed SolutionAnalysis.per_focus_tic.
  • Added optimize_hit_counts() (LP + rounding/local improvement) and integrated it into Protocol.calc_solution(optimize=True, focal_hit_counts=...) with new test coverage.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/test_focal_hit_counts.py New tests for hit count validation, weighting behavior, optimizer feasibility, and JSON round-trip.
src/openlifu/plan/solution_analysis.py Adds per_focus_tic field to expose per-focus TIC before weighting.
src/openlifu/plan/solution.py Adds focal_hit_counts field, validates it, and applies hit-count weighting in analysis and ITA computation.
src/openlifu/plan/protocol.py Adds new calc_solution parameters and integrates hit-count weighting and optional optimizer run.
src/openlifu/plan/hit_count_optimizer.py New optimizer implementation based on a linear program + integer rounding and local improvement.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

# Rearranged: sum(tic[i] * hits[i]) <= max_tic * pulse_count - sum(tic[i]) * min_hits
tic_constraint = param_constraints.get("TIC")
if tic_constraint is not None and tic_constraint.error_value is not None:
max_tic = float(tic_constraint.error_value)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

optimize_hit_counts assumes param_constraints['TIC'].error_value is a single numeric max limit, but ParameterConstraint also supports tuple ranges and operators like inside/within or >/>=. As written, float(tic_constraint.error_value) will raise for tuple thresholds and will misinterpret non-"<" operators. Add validation (e.g., require operator in {'<','<='} with scalar error_value) or explicitly handle/ignore other operators with a clear error.

Suggested change
max_tic = float(tic_constraint.error_value)
tic_operator = getattr(tic_constraint, "operator", None)
if tic_operator not in (None, "<", "<="):
raise ValueError(
"TIC constraint must use a scalar upper-bound operator ('<' or '<=') "
"for hit count optimization."
)
if not np.isscalar(tic_constraint.error_value):
raise ValueError(
"TIC constraint error_value must be a single numeric upper bound "
"for hit count optimization."
)
try:
max_tic = float(tic_constraint.error_value)
except (TypeError, ValueError) as exc:
raise ValueError(
"TIC constraint error_value must be a single numeric upper bound "
"for hit count optimization."
) from exc

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +44
n = len(per_focus_isppa)
if param_constraints is None:
param_constraints = {}

budget = pulse_count - min_hits * n
if budget < 0:
raise ValueError(
f"pulse_count ({pulse_count}) is too small to give each of {n} foci "
f"at least {min_hits} hit(s)."
)

Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

optimize_hit_counts doesn't validate that per_focus_isppa and per_focus_tic have the same non-zero length (or that values are finite / non-negative). A length mismatch will produce malformed constraint rows for linprog and fail with a low-level error. Consider adding explicit checks up front and raising a ValueError with a user-facing message.

Copilot uses AI. Check for mistakes.
"""Return hit counts that maximize the minimum ISPTA across foci, subject to a TIC hard limit.

Solves a linear program over the continuous relaxation of hit counts, then rounds to integers.
Without a TIC constraint the solution is closed-form
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

Docstring says "Without a TIC constraint the solution is closed-form", but the implementation always calls scipy.optimize.linprog even when no TIC constraint is present. Either implement the closed-form shortcut or update the docstring to match behavior.

Suggested change
Without a TIC constraint the solution is closed-form

Copilot uses AI. Check for mistakes.
raise ValueError(f"Apodizations number of elements {self.apodizations.shape[1]} does not match delays shape ({self.delays.shape[1]})")
if self.focal_hit_counts:
if len(self.focal_hit_counts) != len(self.foci):
raise ValueError(f"Focal hit counts length ({len(self.focal_hit_counts)}) does not match number of foci ({len(self.foci)})")
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

Solution.__post_init__ validates focal_hit_counts length/sum, but it doesn't enforce that counts are integers and non-negative. Negative or fractional values can pass the current checks and then propagate into weighting (np.average) and intensity aggregation. Consider validating each entry is an int (or castable to int without loss) and >= 0 (and optionally >= 1 if every focus must be hit).

Suggested change
raise ValueError(f"Focal hit counts length ({len(self.focal_hit_counts)}) does not match number of foci ({len(self.foci)})")
raise ValueError(f"Focal hit counts length ({len(self.focal_hit_counts)}) does not match number of foci ({len(self.foci)})")
validated_focal_hit_counts = []
for count in self.focal_hit_counts:
if isinstance(count, (bool, np.bool_)):
raise ValueError("Focal hit counts must be non-negative integers")
if isinstance(count, (int, np.integer)):
normalized_count = int(count)
elif isinstance(count, (float, np.floating)) and float(count).is_integer():
normalized_count = int(count)
else:
raise ValueError("Focal hit counts must be non-negative integers")
if normalized_count < 0:
raise ValueError("Focal hit counts must be non-negative integers")
validated_focal_hit_counts.append(normalized_count)
self.focal_hit_counts = validated_focal_hit_counts

Copilot uses AI. Check for mistakes.
Comment on lines 251 to 256
analysis_options: SolutionAnalysisOptions | None = None,
on_pulse_mismatch: OnPulseMismatchAction = OnPulseMismatchAction.ERROR,
voltage: float = 1.0,
focal_hit_counts: List[int] | None = None,
optimize: bool = False,
_force_cpu: bool = False
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

calc_solution adds focal_hit_counts / optimize parameters but the docstring Args: section doesn't describe them (and optimize=True is silently ignored when simulate=False). Update the docstring to document these parameters and consider raising a ValueError (or warning) when optimize=True without simulation, since the optimizer depends on simulated per-focus metrics.

Copilot uses AI. Check for mistakes.
@peterhollender
Copy link
Copy Markdown
Contributor

Hey @samueljwu this is very cool! I think that the core functionality is quite useful, although I have a suggestion on implementation. I'd like to be conservative about modifying the top level Solution attributes, so I'm wondering if this focal-ordering can be added as an optional attribute of Sequence instead.

I'd also like to make the ordering more generic- you are correct to point out that currently, multiple foci default to an ABCABC... order. Your proposed ordering makes AAAABBCCC possible, but I think we can take this and make it more flexible. If solution.sequence.focus_order (I'm open to other names) is a list of indices, then we can represent AAAABBCCC as [0,0,0,0,1,1,2,2,2], but could also represent other patterns of arbitrary ordering ([0,1,2,1,0,1,2,1] and such). It's not quite as compact for your case (I suppose we could add something like focus_repeats, but I'm not overly concerned with the length of the representation in Solution since it will be computed).

Now to implement the computing of the ispta balancing, I'd like to see this integrated into the solution.scale method rather than as a separate optimize pipeline in 'calc_solution. Perhaps we introduce a scaling_optionsdict attribute toProtocolthat will get passed through assolution.scale(..., **protocol.scaling_options). This would also lay the groundwork for other types of balancing (like balancing ispta via amplitude, or doing similar repetition balancing, but with minimized repeats at each position (ACABCACABinstead ofAAAABBCCC).

Sound good?

Great work.

@samueljwu
Copy link
Copy Markdown
Author

Thanks @peterhollender for the detailed feedback! This is my first contribution attempt so I really appreciate you taking the time. This approach makes sense to me and I agree it's cleaner. Let me work through the changes and follow up with an updated version. Excited to contribute here.

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.

Add support for non-uniform focal point hit counts

3 participants