Merge pull request #26 from mgifos/feature/mpm-19

Metric vs imperial measurement system #19
master
Nikola Petkov 7 years ago committed by GitHub
commit 7323663098
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 17
      README.md
  2. 7
      src/main/scala/com.github.mgifos.workouts/Main.scala
  3. 14
      src/main/scala/com.github.mgifos.workouts/model/Duration.scala
  4. 6
      src/main/scala/com.github.mgifos.workouts/model/Step.scala
  5. 25
      src/main/scala/com.github.mgifos.workouts/model/Target.scala
  6. 2
      src/main/scala/com.github.mgifos.workouts/model/WeeklyPlan.scala
  7. 2
      src/main/scala/com.github.mgifos.workouts/model/Workout.scala
  8. 37
      src/main/scala/com.github.mgifos.workouts/model/package.scala
  9. 2
      src/test/scala/com/github/mgifos/workouts/WeekPlanSpec.scala
  10. 2
      src/test/scala/com/github/mgifos/workouts/model/DurationSpec.scala
  11. 4
      src/test/scala/com/github/mgifos/workouts/model/StepSpec.scala
  12. 16
      src/test/scala/com/github/mgifos/workouts/model/TargetSpec.scala
  13. 8
      src/test/scala/com/github/mgifos/workouts/model/WorkoutSpec.scala

@ -45,6 +45,8 @@ Usage: quick-plan [import|schedule] [options] <file>
-e, --email <value> E-mail to login to Garmin Connect
-p, --password <value> Password to login to Garmin Connect
-m, --measurement_system <value>
"metric" (default) or "imperial" (miles, inches, ...) measurement system choice.
-x, --delete Delete all existing workouts with same names as the ones that are going to be imported.
--help prints this usage text
@ -95,9 +97,9 @@ The reserved keywords of the notation are: workout, warmup, cooldown, run, bike,
**`<zone-target>`** := `z[1-6]`
**`<pace-target>`** := `<pace> - <pace>`
**`<pace-target>`** := `<pace> - <pace> (mpk | mpm)?`
**`<speed-target>`** := `<kph-speed> - <kph-speed> kph`
**`<speed-target>`** := `<kph-speed> - <kph-speed> (kph | mph)?`
**`<pace>`** := `<minutes>:<seconds>`
@ -106,3 +108,14 @@ The reserved keywords of the notation are: workout, warmup, cooldown, run, bike,
**`<minutes>`** := `\d{1,3}`
**`<seconds>`** := `\d{2}`
## Unit of measurements (metric vs imperial)
As Garmin supports metric and imperial measurement systems, quick-plan can do this as well. There are two ways of usage:
- implicit (through the tool configuration (see the option -m) or
- explicit (it can be specified within the workout definition by using units:
- km vs mi (for distance),
- kph vs mph (for speed) and
- mpk vs mpm (for pace).
If not specified -m value from configuration will be used ('metric' by default).

@ -6,6 +6,7 @@ import java.time.LocalDate
import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.stream.ActorMaterializer
import com.github.mgifos.workouts.model._
import com.github.mgifos.workouts.model.WeeklyPlan
import com.typesafe.scalalogging.Logger
import scopt.OptionParser
@ -22,6 +23,7 @@ object Modes extends Enumeration {
case class Config(
mode: Modes.Mode = Modes.`import`,
system: MeasurementSystems.MeasurementSystem = MeasurementSystems.metric,
csv: String = "",
delete: Boolean = false,
email: String = "",
@ -34,6 +36,7 @@ object Main extends App {
implicit val system: ActorSystem = ActorSystem("quick-plan")
implicit val materializer: ActorMaterializer = ActorMaterializer()
implicit val executionContext: ExecutionContextExecutor = system.dispatcher
implicit val mSystemRead: scopt.Read[MeasurementSystems.MeasurementSystem] = scopt.Read.reads(MeasurementSystems.named)
val log = Logger(getClass)
@ -59,6 +62,8 @@ object Main extends App {
opt[String]('p', "password").action((x, c) => c.copy(password = x)).text("Password to login to Garmin Connect")
opt[MeasurementSystems.MeasurementSystem]('m', "measurement_system").action((x, c) => c.copy(system = x)).text(""""metric" (default) or "imperial" (miles, inches, ...) measurement system choice.""")
opt[Unit]('x', "delete").action((_, c) => c.copy(delete = true)).text("Delete all existing workouts with same names as the ones that are going to be imported.")
help("help").text("prints this usage text")
@ -110,7 +115,7 @@ object Main extends App {
new String(console.readPassword())
}
implicit val plan: WeeklyPlan = new WeeklyPlan(Files.readAllBytes(Paths.get(config.csv)))
implicit val plan: WeeklyPlan = new WeeklyPlan(Files.readAllBytes(Paths.get(config.csv)))(config.system)
implicit val garmin: GarminConnect = new GarminConnect(email, password)

@ -19,18 +19,6 @@ case class DistanceDuration(distance: Float, unit: DistanceUnit) extends Duratio
"endConditionZone" -> JsNull)
}
object DistanceUnits extends Enumeration {
type DistanceUnit = DistVal
val km = Value("km", "kilometer", _ * 1000F)
val mi = Value("mi", "mile", _ * 1609.344F)
val m = Value("m", "meter", _ * 1F)
class DistVal(name: String, val fullName: String, val toMeters: (Float) => Float) extends Val(nextId, name)
protected final def Value(name: String, fullName: String, toMeters: (Float) => Float): DistVal = new DistVal(name, fullName, toMeters)
def named(name: String): DistVal = withName(name).asInstanceOf[DistVal]
}
case class TimeDuration(minutes: Int = 0, seconds: Int = 0) extends Duration {
override def json: JsObject = Json.obj(
"endCondition" -> Json.obj(
@ -59,7 +47,7 @@ object Duration {
private val MinutesRx = """^(\d{1,3}):(\d{2})$""".r
def parse(x: String): Duration = x match {
case DistanceRx(quantity, _, unit) => DistanceDuration(quantity.toFloat, DistanceUnits.named(unit))
case DistanceRx(quantity, _, uom) => DistanceDuration(quantity.toFloat, DistanceUnits.named(uom))
case MinutesRx(minutes, seconds) => TimeDuration(minutes = minutes.toInt, seconds = seconds.toInt)
case "lap-button" => LapButtonPressed
case _ => throw new IllegalArgumentException(s"Duration cannot be parsed $x")

@ -56,7 +56,7 @@ object Step {
private val StepHeader = """^\s*-\s*(\w*):(.*)$""".r
private val ParamsRx = """^([\w-\.:\s]+)\s*(@(.*))?$""".r
def parse(x: String): Step = x match {
def parse(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): Step = x match {
case StepRx(header, subSteps, _) if subSteps.nonEmpty => header match {
case StepHeader(name, params) =>
assert(name == "repeat", "must be 'repeat' if contains sub-steps")
@ -67,7 +67,7 @@ object Step {
case _ => throw new IllegalArgumentException(s"Cannot parse step:$x")
}
private def parseDurationStep(x: String): DurationStep = x match {
private def parseDurationStep(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): DurationStep = x match {
case StepHeader(name, params) => name match {
case "warmup" => WarmupStep.tupled(expect(params))
case "run" | "bike" => IntervalStep.tupled(expect(params))
@ -78,7 +78,7 @@ object Step {
case _ => throw new IllegalArgumentException(s"Cannot parse step type $x")
}
private def expect(x: String): (Duration, Option[Target]) = x match {
private def expect(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): (Duration, Option[Target]) = x match {
case ParamsRx(duration, _, target) =>
val maybeTarget = Option(target).filter(_.trim.nonEmpty).map(Target.parse)
(Duration.parse(duration.trim), maybeTarget)

@ -26,7 +26,7 @@ case class PaceTarget(from: Pace, to: Pace) extends Target {
"zoneNumber" -> JsNull)
}
case class SpeedTarget(from: KphSpeed, to: KphSpeed) extends Target {
case class SpeedTarget(from: Speed, to: Speed) extends Target {
override def json = Json.obj(
"targetType" -> Json.obj(
"workoutTargetTypeId" -> 5,
@ -46,33 +46,38 @@ object NoTarget extends Target {
"zoneNumber" -> JsNull)
}
case class Pace(exp: String) {
case class Pace(uom: DistanceUnits.DistanceUnit, exp: String) {
def minutes: Int = exp.trim.takeWhile(_ != ':').toInt
def seconds: Int = exp.trim.split(":").last.toInt
/**
* @return Speed in m/s
*/
def speed: Double = 1000D / (minutes * 60 + seconds)
def speed: Double = uom.toMeters(1) / (minutes * 60 + seconds)
}
case class KphSpeed(exp: String) {
case class Speed(unit: DistanceUnits.DistanceUnit, exp: String) {
/**
* @return Speed in m/s
*/
def speed: Double = exp.toDouble * 10 / 36
def speed: Double = unit.toMeters(exp.toDouble) / 3600
}
object Target {
private val HrZoneRx = """^z(\d)$""".r
private val PaceRangeRx = """^(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})$""".r
private val SpeedRangeRx = """^(\d{1,3}(\.\d{1})?)\s*-\s*(\d{1,3}(\.\d{1})?)\s*kph$""".r
private val PaceRangeRx = """^(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})\s*(mpk|mpm)?$""".r
def parse(x: String): Target = x.trim match {
private val SpeedRangeRx = """^(\d{1,3}(\.\d{1})?)\s*-\s*(\d{1,3}(\.\d{1})?)\s*(kph|mph)?""".r
def parse(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): Target = x.trim match {
case HrZoneRx(zone) => HrZoneTarget(zone.toInt)
case SpeedRangeRx(from, _, to, _) => SpeedTarget(KphSpeed(from), KphSpeed(to))
case PaceRangeRx(from, to) => PaceTarget(Pace(from), Pace(to))
case SpeedRangeRx(from, _, to, _, uom) =>
val du = Option(uom).fold(msys.distance)(DistanceUnits.withSpeedUOM)
SpeedTarget(Speed(du, from), Speed(du, to))
case PaceRangeRx(from, to, uom) =>
val du = Option(uom).fold(msys.distance)(DistanceUnits.withPaceUOM)
PaceTarget(Pace(du, from), Pace(du, to))
case _ => throw new IllegalArgumentException(s"Unknown target specification: $x")
}
}

@ -4,7 +4,7 @@ import com.github.tototoshi.csv.CSVReader
import scala.io.Source
class WeeklyPlan(csv: Array[Byte]) {
class WeeklyPlan(csv: Array[Byte])(implicit msys: MeasurementSystems.MeasurementSystem) {
type Week = List[String]

@ -37,7 +37,7 @@ object Workout {
private val WorkoutHeader = """^(running|cycling):\s([\u0020-\u007F]+)((\n\s*\-\s[a-z]+:.*)*)$""".r
private val NextStepRx = """^((-\s\w*:\s.*)((\n\s{1,}-\s.*)*))(([\s].*)*)$""".r
def parseDef(x: String): Either[String, WorkoutDef] = {
def parseDef(x: String)(implicit msys: MeasurementSystems.MeasurementSystem): Either[String, WorkoutDef] = {
def loop(w: WorkoutDef, steps: String): Either[String, WorkoutDef] = steps match {
case NextStepRx(next, _, _, _, rest, _) =>
val newWorkout = w.withStep(Step.parse(next.trim))

@ -0,0 +1,37 @@
package com.github.mgifos.workouts
package object model {
object DistanceUnits extends Enumeration {
type DistanceUnit = DistVal
val km = Value("km", "kilometer", _ * 1000F)
val mi = Value("mi", "mile", _ * 1609.344F)
val m = Value("m", "meter", _ * 1F)
class DistVal(name: String, val fullName: String, val toMeters: (Double) => Double) extends Val(nextId, name)
protected final def Value(name: String, fullName: String, toMeters: (Double) => Double): DistVal = new DistVal(name, fullName, toMeters)
def named(name: String): DistVal = withName(name).asInstanceOf[DistVal]
def withPaceUOM(paceUom: String): DistVal = paceUom match {
case "mpk" => km
case "mpm" => mi
case _ => throw new IllegalArgumentException(s"No such pace unit of measurement: '$paceUom'")
}
def withSpeedUOM(speedUom: String): DistVal = speedUom match {
case "kph" => km
case "mph" => mi
case _ => throw new IllegalArgumentException(s"No such speed unit of measurement: '$speedUom'")
}
}
object MeasurementSystems extends Enumeration {
type MeasurementSystem = MSVal
val imperial = Value("imperial", DistanceUnits.mi)
val metric = Value("metric", DistanceUnits.km)
class MSVal(name: String, val distance: DistanceUnits.DistanceUnit) extends Val(nextId, name)
protected final def Value(name: String, distance: DistanceUnits.DistanceUnit): MSVal = new MSVal(name, distance)
def named(name: String): MSVal = withName(name).asInstanceOf[MSVal]
}
}

@ -5,6 +5,8 @@ import org.scalatest.{ FlatSpec, Matchers }
class WeekPlanSpec extends FlatSpec with Matchers {
implicit val msys = MeasurementSystems.metric
val runFast = "running: run-fast\n- warmup: 10:00\n- repeat: 2\n - run: 1500m @ 4:30-5:00\n - recover: 01:30 @ z2\n- cooldown: lap-button"
val runSlow = "running: run-slow\n- warmup: 10:00\n- run: 5km @ z2\n- cooldown: lap-button"
val testPlan = s"""1,"$runFast",,run-fast,,run-fast,,,\n2,,run-fast,"$runSlow",run-fast,,run-slow,,"""

@ -5,6 +5,8 @@ import com.github.mgifos.workouts.model.DistanceUnits._
class DurationSpec extends FlatSpec with Matchers {
implicit val msys = MeasurementSystems.metric
"Duration" should "parse correctly" in {
a[IllegalArgumentException] should be thrownBy Duration.parse("")

@ -5,13 +5,15 @@ import org.scalatest.{ FlatSpec, Matchers }
class StepSpec extends FlatSpec with Matchers {
implicit val msys = MeasurementSystems.metric
"Step" should "parse correctly" in {
a[IllegalArgumentException] should be thrownBy Step.parse("")
a[AssertionError] should be thrownBy Step.parse("- warmup: 5km\n - run: 10km\n - recover: 100m")
Step.parse("- warmup: 5km") should be(WarmupStep(DistanceDuration(5, km)))
Step.parse("- run: 2km @ 5:00-4:50") should be(IntervalStep(DistanceDuration(2, km), Some(PaceTarget(Pace("5:00"), Pace("4:50")))))
Step.parse("- run: 2km @ 5:00-4:50") should be(IntervalStep(DistanceDuration(2, km), Some(PaceTarget(Pace(msys.distance, "5:00"), Pace(msys.distance, "4:50")))))
Step.parse("- recover: 500m @z2") should be(RecoverStep(DistanceDuration(500, m), Some(HrZoneTarget(2))))
Step.parse("- cooldown: 05:00") should be(CooldownStep(TimeDuration(minutes = 5)))
Step.parse("- repeat: 3\n - run: 10km\n - recover: 100m") should be(RepeatStep(3, List(

@ -1,9 +1,12 @@
package com.github.mgifos.workouts.model
import org.scalatest.{ FlatSpec, Matchers }
import com.github.mgifos.workouts.model.DistanceUnits._
class TargetSpec extends FlatSpec with Matchers {
implicit val msys = MeasurementSystems.metric
"Target" should "parse correctly" in {
a[IllegalArgumentException] should be thrownBy Target.parse("")
@ -14,6 +17,17 @@ class TargetSpec extends FlatSpec with Matchers {
val paceTarget = Target.parse("5:20-04:30").asInstanceOf[PaceTarget]
paceTarget.from.minutes should be(5)
paceTarget.to.seconds should be(30)
paceTarget should be(PaceTarget(Pace("5:20"), Pace("04:30")))
paceTarget should be(PaceTarget(Pace(msys.distance, "5:20"), Pace(msys.distance, "04:30")))
}
"Target" should "parse pace UOMs correctly" in {
val mpk = Target.parse("5:20-04:30 mpk").asInstanceOf[PaceTarget]
mpk should be(PaceTarget(Pace(km, "5:20"), Pace(km, "04:30")))
val mpm = Target.parse("4:20-05:30 mpm").asInstanceOf[PaceTarget]
mpm should be(PaceTarget(Pace(mi, "4:20"), Pace(mi, "05:30")))
mpm.from.speed should be(6.189784592848557D)
a[IllegalArgumentException] should be thrownBy Target.parse("5:20-04:30 unknownUOM")
}
}

@ -1,11 +1,13 @@
package com.github.mgifos.workouts.model
import org.scalatest.{ FlatSpec, Matchers }
import com.github.mgifos.workouts.model.DistanceUnits._
import org.scalatest.{ FlatSpec, Matchers }
import play.api.libs.json.Json
class WorkoutSpec extends FlatSpec with Matchers {
implicit val msys = MeasurementSystems.metric
/*
running: run-fast
- warmup: 10:00
@ -29,7 +31,7 @@ class WorkoutSpec extends FlatSpec with Matchers {
WorkoutDef("running", "run-fast", Seq(
WarmupStep(TimeDuration(minutes = 10)),
RepeatStep(2, Seq(
IntervalStep(DistanceDuration(1500, m), Some(PaceTarget(Pace("4:30"), Pace("5:00")))),
IntervalStep(DistanceDuration(1500, m), Some(PaceTarget(Pace(msys.distance, "4:30"), Pace(msys.distance, "5:00")))),
RecoverStep(TimeDuration(1, 30), Some(HrZoneTarget(2))))),
CooldownStep(LapButtonPressed)))))
}
@ -56,7 +58,7 @@ class WorkoutSpec extends FlatSpec with Matchers {
Right(
WorkoutDef("cycling", "cycle-test", Seq(
WarmupStep(TimeDuration(minutes = 5)),
IntervalStep(DistanceDuration(20, km), Some(SpeedTarget(KphSpeed("20.0"), KphSpeed("100")))),
IntervalStep(DistanceDuration(20, km), Some(SpeedTarget(Speed(km, "20.0"), Speed(km, "100")))),
CooldownStep(LapButtonPressed)))))
}
}

Loading…
Cancel
Save