diff --git a/build.sbt b/build.sbt index 8d3a084de..f47cce41a 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -ThisBuild / tlBaseVersion := "0.108" +ThisBuild / tlBaseVersion := "0.109" ThisBuild / tlCiReleaseBranches := Seq("master") ThisBuild / githubWorkflowEnv += "MUNIT_FLAKY_OK" -> "true" diff --git a/modules/core/shared/src/main/scala/lucuma/core/math/dimensional/package.scala b/modules/core/shared/src/main/scala/lucuma/core/math/dimensional/package.scala deleted file mode 100644 index 9ebf87b6f..000000000 --- a/modules/core/shared/src/main/scala/lucuma/core/math/dimensional/package.scala +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA) -// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause - -package lucuma.core.math.dimensional - -import coulomb.Quantity -import lucuma.core.util.* - -extension[N, U](quantity: Quantity[N, U]) - - /** - * Convert a coulomb `Quantity` to a `Measure` with runtime unit representation. - */ - def toMeasure(using unit: UnitOfMeasure[U]): Measure[N] = Measure(quantity.value, unit) - - /** - * Convert a coulomb `Quantity` to a `Measure` with runtime unit representation and tag `Tag`. - */ - def toMeasureTagged[T](using ev: TaggedUnit[U, T]): Measure[N] Of T = { - val tagged: Measure[N] Of T = tag[T](Measure(quantity.value, ev.unit)) - tagged - } diff --git a/modules/core/shared/src/main/scala/lucuma/core/math/dimensional/syntax.scala b/modules/core/shared/src/main/scala/lucuma/core/math/dimensional/syntax.scala new file mode 100644 index 000000000..bdcd3541c --- /dev/null +++ b/modules/core/shared/src/main/scala/lucuma/core/math/dimensional/syntax.scala @@ -0,0 +1,23 @@ +// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package lucuma.core.math.dimensional + +import coulomb.Quantity +import lucuma.core.util.* + +object syntax: + extension[N, U](quantity: Quantity[N, U]) + + /** + * Convert a coulomb `Quantity` to a `Measure` with runtime unit representation. + */ + def toMeasure(using unit: UnitOfMeasure[U]): Measure[N] = Measure(quantity.value, unit) + + /** + * Convert a coulomb `Quantity` to a `Measure` with runtime unit representation and tag `Tag`. + */ + def toMeasureTagged[T](using ev: TaggedUnit[U, T]): Measure[N] Of T = { + val tagged: Measure[N] Of T = tag[T](Measure(quantity.value, ev.unit)) + tagged + } diff --git a/modules/core/shared/src/main/scala/lucuma/core/math/units.scala b/modules/core/shared/src/main/scala/lucuma/core/math/units.scala index 625256f89..dfc799b09 100644 --- a/modules/core/shared/src/main/scala/lucuma/core/math/units.scala +++ b/modules/core/shared/src/main/scala/lucuma/core/math/units.scala @@ -43,8 +43,16 @@ trait units { type PicometersPerPixel = Picometer / Pixels type MetersPerSecond = Meter / Second + given DerivedUnit[MetersPerSecond, Meter / Second, "meters per second", "m/s"] = DerivedUnit() + given TypeString[MetersPerSecond] = TypeString("M_S") + type CentimetersPerSecond = (Centi * Meter) / Second - type KilometersPerSecond = (Kilo * Meter) / Second + given DerivedUnit[CentimetersPerSecond, (Centi * Meter) / Second, "centimeters per second", "cm/s"] = DerivedUnit() + given TypeString[CentimetersPerSecond] = TypeString("CM_S") + + type KilometersPerSecond + given DerivedUnit[KilometersPerSecond, (Kilo * Meter) / Second, "kilometers per second", "km/s"] = DerivedUnit() + given TypeString[KilometersPerSecond] = TypeString("KM_S") type Year given DerivedUnit[Year, 365 * Day, "year", "y"] = DerivedUnit() diff --git a/modules/core/shared/src/main/scala/lucuma/core/model/SourceProfile.scala b/modules/core/shared/src/main/scala/lucuma/core/model/SourceProfile.scala index 06816b23c..3f6b2f704 100644 --- a/modules/core/shared/src/main/scala/lucuma/core/model/SourceProfile.scala +++ b/modules/core/shared/src/main/scala/lucuma/core/model/SourceProfile.scala @@ -40,6 +40,25 @@ sealed trait SourceProfile extends Product with Serializable { * necessary. */ def toGaussian: SourceProfile.Gaussian + + /** + * Returns the band of a source profile closest to the given wavelength. + * It is only valid for source profiles with integrated or surface brightnesses. + */ + def nearestBand(wavelength: Wavelength): Option[Band] = + SourceProfile.integratedBandNormalizedSpectralDefinition.getOption(this).orElse: + SourceProfile.surfaceBandNormalizedSpectralDefinition.getOption(this) + .flatMap(_.nearestBand(wavelength)) + + /** + * Returns the emission line of a source profile closest to the given wavelength. + * It is only valid for source profiles with integrated or surface emission lines. + */ + def nearestLine(wavelength: Wavelength): Option[Wavelength] = + SourceProfile.integratedEmissionLinesSpectralDefinition.getOption(this).orElse: + SourceProfile.surfaceEmissionLinesSpectralDefinition.getOption(this) + .flatMap(_.nearestLine(wavelength)) + } object SourceProfile { @@ -270,30 +289,4 @@ object SourceProfile { /** @group Optics */ val surfaceFluxDensityContinuum: Optional[SourceProfile, FluxDensityContinuumMeasure[Surface]] = surfaceSpectralDefinition.andThen(SpectralDefinition.fluxDensityContinuum[Surface]) - - /** - * Returns the band and brightness value for the band closest to the given wavelength. - */ - def extractBand[T](w: Wavelength, bMap: SortedMap[Band, BrightnessMeasure[T]]): Option[(Band, BrightnessValue, Units)] = - bMap.minByOption { case (b, _) => - (w.toPicometers.value.value - b.center.toPicometers.value.value).abs - }.map(x => (x._1, x._2.value, x._2.units)) - - extension(sp: SourceProfile) - /** - * Returns the band and brightness of a source profile for the band closest to the given wavelength. - * It is only valid for source profiles with integrated or surface brightnesses. - * (Emission lines not supported) - */ - def nearestBand(wavelength: Wavelength): Option[(Band, BrightnessValue, Units)] = - SourceProfile - .integratedBrightnesses - .getOption(sp) - .flatMap(bMap => extractBand[Integrated](wavelength, bMap)) - .orElse( - SourceProfile - .surfaceBrightnesses - .getOption(sp) - .flatMap(bMap => extractBand[Surface](wavelength, bMap)) - ) } diff --git a/modules/core/shared/src/main/scala/lucuma/core/model/SpectralDefinition.scala b/modules/core/shared/src/main/scala/lucuma/core/model/SpectralDefinition.scala index 510b6c2d7..23db7959d 100644 --- a/modules/core/shared/src/main/scala/lucuma/core/model/SpectralDefinition.scala +++ b/modules/core/shared/src/main/scala/lucuma/core/model/SpectralDefinition.scala @@ -59,6 +59,13 @@ object SpectralDefinition { sed, brightnesses.map { case (band, brightness) => band -> brightness.toTag[Brightness[T0]] } ) + + /** + * Returns the defined band closest to the given wavelength. + */ + def nearestBand(wavelength: Wavelength): Option[Band] = + brightnesses.keySet.minByOption: band => + (wavelength.toPicometers.value.value - band.center.toPicometers.value.value).abs } object BandNormalized { @@ -102,6 +109,13 @@ object SpectralDefinition { lines.map { case (wavelength, line) => wavelength -> line.to[T0] }, fluxDensityContinuum.toTag[FluxDensityContinuum[T0]] ) + + /** + * Returns the defined line closest to the given wavelength. + */ + def nearestLine(wavelength: Wavelength): Option[Wavelength] = + lines.keySet.minByOption: line => + (wavelength.toPicometers.value.value - line.toPicometers.value.value).abs } object EmissionLines { diff --git a/modules/core/shared/src/main/scala/lucuma/core/util/Display.scala b/modules/core/shared/src/main/scala/lucuma/core/util/Display.scala index e9224daff..8940728a0 100644 --- a/modules/core/shared/src/main/scala/lucuma/core/util/Display.scala +++ b/modules/core/shared/src/main/scala/lucuma/core/util/Display.scala @@ -4,6 +4,8 @@ package lucuma.core.util import cats.Contravariant +import cats.syntax.contravariant.* +import eu.timepit.refined.api.* /** * Typeclass for things that can be shown in a user interface. @@ -50,4 +52,8 @@ object Display { override def longName(b: B) = fa.longName(f(b)) } } + + given [T, P](using ev: Display[T]): Display[T Refined P] = ev.contramap(_.value) + + given Display[BigDecimal] = byShortName(_.toString.toLowerCase) // Use lower case "e" for exponent } diff --git a/modules/tests/shared/src/test/scala/lucuma/core/math/dimensional/MeasureSuite.scala b/modules/tests/shared/src/test/scala/lucuma/core/math/dimensional/MeasureSuite.scala index ebe56a50d..93361ee2c 100644 --- a/modules/tests/shared/src/test/scala/lucuma/core/math/dimensional/MeasureSuite.scala +++ b/modules/tests/shared/src/test/scala/lucuma/core/math/dimensional/MeasureSuite.scala @@ -38,9 +38,6 @@ class MeasureSuite extends munit.DisciplineSuite { val m = UnitOfMeasure[ABMagnitude].withValue(BigDecimal(1.235)) - implicit val displayBigDecimal: Display[BigDecimal] = - Display.byShortName(_.toString) - assertEquals(m.shortName, "1.235 AB mag") assertEquals(m.withError(BigDecimal(0.005)).shortName, "1.235 ± 0.005 AB mag") assertEquals(m.withError(BigDecimal(0.005)).displayWithoutError, "1.235 AB mag") diff --git a/modules/tests/shared/src/test/scala/lucuma/core/model/SourceProfileSuite.scala b/modules/tests/shared/src/test/scala/lucuma/core/model/SourceProfileSuite.scala index b9b0fe47a..470aa1ad4 100644 --- a/modules/tests/shared/src/test/scala/lucuma/core/model/SourceProfileSuite.scala +++ b/modules/tests/shared/src/test/scala/lucuma/core/model/SourceProfileSuite.scala @@ -128,21 +128,24 @@ final class SourceProfileSuite extends DisciplineSuite { assertEquals(gaussian2.toGaussian, gaussian2) } - test("extractBand") { + test("nearestBand") { val wv = Wavelength(578000.refined[Positive]) - assert(SourceProfile.extractBand(wv, SortedMap.empty).isEmpty) - assert(SourceProfile.extractBand(wv, sd1Brightnesses).exists(_._1 === Band.R)) - assert(SourceProfile.extractBand(wv, sd1BrightnessesB).exists(_._1 === Band.SloanR)) + assert(point1.nearestBand(wv).contains_(Band.R)) + assert(point2.nearestBand(wv).isEmpty) + assert(uniform1.nearestBand(wv).contains_(Band.R)) + assert(uniform2.nearestBand(wv).isEmpty) + assert(gaussian1.nearestBand(wv).contains_(Band.R)) + assert(gaussian2.nearestBand(wv).isEmpty) } - test("nearestBand") { + test("nearestLine") { val wv = Wavelength(578000.refined[Positive]) - assert(point1.nearestBand(wv).exists(_._1 === Band.R)) - assert(point2.nearestBand(wv).isEmpty) // Emission lines not supported - assert(uniform1.nearestBand(wv).exists(_._1 === Band.R)) - assert(uniform2.nearestBand(wv).isEmpty) // Emission lines not supported - assert(gaussian1.nearestBand(wv).exists(_._1 === Band.R)) - assert(gaussian2.nearestBand(wv).isEmpty) // Emission lines not supported + assert(point1.nearestLine(wv).isEmpty) + assert(point2.nearestLine(wv).contains_(Wavelength.Min)) + assert(uniform1.nearestLine(wv).isEmpty) + assert(uniform2.nearestLine(wv).contains_(Wavelength.Min)) + assert(gaussian1.nearestLine(wv).isEmpty) + assert(gaussian2.nearestLine(wv).contains_(Wavelength.Min)) } // Laws for SourceProfile.Point diff --git a/modules/tests/shared/src/test/scala/lucuma/core/model/SpectralDefinitionSuite.scala b/modules/tests/shared/src/test/scala/lucuma/core/model/SpectralDefinitionSuite.scala index 2dcaeec49..357559c77 100644 --- a/modules/tests/shared/src/test/scala/lucuma/core/model/SpectralDefinitionSuite.scala +++ b/modules/tests/shared/src/test/scala/lucuma/core/model/SpectralDefinitionSuite.scala @@ -9,6 +9,7 @@ import cats.syntax.all.* import coulomb.* import coulomb.syntax.* import eu.timepit.refined.cats.* +import eu.timepit.refined.numeric.Positive import lucuma.core.enums.Band import lucuma.core.enums.StellarLibrarySpectrum import lucuma.core.math.BrightnessUnits @@ -25,6 +26,7 @@ import lucuma.core.math.units.* import lucuma.core.model.arb.* import lucuma.core.util.arb.ArbCollection import lucuma.core.util.arb.ArbEnumerated +import lucuma.refined.* import monocle.law.discipline.* import munit.* @@ -44,7 +46,7 @@ final class SpectralDefinitionSuite extends DisciplineSuite { import BrightnessUnits.* // Brightness type conversions - val sd1Integrated: SpectralDefinition[Integrated] = + val sd1Integrated: SpectralDefinition.BandNormalized[Integrated] = SpectralDefinition.BandNormalized( UnnormalizedSED.StellarLibrary(StellarLibrarySpectrum.A0I).some, SortedMap( @@ -52,7 +54,7 @@ final class SpectralDefinitionSuite extends DisciplineSuite { ) ) - val sd1Surface: SpectralDefinition[Surface] = + val sd1Surface: SpectralDefinition.BandNormalized[Surface] = SpectralDefinition.BandNormalized( UnnormalizedSED.StellarLibrary(StellarLibrarySpectrum.A0I).some, SortedMap( @@ -60,7 +62,7 @@ final class SpectralDefinitionSuite extends DisciplineSuite { ) ) - val sd2Integrated: SpectralDefinition[Integrated] = + val sd2Integrated: SpectralDefinition.EmissionLines[Integrated] = SpectralDefinition.EmissionLines( SortedMap( Wavelength.Min -> EmissionLine( @@ -73,7 +75,7 @@ final class SpectralDefinitionSuite extends DisciplineSuite { ) ) - val sd2Surface: SpectralDefinition[Surface] = + val sd2Surface: SpectralDefinition.EmissionLines[Surface] = SpectralDefinition.EmissionLines( SortedMap( Wavelength.Min -> EmissionLine( @@ -103,6 +105,18 @@ final class SpectralDefinitionSuite extends DisciplineSuite { assertEquals(sd2Surface.to[Integrated].to[Surface], sd2Surface) } + test("nearestBand") { + val wv = Wavelength(578000.refined[Positive]) + assert(sd1Integrated.nearestBand(wv).contains_(Band.R)) + assert(sd1Surface.nearestBand(wv).contains_(Band.R)) + } + + test("nearestLine") { + val wv = Wavelength(578000.refined[Positive]) + assert(sd2Integrated.nearestLine(wv).contains_(Wavelength.Min)) + assert(sd2Surface.nearestLine(wv).contains_(Wavelength.Min)) + } + // Laws for SpectralDefinition.BandNormalized checkAll( "Eq[SpectralDefinition.BandNormalized[Integrated]]",