Skip to content
Merged
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
43 changes: 13 additions & 30 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,45 +23,28 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest]
scala: [3.6.4]
java:
- temurin@11
- temurin@17
- temurin@19
- temurin@21
java: [temurin@17, temurin@21]
exclude:
- os: macos-latest
java: temurin@17
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
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
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 @@ -74,15 +57,15 @@ jobs:
run: brew install sbt

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

- name: Lint with scalafix
if: runner.os == 'ubuntu-latest' && runner.java-version == '11'
run: sbt '++ ${{ matrix.scala }}' 'scalafixAll --check'
if: runner.os == 'ubuntu-latest' && runner.java-version == '21'
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ project/*
!project/Dependencies.scala
!project/build.properties
!project/plugins.sbt
.DS_Store
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ 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).

## [v0.6.0] - 2026-07-07

### 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
* Restricted the CI Java version matrix to the LTS releases Temurin 17 and 21 (dropping 11 and 19).

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

### Fixed
Expand Down
23 changes: 17 additions & 6 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ val projectName = "gerlib"
val rootPkg = s"at.ac.oeaw.imba.gerlich.$projectName"
val gitHubOwner = "gerlichlab"
val gitPubUrl = s"https://github.com/$gitHubOwner/$projectName.git"
val primaryJavaVersion = "11"
val primaryJavaVersion = "21"
val primaryOs = "ubuntu-latest"
val isPrimaryOsAndPrimaryJavaTest = s"runner.os == '$primaryOs' && runner.java-version == '$primaryJavaVersion'"

Expand All @@ -24,10 +24,11 @@ ThisBuild / scalafixDependencies ++= Seq(
ThisBuild / githubWorkflowOSes := Set(primaryOs, "macos-latest", "ubuntu-latest").toSeq
ThisBuild / githubWorkflowTargetBranches := Seq("main")
ThisBuild / githubWorkflowPublishTargetBranches := Seq()
ThisBuild / githubWorkflowJavaVersions := Seq(primaryJavaVersion, "17", "19", "21").map(JavaSpec.temurin)
ThisBuild / githubWorkflowJavaVersions := Seq("17", primaryJavaVersion).map(JavaSpec.temurin)
ThisBuild / githubWorkflowBuildMatrixExclusions += MatrixExclude(Map("os" -> "macos-latest", "java" -> "temurin@17"))
ThisBuild / githubWorkflowBuildPreamble ++= Seq(
// Account for the absence of sbt in newer versions of the setup-java GitHub Action.
WorkflowStep.Run(commands = List("brew install sbt"), cond = Some("contains(runner.os, 'macos')")),
WorkflowStep.Run(commands = List("brew install sbt"), cond = Some("contains(runner.os, 'macos')")),
/* Add linting and formatting checks, but only limit to a single platform + Java combo. */
WorkflowStep.Sbt(
List("scalafmtCheckAll"),
Expand All @@ -52,7 +53,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 +66,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 +192,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"

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
10 changes: 10 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,14 @@ 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)

/** Interpret the given length as a distance in Euclidean space.
*
* @throws java.lang.IllegalArgumentException
* if the given length is negative
*/
def unsafe: Length => EuclideanDistance = Distance.applyUnsafe `andThen` EuclideanDistance.apply
end EuclideanDistance
Loading
Loading