From 969d1d393e75a229523c234203059fb570d28ed1 Mon Sep 17 00:00:00 2001 From: Lars-Dominik Braun Date: Tue, 17 Sep 2019 18:31:24 +0200 Subject: Initial import --- lulua/carpalx.py | 325 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 lulua/carpalx.py (limited to 'lulua/carpalx.py') diff --git a/lulua/carpalx.py b/lulua/carpalx.py new file mode 100644 index 0000000..3e104bb --- /dev/null +++ b/lulua/carpalx.py @@ -0,0 +1,325 @@ +# Copyright (c) 2019 lulua contributors +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +""" +Compute typing effort for triads according to +http://mkweb.bcgsc.ca/carpalx/?typing_effort + +Extended by support for multiple layers/multiple key presses based on +suggestion by Martin Krzywinski. b_ix and p_ix with x in {1, 2, 3} are now a +sum of all key’s effort/penalty plus a multi-key penalty weighted by model +parameter k_s. Additionally the stroke path is evaluated for all triple +combinations (see code of _triadEffort). + +Optimized for pypy, not cpython +""" + +from collections import defaultdict, namedtuple +from itertools import chain, product +from typing import List, Tuple, Callable, Mapping, Dict + +from .layout import LEFT, RIGHT, THUMB, INDEX, MIDDLE, RING, LITTLE, ButtonCombination +from .writer import Writer +from .util import first +from .keyboard import Button + +ModelParams = namedtuple ('ModelParams', ['kBPS', 'k123S', + 'w0HRF', 'pHand', 'pRow', 'pFinger', 'fHRF', 'baselineEffort']) + +# model parameters mod_01 from http://mkweb.bcgsc.ca/carpalx/?model_parameters +model01 = ModelParams ( + # k_b, k_p, k_s + kBPS = (0.3555, 0.6423, 0.4268), + # k_1, k_2, k_3 plus extension k_S (weight for simultaneous key presses) + k123S = (1.0, 0.367, 0.235, 1.0), + # w0, wHand, wRow, wFinger + w0HRF = (0.0, 1.0, 1.3088, 2.5948), + pHand = {LEFT: 0.0, RIGHT: 0.0}, + # numbers, top, base, bottom, control (XXX not part of original model) + pRow = (1.5, 0.5, 0.0, 1.0, 1.5), + # symmetric penalties + pFinger = { + LEFT: { + THUMB: 0.0, # XXX: not part of the original model + INDEX: 0.0, + MIDDLE: 0.0, + RING: 0.5, + LITTLE: 1.0, + }, + RIGHT: { + THUMB: 0.0, # XXX: not part of the original model + INDEX: 0.0, + MIDDLE: 0.0, + RING: 0.5, + LITTLE: 1.0, + }, + }, + # fHand, fRow, fFinger + fHRF = (1.0, 0.3, 0.3), + # baseline key effort + baselineEffort = { + 'Bl1': 5.0, + 'Bl2': 5.0, + 'Bl3': 4.0, + 'Bl4': 4.0, + 'Bl5': 4.0, + 'Bl6': 3.5, + 'Bl7': 4.5, + 'Br6': 4.0, + 'Br5': 4.0, + 'Br4': 4.0, + 'Br3': 4.0, + 'Br2': 4.0, + 'Br1': 4.5, + + 'Cl1': 2.0, + 'Cl2': 2.0, + 'Cl3': 2.0, + 'Cl4': 2.0, + 'Cl5': 2.5, + 'Cr7': 3.0, + 'Cr6': 2.0, + 'Cr5': 2.0, + 'Cr4': 2.0, + 'Cr3': 2.5, + 'Cr2': 4.0, + 'Cr1': 6.0, + + 'Dl_caps': 2.0, # XXX: dito + 'Dl1': 0.0, + 'Dl2': 0.0, + 'Dl3': 0.0, + 'Dl4': 0.0, + 'Dl5': 2.0, + 'Dr7': 2.0, + 'Dr6': 0.0, + 'Dr5': 0.0, + 'Dr4': 0.0, + 'Dr3': 0.0, + 'Dr2': 2.0, + 'Dr1': 4.0, # XXX: not in the original model + + 'El_shift': 4.0, # XXX: dito + 'El1': 4.0, # XXX: dito + 'El2': 2.0, + 'El3': 2.0, + 'El4': 2.0, + 'El5': 2.0, + 'El6': 3.5, + 'Er5': 2.0, + 'Er4': 2.0, + 'Er3': 2.0, + 'Er2': 2.0, + 'Er1': 2.0, + 'Er_shift': 4.0, # XXX: dito + + 'Fr_altgr': 4.0, # XXX: dito + }, + ) + +def madd (a, b): + """ Given indexables a and b, computes a[0]*b[0]+a[1]*b[1]+… """ + s = 0 + for i in range (len (a)): + s += a[i] * b[i] + return s + +class Carpalx: + __slots__ = ('absEffort', 'N', 'params', '_cache', 'writer') + + def __init__ (self, params: ModelParams, writer: Writer): + self.params = params + self.writer = writer + # reset should not reset the cache + self._cache : Dict[Tuple[ButtonCombination], float] = dict () + self.reset () + + # some runtime tests + keyboard = writer.layout.keyboard + assert keyboard.getRow (keyboard['Bl1']) == 0 + assert keyboard.getRow (keyboard['Cl1']) == 1 + assert keyboard.getRow (keyboard['Dl1']) == 2 + assert keyboard.getRow (keyboard['El1']) == 3 + + def addTriad (self, triad : Tuple[ButtonCombination], n: float): + self.absEffort += n*self._triadEffort (triad) + self.N += n + + def removeTriad (self, triad: Tuple[ButtonCombination], n: float): + self.absEffort -= n*self._triadEffort (triad) + self.N -= n + + def addTriads (self, triads: Mapping[Tuple[ButtonCombination], float]) -> None: + for t, n in triads.items (): + self.addTriad (t, n) + + def reset (self) -> None: + self.absEffort = 0.0 + self.N = 0.0 + + def copy (self): + """ Create a copy of this instance, sharing the cache """ + c = Carpalx (self.params, self.writer) + c._cache = self._cache + c.absEffort = self.absEffort + c.N = self.N + return c + + @property + def effort (self) -> float: + if self.N == 0: + return 0 + else: + return self.absEffort/self.N + + @staticmethod + def _strokePathHand (hands) -> int: + same = hands[0] == hands[1] and hands[1] == hands[2] + alternating = hands[0] == hands[2] and hands[0] != hands[1] + if alternating: + return 1 + elif same: + return 2 + else: + # both hands, but not alternating + return 0 + + @staticmethod + def _strokePathRow (rows: List[int]) -> int: + # d will be positive for upward row changes and negative for downward + d = (rows[0]-rows[1], rows[1]-rows[2], rows[0]-rows[2]) + #print ('rows', t, rows, d) + if d[0] == 0 and d[1] == 0: + # same row + return 0 + elif (rows[0] == rows[1] and rows[2] > rows[1]) or (rows[1] > rows[0] and rows[1] == rows[2]): + # downward progression, with repetition + return 1 + elif (rows[0] == rows[1] and rows[2] < rows[1]) or (rows[1] < rows[0] and rows[1] == rows[2]): + # upward progression, with repetition + return 2 + elif max (map (abs, d)) <= 1: + # some different, not monotonic, max row change 1 + return 3 + elif d[0] < 0 and d[1] < 0: + # downward progression + return 4 + elif d[0] > 0 and d[1] > 0: + # upward progression + # needs to be before 5 + return 6 + elif min (d[0], d[1]) < -1: + # some different, not monotonic, max row change downward >1 + return 5 + elif max (d[0], d[1]) > 1: + # some different, not monotonic, max row change upward >1 + return 7 + else: + assert False, (rows, d) + + @staticmethod + def _strokePathFinger (fingers, t) -> int: + fingers = [int (f[1]) if f[0] == LEFT else 6+(5-f[1]) for f in fingers] + same = fingers[0] == fingers[1] == fingers[2] + allDifferent = fingers[0] != fingers[1] and fingers[1] != fingers[2] and fingers[0] != fingers[2] + someDifferent = not same and not allDifferent + if same: + keyRepeat = t[0] == t[1] or t[1] == t[2] or t[0] == t[2] + if keyRepeat: + return 5 + else: # not keyRepeat + return 7 + elif fingers[0] > fingers[2] > fingers[1] or fingers[0] < fingers[2] < fingers[1]: + # rolling + return 2 + elif allDifferent: + monotonic = fingers[0] <= fingers[1] <= fingers[2] or fingers[0] >= fingers[1] >= fingers[2] + if monotonic: + return 0 + else: + return 3 + elif someDifferent: + monotonic = fingers[0] <= fingers[1] <= fingers[2] or fingers[0] >= fingers[1] >= fingers[2] + if monotonic: + keyRepeat = t[0] == t[1] or t[1] == t[2] or t[0] == t[2] + if keyRepeat: + return 1 + else: + return 6 + else: + return 4 + else: + assert False + + def _strokePath (self, t: Tuple[Button, Button, Button]) -> Tuple[int, int, int]: + """ Compute stroke path s for triad t """ + fingers = [self.writer.getHandFinger (x) for x in t] + hands = [f[0] for f in fingers] + keyboard = self.writer.layout.keyboard + rows = [keyboard.getRow (key) for key in t] + + return self._strokePathHand (hands), self._strokePathRow (rows), self._strokePathFinger (fingers, t) + + def _penalty (self, key): + hand, finger = self.writer.getHandFinger (key) + keyboard = self.writer.layout.keyboard + row = keyboard.getRow (key) + params = self.params + return madd (self.params.w0HRF, (1, params.pHand[hand], params.pRow[row], params.pFinger[hand][finger])) + + def _baseEffort (self, triad: Tuple[ButtonCombination], f: Callable[[Button], float]) -> float: + """ + Compute b_i or p_i, depending on function f + """ + + k1, k2, k3, kS = self.params.k123S + b = [] + for comb in triad: + perButton = [f (btn) for btn in comb] + numKeys = len (perButton) + # extra effort for hitting multiple buttons, no extra effort for + # just one button + simultaneousPenalty = (numKeys-1)*kS + b.append (sum (perButton) + simultaneousPenalty) + return k1 * b[0] * (1 + k2 * b[1] * (1 + k3 * b[2])) + + def _triadEffort (self, triad: Tuple[ButtonCombination]) -> float: + """ Compute effort for a single triad t, e_i """ + ret = self._cache.get (triad) + if ret is not None: + return ret + #t = [first (x.buttons) for x in triad] + params = self.params + bmap = params.baselineEffort + + b = self._baseEffort (triad, lambda x: bmap[x.name]) + p = self._baseEffort (triad, self._penalty) + + # calculate stroke path for all possible triad combinations, i.e. + # (Mod1-a, b, c) -> (Mod1, b, c), (a, b, c) and use the smallest + # value. Suggested by Martin Krzywinski XXX: why? + s = [madd (params.fHRF, self._strokePath (singleBtnTriad)) \ + for singleBtnTriad in product (*map (iter, triad))] + s = min (s) + + ret = madd (params.kBPS, (b, p, s)) + self._cache[triad] = ret + return ret + -- cgit v1.2.3