Skip to content

Commit

Permalink
bringing over lots of distance-related code from tracing project, and…
Browse files Browse the repository at this point in the history
… writing typeclasses to assist in information/member hiding
  • Loading branch information
vreuter committed Aug 26, 2024
1 parent 0e5b23b commit 392bb13
Show file tree
Hide file tree
Showing 16 changed files with 310 additions and 20 deletions.
213 changes: 213 additions & 0 deletions modules/geometry/src/main/scala/Distance.scala
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions modules/geometry/src/main/scala/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
19 changes: 19 additions & 0 deletions modules/geometry/src/main/scala/syntax/package.scala
Original file line number Diff line number Diff line change
@@ -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)
)
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
package at.ac.oeaw.imba.gerlich.gerlib
package json
package at.ac.oeaw.imba.gerlich.gerlib.json

import cats.*

Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions modules/json/src/main/scala/instances/geometry/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package at.ac.oeaw.imba.gerlich.gerlib.json
package instances

package object geometry extends JsonInstancesForGeometry
4 changes: 4 additions & 0 deletions modules/json/src/main/scala/instances/numeric/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package at.ac.oeaw.imba.gerlich.gerlib.json
package instances

package object numeric extends JsonInstancesForNumeric
8 changes: 8 additions & 0 deletions modules/json/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.json

package object instances:
object all extends AllJsonInstances

trait AllJsonInstances
extends JsonInstancesForGeometry,
JsonInstancesForNumeric
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 392bb13

Please sign in to comment.