Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,37 +31,37 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout current branch (full)
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Setup Java (temurin@11)
if: matrix.java == 'temurin@11'
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 11
cache: sbt

- name: Setup Java (temurin@17)
if: matrix.java == 'temurin@17'
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
cache: sbt

- name: Setup Java (temurin@19)
if: matrix.java == 'temurin@19'
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 19
cache: sbt

- name: Setup Java (temurin@21)
if: matrix.java == 'temurin@21'
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
Expand All @@ -75,14 +75,14 @@ jobs:

- name: Check formatting with scalafmt
if: runner.os == 'ubuntu-latest' && runner.java-version == '11'
run: sbt '++ ${{ matrix.scala }}' scalafmtCheckAll
run: sbt '++ ${{ matrix.scala }}; scalafmtCheckAll'

- name: Lint with scalafix
if: runner.os == 'ubuntu-latest' && runner.java-version == '11'
run: sbt '++ ${{ matrix.scala }}' 'scalafixAll --check'
run: sbt '++ ${{ matrix.scala }}; scalafixAll --check'

- name: Check that workflows are up to date
run: sbt '++ ${{ matrix.scala }}' githubWorkflowCheck
run: sbt '++ ${{ matrix.scala }}; githubWorkflowCheck'

- name: Build project
run: sbt '++ ${{ matrix.scala }}' test
run: sbt '++ ${{ matrix.scala }}; test'
3 changes: 3 additions & 0 deletions .github/workflows/clean.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ name: Clean

on: push

permissions:
actions: write

jobs:
delete-artifacts:
name: Delete Artifacts
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added
* `EuclideanDistance.unsafe` method, wrapping a `squants.space.Length` value as a `EuclideanDistance`,
performing the necessary validation as nonnegative and erroring otherwise.
* `configuration` module, with support for parsing definition of pixel lengths for `scopt` or `pureconfig`.
* Instances of `Show` and `SimpleShow` for `geometry.Distance`, with an implementation which can be guaranteed to roundtrip through `Distance.parse`
* Tests for `PixelDefinition` and `Pixels3D`

### Changed
* Changed the error message for refinement of `squants.space.Length` as a `Distance` value from "Allegedly nonnegative length must actually be nonnegative." to "Allegedly nonnegative length is actually negative."
* Updated `sbt` to version 1.12.13

## [v0.5.2] - 2026-03-18

### Fixed
Expand Down
16 changes: 13 additions & 3 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ ThisBuild / testOptions += Tests.Argument("-oF") // full stack traces

lazy val root = project
.in(file("."))
.aggregate(cell, geometry, graph, imaging, io, json, numeric, pan, refinement, roi, testing, zarr)
.aggregate(cell, configuration, geometry, graph, imaging, io, json, numeric, pan, refinement, roi, testing, zarr)
.enablePlugins(BuildInfoPlugin)
.settings(commonSettings)
.settings(noPublishSettings)
Expand All @@ -65,6 +65,15 @@ lazy val root = project
lazy val cell = defineModule("cell")(project)
.dependsOn(numeric)

lazy val configuration = defineModule("configuration")(project)
.dependsOn(imaging)
.settings(
libraryDependencies ++= Seq(
pureconfigCore,
pureconfigGeneric,
)
)

lazy val geometry = defineModule("geometry")(project)
.dependsOn(numeric, refinement)
.settings(
Expand Down Expand Up @@ -182,15 +191,16 @@ lazy val compileSettings = Def.settings(
"-rewrite",
// for scalafix RemoveUnused: https://scalacenter.github.io/scalafix/docs/rules/RemoveUnused.html
"-Wunused:all",
// Warnings about an unused symbol are expected in some source files testing whether code compiles or not.
/* Warnings about an unused symbol are expected in some source files testing whether code compiles or not. */
"-Wconf:msg=unused import&src=./modules/geometry/src/test/scala/TestDistance.scala:silent",
"-Wconf:msg=unused import&src=./modules/imaging/src/test/scala/TestImagingInstances.scala:silent",
"-Wconf:msg=unused import&src=./modules/testing/src/test/scala/TestInstanceAvailability.scala:silent",
"-Werror",
),
Test / console / scalacOptions := (Compile / console / scalacOptions).value,
)

lazy val versionNumber = "0.5.2"
lazy val versionNumber = "0.6.0-SNAPSHOT"

lazy val metadataSettings = Def.settings(
name := projectName,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package at.ac.oeaw.imba.gerlich.gerlib.configuration
package instances

import cats.syntax.all.*
import pureconfig.ConfigReader
import pureconfig.error.CannotConvert
import pureconfig.generic.semiauto.deriveReader
import squants.space.Length

import at.ac.oeaw.imba.gerlich.gerlib.imaging.{Pixels3D, PixelDefinition}

/** PureConfig typeclasses instances for imaging-related domain-specific types */
trait PureConfigImagingInstances:
given (ConfigReader[PixelDefinition]) => ConfigReader[Pixels3D] =
deriveReader[Pixels3D]

given (readLength: ConfigReader[Length]) => ConfigReader[PixelDefinition] =
readLength.emap(l =>
PixelDefinition
.tryToDefine(l)
.leftMap(msg =>
CannotConvert(value = l.toString, toType = "PixelDefinition", because = msg)
)
)
end PureConfigImagingInstances
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package at.ac.oeaw.imba.gerlich.gerlib.configuration
package instances

import cats.syntax.all.*
import pureconfig.ConfigReader
import pureconfig.error.CannotConvert
import squants.space.Length

/** PureConfig typeclass instances for squants */
trait PureConfigSquantsInstances:
given (readString: ConfigReader[String]) => ConfigReader[Length] =
readString.emap: s =>
Length
.parseString(s)
.toEither
.leftMap { e =>
CannotConvert(
value = s,
toType = "squants.space.Length",
because = e.getMessage
)
}
end PureConfigSquantsInstances
8 changes: 8 additions & 0 deletions modules/configuration/src/main/scala/instances/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package at.ac.oeaw.imba.gerlich.gerlib.configuration

/** Configuration-related typeclass instances */
package object instances:
object all extends AllConfigurationInstances

trait AllConfigurationInstances extends PureConfigImagingInstances, PureConfigSquantsInstances
end instances
4 changes: 4 additions & 0 deletions modules/configuration/src/main/scala/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package at.ac.oeaw.imba.gerlich.gerlib

/** Functionality related to configuration (e.g., from file or CLI) */
package object configuration
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package at.ac.oeaw.imba.gerlich.gerlib.configuration

import cats.*
import cats.syntax.all.*
import pureconfig.*
import squants.space.{LengthUnit, Microns, Nanometers}
import org.scalacheck.*
import org.scalactic.{Equality, TolerantNumerics}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks

import at.ac.oeaw.imba.gerlich.gerlib.configuration.instances.all.given // Bring in the pureconfig.ConfigReader instances.
import at.ac.oeaw.imba.gerlich.gerlib.imaging.Pixels3D

/** Tests for the parsing of pixel definitions */
class TestPixelDefinitionConfiguration
extends AnyFunSuite,
ScalaCheckPropertyChecks,
should.Matchers:

private type MicroOrNano = Microns.type | Nanometers.type

private def maxSize: Double = 1e6

private def genMicroOrNano: Gen[MicroOrNano] = Gen.oneOf(Microns, Nanometers)

private def genPlusMinusMillion: Gen[Double] = Gen.choose(-maxSize, maxSize)

private type LengthParseInputs[A] = (A, LengthUnit)

private def buildRawConfig[A: Show](
x: LengthParseInputs[A],
y: LengthParseInputs[A],
z: LengthParseInputs[A]
): String =
given Show[LengthParseInputs[A]] = Show.show { case (num, unit) =>
s"${num.show} ${unit.symbol}"
}
s"{ x: ${x.show}, y: ${y.show}, z: ${z.show} }"

test("Basic examples parse as expected."):
given Arbitrary[Double] = Arbitrary(genPlusMinusMillion)
given Shrink[Double] = Shrink.shrinkAny
given Equality[Double] =
TolerantNumerics.tolerantDoubleEquality(1 / maxSize)

given Arbitrary[LengthUnit] = Arbitrary { genMicroOrNano }

forAll(minSuccessful(10000)) { (x: Double, y: Double, z: Double, unit: LengthUnit) =>
val perX = 100
val perY = 200
val perZ = 400
val rawConfigData =
buildRawConfig(perX -> unit, perY -> unit, perZ -> unit)
ConfigSource.string(rawConfigData).load[Pixels3D] match {
case Left(errors) => fail(s"Errors: $errors")
case Right(scaling) =>
(scaling.liftX(x) in unit).value shouldEqual x * perX
(scaling.liftY(y) in unit).value shouldEqual y * perY
(scaling.liftZ(z) in unit).value shouldEqual z * perZ
}
}

test("Pixels3D parse requires the correct combination (x, y, z) of keys."):
val legitKeys = Set("x", "y", "z")

def genSubstitutions: Gen[List[(String, String)]] =
Gen
.containerOf[Set, String](Gen.oneOf(legitKeys))
.map(_.toList)
.flatMap { toSwapOut =>
Gen.listOfN(toSwapOut.length, Gen.alphaNumStr).map(toSwapOut.zip)
}

val MyUnit = Nanometers
def genInputAndExpectation: Gen[(String, Boolean)] =
val base = buildRawConfig(100 -> MyUnit, 200 -> MyUnit, 300 -> MyUnit)
genSubstitutions.map { subs =>
val updated = subs.foldLeft(base) { case (acc, (oldKey, newKey)) =>
acc.replace(oldKey ++ ":", newKey ++ ":")
}
val expSuccess =
val newKeys =
legitKeys -- subs.map(_._1).toSet ++ subs.map(_._2).toSet
subs.isEmpty || legitKeys === newKeys
updated -> expSuccess
}

given [A] => Shrink[A] = Shrink.shrinkAny // no shrinking whatsoever

forAll(genInputAndExpectation, minSuccessful(1000)) { (rawConfigData, shouldSucceed) =>
ConfigSource.string(rawConfigData).load[Pixels3D] match {
case Left(errors) =>
if !shouldSucceed then succeed
else
fail(
s"Expected succeess with $rawConfigData but failed: ${errors.prettyPrint}"
)
case Right(_) =>
if shouldSucceed then succeed
else fail(s"Expected failure with $rawConfigData but succeeded")
}
}

test(
"Pixels3D parse requires proper length units with strictly positive values."
):
val inputsAndExpectations = Table(
("rawConfigData", "shouldSucceed"),
("{ x: 100 nm, y: 200 nm, z: 300 nm }", true), // good example
("{ x: 100 nm, y: 200, z: 300 nm }", false), // missing a units on y
("{ x: 100 nm, y: 200 nm, z: -300 nm }", false) // negative z
)
forAll(inputsAndExpectations) { (rawConfigData, shouldSucceed) =>
ConfigSource.string(rawConfigData).load[Pixels3D] match {
case Left(errors) =>
if !shouldSucceed then succeed
else
fail(
s"Expected succeess with $rawConfigData but failed: ${errors.prettyPrint}"
)
case Right(_) =>
if shouldSucceed then succeed
else fail(s"Expected failure with $rawConfigData but succeeded")
}

}
end TestPixelDefinitionConfiguration
6 changes: 6 additions & 0 deletions modules/geometry/src/main/scala/Distance.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import cats.*
import cats.data.Validated
import cats.syntax.all.*

import squants.space.Length

import at.ac.oeaw.imba.gerlich.gerlib.numeric.*

/** Piecewise / by-component distance, as absolute differences
Expand Down Expand Up @@ -91,6 +93,10 @@ object EuclideanDistance:
import Distance.given_Order_Distance
Order.by(_.get) // use the Double backing the squants.space.Length.

/** Try to read the given text (e.g., a configuration value) as a distance in Euclidean space. */
def parse: String => Either[String, EuclideanDistance] =
s => Distance.parse(s).map(EuclideanDistance.apply)

/** Try to interpret the given length value as a distance in Euclidean space. */
def unsafe: Length => EuclideanDistance = Distance.applyUnsafe `andThen` EuclideanDistance.apply
end EuclideanDistance
16 changes: 16 additions & 0 deletions modules/geometry/src/main/scala/instances/DistanceInstances.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package at.ac.oeaw.imba.gerlich.gerlib.geometry
package instances

import cats.Show
import at.ac.oeaw.imba.gerlich.gerlib.SimpleShow

/** Typeclass instances related to distance values */
trait DistanceInstances:
/** Show the quantity and units with a space in between */
given Show[Distance] = Show.show { d =>
s"${d.value} ${d.unit.symbol}"
}

/** Use .show to derive .show_ */
given Show[Distance] => SimpleShow[Distance] = SimpleShow.fromShow
end DistanceInstances
2 changes: 1 addition & 1 deletion modules/geometry/src/main/scala/instances/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ package object instances:
object all extends AllGeometryInstances

/** Aggregation of all the geometry-related data types' typeclass instances */
trait AllGeometryInstances extends CoordinateInstances, PointInstances
trait AllGeometryInstances extends CoordinateInstances, DistanceInstances, PointInstances
end instances
2 changes: 1 addition & 1 deletion modules/geometry/src/main/scala/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ package object geometry:
given RuntimeConstraint[Length, Not[Negative]] =
new RuntimeConstraint(
_.value >= 0,
"Allegedly nonnegative length must actually be nonnegative."
"Allegedly nonnegative length is actually negative."
)

/** Leave this alias transparent, since we just want the typelevel 'check' that the length is
Expand Down
Loading
Loading