Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c7fab38
bump iron to 3.0.0, simplify refinement types, and remove some unnece…
vreuter Apr 11, 2025
572c867
bring in types and logic related to defining pixels as units, and tes…
vreuter Apr 15, 2025
911ee50
use recommended syntax from squants
vreuter Apr 15, 2025
e0c56f3
simpler use of indexing sequences by nonnegative integer
vreuter Apr 17, 2025
7df64cb
apply formatter
vreuter Apr 24, 2025
2f2b919
add a codec for Distance
vreuter Apr 24, 2025
96534b1
better access to Order for distances, using the only natural choice
vreuter Apr 25, 2025
12afba4
remove unused exception subtype
vreuter Apr 25, 2025
c7bef32
better computation of Euclidean distance
vreuter Apr 25, 2025
26366e4
more general way to define pixel as a length unit
vreuter Apr 25, 2025
8a63932
add NonnegativeInt.parse helper back
vreuter Apr 25, 2025
8530b66
fix the implementation of NonnegativeInt.parse to return a refined value
vreuter Apr 25, 2025
1ffa1d7
add decoders for Length and for EuclideanDistance
vreuter May 5, 2025
b03d965
add infix max operator based on cats.Order
vreuter May 5, 2025
7c582bd
IncompleteParseException, not IllegalRefinement, when parsing JSON to…
vreuter May 5, 2025
3a8b912
JSON codecs for squants.space.Length and Distance
vreuter May 9, 2025
b78abe3
parsing and encoding/deocding helpers for Length, Distance, and Eucli…
vreuter May 9, 2025
59ac468
new version release prep
vreuter May 10, 2025
887ab73
generalise comment across different lengths
vreuter May 22, 2025
c9ae3ca
update release date for version 0.5.0
vreuter May 23, 2025
4eced30
update release date
vreuter Jun 17, 2025
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
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.5.0] - 2025-06-17

### Added
* Introduced a transparent `Distance` type alias for a `squants.space.Length` value refined to be nonnegative
* Introduced `Pixels3D` and `PixelDefinition` types

### Changed
* `iron` is now v3.0.0.
* Removed the `ProximityComparable` trait
* Simplified the distance types considerably, removing the `DistanceThreshold` and its subtypes
* Simplification of the numeric refinement types, relying more directly on reference to the underlying `iron` type names
* More general and typesafe way to define a pixel as a unit of length
* Better definition of distance-related types

## [v0.4.1] - 2025-03-19

### Added
Expand Down
23 changes: 18 additions & 5 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, roi, testing, zarr)
.aggregate(cell, geometry, graph, imaging, io, json, numeric, pan, refinement, roi, testing, zarr)
.enablePlugins(BuildInfoPlugin)
.settings(commonSettings)
.settings(noPublishSettings)
Expand All @@ -66,7 +66,12 @@ lazy val cell = defineModule("cell")(project)
.dependsOn(numeric)

lazy val geometry = defineModule("geometry")(project)
.dependsOn(numeric)
.dependsOn(numeric, refinement)
.settings(
libraryDependencies ++= Seq(
squants,
)
)

lazy val graph = defineModule("graph")(project)
.settings(
Expand All @@ -88,7 +93,7 @@ lazy val io = defineModule("io")(project)
)

lazy val imaging = defineModule("imaging")(project)
.dependsOn(json, numeric)
.dependsOn(json, numeric, refinement)
.settings(
libraryDependencies ++= Seq(
iron % Test,
Expand All @@ -108,7 +113,7 @@ lazy val json = defineModule("json")(project)
)

lazy val numeric = defineModule("numeric")(project)
.dependsOn(pan)
.dependsOn(pan, refinement)
.settings(
libraryDependencies ++= Seq(
iron,
Expand All @@ -128,6 +133,14 @@ lazy val pan = defineModule("pan")(project)
)
)

lazy val refinement = defineModule("refinement")(project)
.settings(
libraryDependencies ++= Seq(
iron,
ironScalacheck % Test,
)
)

lazy val roi = defineModule("roi")(project)
.dependsOn(geometry, numeric, zarr)

Expand Down Expand Up @@ -176,7 +189,7 @@ lazy val compileSettings = Def.settings(
Test / console / scalacOptions := (Compile / console / scalacOptions).value,
)

lazy val versionNumber = "0.4.1"
lazy val versionNumber = "0.5.0"

lazy val metadataSettings = Def.settings(
name := projectName,
Expand Down
16 changes: 6 additions & 10 deletions modules/cell/src/main/scala/NuclearDesignation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import cats.derived.*
import cats.syntax.all.*

import at.ac.oeaw.imba.gerlich.gerlib.SimpleShow
import at.ac.oeaw.imba.gerlich.gerlib.syntax.all.*
import at.ac.oeaw.imba.gerlich.gerlib.numeric.*
import at.ac.oeaw.imba.gerlich.gerlib.numeric.instances.positiveInt.given
import at.ac.oeaw.imba.gerlich.gerlib.syntax.all.*

/** Designation of whether something's in a cell nucleus or not */
sealed trait NuclearDesignation
Expand All @@ -27,11 +27,8 @@ object NucleusNumber:
/** Try to read the given string as a nucleus number. */
def parse(s: String): Either[String, NucleusNumber] =
readAsInt(s)
.flatMap(PositiveInt.either)
.bimap(
msg => s"Cannot parse value ($s) as nucleus number: $msg",
NucleusNumber.apply
)
.flatMap(i => PositiveInt.option(i).toRight(s"Cannot parse value ($s) as nucleus number"))
.map(NucleusNumber.apply)
end NucleusNumber

/** Helpers for working with nuclei number labels */
Expand All @@ -47,10 +44,9 @@ object NuclearDesignation:

/** Attempt to read the given text as a nucleus number. */
def parse(s: String): Either[String, NuclearDesignation] =
readAsInt(s).flatMap { z =>
if z > 0 then NucleusNumber(PositiveInt.unsafe(z)).asRight
else if z === 0 then OutsideNucleus.asRight
else s"Negative value parsed for nucleus number: $z".asLeft
readAsInt(s).flatMap {
case 0 => OutsideNucleus.asRight
case z => PositiveInt.either(z).map(NucleusNumber.apply)
}

/** Represent the extranuclear designation as 0, and intranuclear by the wrapped number. */
Expand Down
9 changes: 8 additions & 1 deletion modules/cell/src/test/scala/TestNuclearDesignation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@ import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks

import at.ac.oeaw.imba.gerlich.gerlib.numeric.PositiveInt
import at.ac.oeaw.imba.gerlich.gerlib.numeric.instances.all.given
import at.ac.oeaw.imba.gerlich.gerlib.refinement.IllegalRefinement

/** Tests for data type for nucleuar / non-nuclear attribution */
class TestNuclearDesignation extends AnyFunSuite, ScalaCheckPropertyChecks, should.Matchers:
given Arbitrary[PositiveInt] = Arbitrary:
Gen.choose(1, Int.MaxValue).map(PositiveInt.unsafe)
Gen
.choose(1, Int.MaxValue)
.map(n =>
PositiveInt
.option(n)
.getOrElse { throw IllegalRefinement(n, "Cannot refine as positive") }
)

given (arbPosInt: Arbitrary[PositiveInt]) => Arbitrary[NucleusNumber] =
Arbitrary { arbPosInt.arbitrary.map(NucleusNumber.apply) }
Expand Down
136 changes: 14 additions & 122 deletions modules/geometry/src/main/scala/Distance.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package at.ac.oeaw.imba.gerlich.gerlib.geometry

import scala.math.{pow, sqrt}
import scala.util.NotGiven
import scala.util.chaining.* // for pipe
import cats.*
Expand All @@ -9,91 +8,6 @@ import cats.syntax.all.*

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

/** Something that can compare two {@code A} values w.r.t. threshold value of type {@code T}
*/
trait ProximityComparable[A]:
/** Are the two {@code A} values within threshold {@code T} of each other? */
def proximal: (A, A) => Boolean
end ProximityComparable

/** Helpers for working with proximity comparisons */
object ProximityComparable:
extension [A](a1: A)(using ev: ProximityComparable[A])
infix def proximal(a2: A): Boolean = ev.proximal(a1, a2)

given contravariantForProximityComparable: Contravariant[ProximityComparable] =
new Contravariant[ProximityComparable]:
override def contramap[A, B](fa: ProximityComparable[A])(f: B => A) =
new ProximityComparable[B]:
override def proximal = (b1, b2) => fa.proximal(f(b1), f(b2))
end ProximityComparable

/** A threshold on distances, which should be nonnegative, to be semantically contextualised by the
* subtype
*/
sealed trait DistanceThreshold:
def get: NonnegativeReal

/** Helpers for working with distance thresholds */
object DistanceThreshold:
given showForDistanceThreshold: Show[DistanceThreshold] = Show.show { (t: DistanceThreshold) =>
val typeName = t match
case _: EuclideanDistance.Threshold => "Euclidean"
case _: PiecewiseDistance.ConjunctiveThreshold => "Conjunctive"
s"${typeName}Threshold(${t.get})"
}

/** Define a proximity comparison for 3D points values.
*
* @tparam C
* The type of raw value wrapped in a coordinate for each 3D point
* @param threshold
* The distance beneath which to consider a given pair of points as proximal
* @return
* An instance with which to check pairs of points for proximity, according to the given
* threshold value ('think': decision boundary)
* @see
* [[at.ac.oeaw.imba.gerlich.gerlib.geometry.Point3D]]
*/
def defineProximityPointwise[C: Numeric](
threshold: DistanceThreshold
): ProximityComparable[Point3D[C]] = threshold match
case t: EuclideanDistance.Threshold =>
new ProximityComparable[Point3D[C]]:
override def proximal = (a, b) =>
val d = EuclideanDistance.between(a, b)
if d.isInfinite then
throw new EuclideanDistance.OverflowException(
s"Cannot compute finite distance between $a and $b"
)
d `lessThan` t
case t: PiecewiseDistance.ConjunctiveThreshold =>
new ProximityComparable[Point3D[C]]:
override def proximal = PiecewiseDistance.within(t)

/** Define a proximity comparison for values of arbitrary type, according to given threshold and
* how to extract a 3D point value.
*
* @tparam A
* The type of value from which a 3D point will be extracted for purpose of proximity check /
* comparison
* @tparam C
* The type of raw value wrapped in a coordinate for each 3D point
* @param threshold
* The distance beneath which to consider a given pair of points as proximal
* @return
* An instance with which to check pairs of values for proximity, according to the given
* threshold value ('think': decision boundary), and how to get a 3D point from a value of type
* `A`
* @see
* [[at.ac.oeaw.imba.gerlich.gerlib.geometry.Point3D]]
*/
def defineProximityPointwise[A, C: Numeric](
threshold: DistanceThreshold
): (A => Point3D[C]) => ProximityComparable[A] =
defineProximityPointwise(threshold).contramap
end DistanceThreshold

/** Piecewise / by-component distance, as absolute differences
*
* @param x
Expand All @@ -118,7 +32,7 @@ object PiecewiseDistance:
/** Distance threshold in which predicate comparing values to this threshold operates
* conjunctively over components
*/
final case class ConjunctiveThreshold(get: NonnegativeReal) extends DistanceThreshold
final case class Conjunctive(get: NonnegativeReal)

/** Compute the piecewise / component-wise distance between the given points.
*
Expand Down Expand Up @@ -147,7 +61,7 @@ object PiecewiseDistance:

/** Are points closer than given threshold along each axis? */
def within[C: Numeric](
threshold: ConjunctiveThreshold
threshold: Conjunctive
)(a: Point3D[C], b: Point3D[C]): Boolean =
val d = between(a, b)
d.getX < threshold.get && d.getY < threshold.get && d.getZ < threshold.get
Expand All @@ -159,46 +73,24 @@ object PiecewiseDistance:
NonnegativeReal.either((a.value - b.value).toDouble.abs)
end PiecewiseDistance

/** Semantic wrapper to denote that a nonnegative real number represents a Euclidean distance
sealed trait DistanceLike:
def getDistanceValue: Distance

/** Semantic wrapper to denote that a nonnegative length represents a Euclidean distance
*/
final case class EuclideanDistance private (get: NonnegativeReal):
final def lessThan(t: EuclideanDistance.Threshold): Boolean = get < t.get
final def greaterThan = !lessThan(_: EuclideanDistance.Threshold)
final def equalTo(t: EuclideanDistance.Threshold) =
!lessThan(t) && !greaterThan(t)
final def lteq(t: EuclideanDistance.Threshold) = lessThan(t) || equalTo(t)
final def gteq(t: EuclideanDistance.Threshold) = greaterThan(t) || equalTo(t)
final def isFinite = get.isFinite
final case class EuclideanDistance(get: Distance) extends DistanceLike:
final def isFinite = get.value.isFinite
final def isInfinite = !isFinite
final override def getDistanceValue: Distance = get
end EuclideanDistance

/** Helpers for working with Euclidean distances */
object EuclideanDistance:
import at.ac.oeaw.imba.gerlich.gerlib.numeric.instances.nonnegativeReal.given // for Order

/** Order distance by the wrapped value. */
given Order[EuclideanDistance] = Order.by(_.get)

/** When something goes wrong with a distance computation or comparison */
final case class OverflowException(message: String) extends Exception(message)

/** Comparison basis for Euclidean distance between points */
final case class Threshold(get: NonnegativeReal) extends DistanceThreshold
given Order[EuclideanDistance] =
import Distance.given_Order_Distance
Order.by(_.get) // use the Double backing the squants.space.Length.

// TODO: account for infinity/null-numeric cases.
def between[C: Numeric](a: Point3D[C], b: Point3D[C]): EuclideanDistance =
import scala.math.Numeric.Implicits.infixNumericOps
(a, b) match
case (Point3D(x1, y1, z1), Point3D(x2, y2, z2)) =>
List(x1 -> x2, y1 -> y2, z1 -> z2)
.foldLeft(0.0) { case (acc, (a, b)) => acc + pow((a.value - b.value).toDouble, 2) }
.pipe(sqrt)
.pipe(NonnegativeReal.unsafe)
.pipe(EuclideanDistance.apply)

/** Use a lens of a 3D point from arbitrary type {@code A} to compute distance between {@code A}
* values.
*/
def between[A, C: Numeric](p: A => Point3D[C])(a1: A, a2: A): EuclideanDistance =
between(p(a1), p(a2))
def parse: String => Either[String, EuclideanDistance] =
s => Distance.parse(s).map(EuclideanDistance.apply)
end EuclideanDistance
26 changes: 26 additions & 0 deletions modules/geometry/src/main/scala/package.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package at.ac.oeaw.imba.gerlich.gerlib

import cats.Order
import io.github.iltotore.iron.{RefinedType, RuntimeConstraint}
import io.github.iltotore.iron.constraint.any.Not
import io.github.iltotore.iron.constraint.numeric.Negative
import squants.space.Length

/** Types and tools related to geometry */
package object geometry:
/** Centroid of a region of interest */
Expand Down Expand Up @@ -28,6 +34,26 @@ package object geometry:
private[gerlib] def z: ZCoordinate[C] = c.z
end Centroid

/** Constrain a length to be like a distance (nonnegative). */
given RuntimeConstraint[Length, Not[Negative]] =
new RuntimeConstraint(
_.value >= 0,
"Allegedly nonnegative length must actually be nonnegative."
)

/** Leave this alias transparent, since we just want the typelevel 'check' that the length is
* nonnegative; we don't want the underlying type masked.
*/
type Distance = Distance.T

object Distance extends RefinedType[Length, Not[Negative]]:
given Order[Distance] = Order.fromOrdering

def parse(s: String): Either[String, Distance] =
import syntax.*
Length.parse(s).flatMap(either)
end Distance

type AxisX = EuclideanAxis.X.type

type AxisY = EuclideanAxis.Y.type
Expand Down
11 changes: 10 additions & 1 deletion modules/geometry/src/main/scala/syntax/package.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package at.ac.oeaw.imba.gerlich.gerlib.geometry

import cats.Monoid
import scala.util.NotGiven
import cats.{Monoid, Order}
import cats.data.NonEmptyList
import cats.syntax.all.*
import squants.space.Length

/** Syntax enrichment on values of data types related to geometry */
package object syntax:
Expand All @@ -27,3 +29,10 @@ package object syntax:
YCoordinate(a.y.value - b.y.value),
ZCoordinate(a.z.value - b.z.value)
)

extension [A: Order, C <: Coordinate[A]: [C] =>> NotGiven[C =:= Coordinate[A]]](c1: C)(using
ord: Order[C]
) infix def max(c2: C): C = ord.max(c1, c2)

extension (L: Length.type)
def parse(s: String): Either[String, Length] = L(s).toEither.leftMap(_.getMessage)
Loading
Loading