Metric vs imperial measurement system #19

master
mgifos 7 years ago
parent 56a0e2c5ae
commit 67883d5903
  1. 19
      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 -e, --email <value> E-mail to login to Garmin Connect
-p, --password <value> Password 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. -x, --delete Delete all existing workouts with same names as the ones that are going to be imported.
--help prints this usage text --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]` **`<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>` **`<pace>`** := `<minutes>:<seconds>`
@ -105,4 +107,15 @@ The reserved keywords of the notation are: workout, warmup, cooldown, run, bike,
**`<minutes>`** := `\d{1,3}` **`<minutes>`** := `\d{1,3}`
**`<seconds>`** := `\d{2}` **`<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.actor.ActorSystem
import akka.http.scaladsl.Http import akka.http.scaladsl.Http
import akka.stream.ActorMaterializer import akka.stream.ActorMaterializer
import com.github.mgifos.workouts.model._
import com.github.mgifos.workouts.model.WeeklyPlan import com.github.mgifos.workouts.model.WeeklyPlan
import com.typesafe.scalalogging.Logger import com.typesafe.scalalogging.Logger
import scopt.OptionParser import scopt.OptionParser
@ -22,6 +23,7 @@ object Modes extends Enumeration {
case class Config( case class Config(
mode: Modes.Mode = Modes.`import`, mode: Modes.Mode = Modes.`import`,
system: MeasurementSystems.MeasurementSystem = MeasurementSystems.metric,
csv: String = "", csv: String = "",
delete: Boolean = false, delete: Boolean = false,
email: String = "", email: String = "",
@ -34,6 +36,7 @@ object Main extends App {
implicit val system: ActorSystem = ActorSystem("quick-plan") implicit val system: ActorSystem = ActorSystem("quick-plan")
implicit val materializer: ActorMaterializer = ActorMaterializer() implicit val materializer: ActorMaterializer = ActorMaterializer()
implicit val executionContext: ExecutionContextExecutor = system.dispatcher implicit val executionContext: ExecutionContextExecutor = system.dispatcher
implicit val mSystemRead: scopt.Read[MeasurementSystems.MeasurementSystem] = scopt.Read.reads(MeasurementSystems.named)
val log = Logger(getClass) 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[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.") 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") help("help").text("prints this usage text")
@ -110,7 +115,7 @@ object Main extends App {
new String(console.readPassword()) 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) implicit val garmin: GarminConnect = new GarminConnect(email, password)

@ -19,18 +19,6 @@ case class DistanceDuration(distance: Float, unit: DistanceUnit) extends Duratio
"endConditionZone" -> JsNull) "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 { case class TimeDuration(minutes: Int = 0, seconds: Int = 0) extends Duration {
override def json: JsObject = Json.obj( override def json: JsObject = Json.obj(
"endCondition" -> Json.obj( "endCondition" -> Json.obj(
@ -59,7 +47,7 @@ object Duration {
private val MinutesRx = """^(\d{1,3}):(\d{2})$""".r private val MinutesRx = """^(\d{1,3}):(\d{2})$""".r
def parse(x: String): Duration = x match { 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 MinutesRx(minutes, seconds) => TimeDuration(minutes = minutes.toInt, seconds = seconds.toInt)
case "lap-button" => LapButtonPressed case "lap-button" => LapButtonPressed
case _ => throw new IllegalArgumentException(s"Duration cannot be parsed $x") 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 StepHeader = """^\s*-\s*(\w*):(.*)$""".r
private val ParamsRx = """^([\w-\.:\s]+)\s*(@(.*))?$""".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 StepRx(header, subSteps, _) if subSteps.nonEmpty => header match {
case StepHeader(name, params) => case StepHeader(name, params) =>
assert(name == "repeat", "must be 'repeat' if contains sub-steps") 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") 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 StepHeader(name, params) => name match {
case "warmup" => WarmupStep.tupled(expect(params)) case "warmup" => WarmupStep.tupled(expect(params))
case "run" | "bike" => IntervalStep.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") 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) => case ParamsRx(duration, _, target) =>
val maybeTarget = Option(target).filter(_.trim.nonEmpty).map(Target.parse) val maybeTarget = Option(target).filter(_.trim.nonEmpty).map(Target.parse)
(Duration.parse(duration.trim), maybeTarget) (Duration.parse(duration.trim), maybeTarget)

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

@ -4,7 +4,7 @@ import com.github.tototoshi.csv.CSVReader
import scala.io.Source import scala.io.Source
class WeeklyPlan(csv: Array[Byte]) { class WeeklyPlan(csv: Array[Byte])(implicit msys: MeasurementSystems.MeasurementSystem) {
type Week = List[String] 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 WorkoutHeader = """^(running|cycling):\s([\u0020-\u007F]+)((\n\s*\-\s[a-z]+:.*)*)$""".r
private val NextStepRx = """^((-\s\w*:\s.*)((\n\s{1,}-\s.*)*))(([\s].*)*)$""".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 { def loop(w: WorkoutDef, steps: String): Either[String, WorkoutDef] = steps match {
case NextStepRx(next, _, _, _, rest, _) => case NextStepRx(next, _, _, _, rest, _) =>
val newWorkout = w.withStep(Step.parse(next.trim)) 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 { 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 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 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,,""" 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 { class DurationSpec extends FlatSpec with Matchers {
implicit val msys = MeasurementSystems.metric
"Duration" should "parse correctly" in { "Duration" should "parse correctly" in {
a[IllegalArgumentException] should be thrownBy Duration.parse("") a[IllegalArgumentException] should be thrownBy Duration.parse("")

@ -5,13 +5,15 @@ import org.scalatest.{ FlatSpec, Matchers }
class StepSpec extends FlatSpec with Matchers { class StepSpec extends FlatSpec with Matchers {
implicit val msys = MeasurementSystems.metric
"Step" should "parse correctly" in { "Step" should "parse correctly" in {
a[IllegalArgumentException] should be thrownBy Step.parse("") a[IllegalArgumentException] should be thrownBy Step.parse("")
a[AssertionError] should be thrownBy Step.parse("- warmup: 5km\n - run: 10km\n - recover: 100m") 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("- 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("- 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("- cooldown: 05:00") should be(CooldownStep(TimeDuration(minutes = 5)))
Step.parse("- repeat: 3\n - run: 10km\n - recover: 100m") should be(RepeatStep(3, List( 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 package com.github.mgifos.workouts.model
import org.scalatest.{ FlatSpec, Matchers } import org.scalatest.{ FlatSpec, Matchers }
import com.github.mgifos.workouts.model.DistanceUnits._
class TargetSpec extends FlatSpec with Matchers { class TargetSpec extends FlatSpec with Matchers {
implicit val msys = MeasurementSystems.metric
"Target" should "parse correctly" in { "Target" should "parse correctly" in {
a[IllegalArgumentException] should be thrownBy Target.parse("") 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] val paceTarget = Target.parse("5:20-04:30").asInstanceOf[PaceTarget]
paceTarget.from.minutes should be(5) paceTarget.from.minutes should be(5)
paceTarget.to.seconds should be(30) 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 package com.github.mgifos.workouts.model
import org.scalatest.{ FlatSpec, Matchers }
import com.github.mgifos.workouts.model.DistanceUnits._ import com.github.mgifos.workouts.model.DistanceUnits._
import org.scalatest.{ FlatSpec, Matchers }
import play.api.libs.json.Json import play.api.libs.json.Json
class WorkoutSpec extends FlatSpec with Matchers { class WorkoutSpec extends FlatSpec with Matchers {
implicit val msys = MeasurementSystems.metric
/* /*
running: run-fast running: run-fast
- warmup: 10:00 - warmup: 10:00
@ -29,7 +31,7 @@ class WorkoutSpec extends FlatSpec with Matchers {
WorkoutDef("running", "run-fast", Seq( WorkoutDef("running", "run-fast", Seq(
WarmupStep(TimeDuration(minutes = 10)), WarmupStep(TimeDuration(minutes = 10)),
RepeatStep(2, Seq( 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))))), RecoverStep(TimeDuration(1, 30), Some(HrZoneTarget(2))))),
CooldownStep(LapButtonPressed))))) CooldownStep(LapButtonPressed)))))
} }
@ -56,7 +58,7 @@ class WorkoutSpec extends FlatSpec with Matchers {
Right( Right(
WorkoutDef("cycling", "cycle-test", Seq( WorkoutDef("cycling", "cycle-test", Seq(
WarmupStep(TimeDuration(minutes = 5)), 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))))) CooldownStep(LapButtonPressed)))))
} }
} }

Loading…
Cancel
Save