Skip to content

Conversation

@swalker2m
Copy link
Contributor

@swalker2m swalker2m commented Nov 20, 2025

The next installment of GMOS imaging support, this PR adds offset generators to the API. The offset generation isn't used yet per se, but obviously this was a sufficiently large chunk of work already.

The offset generators offer the offset generation parameters in the API and store them in the database. The idea is that the ITC results will provide the offset count and the ODB will combine that with the other parameters and then mostly delegate the actual offset generation to the core offset generator.

N.B., I might hold off on incorporating any of this into Explore since the API is still being developed.

@github-actions github-actions bot added the skip-sc Don't complete the associated story when merged. label Nov 20, 2025
lst.pure[F]

case OffsetGeneratorInput.Grid(a, b) =>
val w = (a.p.toSignedDecimalArcseconds - b.p.toSignedDecimalArcseconds).abs
Copy link
Contributor

Choose a reason for hiding this comment

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

we have a grid generator on lucuma-core, does it not work here? maybe we should update core if that is the case

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The issue is that the core grid generator parameters require an explicit n x m count and a step size. If we used it directly, then the offset parameters will have to be explicitly adjusted as changes are made that increase or decrease the ITC exposure count result. I could be wrong but I think the idea with the grid is to cover a region as evenly as possible with exposures for any exposure count that the ITC returns.

Copy link
Contributor

Choose a reason for hiding this comment

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

ok, can we make the core one do that? passing a count parameter instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good. I'll add that as an option and make the ODB use it instead of its own implementation, but maybe after this PR?

Copy link
Contributor

Choose a reason for hiding this comment

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

yes, no hurry

@swalker2m swalker2m force-pushed the sc-4028-gmos-imaging-03 branch 3 times, most recently from 3733a99 to 47f2db7 Compare November 25, 2025 12:08
@swalker2m swalker2m force-pushed the sc-4028-gmos-imaging-03 branch 4 times, most recently from 81e88a1 to 40ea91a Compare November 26, 2025 18:59
@swalker2m swalker2m marked this pull request as ready for review November 26, 2025 19:07
offsets: [Offset!]!

"Temporary, WIP -- will be moved."
objectOffsetGenerator: OffsetGenerator
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note the offsets field above would be removed and the generators themselves placed into new imaging observation sub-types 😢. I didn't want to break anything yet though.

@cquiroz cquiroz requested a review from Copilot November 27, 2025 12:58
Copilot finished reviewing on behalf of cquiroz November 27, 2025 13:00
Copy link

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 offset generators to the ODB API for GMOS imaging observations. The offset generators define how telescope offsets are generated (enumerated, grid, random, or spiral patterns) and are stored in the database but not yet used in actual sequence generation.

Key Changes

  • Introduced OffsetGeneratorService to manage offset generation parameters
  • Added database tables and views for storing offset generator configurations
  • Extended GraphQL schema with OffsetGenerator types and input handling
  • Integrated offset generators into GMOS imaging observing modes

Reviewed changes

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

Show a summary per file
File Description
OffsetGeneratorService.scala Core service implementing offset generation logic with enumerated, grid, random, and spiral patterns
V1064__offset_generator.sql Database migration adding offset generator tables, enums, and triggers
OffsetGeneratorInput.scala GraphQL input handling with validation ensuring only one generator type is specified
OffsetGeneratorServiceSuite.scala Test suite covering different offset generation scenarios
GmosImagingService.scala Integration layer connecting offset generators with GMOS imaging modes
OdbSchema.graphql GraphQL schema additions exposing offset generator types and inputs
OffsetMapping.scala Grackle mappings for offset generator GraphQL queries
Various mapping files Updated to include offset generator views and joins

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

List(
offsetMappingAtPath(StepRecordType / "telescopeConfig" / "offset", StepRecordView.Id)
)
offsetMappingAtPath(SpiralPath, OffsetGeneratorView.ObservationId, OffsetGeneratorView.Spiral.ObservationId, OffsetGeneratorView.Spiral.OffsetGeneratorRole),
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The SpiralPath mapping is inconsistent with the other paths. Line 65 passes three column references (OffsetGeneratorView.ObservationId, OffsetGeneratorView.Spiral.ObservationId, OffsetGeneratorView.Spiral.OffsetGeneratorRole) but the RandomPath mapping on line 61 only passes two. This appears to be an error - it should match the pattern used for Random and only pass OffsetGeneratorView.Spiral.ObservationId and OffsetGeneratorView.Spiral.OffsetGeneratorRole.

Suggested change
offsetMappingAtPath(SpiralPath, OffsetGeneratorView.ObservationId, OffsetGeneratorView.Spiral.ObservationId, OffsetGeneratorView.Spiral.OffsetGeneratorRole),
offsetMappingAtPath(SpiralPath, OffsetGeneratorView.Spiral.ObservationId, OffsetGeneratorView.Spiral.OffsetGeneratorRole),

Copilot uses AI. Check for mistakes.
Comment on lines 229 to 233
val withinSizeLimit =
r.values
.flatten
.map(_.offset.distance(Offset.Zero).toMicroarcseconds)
.forall(_ > sizeMicroarcseconds)
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The logic for withinSizeLimit appears inverted. The test checks that offset distances are greater than sizeMicroarcseconds (line 233: _ > sizeMicroarcseconds), but it should verify that they are less than or equal to the size limit. Random and spiral offsets should be contained within the specified size/radius, not outside of it.

Copilot uses AI. Check for mistakes.
Comment on lines +218 to +232
val stepP = if cols <= 2 then w else w / (cols - 1) // arcseconds
val stepQ = if rows <= 2 then h else h / (rows - 1) // arcseconds
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The edge case handling for grid step calculation has an off-by-one issue. When cols <= 2 or rows <= 2, the step size is set to the full width/height. However, for exactly 2 columns/rows, this will place both points at the same position (corner). The condition should be cols <= 1 and rows <= 1 to correctly handle 2 points spanning the full width/height.

Suggested change
val stepP = if cols <= 2 then w else w / (cols - 1) // arcseconds
val stepQ = if rows <= 2 then h else h / (rows - 1) // arcseconds
val stepP = if cols <= 1 then w else w / (cols - 1) // arcseconds
val stepQ = if rows <= 1 then h else h / (rows - 1) // arcseconds

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

w = w / (2 - 1) and h = h / (2 - 1)

List.empty[TelescopeConfig].pure[F]

case OffsetGeneratorInput.Enumerated(lst) =>
LazyList.continually(lst).flatten.take(count).toList.pure[F]
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The Enumerated case doesn't respect the guideState parameter. Each TelescopeConfig in lst already has its own guiding value, but the method signature suggests the passed guideState should be applied. This inconsistency could lead to incorrect guiding behavior. Consider either documenting that enumerated offsets preserve their own guide states, or apply the parameter's guide state to all returned configs.

Suggested change
LazyList.continually(lst).flatten.take(count).toList.pure[F]
LazyList.continually(lst)
.flatten
.take(count)
.map(tc => tc.copy(guideState = guideState))
.toList
.pure[F]

Copilot uses AI. Check for mistakes.
}
}

test("close GMOS imaging observation preserves offset generator"):
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

Typo in test name: "close" should be "clone".

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@cquiroz cquiroz left a comment

Choose a reason for hiding this comment

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

Looks like an amazing work, I didn't check the tests but the code looks great. I left a few comments but none is a stopper

offsets: [OffsetInput!]

"Temporary, WIP -- will be moved."
objectOffsetGenerator: OffsetGeneratorInput
Copy link
Contributor

Choose a reason for hiding this comment

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

What's the difference between object and sky offsets?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sky offsets are used to calibrate the science data. I think they measure sky background to contrast with the science, but Andy or Bryan would have a real explanation. For our purposes, some imaging sequences will come with "sky" positions sandwiched before/after the science data. These are typically far enough away that they are cannot be guided (I believe) and may have a different pattern than the science offsets.

# Defines a region of the sky using two corners. Exposures are
# then distributed across this region as evenly as possible.
type GridOffsetGenerator {
cornerA: Offset!
Copy link
Contributor

Choose a reason for hiding this comment

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

are these offsets from the base?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right.

array_remove(array_agg(c_filter ORDER BY c_filter), NULL) AS c_filters
FROM t_gmos_south_imaging_filter
WHERE c_version = 'current'
GROUP BY c_observation_id
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we drop the existing offset columns/table?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, in a future PR there will be updates to the existing imaging offset positions in the API and database.

case object NoGenerator extends OffsetGeneratorInput

case class Enumerated(
values: List[TelescopeConfig]
Copy link
Contributor

Choose a reason for hiding this comment

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

Should the be nonempty?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I didn't see why it should be limited if an empty list works but now that you mention it, that just gives another way to have a NoGenerator option. I think I'll change it.

case OffsetGeneratorInput.Grid(a, b) =>
val w = (a.p.toSignedDecimalArcseconds - b.p.toSignedDecimalArcseconds).abs
val h = (a.q.toSignedDecimalArcseconds - b.q.toSignedDecimalArcseconds).abs
val aspectRatio = w / h
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I need to handle the case where the q values are the same (and thus h is 0) before this can be merged. I think I'll rename it uniform to distinguish it from the grid option. I'll add it to core as well for eventual use from the ODB.

@swalker2m swalker2m force-pushed the sc-4028-gmos-imaging-03 branch from 40ea91a to 6f52dc1 Compare November 27, 2025 20:50
@swalker2m swalker2m merged commit 895c11d into main Nov 28, 2025
25 checks passed
@swalker2m swalker2m deleted the sc-4028-gmos-imaging-03 branch November 28, 2025 11:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

calibrations obscalc schema sequence skip-sc Don't complete the associated story when merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants