Skip to content

Commit c7fab38

Browse files
committed
bump iron to 3.0.0, simplify refinement types, and remove some unnecessary types
1 parent f7aa973 commit c7fab38

35 files changed

Lines changed: 326 additions & 527 deletions

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [v0.5.0] - 2025-04-11
8+
9+
### Added
10+
* Introduced a transparent `Distance` type alias for a `squants.space.Length` value refined to be nonnegative
11+
12+
### Changed
13+
* `iron` is now v3.0.0.
14+
* Removed the `ProximityComparable` trait
15+
* Simplified the distance types considerably, removing the `DistanceThreshold` and its subtypes
16+
* Simplification of the numeric refinement types, relying more directly on reference to the underlying `iron` type names
17+
718
## [v0.4.1] - 2025-03-19
819

920
### Added

build.sbt

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ ThisBuild / testOptions += Tests.Argument("-oF") // full stack traces
5252

5353
lazy val root = project
5454
.in(file("."))
55-
.aggregate(cell, geometry, graph, imaging, io, json, numeric, pan, roi, testing, zarr)
55+
.aggregate(cell, geometry, graph, imaging, io, json, numeric, pan, refinement, roi, testing, zarr)
5656
.enablePlugins(BuildInfoPlugin)
5757
.settings(commonSettings)
5858
.settings(noPublishSettings)
@@ -66,7 +66,12 @@ lazy val cell = defineModule("cell")(project)
6666
.dependsOn(numeric)
6767

6868
lazy val geometry = defineModule("geometry")(project)
69-
.dependsOn(numeric)
69+
.dependsOn(numeric, refinement)
70+
.settings(
71+
libraryDependencies ++= Seq(
72+
squants,
73+
)
74+
)
7075

7176
lazy val graph = defineModule("graph")(project)
7277
.settings(
@@ -88,7 +93,7 @@ lazy val io = defineModule("io")(project)
8893
)
8994

9095
lazy val imaging = defineModule("imaging")(project)
91-
.dependsOn(json, numeric)
96+
.dependsOn(json, numeric, refinement)
9297
.settings(
9398
libraryDependencies ++= Seq(
9499
iron % Test,
@@ -108,7 +113,7 @@ lazy val json = defineModule("json")(project)
108113
)
109114

110115
lazy val numeric = defineModule("numeric")(project)
111-
.dependsOn(pan)
116+
.dependsOn(pan, refinement)
112117
.settings(
113118
libraryDependencies ++= Seq(
114119
iron,
@@ -128,6 +133,14 @@ lazy val pan = defineModule("pan")(project)
128133
)
129134
)
130135

136+
lazy val refinement = defineModule("refinement")(project)
137+
.settings(
138+
libraryDependencies ++= Seq(
139+
iron,
140+
ironScalacheck % Test,
141+
)
142+
)
143+
131144
lazy val roi = defineModule("roi")(project)
132145
.dependsOn(geometry, numeric, zarr)
133146

@@ -176,7 +189,7 @@ lazy val compileSettings = Def.settings(
176189
Test / console / scalacOptions := (Compile / console / scalacOptions).value,
177190
)
178191

179-
lazy val versionNumber = "0.4.1"
192+
lazy val versionNumber = "0.5.0"
180193

181194
lazy val metadataSettings = Def.settings(
182195
name := projectName,

modules/cell/src/main/scala/NuclearDesignation.scala

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import cats.derived.*
66
import cats.syntax.all.*
77

88
import at.ac.oeaw.imba.gerlich.gerlib.SimpleShow
9-
import at.ac.oeaw.imba.gerlich.gerlib.syntax.all.*
109
import at.ac.oeaw.imba.gerlich.gerlib.numeric.*
1110
import at.ac.oeaw.imba.gerlich.gerlib.numeric.instances.positiveInt.given
11+
import at.ac.oeaw.imba.gerlich.gerlib.syntax.all.*
1212

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

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

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

5652
/** Represent the extranuclear designation as 0, and intranuclear by the wrapped number. */

modules/cell/src/test/scala/TestNuclearDesignation.scala

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,18 @@ import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
1010

1111
import at.ac.oeaw.imba.gerlich.gerlib.numeric.PositiveInt
1212
import at.ac.oeaw.imba.gerlich.gerlib.numeric.instances.all.given
13+
import at.ac.oeaw.imba.gerlich.gerlib.refinement.IllegalRefinement
1314

1415
/** Tests for data type for nucleuar / non-nuclear attribution */
1516
class TestNuclearDesignation extends AnyFunSuite, ScalaCheckPropertyChecks, should.Matchers:
1617
given Arbitrary[PositiveInt] = Arbitrary:
17-
Gen.choose(1, Int.MaxValue).map(PositiveInt.unsafe)
18+
Gen
19+
.choose(1, Int.MaxValue)
20+
.map(n =>
21+
PositiveInt
22+
.option(n)
23+
.getOrElse { throw IllegalRefinement(n, "Cannot refine as positive") }
24+
)
1825

1926
given (arbPosInt: Arbitrary[PositiveInt]) => Arbitrary[NucleusNumber] =
2027
Arbitrary { arbPosInt.arbitrary.map(NucleusNumber.apply) }
Lines changed: 6 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package at.ac.oeaw.imba.gerlich.gerlib.geometry
22

3-
import scala.math.{pow, sqrt}
43
import scala.util.NotGiven
54
import scala.util.chaining.* // for pipe
65
import cats.*
@@ -9,91 +8,6 @@ import cats.syntax.all.*
98

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

12-
/** Something that can compare two {@code A} values w.r.t. threshold value of type {@code T}
13-
*/
14-
trait ProximityComparable[A]:
15-
/** Are the two {@code A} values within threshold {@code T} of each other? */
16-
def proximal: (A, A) => Boolean
17-
end ProximityComparable
18-
19-
/** Helpers for working with proximity comparisons */
20-
object ProximityComparable:
21-
extension [A](a1: A)(using ev: ProximityComparable[A])
22-
infix def proximal(a2: A): Boolean = ev.proximal(a1, a2)
23-
24-
given contravariantForProximityComparable: Contravariant[ProximityComparable] =
25-
new Contravariant[ProximityComparable]:
26-
override def contramap[A, B](fa: ProximityComparable[A])(f: B => A) =
27-
new ProximityComparable[B]:
28-
override def proximal = (b1, b2) => fa.proximal(f(b1), f(b2))
29-
end ProximityComparable
30-
31-
/** A threshold on distances, which should be nonnegative, to be semantically contextualised by the
32-
* subtype
33-
*/
34-
sealed trait DistanceThreshold:
35-
def get: NonnegativeReal
36-
37-
/** Helpers for working with distance thresholds */
38-
object DistanceThreshold:
39-
given showForDistanceThreshold: Show[DistanceThreshold] = Show.show { (t: DistanceThreshold) =>
40-
val typeName = t match
41-
case _: EuclideanDistance.Threshold => "Euclidean"
42-
case _: PiecewiseDistance.ConjunctiveThreshold => "Conjunctive"
43-
s"${typeName}Threshold(${t.get})"
44-
}
45-
46-
/** Define a proximity comparison for 3D points values.
47-
*
48-
* @tparam C
49-
* The type of raw value wrapped in a coordinate for each 3D point
50-
* @param threshold
51-
* The distance beneath which to consider a given pair of points as proximal
52-
* @return
53-
* An instance with which to check pairs of points for proximity, according to the given
54-
* threshold value ('think': decision boundary)
55-
* @see
56-
* [[at.ac.oeaw.imba.gerlich.gerlib.geometry.Point3D]]
57-
*/
58-
def defineProximityPointwise[C: Numeric](
59-
threshold: DistanceThreshold
60-
): ProximityComparable[Point3D[C]] = threshold match
61-
case t: EuclideanDistance.Threshold =>
62-
new ProximityComparable[Point3D[C]]:
63-
override def proximal = (a, b) =>
64-
val d = EuclideanDistance.between(a, b)
65-
if d.isInfinite then
66-
throw new EuclideanDistance.OverflowException(
67-
s"Cannot compute finite distance between $a and $b"
68-
)
69-
d `lessThan` t
70-
case t: PiecewiseDistance.ConjunctiveThreshold =>
71-
new ProximityComparable[Point3D[C]]:
72-
override def proximal = PiecewiseDistance.within(t)
73-
74-
/** Define a proximity comparison for values of arbitrary type, according to given threshold and
75-
* how to extract a 3D point value.
76-
*
77-
* @tparam A
78-
* The type of value from which a 3D point will be extracted for purpose of proximity check /
79-
* comparison
80-
* @tparam C
81-
* The type of raw value wrapped in a coordinate for each 3D point
82-
* @param threshold
83-
* The distance beneath which to consider a given pair of points as proximal
84-
* @return
85-
* An instance with which to check pairs of values for proximity, according to the given
86-
* threshold value ('think': decision boundary), and how to get a 3D point from a value of type
87-
* `A`
88-
* @see
89-
* [[at.ac.oeaw.imba.gerlich.gerlib.geometry.Point3D]]
90-
*/
91-
def defineProximityPointwise[A, C: Numeric](
92-
threshold: DistanceThreshold
93-
): (A => Point3D[C]) => ProximityComparable[A] =
94-
defineProximityPointwise(threshold).contramap
95-
end DistanceThreshold
96-
9711
/** Piecewise / by-component distance, as absolute differences
9812
*
9913
* @param x
@@ -118,7 +32,7 @@ object PiecewiseDistance:
11832
/** Distance threshold in which predicate comparing values to this threshold operates
11933
* conjunctively over components
12034
*/
121-
final case class ConjunctiveThreshold(get: NonnegativeReal) extends DistanceThreshold
35+
final case class Conjunctive(get: NonnegativeReal)
12236

12337
/** Compute the piecewise / component-wise distance between the given points.
12438
*
@@ -147,7 +61,7 @@ object PiecewiseDistance:
14761

14862
/** Are points closer than given threshold along each axis? */
14963
def within[C: Numeric](
150-
threshold: ConjunctiveThreshold
64+
threshold: Conjunctive
15165
)(a: Point3D[C], b: Point3D[C]): Boolean =
15266
val d = between(a, b)
15367
d.getX < threshold.get && d.getY < threshold.get && d.getZ < threshold.get
@@ -161,44 +75,17 @@ end PiecewiseDistance
16175

16276
/** Semantic wrapper to denote that a nonnegative real number represents a Euclidean distance
16377
*/
164-
final case class EuclideanDistance private (get: NonnegativeReal):
165-
final def lessThan(t: EuclideanDistance.Threshold): Boolean = get < t.get
166-
final def greaterThan = !lessThan(_: EuclideanDistance.Threshold)
167-
final def equalTo(t: EuclideanDistance.Threshold) =
168-
!lessThan(t) && !greaterThan(t)
169-
final def lteq(t: EuclideanDistance.Threshold) = lessThan(t) || equalTo(t)
170-
final def gteq(t: EuclideanDistance.Threshold) = greaterThan(t) || equalTo(t)
171-
final def isFinite = get.isFinite
78+
final case class EuclideanDistance private (get: Distance):
79+
final def isFinite = get.value.isFinite
17280
final def isInfinite = !isFinite
17381
end EuclideanDistance
17482

17583
/** Helpers for working with Euclidean distances */
17684
object EuclideanDistance:
177-
import at.ac.oeaw.imba.gerlich.gerlib.numeric.instances.nonnegativeReal.given // for Order
178-
17985
/** Order distance by the wrapped value. */
180-
given Order[EuclideanDistance] = Order.by(_.get)
86+
given Order[EuclideanDistance] =
87+
Order.by(_.get.value) // use the Double backing the squants.space.Length.
18188

18289
/** When something goes wrong with a distance computation or comparison */
18390
final case class OverflowException(message: String) extends Exception(message)
184-
185-
/** Comparison basis for Euclidean distance between points */
186-
final case class Threshold(get: NonnegativeReal) extends DistanceThreshold
187-
188-
// TODO: account for infinity/null-numeric cases.
189-
def between[C: Numeric](a: Point3D[C], b: Point3D[C]): EuclideanDistance =
190-
import scala.math.Numeric.Implicits.infixNumericOps
191-
(a, b) match
192-
case (Point3D(x1, y1, z1), Point3D(x2, y2, z2)) =>
193-
List(x1 -> x2, y1 -> y2, z1 -> z2)
194-
.foldLeft(0.0) { case (acc, (a, b)) => acc + pow((a.value - b.value).toDouble, 2) }
195-
.pipe(sqrt)
196-
.pipe(NonnegativeReal.unsafe)
197-
.pipe(EuclideanDistance.apply)
198-
199-
/** Use a lens of a 3D point from arbitrary type {@code A} to compute distance between {@code A}
200-
* values.
201-
*/
202-
def between[A, C: Numeric](p: A => Point3D[C])(a1: A, a2: A): EuclideanDistance =
203-
between(p(a1), p(a2))
20491
end EuclideanDistance

modules/geometry/src/main/scala/package.scala

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
package at.ac.oeaw.imba.gerlich.gerlib
22

3+
import io.github.iltotore.iron.{RefinedType, RuntimeConstraint}
4+
import io.github.iltotore.iron.constraint.any.Not
5+
import io.github.iltotore.iron.constraint.numeric.Negative
6+
import squants.space.Length
7+
38
/** Types and tools related to geometry */
49
package object geometry:
510
/** Centroid of a region of interest */
@@ -28,6 +33,20 @@ package object geometry:
2833
private[gerlib] def z: ZCoordinate[C] = c.z
2934
end Centroid
3035

36+
/** Constrain a length to be like a distance (nonnegative). */
37+
given RuntimeConstraint[Length, Not[Negative]] =
38+
new RuntimeConstraint(
39+
_.value >= 0,
40+
"Allegedly nonnegative length must actually be nonnegative."
41+
)
42+
43+
/** Leave this alias transparent, since we just want the typelevel 'check' that the length is
44+
* nonnegative; we don't want the underlying type masked.
45+
*/
46+
type Distance = Distance.T
47+
48+
object Distance extends RefinedType[Length, Not[Negative]]
49+
3150
type AxisX = EuclideanAxis.X.type
3251

3352
type AxisY = EuclideanAxis.Y.type
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package at.ac.oeaw.imba.gerlich.gerlib.geometry
2+
3+
import squants.space.*
4+
5+
import org.scalacheck.{Arbitrary, Gen}
6+
import org.scalacheck.Arbitrary.arbitrary
7+
import org.scalatest.funsuite.AnyFunSuite
8+
import org.scalatest.matchers.should
9+
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
10+
11+
/** Tests for the refinement of a [[squants.space.Length]] value as a distance */
12+
class TestDistance extends AnyFunSuite, should.Matchers, ScalaCheckPropertyChecks:
13+
14+
given Arbitrary[LengthUnit] = Arbitrary:
15+
Gen.oneOf(
16+
Angstroms,
17+
Nanometers,
18+
Microns,
19+
Millimeters
20+
)
21+
22+
given (Arbitrary[Double]) => Arbitrary[Length] = Arbitrary:
23+
for
24+
v <- arbitrary[Double]
25+
u <- arbitrary[LengthUnit]
26+
yield Length(v -> u.symbol).fold(throw _, identity)
27+
28+
test("Distance.option works if and only if the length is nonnegative."):
29+
forAll { (l: Length) =>
30+
(l.value < 0, Distance.option(l)) match {
31+
case (false, Some(d)) => d shouldEqual l
32+
case (true, None) => succeed
33+
case (_, _) =>
34+
}
35+
}
36+
37+
test("Distance instantiation CANNOT be done with apply syntax."):
38+
assertCompiles("val l: Length = Length(1 -> \"nm\").get")
39+
assertTypeError(
40+
"Distance(Length(1 -> \"nm\").get)"
41+
) // should be missing Constraint[Length, Not[Negative]]
42+
43+
end TestDistance

0 commit comments

Comments
 (0)