diff --git a/common/src/main/scala/explore/model/reusability.scala b/common/src/main/scala/explore/model/reusability.scala index 350aae8844..87dcf44990 100644 --- a/common/src/main/scala/explore/model/reusability.scala +++ b/common/src/main/scala/explore/model/reusability.scala @@ -5,6 +5,7 @@ package explore.model import cats.Eq import cats.data.NonEmptyChain +import cats.syntax.all.* import clue.PersistentClientStatus import explore.data.KeyedIndexedList import explore.model.IsActive @@ -24,7 +25,15 @@ import lucuma.ags.AgsPosition import lucuma.ags.GuideStarCandidate import lucuma.catalog.AngularSize import lucuma.catalog.CatalogTargetResult +import lucuma.core.enums.GmosNorthFilter +import lucuma.core.enums.GmosNorthFpu +import lucuma.core.enums.GmosNorthGrating +import lucuma.core.enums.GmosSouthFilter +import lucuma.core.enums.GmosSouthFpu +import lucuma.core.enums.GmosSouthGrating +import lucuma.core.math.Offset import lucuma.core.math.SignalToNoise +import lucuma.core.math.WavelengthDither import lucuma.core.model.ObjectTracking import lucuma.core.model.PosAngleConstraint import lucuma.core.model.TimingWindow @@ -91,7 +100,6 @@ object reusability: given Reusability[Progress] = Reusability.byEq given Reusability[AngularSize] = Reusability.byEq given Reusability[CatalogTargetResult] = Reusability.byEq - given Reusability[ObservingMode] = Reusability.byEq given Reusability[BasicConfiguration] = Reusability.byEq given Reusability[BasicConfigAndItc] = Reusability.byEq given Reusability[GuideStarCandidate] = Reusability.by(_.name.value) @@ -132,6 +140,133 @@ object reusability: given Reusability[CategoryAllocationList] = Reusability.byEq given Reusability[InstrumentOverrides] = Reusability.byEq given [A: Reusability]: Reusability[NonEmptyChain[A]] = Reusability.by(_.toNonEmptyList) + given Reusability[WavelengthDither] = Reusability.byEq + given [A]: Reusability[Offset.Component[A]] = Reusability.byEq + // We explicitly leave default binning out of ObservingMode Reusability since we compute it each time, ignoring the server value. + given Reusability[ObservingMode.GmosNorthLongSlit] = + Reusability.by: x => + (x.grating, + x.filter, + x.fpu, + x.centralWavelength, + (x.explicitXBin, x.explicitYBin).tupled, + x.explicitAmpReadMode.getOrElse(x.defaultAmpReadMode), + x.explicitAmpGain.getOrElse(x.defaultAmpGain), + x.explicitRoi.getOrElse(x.defaultRoi), + x.explicitWavelengthDithers.getOrElse(x.defaultWavelengthDithers), + x.explicitSpatialOffsets.getOrElse(x.defaultSpatialOffsets) + ) + given Reusability[ObservingMode.GmosSouthLongSlit] = + Reusability.by: x => + (x.grating, + x.filter, + x.fpu, + x.centralWavelength, + (x.explicitXBin, x.explicitYBin).tupled, + x.explicitAmpReadMode.getOrElse(x.defaultAmpReadMode), + x.explicitAmpGain.getOrElse(x.defaultAmpGain), + x.explicitRoi.getOrElse(x.defaultRoi), + x.explicitWavelengthDithers.getOrElse(x.defaultWavelengthDithers), + x.explicitSpatialOffsets.getOrElse(x.defaultSpatialOffsets) + ) + given Reusability[ObservingMode] = Reusability: + case (x @ ObservingMode.GmosNorthLongSlit(_, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _ + ), + y @ ObservingMode.GmosNorthLongSlit(_, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _ + ) + ) => + summon[Reusability[ObservingMode.GmosNorthLongSlit]].test(x, y) + case (x @ ObservingMode.GmosSouthLongSlit(_, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _ + ), + y @ ObservingMode.GmosSouthLongSlit(_, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _, + _ + ) + ) => + summon[Reusability[ObservingMode.GmosSouthLongSlit]].test(x, y) + case _ => false // We want to re render only when the vizTime changes at least a month // We keep the candidates data pm corrected for the viz time diff --git a/explore/src/main/scala/explore/itc/ItcProps.scala b/explore/src/main/scala/explore/itc/ItcProps.scala index 351d381b52..387dd0d5d6 100644 --- a/explore/src/main/scala/explore/itc/ItcProps.scala +++ b/explore/src/main/scala/explore/itc/ItcProps.scala @@ -152,14 +152,13 @@ case class ItcProps( action.getOrElse(orElse) object ItcProps: - given Reusability[ItcProps] = Reusability.by(p => - (p.observation.scienceTargetIds.toList, - p.observation.constraints, - p.observation.scienceRequirements, - p.observation.observingMode, - p.observation.wavelength, - p.selectedConfig, - p.at, - p.modeOverrides - ) - ) + given Reusability[ItcProps] = + Reusability.by: p => + (p.observation.scienceTargetIds.toList.sorted, + p.observation.constraints, + p.observation.scienceRequirements, + p.observation.wavelength, + p.selectedConfig, + p.at, + p.modeOverrides + ) diff --git a/explore/src/main/scala/explore/tabs/ObsTabTiles.scala b/explore/src/main/scala/explore/tabs/ObsTabTiles.scala index 5c193841fe..0490dc7376 100644 --- a/explore/src/main/scala/explore/tabs/ObsTabTiles.scala +++ b/explore/src/main/scala/explore/tabs/ObsTabTiles.scala @@ -131,9 +131,9 @@ object ObsTabTiles: private def itcQueryProps( obs: Observation, selectedConfig: Option[BasicConfigAndItc], - targetsList: TargetList + targetList: TargetList ): ItcProps = - ItcProps(obs, selectedConfig, targetsList, obs.toModeOverride) + ItcProps(obs, selectedConfig, targetList, obs.toModeOverride(targetList)) private case class Offsets( science: Option[NonEmptyList[Offset]], @@ -182,39 +182,39 @@ object ObsTabTiles: .useStateWithReuse(LoadingState.Done) .useEffectWithDepsBy((props, _, _, _, _, selectedConfig, _, _, _, _) => itcQueryProps(props.observation.get, selectedConfig.get, props.allTargets) - ) { (props, ctx, _, _, _, _, oldItcProps, graphs, brightestTarget, loading) => itcProps => - import ctx.given + ): (props, ctx, _, _, _, _, oldItcProps, graphs, brightestTarget, loading) => + itcProps => + import ctx.given - oldItcProps.setState(itcProps).when_(itcProps.isExecutable) *> - itcProps - .requestGraphs( - (asterismGraphs, brightestTargetResult) => { - val graphsResult = - asterismGraphs - .map: - case (k, Left(e)) => - k -> (Pot.error(new RuntimeException(e.shortName)): Pot[ItcGraphResult]) - case (k, Right(e)) => - k -> (Pot.Ready(e): Pot[ItcGraphResult]) + oldItcProps.setState(itcProps).when_(itcProps.isExecutable) *> + itcProps + .requestGraphs( + (asterismGraphs, brightestTargetResult) => { + val graphsResult = + asterismGraphs + .map: + case (k, Left(e)) => + k -> (Pot.error(new RuntimeException(e.shortName)): Pot[ItcGraphResult]) + case (k, Right(e)) => + k -> (Pot.Ready(e): Pot[ItcGraphResult]) + .toMap + graphs.setStateAsync(graphsResult) *> + brightestTarget.setStateAsync(brightestTargetResult) *> + loading.setState(LoadingState.Done).value.toAsync + }, + (graphs.setState( + itcProps.targets + .map: t => + t -> Pot.error: + new RuntimeException("Not enough information to calculate the ITC graph") .toMap - graphs.setStateAsync(graphsResult) *> - brightestTarget.setStateAsync(brightestTargetResult) *> - loading.setState(LoadingState.Done).value.toAsync - }, - (graphs.setState( - itcProps.targets - .map: t => - t -> Pot.error: - new RuntimeException("Not enough information to calculate the ITC graph") - .toMap - ) *> - brightestTarget.setState(none) *> - loading.setState(LoadingState.Done)).toAsync, - loading.setState(LoadingState.Loading).value.toAsync - ) - .whenA(itcProps.isExecutable) - .runAsyncAndForget - } + ) *> + brightestTarget.setState(none) *> + loading.setState(LoadingState.Done)).toAsync, + loading.setState(LoadingState.Loading).value.toAsync + ) + .whenA(itcProps.isExecutable) + .runAsyncAndForget // Signal that the sequence has changed .useStateView(().ready) .useEffectKeepResultWithDepsBy((p, _, _, _, _, _, _, _, _, _, _) => diff --git a/model/shared/src/main/scala/explore/model/Observation.scala b/model/shared/src/main/scala/explore/model/Observation.scala index 5f7f96d8d2..5063178e61 100644 --- a/model/shared/src/main/scala/explore/model/Observation.scala +++ b/model/shared/src/main/scala/explore/model/Observation.scala @@ -5,6 +5,7 @@ package explore.model import cats.Eq import cats.Order.given +import cats.data.NonEmptyList import cats.derived.* import cats.syntax.all.* import eu.timepit.refined.cats.* @@ -18,8 +19,6 @@ import io.circe.generic.semiauto.* import io.circe.refined.given import lucuma.core.enums.CalibrationRole import lucuma.core.enums.GmosAmpCount -import lucuma.core.enums.GmosAmpGain -import lucuma.core.enums.GmosAmpReadMode import lucuma.core.enums.GmosXBinning import lucuma.core.enums.GmosYBinning import lucuma.core.enums.ObsActiveStatus @@ -30,9 +29,11 @@ import lucuma.core.model.Group import lucuma.core.model.ObsAttachment import lucuma.core.model.ObservationValidation import lucuma.core.model.PosAngleConstraint +import lucuma.core.model.SourceProfile import lucuma.core.model.Target import lucuma.core.model.TimingWindow import lucuma.core.model.sequence.gmos.GmosCcdMode +import lucuma.core.model.sequence.gmos.longslit.* import lucuma.core.util.TimeSpan import lucuma.core.util.Timestamp import lucuma.odb.json.time.decoder.given @@ -82,97 +83,97 @@ case class Observation( val needsAGS: Boolean = calibrationRole.forall(_.needsAGS) - val toModeOverride: Option[InstrumentOverrides] = observingMode.map { - case ObservingMode.GmosNorthLongSlit( - _, - _, - _, - _, - _, - _, - _, - _, - defaultXBin, - explicitXBin, - defaultYBin, - explicitYBin, - defaultAmpReadMode, - explicitAmpReadMode, - defaultAmpGain, - explicitAmpGain, - _, - explicitRoi, - _, - _, - _, - _ - ) => - val defaultMode = - GmosCcdMode( - defaultXBin, - defaultYBin, - GmosAmpCount.Twelve, - defaultAmpGain, - defaultAmpReadMode - ) + // For multiple targets, we take the smallest binning for each axis. + // https://docs.google.com/document/d/1P8_pXLRVomUSvofyVkAniOyGROcAtiJ7EMYt9wWXB0o/edit?disco=AAAA32SmtD4 + private def asterismBinning( + bs: NonEmptyList[(GmosXBinning, GmosYBinning)] + ): (GmosXBinning, GmosYBinning) = + (bs.map(_._1).minimumBy(_.count), bs.map(_._2).minimumBy(_.count)) - val overridenMode: GmosCcdMode = - List(explicitXBin, explicitYBin, explicitAmpGain, explicitAmpReadMode).foldLeft( - defaultMode - ) { - case (mode, Some(x: GmosXBinning)) => mode.copy(xBin = x) - case (mode, Some(x: GmosYBinning)) => mode.copy(yBin = x) - case (mode, Some(x: GmosAmpGain)) => mode.copy(ampGain = x) - case (mode, Some(x: GmosAmpReadMode)) => mode.copy(ampReadMode = x) - case (mode, _) => mode - } + private def profiles(targets: TargetList): Option[NonEmptyList[SourceProfile]] = + NonEmptyList.fromList: + scienceTargetIds.toList.map(targets.get).flattenOption.map(_.sourceProfile) - GmosSpectroscopyOverrides(overridenMode.some, explicitRoi) - case ObservingMode.GmosSouthLongSlit( - _, - _, - _, - _, - _, - _, - _, - _, - defaultXBin, - explicitXBin, - defaultYBin, - explicitYBin, - defaultAmpReadMode, - explicitAmpReadMode, - defaultAmpGain, - explicitAmpGain, - _, - explicitRoi, - _, - _, - _, - _ - ) => - val defaultMode = - GmosCcdMode( - defaultXBin, - defaultYBin, - GmosAmpCount.Twelve, - defaultAmpGain, - defaultAmpReadMode - ) - - val overridenMode: GmosCcdMode = - List(explicitXBin, explicitYBin, explicitAmpGain, explicitAmpReadMode).foldLeft( - defaultMode - ) { - case (mode, Some(x: GmosXBinning)) => mode.copy(xBin = x) - case (mode, Some(x: GmosYBinning)) => mode.copy(yBin = x) - case (mode, Some(x: GmosAmpGain)) => mode.copy(ampGain = x) - case (mode, Some(x: GmosAmpReadMode)) => mode.copy(ampReadMode = x) - case (mode, _) => mode - } - GmosSpectroscopyOverrides(overridenMode.some, explicitRoi) - } + def toModeOverride(targets: TargetList): Option[InstrumentOverrides] = + observingMode.flatMap: + case ObservingMode.GmosNorthLongSlit( + _, + grating, + _, + _, + _, + fpu, + _, + _, + _, + explicitXBin, + _, + explicitYBin, + defaultAmpReadMode, + explicitAmpReadMode, + defaultAmpGain, + explicitAmpGain, + _, + explicitRoi, + _, + _, + _, + _ + ) => + profiles(targets).map: ps => + val (xBinning, yBinning): (GmosXBinning, GmosYBinning) = + (explicitXBin, explicitYBin).tupled.getOrElse: + if (fpu.isIFU) (GmosXBinning.One, GmosYBinning.One) + else asterismBinning(ps.map(northBinning(fpu, _, constraints.imageQuality, grating))) + GmosSpectroscopyOverrides( + GmosCcdMode( + xBinning, + yBinning, + GmosAmpCount.Twelve, + explicitAmpGain.getOrElse(defaultAmpGain), + explicitAmpReadMode.getOrElse(defaultAmpReadMode) + ).some, + explicitRoi + ) + case ObservingMode.GmosSouthLongSlit( + _, + grating, + _, + _, + _, + fpu, + _, + _, + _, + explicitXBin, + _, + explicitYBin, + defaultAmpReadMode, + explicitAmpReadMode, + defaultAmpGain, + explicitAmpGain, + _, + explicitRoi, + _, + _, + _, + _ + ) => + profiles(targets).map: ps => + val (xBinning, yBinning): (GmosXBinning, GmosYBinning) = + (explicitXBin, explicitYBin).tupled.getOrElse: + if (fpu.isIFU) (GmosXBinning.One, GmosYBinning.One) + else asterismBinning(ps.map(southBinning(fpu, _, constraints.imageQuality, grating))) + GmosSpectroscopyOverrides( + GmosCcdMode( + xBinning, + yBinning, + GmosAmpCount.Twelve, + explicitAmpGain.getOrElse(defaultAmpGain), + explicitAmpReadMode.getOrElse(defaultAmpReadMode) + ).some, + explicitRoi + ) lazy val constraintsSummary: String = s"${constraints.imageQuality.label} ${constraints.cloudExtinction.label} ${constraints.skyBackground.label} ${constraints.waterVapor.label}" diff --git a/model/shared/src/main/scala/explore/modes/SpectroscopyModesMatrix.scala b/model/shared/src/main/scala/explore/modes/SpectroscopyModesMatrix.scala index 7b173ed7ff..98a6897e6e 100644 --- a/model/shared/src/main/scala/explore/modes/SpectroscopyModesMatrix.scala +++ b/model/shared/src/main/scala/explore/modes/SpectroscopyModesMatrix.scala @@ -75,7 +75,6 @@ case class GmosNorthSpectroscopyRow( val instrument = Instrument.GmosNorth val site = Site.GN val hasFilter = filter.isDefined - } case class GmosSouthSpectroscopyRow( diff --git a/model/shared/src/main/scala/queries/schemas/itc/syntax.scala b/model/shared/src/main/scala/queries/schemas/itc/syntax.scala index f5426f0384..b4da17560b 100644 --- a/model/shared/src/main/scala/queries/schemas/itc/syntax.scala +++ b/model/shared/src/main/scala/queries/schemas/itc/syntax.scala @@ -13,10 +13,7 @@ import explore.modes.GmosNorthSpectroscopyRow import explore.modes.GmosSouthSpectroscopyRow import explore.modes.InstrumentRow import explore.optics.all.* -import lucuma.core.enums.GmosNorthFpu -import lucuma.core.enums.GmosSouthFpu -import lucuma.core.enums.GmosXBinning -import lucuma.core.enums.GmosYBinning +import lucuma.core.enums.GmosRoi import lucuma.core.enums.ImageQuality import lucuma.core.math.RadialVelocity import lucuma.core.model.* @@ -28,56 +25,22 @@ import lucuma.itc.client.TargetInput trait syntax: - // For multiple targets, we take the smallest binning for each axis. - // https://docs.google.com/document/d/1P8_pXLRVomUSvofyVkAniOyGROcAtiJ7EMYt9wWXB0o/edit?disco=AAAA32SmtD4 - private def asterismBinning( - bs: NonEmptyList[(GmosXBinning, GmosYBinning)] - ): (GmosXBinning, GmosYBinning) = - (bs.map(_._1).minimumBy(_.count), bs.map(_._2).minimumBy(_.count)) - extension (row: InstrumentRow) def toItcClientMode(ps: NonEmptyList[SourceProfile], iq: ImageQuality): Option[InstrumentMode] = - row match { + row match case GmosNorthSpectroscopyRow(grating, fpu, filter, modeOverrides) => - val (xbin, ybin) = - if (fpu.isIFU) (GmosXBinning.One, GmosYBinning.One) - else asterismBinning(ps.map(northBinning(fpu, _, iq, grating))) - val roi = modeOverrides.flatMap(_.roi).orElse(DefaultRoi.some) - val ccd = modeOverrides - .flatMap(_.ccdMode) - .orElse( - GmosCcdMode( - xbin, - ybin, - DefaultAmpCount, - DefaultAmpGain, - DefaultAmpReadMode - ).some - ) + val roi: Option[GmosRoi] = modeOverrides.flatMap(_.roi).orElse(DefaultRoi.some) + val ccd: Option[GmosCcdMode] = modeOverrides.flatMap(_.ccdMode) InstrumentMode .GmosNorthSpectroscopy(grating, filter, GmosFpu.North(fpu.asRight), ccd, roi) .some case GmosSouthSpectroscopyRow(grating, fpu, filter, modeOverrides) => - val (xbin, ybin) = - if (fpu.isIFU) (GmosXBinning.One, GmosYBinning.One) - else asterismBinning(ps.map(southBinning(fpu, _, iq, grating))) - val roi = modeOverrides.flatMap(_.roi).orElse(DefaultRoi.some) - val ccd = modeOverrides - .flatMap(_.ccdMode) - .orElse( - GmosCcdMode( - xbin, - ybin, - DefaultAmpCount, - DefaultAmpGain, - DefaultAmpReadMode - ).some - ) + val roi: Option[GmosRoi] = modeOverrides.flatMap(_.roi).orElse(DefaultRoi.some) + val ccd: Option[GmosCcdMode] = modeOverrides.flatMap(_.ccdMode) InstrumentMode .GmosSouthSpectroscopy(grating, filter, GmosFpu.South(fpu.asRight), ccd, roi) .some case _ => None - } // We may consider adjusting this to consider small variations of RV identical for the // purpose of doing ITC calculations diff --git a/workers/src/main/scala/workers/itc/ITCGraphRequests.scala b/workers/src/main/scala/workers/itc/ITCGraphRequests.scala index f6b0401ba0..c25cf832e3 100644 --- a/workers/src/main/scala/workers/itc/ITCGraphRequests.scala +++ b/workers/src/main/scala/workers/itc/ITCGraphRequests.scala @@ -43,7 +43,7 @@ object ITCGraphRequests: )(using Monoid[F[Unit]], ItcClient[F]): F[Unit] = val itcRowsParams = mode match // Only handle known modes - case m: GmosNorthSpectroscopyRow => + case m @ GmosNorthSpectroscopyRow(_, _, _, _) => ItcGraphRequestParams( wavelength, signalToNoise, @@ -52,7 +52,7 @@ object ITCGraphRequests: targets, m ).some - case m: GmosSouthSpectroscopyRow => + case m @ GmosSouthSpectroscopyRow(_, _, _, _) => ItcGraphRequestParams( wavelength, signalToNoise, @@ -61,7 +61,7 @@ object ITCGraphRequests: targets, m ).some - case _ => + case _ => none def doRequest(request: ItcGraphRequestParams): F[GraphResponse] =