From 392bb13eff15c1a6b183980cac1713ff28ecfc35 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Mon, 26 Aug 2024 16:03:55 +0200 Subject: [PATCH] bringing over lots of distance-related code from tracing project, and writing typeclasses to assist in information/member hiding --- .../geometry/src/main/scala/Distance.scala | 213 ++++++++++++++++++ .../scala/instances/CoordinateInstances.scala | 8 + .../main/scala/instances/PointInstances.scala | 5 + modules/geometry/src/main/scala/package.scala | 6 + .../src/main/scala/syntax/package.scala | 19 ++ .../main/scala}/JsonObjectWriter.scala | 3 +- .../main/scala}/JsonValueWriter.scala | 12 +- .../instances/JsonInstancesForGeometry.scala | 14 ++ .../instances/JsonInstancesForNumeric.scala | 23 ++ .../scala/instances/geometry/package.scala | 4 + .../scala/instances/numeric/package.scala | 4 + .../src/main/scala/instances/package.scala | 8 + .../json/{ => src/main/scala}/package.scala | 2 +- .../instances/NonnegativeIntInstances.scala | 3 - .../instances/NonnegativeRealInstances.scala | 3 - .../scala/instances/SimpleShowInstances.scala | 3 + 16 files changed, 310 insertions(+), 20 deletions(-) create mode 100644 modules/geometry/src/main/scala/Distance.scala create mode 100644 modules/geometry/src/main/scala/syntax/package.scala rename modules/json/{ => src/main/scala}/JsonObjectWriter.scala (60%) rename modules/json/{ => src/main/scala}/JsonValueWriter.scala (68%) create mode 100644 modules/json/src/main/scala/instances/JsonInstancesForGeometry.scala create mode 100644 modules/json/src/main/scala/instances/JsonInstancesForNumeric.scala create mode 100644 modules/json/src/main/scala/instances/geometry/package.scala create mode 100644 modules/json/src/main/scala/instances/numeric/package.scala create mode 100644 modules/json/src/main/scala/instances/package.scala rename modules/json/{ => src/main/scala}/package.scala (75%) diff --git a/modules/geometry/src/main/scala/Distance.scala b/modules/geometry/src/main/scala/Distance.scala new file mode 100644 index 0000000..a4f15de --- /dev/null +++ b/modules/geometry/src/main/scala/Distance.scala @@ -0,0 +1,213 @@ +package at.ac.oeaw.imba.gerlich.gerlib.geometry + +import scala.math.pow +import cats.* +import cats.data.Validated +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. + * + * @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( + threshold: DistanceThreshold + ): ProximityComparable[Point3D[Double]] = threshold match { + case t: EuclideanDistance.Threshold => + new ProximityComparable[Point3D[Double]] { + override def proximal = (a, b) => + val d = EuclideanDistance.between(a, b) + if (d.isInfinite) { + throw new EuclideanDistance.OverflowException( + s"Cannot compute finite distance between $a and $b" + ) + } + d `lessThan` t + } + case t: PiecewiseDistance.ConjunctiveThreshold => + new ProximityComparable[Point3D[Double]] { + 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 + * @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]( + threshold: DistanceThreshold + ): (A => Point3D[Double]) => ProximityComparable[A] = + defineProximityPointwise(threshold).contramap +end DistanceThreshold + +/** Piecewise / by-component distance, as absolute differences + * + * @param x + * The x-component of the absolute difference between two points' + * coordinatess + * @param y + * The y-component of the absolute difference between two points' coordinates + * @param z + * The z-component of the absolute difference between two points' coordinates + */ +final class PiecewiseDistance private ( + x: NonnegativeReal, + y: NonnegativeReal, + z: NonnegativeReal +): + def getX: NonnegativeReal = x + def getY: NonnegativeReal = y + def getZ: NonnegativeReal = z + +/** Helpers for working with distances in by-component / piecewise fashion */ +object PiecewiseDistance: + + /** Distance threshold in which predicate comparing values to this threshold + * operates conjunctively over components + */ + final case class ConjunctiveThreshold(get: NonnegativeReal) + extends DistanceThreshold + + /** Compute the piecewise / component-wise distance between the given points. + * + * @param a + * One point + * @param b + * The other point + * @return + * A wrapper with access to the (absolute) difference between each + * component / dimension of the two given points' coordinates + * @throws java.lang.ArithmeticException + * if taking any absolute difference fails to refine as nonnegative + */ + def between(a: Point3D[Double], b: Point3D[Double]): PiecewiseDistance = + val xNel = + NonnegativeReal.either((a.x.value - b.x.value).abs).toValidatedNel + val yNel = + NonnegativeReal.either((a.y.value - b.y.value).abs).toValidatedNel + val zNel = + NonnegativeReal.either((a.z.value - b.z.value).abs).toValidatedNel + (xNel, yNel, zNel).tupled match { + case Validated.Valid((delX, delY, delZ)) => + PiecewiseDistance(x = delX, y = delY, z = delZ) + case Validated.Invalid(es) => + throw new ArithmeticException { + s"Computing distance between point $a and point $b yielded ${es.length} error(s): ${es.mkString_("; ")}" + } + } + + /** Are points closer than given threshold along each axis? */ + def within( + threshold: ConjunctiveThreshold + )(a: Point3D[Double], b: Point3D[Double]): Boolean = + val d = between(a, b) + d.getX < threshold.get && d.getY < threshold.get && d.getZ < threshold.get +end PiecewiseDistance + +/** Semantic wrapper to denote that a nonnegative real number 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 def isInfinite = !isFinite +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 + + // TODO: account for infinity/null-numeric cases. + def between(a: Point3D[Double], b: Point3D[Double]): EuclideanDistance = + (a, b) match { + case (Point3D(x1, y1, z1), Point3D(x2, y2, z2)) => + val d = NonnegativeReal.unsafe( + math.sqrt { + pow(x1.value - x2.value, 2) + pow(y1.value - y2.value, 2) + pow( + z1.value - z2.value, + 2 + ) + } + ) + new EuclideanDistance(d) + } + + /** Use a lens of a 3D point from arbitrary type {@code A} to compute distance + * between {@code A} values. + */ + def between[A](p: A => Point3D[Double])(a1: A, a2: A): EuclideanDistance = + between(p(a1), p(a2)) +end EuclideanDistance diff --git a/modules/geometry/src/main/scala/instances/CoordinateInstances.scala b/modules/geometry/src/main/scala/instances/CoordinateInstances.scala index 4cc6c77..f9ed160 100644 --- a/modules/geometry/src/main/scala/instances/CoordinateInstances.scala +++ b/modules/geometry/src/main/scala/instances/CoordinateInstances.scala @@ -6,6 +6,9 @@ import scala.util.NotGiven import cats.* import cats.syntax.all.* +import at.ac.oeaw.imba.gerlich.gerlib.SimpleShow +import at.ac.oeaw.imba.gerlich.gerlib.syntax.all.* + /** Typeclass instances for coordinate data types */ trait CoordinateInstances: private def instance[A: Monoid, C <: Coordinate[A]: [C] =>> NotGiven[ @@ -25,4 +28,9 @@ trait CoordinateInstances: given monoidForZ[A: Monoid]: Monoid[ZCoordinate[A]] = instance( ZCoordinate.apply ) + + given simpleShowForCoordinate[A: SimpleShow, C <: Coordinate[ + A + ]: [C] =>> NotGiven[C =:= Coordinate[A]]]: SimpleShow[C] = + SimpleShow.instance { c => c.value.show_ } end CoordinateInstances diff --git a/modules/geometry/src/main/scala/instances/PointInstances.scala b/modules/geometry/src/main/scala/instances/PointInstances.scala index 147021f..950ed44 100644 --- a/modules/geometry/src/main/scala/instances/PointInstances.scala +++ b/modules/geometry/src/main/scala/instances/PointInstances.scala @@ -8,12 +8,17 @@ import at.ac.oeaw.imba.gerlich.gerlib.geometry.instances.coordinate.given /** Typeclass instances related to the notion of a point in space */ trait PointInstances: + /** Make points monoidally combinable when the underlying value type is such. + */ given monoidForPoint[A](using raw: Monoid[A]): Monoid[Point3D[A]] with + /** Simply create the base value of each coordinate. */ override def empty: Point3D[A] = Point3D( summon[Monoid[XCoordinate[A]]].empty, summon[Monoid[YCoordinate[A]]].empty, summon[Monoid[ZCoordinate[A]]].empty ) + + /** Simply combine component-wise, then re-wrap. */ override def combine(a: Point3D[A], b: Point3D[A]): Point3D[A] = Point3D( a.x |+| b.x, a.y |+| b.y, diff --git a/modules/geometry/src/main/scala/package.scala b/modules/geometry/src/main/scala/package.scala index 35eb202..ef00485 100644 --- a/modules/geometry/src/main/scala/package.scala +++ b/modules/geometry/src/main/scala/package.scala @@ -7,6 +7,12 @@ package object geometry: /** Helpers for working with {@code Centroid} values. */ object Centroid: + extension [C](c: Centroid[C]) + /** Allow the centroid to be used as an ordinary point, but force + * awareness by the caller. + */ + def asPoint: Point3D[C] = c + /** Semantically designate the given value as a centroid. */ def fromPoint[C](pt: Point3D[C]): Centroid[C] = (pt: Centroid[C]) diff --git a/modules/geometry/src/main/scala/syntax/package.scala b/modules/geometry/src/main/scala/syntax/package.scala new file mode 100644 index 0000000..8933cd7 --- /dev/null +++ b/modules/geometry/src/main/scala/syntax/package.scala @@ -0,0 +1,19 @@ +package at.ac.oeaw.imba.gerlich.gerlib.geometry + +import cats.Monoid +import cats.data.NonEmptyList +import cats.syntax.all.* + +/** Syntax enrichment on values of data types related to geometry */ +package object syntax: + extension [C](points: NonEmptyList[Point3D[C]]) + /** Take the centroid simply as the arithmetic mean of the points. */ + def centroid(using Monoid[Point3D[C]], Fractional[C]): Point3D[C] = + import scala.math.Fractional.Implicits.infixFractionalOps + val total = points.combineAll + val n = summon[Fractional[C]].fromInt(points.length) + Point3D( + XCoordinate(total.x.value / n), + YCoordinate(total.y.value / n), + ZCoordinate(total.z.value / n) + ) diff --git a/modules/json/JsonObjectWriter.scala b/modules/json/src/main/scala/JsonObjectWriter.scala similarity index 60% rename from modules/json/JsonObjectWriter.scala rename to modules/json/src/main/scala/JsonObjectWriter.scala index 5b275c7..37f38ec 100644 --- a/modules/json/JsonObjectWriter.scala +++ b/modules/json/src/main/scala/JsonObjectWriter.scala @@ -1,5 +1,4 @@ -package at.ac.oeaw.imba.gerlich.gerlib -package json +package at.ac.oeaw.imba.gerlich.gerlib.json trait JsonObjectWriter[A]: def apply(a: A): ujson.Obj diff --git a/modules/json/JsonValueWriter.scala b/modules/json/src/main/scala/JsonValueWriter.scala similarity index 68% rename from modules/json/JsonValueWriter.scala rename to modules/json/src/main/scala/JsonValueWriter.scala index 0f8296b..a7da17b 100644 --- a/modules/json/JsonValueWriter.scala +++ b/modules/json/src/main/scala/JsonValueWriter.scala @@ -1,5 +1,4 @@ -package at.ac.oeaw.imba.gerlich.gerlib -package json +package at.ac.oeaw.imba.gerlich.gerlib.json import cats.* @@ -22,13 +21,4 @@ object JsonValueWriter: f: B => A ): JsonValueWriter[B, O] = new: override def apply(i: B): O = writeA(f(i)) - - given JsonValueWriter[Int, ujson.Num] with - override def apply(a: Int): ujson.Num = ujson.Num(a) - - given JsonValueWriter[Boolean, ujson.Bool] with - override def apply(a: Boolean): ujson.Bool = ujson.Bool(a) - - given JsonValueWriter[String, ujson.Str] with - override def apply(a: String): ujson.Str = ujson.Str(a) end JsonValueWriter diff --git a/modules/json/src/main/scala/instances/JsonInstancesForGeometry.scala b/modules/json/src/main/scala/instances/JsonInstancesForGeometry.scala new file mode 100644 index 0000000..663270d --- /dev/null +++ b/modules/json/src/main/scala/instances/JsonInstancesForGeometry.scala @@ -0,0 +1,14 @@ +package at.ac.oeaw.imba.gerlich.gerlib.json +package instances + +import scala.util.NotGiven +import at.ac.oeaw.imba.gerlich.gerlib.geometry.Coordinate + +trait JsonInstancesForGeometry: + def getPlainJsonValueWriter[A, C <: Coordinate[ + A + ], O <: ujson.Value: [C] =>> NotGiven[C =:= Coordinate[A]]](using + writeRaw: JsonValueWriter[A, O] + ): JsonValueWriter[C, O] = new: + override def apply(c: C): O = writeRaw(c.value) +end JsonInstancesForGeometry diff --git a/modules/json/src/main/scala/instances/JsonInstancesForNumeric.scala b/modules/json/src/main/scala/instances/JsonInstancesForNumeric.scala new file mode 100644 index 0000000..02b126c --- /dev/null +++ b/modules/json/src/main/scala/instances/JsonInstancesForNumeric.scala @@ -0,0 +1,23 @@ +package at.ac.oeaw.imba.gerlich.gerlib.json +package instances + +import at.ac.oeaw.imba.gerlich.gerlib.numeric.* + +/** Typeclass instances related to JSON for numeric data types */ +trait JsonInstancesForNumeric: + given JsonValueWriter[NonnegativeInt, ujson.Num] = JsonValueWriter(identity) + + given JsonValueWriter[NonnegativeReal, ujson.Num] = JsonValueWriter(identity) + + given JsonValueWriter[Boolean, ujson.Bool] = + JsonValueWriter.instance(ujson.Bool.apply) + + given JsonValueWriter[Double, ujson.Num] = + JsonValueWriter.instance(ujson.Num.apply) + + given JsonValueWriter[Int, ujson.Num] = + JsonValueWriter.instance(z => ujson.Num.apply(z.toDouble)) + + given JsonValueWriter[String, ujson.Str] = + JsonValueWriter.instance(ujson.Str.apply) +end JsonInstancesForNumeric diff --git a/modules/json/src/main/scala/instances/geometry/package.scala b/modules/json/src/main/scala/instances/geometry/package.scala new file mode 100644 index 0000000..3d897af --- /dev/null +++ b/modules/json/src/main/scala/instances/geometry/package.scala @@ -0,0 +1,4 @@ +package at.ac.oeaw.imba.gerlich.gerlib.json +package instances + +package object geometry extends JsonInstancesForGeometry diff --git a/modules/json/src/main/scala/instances/numeric/package.scala b/modules/json/src/main/scala/instances/numeric/package.scala new file mode 100644 index 0000000..d5d1769 --- /dev/null +++ b/modules/json/src/main/scala/instances/numeric/package.scala @@ -0,0 +1,4 @@ +package at.ac.oeaw.imba.gerlich.gerlib.json +package instances + +package object numeric extends JsonInstancesForNumeric diff --git a/modules/json/src/main/scala/instances/package.scala b/modules/json/src/main/scala/instances/package.scala new file mode 100644 index 0000000..f1fa8a7 --- /dev/null +++ b/modules/json/src/main/scala/instances/package.scala @@ -0,0 +1,8 @@ +package at.ac.oeaw.imba.gerlich.gerlib.json + +package object instances: + object all extends AllJsonInstances + + trait AllJsonInstances + extends JsonInstancesForGeometry, + JsonInstancesForNumeric diff --git a/modules/json/package.scala b/modules/json/src/main/scala/package.scala similarity index 75% rename from modules/json/package.scala rename to modules/json/src/main/scala/package.scala index da54fcf..37b66f9 100644 --- a/modules/json/package.scala +++ b/modules/json/src/main/scala/package.scala @@ -5,5 +5,5 @@ package object json: object syntax: extension [I](i: I) /** Represent the syntax-enriched value as a [[ujson.Value]] value. */ - def asJson(using write: JsonValueWriter[I, ujson.Value]): ujson.Value = + def asJson[O <: ujson.Value](using write: JsonValueWriter[I, O]): O = write(i) diff --git a/modules/numeric/src/main/scala/instances/NonnegativeIntInstances.scala b/modules/numeric/src/main/scala/instances/NonnegativeIntInstances.scala index f10804e..9d30457 100644 --- a/modules/numeric/src/main/scala/instances/NonnegativeIntInstances.scala +++ b/modules/numeric/src/main/scala/instances/NonnegativeIntInstances.scala @@ -5,13 +5,10 @@ import cats.* import io.github.iltotore.iron.cats.given import at.ac.oeaw.imba.gerlich.gerlib.SimpleShow -import at.ac.oeaw.imba.gerlich.gerlib.json.JsonValueWriter trait NonnegativeIntInstances: given IntLike[NonnegativeInt] with override def asInt = identity - given JsonValueWriter[NonnegativeInt, ujson.Num] with - override def apply(n: NonnegativeInt): ujson.Num = ujson.Num(n) given Order[NonnegativeInt] = summon[Order[NonnegativeInt]] given Show[NonnegativeInt] = summon[Show[NonnegativeInt]] given SimpleShow[NonnegativeInt] = SimpleShow.fromShow diff --git a/modules/numeric/src/main/scala/instances/NonnegativeRealInstances.scala b/modules/numeric/src/main/scala/instances/NonnegativeRealInstances.scala index 736ebe0..3fc97e5 100644 --- a/modules/numeric/src/main/scala/instances/NonnegativeRealInstances.scala +++ b/modules/numeric/src/main/scala/instances/NonnegativeRealInstances.scala @@ -6,13 +6,10 @@ import cats.syntax.all.* import io.github.iltotore.iron.cats.given import at.ac.oeaw.imba.gerlich.gerlib.SimpleShow -import at.ac.oeaw.imba.gerlich.gerlib.json.JsonValueWriter trait NonnegativeRealInstances: given orderForNonnegativeReal: Order[NonnegativeReal] = summon[Order[NonnegativeReal]] - given JsonValueWriter[NonnegativeReal, ujson.Num] with - override def apply(x: NonnegativeReal): ujson.Num = ujson.Num(x) given showForNonnegativeReal(using ev: Show[Double]): Show[NonnegativeReal] = ev.contramap(identity) given simpleShowForNonnegativeReal(using diff --git a/modules/pan/src/main/scala/instances/SimpleShowInstances.scala b/modules/pan/src/main/scala/instances/SimpleShowInstances.scala index 0cfe410..3909dac 100644 --- a/modules/pan/src/main/scala/instances/SimpleShowInstances.scala +++ b/modules/pan/src/main/scala/instances/SimpleShowInstances.scala @@ -4,6 +4,9 @@ package instances /** {@code SimpleShow} instances for core/builtin Scala types */ trait SimpleShowInstances: + /** Simply print the numeric value as-is. */ + given SimpleShow[Double] = SimpleShow.fromToString + /** Show the integer by its {@code toString} representation. */ given SimpleShow[Int] = SimpleShow.fromToString