├── layouts ├── APT ├── APTx ├── ARTS ├── BEAKL-15 ├── BEAKL-19 ├── CTGAP ├── CTGAP_3 ├── FLAW ├── ISRT ├── QGMLWY ├── QWERF ├── QWPR ├── RTNA ├── SIND ├── arensito ├── asset ├── boo ├── colemaq ├── colman ├── dvarf ├── dwarf ├── heart ├── kaehi ├── klausler ├── niro ├── octa8 ├── pine ├── qwerty ├── real ├── rolll ├── soul ├── three ├── trendy ├── typehack ├── vitrimak ├── whorf ├── BEAKL-19-bis ├── HIEAMTSRN ├── MTGAP_30 ├── capewell ├── colemak ├── colemak_dh ├── colemak_qiou ├── colemaq-f ├── dvorak ├── dvormax ├── foalmak ├── halmak ├── minimak-12 ├── norman ├── oneproduct ├── semimak_jq ├── sertain ├── turducken ├── workman ├── BEAKL-49 ├── RSI-terminated ├── TNWMLC ├── balance-12 ├── capewell-dvorak ├── colemak_dhv ├── colemak_qi ├── colemak_qi_x ├── engram ├── hands_down_neu ├── ADNW_english_bigram ├── hands_down_reference ├── vitrimak_ortho ├── colemak_ca_ansi ├── selt-angle ├── ints ├── semimak ├── boom └── qwerty-angle ├── misc ├── medley_1.png ├── anneal_image_1.png ├── stats_image_1.png ├── stats_image_2.png ├── stats_image_3.png ├── stats_image_4.png ├── stats_image_5.png ├── stats_image_6.png ├── typingtest_image_1.png ├── typingtest_image_2.png └── typingtest_image_3.png ├── constraintmaps ├── angle-mid ├── angle-default ├── trad-mid ├── angle-dead-pinkies ├── trad-dead-pinkies └── traditional-default ├── boards ├── ortho └── ansi ├── fingermaps ├── zwou ├── ansi_angle ├── kathy_angle ├── traditional ├── ansi_angle_pinky └── ansi_angle_prog ├── .gitignore ├── test2.py ├── LICENSE ├── board.py ├── fingermap.py ├── testing.py ├── constraintmap.py ├── cmini_layout_loader_experiment.py ├── funny_graphs.py ├── gui_util.py ├── remap.py ├── typingtest.py ├── session.py ├── layout.py ├── README.md ├── corpus.py ├── nstroke.py ├── typingdata.py └── analysis.py /layouts/APT: -------------------------------------------------------------------------------- 1 | w g d f b q l u o y 2 | r s t h k j n e a i ; 3 | x c m p v z , . ' / -------------------------------------------------------------------------------- /layouts/APTx: -------------------------------------------------------------------------------- 1 | q c d w x z p o u ; 2 | r s t n v y h e i a 3 | l g b m j k f ' , . -------------------------------------------------------------------------------- /layouts/ARTS: -------------------------------------------------------------------------------- 1 | q l d y g j m o u ; 2 | a r t s c p n e i h / 3 | z x k w v b f ' , . -------------------------------------------------------------------------------- /layouts/BEAKL-15: -------------------------------------------------------------------------------- 1 | q h o u x g c r f z 2 | y i e a . d s t n b 3 | j / , k ' w m l p v -------------------------------------------------------------------------------- /layouts/BEAKL-19: -------------------------------------------------------------------------------- 1 | q . o u j w d n m , 2 | h a e i k g s r t p 3 | z ' / y x b c l f v -------------------------------------------------------------------------------- /layouts/CTGAP: -------------------------------------------------------------------------------- 1 | w c l d k j y o u / 2 | r s t h m p n e i a 3 | q v g f b z x ' , . -------------------------------------------------------------------------------- /layouts/CTGAP_3: -------------------------------------------------------------------------------- 1 | v p l c f k u o y j 2 | r n t s d ' a e i h 3 | z b m g w x , . ; q -------------------------------------------------------------------------------- /layouts/FLAW: -------------------------------------------------------------------------------- 1 | f l a w p z k u r / 2 | h s o y c m t e n i 3 | b j ' g v q d . x , -------------------------------------------------------------------------------- /layouts/ISRT: -------------------------------------------------------------------------------- 1 | y c l m k z f u , ' 2 | i s r t g p n e a o ; 3 | q v w d j b h / . x -------------------------------------------------------------------------------- /layouts/QGMLWY: -------------------------------------------------------------------------------- 1 | q g m l w y f u b ; 2 | d s t n r i a e o h ' 3 | z x c v j k p , . / -------------------------------------------------------------------------------- /layouts/QWERF: -------------------------------------------------------------------------------- 1 | q w e r f j y l k ; 2 | a s d t g h u i o p ' 3 | z x c v b n m , . / -------------------------------------------------------------------------------- /layouts/QWPR: -------------------------------------------------------------------------------- 1 | q w p r f y u k l ; 2 | a s d t g h n i o e ' 3 | z x c v b j m , . / -------------------------------------------------------------------------------- /layouts/RTNA: -------------------------------------------------------------------------------- 1 | x d h . q b f o u j 2 | r t n a ; g w e i s 3 | l k m , / p c z y v -------------------------------------------------------------------------------- /layouts/SIND: -------------------------------------------------------------------------------- 1 | y , h w f q k o u x 2 | s i n d c v t a e r 3 | j . l p b g m ; / z -------------------------------------------------------------------------------- /layouts/arensito: -------------------------------------------------------------------------------- 1 | q l . p ' ; f u d k 2 | a r e n b g s i t o 3 | z w , h j v c y m x -------------------------------------------------------------------------------- /layouts/asset: -------------------------------------------------------------------------------- 1 | q w j f g y p u l ; 2 | a s e t d h n i o r ' 3 | z x c v b k m , . / -------------------------------------------------------------------------------- /layouts/boo: -------------------------------------------------------------------------------- 1 | , . u c v q f d l y ? 2 | a o e s g b n t r i - 3 | ; x ' w z p h m k j -------------------------------------------------------------------------------- /layouts/colemaq: -------------------------------------------------------------------------------- 1 | ; w f p b j l u y q 2 | a r s t g m n e i o 3 | z x c d k v h / . , -------------------------------------------------------------------------------- /layouts/colman: -------------------------------------------------------------------------------- 1 | q l r w b j m u y ; 2 | a n h s f p t e i o ' 3 | z x v c k g d , . / -------------------------------------------------------------------------------- /layouts/dvarf: -------------------------------------------------------------------------------- 1 | ' u o w p j v d r f 2 | a i e y g l h t n s 3 | , . ; c q k m b x z -------------------------------------------------------------------------------- /layouts/dwarf: -------------------------------------------------------------------------------- 1 | f l h d v z g o u . 2 | s r n t m p y e i a 3 | x j b k q c w ' , ; -------------------------------------------------------------------------------- /layouts/heart: -------------------------------------------------------------------------------- 1 | q g d v x j y o u ; 2 | r s t h l p n a i e 3 | w c b m k z f , . / -------------------------------------------------------------------------------- /layouts/kaehi: -------------------------------------------------------------------------------- 1 | q w l d g j u o p / 2 | n r s t m k a e h i ' 3 | z x c v b y f , . ; -------------------------------------------------------------------------------- /layouts/klausler: -------------------------------------------------------------------------------- 1 | k , u y p w l m f c 2 | o a e i d r n t h s 3 | q . ' ; z x v g b j -------------------------------------------------------------------------------- /layouts/niro: -------------------------------------------------------------------------------- 1 | q w u d p j f y l ; 2 | a s e t g h n i r o ' 3 | z x c v b k m , . / -------------------------------------------------------------------------------- /layouts/octa8: -------------------------------------------------------------------------------- 1 | y o u k x g w d l , 2 | i a e n f b s t r c 3 | q / z h ' v p m j . -------------------------------------------------------------------------------- /layouts/pine: -------------------------------------------------------------------------------- 1 | y l r d w j m o u , 2 | c s n t g p h a e i 3 | x z q v k b f ' / . -------------------------------------------------------------------------------- /layouts/qwerty: -------------------------------------------------------------------------------- 1 | q w e r t y u i o p 2 | a s d f g h j k l ; ' 3 | z x c v b n m , . / -------------------------------------------------------------------------------- /layouts/real: -------------------------------------------------------------------------------- 1 | y l u o . z f h c w 2 | i r e a , d t n s m 3 | ; j ' q x p k b g v -------------------------------------------------------------------------------- /layouts/rolll: -------------------------------------------------------------------------------- 1 | y o u w b x k c l v 2 | i a e n p d h s r t 3 | j / , . q f m g ' z -------------------------------------------------------------------------------- /layouts/soul: -------------------------------------------------------------------------------- 1 | q w l d p k m u y ; 2 | a s r t g f n e i o ' 3 | z x c v j b h , . / -------------------------------------------------------------------------------- /layouts/three: -------------------------------------------------------------------------------- 1 | q f u y z x k c w b 2 | o h e a i d r t n s 3 | , m . j ; g l p v ' -------------------------------------------------------------------------------- /layouts/trendy: -------------------------------------------------------------------------------- 1 | k l h w z j f o u , 2 | t r n d c y s a e i 3 | q x m p b g v . ' ; -------------------------------------------------------------------------------- /layouts/typehack: -------------------------------------------------------------------------------- 1 | j g h p f q v o u ; 2 | r s n t k y i a e l 3 | z w m d b c , ' . x -------------------------------------------------------------------------------- /layouts/vitrimak: -------------------------------------------------------------------------------- 1 | t k v u m i a j b r 2 | w x / f p d g q , s 3 | h . ' c o l n z y e -------------------------------------------------------------------------------- /layouts/whorf: -------------------------------------------------------------------------------- 1 | f l h d m v w o u , 2 | s r n t k g y a e i / 3 | x j b z q p c ' ; . -------------------------------------------------------------------------------- /layouts/BEAKL-19-bis: -------------------------------------------------------------------------------- 1 | q y o u z w d n c k 2 | h i e a , g t r s p 3 | j ' / . x v m l f b -------------------------------------------------------------------------------- /layouts/HIEAMTSRN: -------------------------------------------------------------------------------- 1 | b y o u ' k d c l p q 2 | h i e a , m t s r n v 3 | x ( ) . ? w g f j z -------------------------------------------------------------------------------- /layouts/MTGAP_30: -------------------------------------------------------------------------------- 1 | y p o u j k d l c w 2 | i n e a , m h t s r 3 | q z / . ; b f g v x -------------------------------------------------------------------------------- /layouts/capewell: -------------------------------------------------------------------------------- 1 | . y w d f j p l u q / 2 | a e r s g b t n i o - 3 | x z c v ; k m h , ' -------------------------------------------------------------------------------- /layouts/colemak: -------------------------------------------------------------------------------- 1 | q w f p g j l u y ; 2 | a r s t d h n e i o ' 3 | z x c v b k m , . / -------------------------------------------------------------------------------- /layouts/colemak_dh: -------------------------------------------------------------------------------- 1 | q w f p b j l u y ; 2 | a r s t g m n e i o ' 3 | z x c d v k h , . / -------------------------------------------------------------------------------- /layouts/colemak_qiou: -------------------------------------------------------------------------------- 1 | q l c m k j f o y ' 2 | a r s t g b n e i u 3 | z x w d v p h , . / -------------------------------------------------------------------------------- /layouts/colemaq-f: -------------------------------------------------------------------------------- 1 | ; w g p b j l u y q 2 | a r s t f m n e i o 3 | z x c d k v h / . , -------------------------------------------------------------------------------- /layouts/dvorak: -------------------------------------------------------------------------------- 1 | ' , . p y f g c r l / 2 | a o e u i d h t n s - 3 | ; q j k x b m w v z -------------------------------------------------------------------------------- /layouts/dvormax: -------------------------------------------------------------------------------- 1 | k y u . ? z l m d p v 2 | r i e a o h n s t c w 3 | x ) ' , ( j q f g b -------------------------------------------------------------------------------- /layouts/foalmak: -------------------------------------------------------------------------------- 1 | b x , w v z / u t k 2 | f o a l s n e i g h 3 | p ' . m c q j y d r -------------------------------------------------------------------------------- /layouts/halmak: -------------------------------------------------------------------------------- 1 | w l r b z ; q u d j 2 | s h n t , . a e o i ' 3 | f m v c / g p x k y -------------------------------------------------------------------------------- /layouts/minimak-12: -------------------------------------------------------------------------------- 1 | q w d f k y u i l ; 2 | a s t r g h n e o p ' 3 | z x c v b j m , . / -------------------------------------------------------------------------------- /layouts/norman: -------------------------------------------------------------------------------- 1 | q w d f k j u r l ; 2 | a s e t g y n i o h ' 3 | z x c v b p m , . / -------------------------------------------------------------------------------- /layouts/oneproduct: -------------------------------------------------------------------------------- 1 | p l d w g j x o y q 2 | n r s t m u a e i h 3 | z c f v b , . ? ; k -------------------------------------------------------------------------------- /layouts/semimak_jq: -------------------------------------------------------------------------------- 1 | f l h v z ' w u o y 2 | s r n t k c d e a i ; 3 | x j b m q p g , . / -------------------------------------------------------------------------------- /layouts/sertain: -------------------------------------------------------------------------------- 1 | x l d k v z w o u . 2 | s r t n f g y e i a / 3 | q j m h b p c ' , ; -------------------------------------------------------------------------------- /layouts/turducken: -------------------------------------------------------------------------------- 1 | y l u v k j w o b , 2 | i r e t g f s a n h 3 | z x ; d p m c q . / -------------------------------------------------------------------------------- /layouts/workman: -------------------------------------------------------------------------------- 1 | q d r w b j f u p ; 2 | a s h t g y n e o i ' 3 | z x m c v k l , . / -------------------------------------------------------------------------------- /layouts/BEAKL-49: -------------------------------------------------------------------------------- 1 | z y o u k g m l p j ; = 2 | h i e a f d s t n r - \ 3 | / . , ' x w c v b q -------------------------------------------------------------------------------- /layouts/RSI-terminated: -------------------------------------------------------------------------------- 1 | z c y w k x l u , q 2 | r s i t g m n e a o 3 | j f p d b v h ' . ? -------------------------------------------------------------------------------- /layouts/TNWMLC: -------------------------------------------------------------------------------- 1 | t n w m l c b p r h [ ] \ 2 | s g x j f k q z v ; ' 3 | e a d i o y u , . / -------------------------------------------------------------------------------- /layouts/balance-12: -------------------------------------------------------------------------------- 1 | p l c d w ' u o y k q 2 | n r s t m , a e i h v 3 | z j f g b ? . ( ) x -------------------------------------------------------------------------------- /layouts/capewell-dvorak: -------------------------------------------------------------------------------- 1 | ' , . p y q f g r k 2 | o a e i u d h t n s 3 | z x c v j l m w b ; -------------------------------------------------------------------------------- /layouts/colemak_dhv: -------------------------------------------------------------------------------- 1 | q w c p b j l u y ; - 2 | a r s t g m n e i o ' 3 | x v f d z k h / . , -------------------------------------------------------------------------------- /layouts/colemak_qi: -------------------------------------------------------------------------------- 1 | q l w m k j f u y ' 2 | a r s t g p n e i o ; 3 | z x c d v b h , . / -------------------------------------------------------------------------------- /layouts/colemak_qi_x: -------------------------------------------------------------------------------- 1 | ; l c m k j f u y q 2 | a r s t g p n e i o ' 3 | z x w d v b h / . , -------------------------------------------------------------------------------- /layouts/engram: -------------------------------------------------------------------------------- 1 | b y o u ' " l d w v z # @ 2 | c i e a , . h t s n q 3 | g x j k - ? r m f p -------------------------------------------------------------------------------- /layouts/hands_down_neu: -------------------------------------------------------------------------------- 1 | w f m p v - u o y k z 2 | r s n t g , a e i h j 3 | x c l d b / . q " ' -------------------------------------------------------------------------------- /misc/medley_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelxyz/trialyzer/HEAD/misc/medley_1.png -------------------------------------------------------------------------------- /constraintmaps/angle-mid: -------------------------------------------------------------------------------- 1 | 0.019 0.019 2 | 0.019 3 | 0.04 0.04 0.04 0.04 0.019 -------------------------------------------------------------------------------- /layouts/ADNW_english_bigram: -------------------------------------------------------------------------------- 1 | q y u . ? z m l d b p 2 | s i e a o h n r t c g 3 | j ) ' , ( f x w k v -------------------------------------------------------------------------------- /layouts/hands_down_reference: -------------------------------------------------------------------------------- 1 | q c h p v k y o j / 2 | r s n t g w u e i a 3 | x m l d b z f ' , . -------------------------------------------------------------------------------- /constraintmaps/angle-default: -------------------------------------------------------------------------------- 1 | 0.025 0.025 2 | 0.025 3 | 0.05 0.05 0.05 0.05 0.025 -------------------------------------------------------------------------------- /constraintmaps/trad-mid: -------------------------------------------------------------------------------- 1 | 0.019 0.019 2 | 0.019 3 | 0.019 0.04 0.04 0.04 0.04 0.019 -------------------------------------------------------------------------------- /misc/anneal_image_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelxyz/trialyzer/HEAD/misc/anneal_image_1.png -------------------------------------------------------------------------------- /misc/stats_image_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelxyz/trialyzer/HEAD/misc/stats_image_1.png -------------------------------------------------------------------------------- /misc/stats_image_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelxyz/trialyzer/HEAD/misc/stats_image_2.png -------------------------------------------------------------------------------- /misc/stats_image_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelxyz/trialyzer/HEAD/misc/stats_image_3.png -------------------------------------------------------------------------------- /misc/stats_image_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelxyz/trialyzer/HEAD/misc/stats_image_4.png -------------------------------------------------------------------------------- /misc/stats_image_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelxyz/trialyzer/HEAD/misc/stats_image_5.png -------------------------------------------------------------------------------- /misc/stats_image_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelxyz/trialyzer/HEAD/misc/stats_image_6.png -------------------------------------------------------------------------------- /constraintmaps/angle-dead-pinkies: -------------------------------------------------------------------------------- 1 | 0.015 0.015 2 | 0.015 3 | 0.05 0.05 0.05 0.05 0.015 -------------------------------------------------------------------------------- /layouts/vitrimak_ortho: -------------------------------------------------------------------------------- 1 | e . v i a c o q y t 2 | s j / d g m p x , w 3 | r k ' l n f u z b h 4 | board: ortho -------------------------------------------------------------------------------- /constraintmaps/trad-dead-pinkies: -------------------------------------------------------------------------------- 1 | 0.015 0.015 2 | 0.015 3 | 0.015 0.05 0.05 0.05 0.05 0.015 -------------------------------------------------------------------------------- /constraintmaps/traditional-default: -------------------------------------------------------------------------------- 1 | 0.025 0.025 2 | 0.015 3 | 0.025 0.05 0.05 0.05 0.05 0.025 -------------------------------------------------------------------------------- /misc/typingtest_image_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelxyz/trialyzer/HEAD/misc/typingtest_image_1.png -------------------------------------------------------------------------------- /misc/typingtest_image_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelxyz/trialyzer/HEAD/misc/typingtest_image_2.png -------------------------------------------------------------------------------- /misc/typingtest_image_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelxyz/trialyzer/HEAD/misc/typingtest_image_3.png -------------------------------------------------------------------------------- /layouts/colemak_ca_ansi: -------------------------------------------------------------------------------- 1 | fingermap: ansi_angle 2 | q w f p b j l u y ; 3 | a r s t g m n e i o ' 4 | x c d v z k h , . / -------------------------------------------------------------------------------- /layouts/selt-angle: -------------------------------------------------------------------------------- 1 | fingermap: ansi_angle 2 | f u h v q , g n o y 3 | s e l t k w d r a i / 4 | ' m p z j c b x . ; -------------------------------------------------------------------------------- /boards/ortho: -------------------------------------------------------------------------------- 1 | NUMBER 0 0.0 4.0 12 2 | TOP 0 0.0 3.0 12 3 | HOME 0 0.0 2.0 12 4 | BOTTOM 0 0.0 1.0 12 5 | THUMB 0 0.0 0.0 12 -------------------------------------------------------------------------------- /layouts/ints: -------------------------------------------------------------------------------- 1 | fingermap: traditional 2 | board: ansi 3 | first_pos: TOP 1 4 | r o u l j v g d p . 5 | y a e m z f s t n i 6 | x ' / h q w c k b , -------------------------------------------------------------------------------- /layouts/semimak: -------------------------------------------------------------------------------- 1 | fingermap: traditional 2 | board: ansi 3 | first_pos: TOP 1 4 | f l h v z q w u o y 5 | s r n t k c d e a i ; 6 | x ' b m j p g , . / -------------------------------------------------------------------------------- /layouts/boom: -------------------------------------------------------------------------------- 1 | fingermap: ansi_angle_prog 2 | board: ansi 3 | first_pos: TOP 1 4 | , . u c v q f d l y 5 | a o e s g b n t r i - 6 | x ' w ; z p h m k j -------------------------------------------------------------------------------- /layouts/qwerty-angle: -------------------------------------------------------------------------------- 1 | fingermap: ansi_angle_prog 2 | board: ansi 3 | first_pos: TOP 1 4 | q w e r t y u i o p [ ] 5 | a s d f g h j k l ; ' 6 | z x c v b n m , . / -------------------------------------------------------------------------------- /fingermaps/zwou: -------------------------------------------------------------------------------- 1 | first_pos: NUMBER 0 2 | LP LP LR LM LI LI RI RI RM RR RP RP RP RP 3 | LP LP LR LM LI LI RI RI RM RR RP RP RP RP 4 | LP LP LR LM LI LI RI RI RM RR RP RP RP 5 | LP LP LR LM LT LT RI RI RM RR RP RP 6 | LP LT LT RT RT -------------------------------------------------------------------------------- /fingermaps/ansi_angle: -------------------------------------------------------------------------------- 1 | first_pos: NUMBER 0 2 | LP LP LR LM LI LI RI RI RM RR RP RP RP RP 3 | LP LP LR LM LI LI RI RI RM RR RP RP RP RP 4 | LP LP LR LM LI LI RI RI RM RR RP RP RP 5 | LP LR LM LI LI LI RI RI RM RR RP RP 6 | LP LT LT RT RT -------------------------------------------------------------------------------- /fingermaps/kathy_angle: -------------------------------------------------------------------------------- 1 | first_pos: NUMBER 0 2 | LP LP LR LM LI LI RI RI RM RR RP RP RP RP 3 | LP LP LR LM LI LI RI RI RM RR RP RP RP RP 4 | LP LP LR LM LI LI RI RI RM RR RP RP RP 5 | LP LR LT LI LI LI RI RI RM RR RP RP 6 | LP LT LT RT RT -------------------------------------------------------------------------------- /fingermaps/traditional: -------------------------------------------------------------------------------- 1 | first_pos: NUMBER 0 2 | LP LP LR LM LI LI RI RI RM RR RP RP RP RP 3 | LP LP LR LM LI LI RI RI RM RR RP RP RP RP 4 | LP LP LR LM LI LI RI RI RM RR RP RP RP 5 | LP LP LR LM LI LI RI RI RM RR RP RP 6 | LP LT LT RT RT -------------------------------------------------------------------------------- /fingermaps/ansi_angle_pinky: -------------------------------------------------------------------------------- 1 | first_pos: NUMBER 0 2 | LP LP LR LM LI LI RI RI RM RR RP RP RP RP 3 | LP LP LR LM LI LI RI RI RM RR RP RP RP RP 4 | LP LP LR LM LI LI RI RI RM RR RP RP RP 5 | LP LP LM LI LI LI RI RI RM RR RP RP 6 | LP LT LT RT RT -------------------------------------------------------------------------------- /fingermaps/ansi_angle_prog: -------------------------------------------------------------------------------- 1 | first_pos: NUMBER 0 2 | LP LP LP LR LM LI LI RI RI RM RR RP RP RP 3 | LP LP LR LM LI LI RI RI RM RR RP RP RP RP 4 | LP LP LR LM LI LI RI RI RM RR RP RP RP 5 | LP LR LM LI LI LI RI RI RM RR RP RP 6 | LP LT LT RT RT -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /trivenv 2 | /.trivenv 3 | /__pycache__ 4 | /.vscode 5 | /data/*.csv 6 | /output/* 7 | /layouts/* 8 | /old-layouts 9 | session_settings.json 10 | todo 11 | fingermaps/kathy 12 | .tmp* 13 | /corpus/shai.txt 14 | /corpus/*.json 15 | trialyzer.code-workspace 16 | /cmini-layouts -------------------------------------------------------------------------------- /test2.py: -------------------------------------------------------------------------------- 1 | import curses 2 | 3 | import command 4 | from session import Session 5 | 6 | def main(stdscr: curses.window): 7 | session_ = Session(stdscr) 8 | while True: 9 | for name, args in session_.prompt_user_command(): 10 | if command.run_command(name, args, session_) == "quit": 11 | return 12 | 13 | if __name__ == "__main__": 14 | curses.wrapper(main) 15 | -------------------------------------------------------------------------------- /boards/ansi: -------------------------------------------------------------------------------- 1 | NUMBER 0 0.0 4.0 13 2 | TOP 0 0.5 3.0 13 3 | HOME 0 0.75 2.0 12 4 | BOTTOM 0 1.25 1.0 11 5 | THUMB 0 0.5 0.0 1 // ctrl_l, win_l 6 | THUMB 2 3.0 0.0 // alt_l 7 | THUMB 3 5.0 0.0 // space_l 8 | THUMB 4 7.5 0.0 // space_r 9 | THUMB 5 10 0.0 // alt_r 10 | THUMB 6 11.5 0.0 8 // win_r, menu, ctrl_r 11 | 12 | default_key: BOTTOM 0 shift_l 13 | default_key: BOTTOM 11 shift_r 14 | default_key: THUMB 3 space_l 15 | default_key: THUMB 4 space_r 16 | default_key: THUMB 2 alt_l -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tanamr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /board.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | from fingermap import Pos, Row 4 | 5 | # Coord = collections.namedtuple("Coord", ["x", "y"]) 6 | class Coord(NamedTuple): 7 | x: float 8 | y: float 9 | 10 | class Board: 11 | 12 | loaded = {} # dict of boards 13 | 14 | def __init__(self, name: str) -> None: 15 | self.name = name 16 | self.positions = {} # type: dict[Coord, Pos] 17 | self.coords = {} # type: dict[Pos, Coord] 18 | self.default_keys = {} # type: dict[Pos, str] 19 | with open("boards/" + name) as file: 20 | self.build_from_string(file.read()) 21 | 22 | def build_from_string(self, s: str): 23 | for row in s.splitlines(): 24 | # allow comments at end with "//" 25 | tokens = row.split("//", 1)[0].split() 26 | if not tokens: 27 | continue 28 | key_specified = (tokens[0] == "default_key:") 29 | if key_specified: 30 | tokens.pop(0) 31 | try: 32 | r = int(tokens[0]) 33 | except ValueError: 34 | r = Row[tokens[0]].value 35 | c1 = int(tokens[1]) 36 | if key_specified: 37 | self.default_keys[Pos(r, c1)] = tokens[2] 38 | else: 39 | x = float(tokens[2]) 40 | y = float(tokens[3]) 41 | if len(tokens) >= 5: 42 | c2 = int(tokens[4]) 43 | else: 44 | c2 = c1 45 | for c in range(c1, c2 + 1): 46 | pos = Pos(r, c) 47 | coord = Coord(x, y) 48 | self.positions[coord] = pos 49 | self.coords[pos] = coord 50 | x += 1 51 | 52 | def get_board(name: str) -> Board: 53 | if name not in Board.loaded: 54 | Board.loaded[name] = Board(name) 55 | return Board.loaded[name] -------------------------------------------------------------------------------- /fingermap.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import enum 3 | #from collections import namedtuple 4 | from typing import Dict, List, NamedTuple 5 | 6 | class Row(enum.IntEnum): 7 | NUMBER = 0 8 | TOP = 1 9 | HOME = 2 10 | BOTTOM = 3 11 | THUMB = 4 12 | 13 | # Pos = collections.namedtuple("Pos", ["row", "col"]) # integer row and col 14 | class Pos(NamedTuple): 15 | row: Row 16 | col: int 17 | 18 | class Finger(enum.IntEnum): 19 | LP = -5 20 | LR = -4 21 | LM = -3 22 | LI = -2 23 | LT = -1 24 | RT = 1 25 | RI = 2 26 | RM = 3 27 | RR = 4 28 | RP = 5 29 | UNKNOWN = 0 30 | 31 | def unknown_finger(): # for picklability 32 | return Finger.UNKNOWN 33 | 34 | class Fingermap: 35 | 36 | loaded = {} # dict of fingermaps 37 | 38 | def __init__(self, name: str) -> None: 39 | self.name = name 40 | self.fingers = defaultdict(unknown_finger) # type: Dict[Pos, Finger] 41 | self.cols: Dict[Finger, List[Pos]] = {finger: [] for finger in Finger} 42 | with open("fingermaps/" + name) as file: 43 | self.build_from_string(file.read()) 44 | 45 | def build_from_string(self, s: str): 46 | rows = [] # rows in the string which specify the layout 47 | first_row = Row.NUMBER 48 | first_col = 0 49 | for row in s.splitlines(): 50 | # double spaces matter so not just .split() 51 | # also, allow comments at end with "//" 52 | tokens = row.split("//", 1)[0].split(" ") 53 | if tokens[0] == "first_pos:" and len(tokens) >= 3: 54 | try: 55 | first_row = int(tokens[1]) 56 | except ValueError: 57 | first_row = Row[tokens[1]].value 58 | first_col = int(tokens[2]) 59 | else: 60 | rows.append(tokens) 61 | for r, row in enumerate(rows): 62 | for c, token in enumerate(row): 63 | if token: 64 | try: 65 | finger = Finger(int(token)) 66 | except ValueError: 67 | try: 68 | finger = Finger[token] 69 | except KeyError: 70 | finger = Finger.UNKNOWN 71 | pos = Pos(r + first_row, c + first_col) 72 | self.fingers[pos] = finger 73 | self.cols[finger].append(pos) 74 | 75 | def get_fingermap(name: str) -> Fingermap: 76 | if name not in Fingermap.loaded: 77 | Fingermap.loaded[name] = Fingermap(name) 78 | return Fingermap.loaded[name] -------------------------------------------------------------------------------- /testing.py: -------------------------------------------------------------------------------- 1 | from typing import Counter 2 | from trialyzer import * 3 | import timeit 4 | import cProfile 5 | import corpus 6 | import typingdata 7 | import time 8 | 9 | # ok what is the chain here 10 | # note vscode debugger makes this roughly 10x slower than straight Python 11 | 12 | # layout things 13 | # layout construction: 0.3 ms 14 | # calculate_counts: 1798 ms (includes all_nstrokes) 15 | # just a plain Counter instead (without calculating sums): 1878 ms 16 | # after making all the nstroke category functions @functools.cache: 17 | # calculate_counts: 1266 ms 18 | # Counter(): 1348 ms 19 | # ngram to nstroke: 20 | # how long does it take to iterate all tristrokes 21 | # tuple(all_nstrokes()): 1416 ms 22 | # after caching to_nstroke(): 275 ms!! 23 | # tuple(itertools all trigrams): 3 ms 24 | # Very interesting, seems like to_nstroke is the big deal. Cache it? 25 | # After unwrapping layout.finger() and .coord() from their functions: 26 | # tuple(all_nstrokes()): 1359 ms 27 | # After making dedicated dictionaries to replace the functions: 28 | # tuple(all_nstrokes()): 1216 ms 29 | 30 | # trialyzer things 31 | # stuff that only runs through what is saved 32 | # load csv data: 3 ms 33 | # get medians: 125 ms 34 | # tristroke_category_data: 14 ms 35 | # runs through all tristrokes 36 | # summary_tristroke_analysis: 1923 ms 37 | # load shai: 162 ms 38 | # summary tristroke rank 3 layouts: 3687 ms 39 | # after caching layout.to_nstroke(): 1069 ms!! 40 | # full tristroke rank 3 layouts: 4897 ms 41 | # after caching layout.to_nstroke(): 1206 ms!! 42 | 43 | # nstroke things 44 | # tristroke_category: 180 ms to go through a precomputed list of qwerty nstrokes 45 | 46 | qwerty = layout.Layout("qwerty", False) 47 | # typingdata_ = typingdata.TypingData("tanamr") 48 | # corpus_ = corpus.get_corpus("shai.txt", precision=5000) 49 | # constraintmap_ = constraintmap.get_constraintmap("trad-dead-pinkies") 50 | # n = 3 51 | # keys = ("a", "b", "c") 52 | 53 | def stuff(): 54 | # csvdata = load_csv_data("default") 55 | # for layoutname in ("qwerty", "semimak", "boom"): 56 | # lay = layout.get_layout(layoutname) 57 | # medians = get_medians_for_layout(csvdata, lay) 58 | # tricatdata = tristroke_category_data(medians) 59 | # summary_tristroke_analysis(lay, tricatdata, medians) 60 | # set_1 = {nstroke for nstroke in qwerty.nstrokes_with_any_of(keys, n)} # 116 61 | # set_2 = {nstroke for nstroke in qwerty.by_brute_force(keys, n)} # 235 62 | # corpus.Corpus("tr_quotes.txt") 63 | # for lay, score, swap in steepest_ascent(qwerty, typingdata_, 64 | # corpus_.trigram_counts, constraintmap_, 65 | # pins=qwerty.get_board_keys()[0].values()): 66 | # print(f"{swap} gives {score:.3f}") 67 | # print(repr(lay)) 68 | corpus_ = corpus.get_corpus("shai.txt", space_key="space_r") 69 | 70 | # n_ = 1 71 | # print(timeit.timeit("stuff()", globals=globals(), number=n_)/n_ * 1000) 72 | cProfile.run("corpus.get_corpus('shai.txt', space_key='space_r')", sort="tottime") -------------------------------------------------------------------------------- /constraintmap.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import Container 3 | 4 | from fingermap import Pos, Row 5 | from layout import Layout 6 | import remap 7 | 8 | class Constraintmap: 9 | 10 | loaded = {} 11 | 12 | def __init__(self, name: str) -> None: 13 | self.name = name 14 | self.caps = {} # type: dict[Pos, float] 15 | with open("constraintmaps/" + name) as file: 16 | self.build_from_string(file.read()) 17 | 18 | def build_from_string(self, s: str): 19 | rows = [] 20 | first_row = Row.TOP 21 | first_col = 1 22 | for row in s.splitlines(): 23 | # double spaces matter so not just .split() 24 | # also, allow comments at end with "//" 25 | tokens = row.split("//", 1)[0].split(" ") 26 | if tokens[0] == "first_pos:" and len(tokens) >= 3: 27 | try: 28 | first_row = int(tokens[1]) 29 | except ValueError: 30 | first_row = Row[tokens[1]].value 31 | first_col = int(tokens[2]) 32 | else: 33 | rows.append(tokens) 34 | for r, row in enumerate(rows): 35 | for c, token in enumerate(row): 36 | if token: 37 | freq = float(token) 38 | pos = Pos(r + first_row, c + first_col) 39 | self.caps[pos] = freq 40 | 41 | def is_layout_legal(self, layout_: Layout, key_freqs: dict[str, float]): 42 | for key, pos in layout_.positions.items(): 43 | try: 44 | if key_freqs[key] > self.caps[pos]: 45 | return False 46 | except KeyError: 47 | continue 48 | return True 49 | 50 | def is_remap_legal(self, layout_: Layout, key_freqs: dict[str, float], 51 | remap: dict[str, str]): 52 | for key, dest in remap.items(): 53 | try: 54 | if key_freqs[key] > self.caps[layout_.positions[dest]]: 55 | return False 56 | except KeyError: 57 | continue 58 | return True 59 | 60 | def random_legal_swap(self, layout_: Layout, 61 | key_freqs: dict[str, float], 62 | pins: Container[str] = tuple()): 63 | destinations = False 64 | while not destinations: 65 | first_key = random.choice( 66 | tuple(k for k in layout_.positions if k not in pins)) 67 | first_pos = layout_.positions[first_key] 68 | first_freq = key_freqs[first_key] 69 | first_cap = self.caps.get(first_pos, 1.0) 70 | destinations = tuple(key for key, pos in layout_.positions.items() 71 | if (key != first_key 72 | and key not in pins 73 | and first_freq < self.caps.get(pos, 1.0) 74 | and key_freqs[key] < first_cap 75 | )) 76 | return remap.swap(first_key, random.choice(destinations)) 77 | 78 | def get_constraintmap(name: str) -> Constraintmap: 79 | if name not in Constraintmap.loaded: 80 | Constraintmap.loaded[name] = Constraintmap(name) 81 | return Constraintmap.loaded[name] -------------------------------------------------------------------------------- /cmini_layout_loader_experiment.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import os 3 | import json 4 | import itertools 5 | 6 | import layout 7 | import corpus 8 | 9 | with os.scandir("cmini-layouts/") as files: 10 | file_list = [f.path for f in files if f.is_file()] 11 | 12 | def load_cmini_layout(path: str): 13 | """May raise ValueError for ill-behaved layouts 14 | returns layout, number of rows 15 | 16 | """ 17 | 18 | with open(path) as f: 19 | ll = json.load(f) 20 | 21 | max_width = max(x['col'] for x in ll['keys'].values()) + 1 22 | max_height = max(x['row'] for x in ll['keys'].values()) + 1 23 | matrix = [[' ']*max_width for _ in range(max_height)] 24 | 25 | for char, info in ll['keys'].items(): 26 | row = info['row'] 27 | col = info['col'] 28 | 29 | matrix[row][col] = char 30 | 31 | if len(matrix) > 3: 32 | matrix[3][0] = ' ' * 4 + matrix[3][0] 33 | 34 | matrix_str = '\n'.join(' '.join(x) for x in matrix) 35 | 36 | if ll["board"] == "angle": 37 | matrix_str += "\nfingermap: ansi_angle" 38 | 39 | return layout.Layout(ll["name"], False, matrix_str), len(matrix) 40 | 41 | # print(repr(load_cmini_layout('cmini-layouts/inqwerted.json'))) 42 | 43 | def calc_dsfb(c: corpus.Corpus, l: layout.Layout): 44 | count = 0 45 | for finger, ps in l.fingermap.cols.items(): 46 | if not finger: 47 | continue 48 | keys = (l.keys[p] for p in ps if p in l.keys) 49 | keys2 = tuple(k for k in keys if k in c.key_counts) 50 | for combo in itertools.product(keys2, keys2): 51 | count += c.dsfb[combo] 52 | return count / c.bigram_counts.total() 53 | 54 | s = 3.22 # sfb time / nonsfb time, bigrams 55 | 56 | corpuses = { 57 | "sfb exponential": corpus.Corpus("tr_quotes.txt", "", "", 58 | dsfb_weights=(0, *(2**-n for n in range(8)))), 59 | "sfb harmonic": corpus.Corpus("tr_quotes.txt", "", "", 60 | dsfb_weights=(0, *(n**-1 for n in range(1, 9)))), 61 | "sfb inverse square": corpus.Corpus("tr_quotes.txt", "", "", 62 | dsfb_weights=(0, *(n**-2 for n in range(1, 9)))), 63 | "sfs exponential": corpus.Corpus("tr_quotes.txt", "", "", 64 | dsfb_weights=(0, 0, *(2**-n for n in range(7)))), 65 | "sfs harmonic": corpus.Corpus("tr_quotes.txt", "", "", 66 | dsfb_weights=(0, 0, *(n**-1 for n in range(1, 8)))), 67 | "sfs inverse square": corpus.Corpus("tr_quotes.txt", "", "", 68 | dsfb_weights=(0, 0, *(n**-2 for n in range(1, 8)))), 69 | "typing speed model": corpus.Corpus("tr_quotes.txt", "", "", 70 | dsfb_weights=(0, *(max(0, 1-n/s) for n in range(1, 9)))), 71 | "finger speed model": corpus.Corpus("tr_quotes.txt", "", "", 72 | dsfb_weights=(0, *(min(1, s/n) for n in range(1, 9)))), 73 | } 74 | # print(c.dsfb.most_common(30)) 75 | 76 | layout_data = {} # layoutname: numrows, *dsfb according to each corpus 77 | for fname in file_list: 78 | try: 79 | l, numrows = load_cmini_layout(fname) 80 | layout_data[l.name] = (numrows, 81 | *tuple(calc_dsfb(c, l) for c in corpuses.values()) 82 | ) 83 | except (ValueError, KeyError): 84 | continue 85 | 86 | with open("output/dsfb_experiment.csv", "w", newline="") as f: 87 | w = csv.writer(f) 88 | w.writerow(("layout", "num rows", *corpuses.keys())) 89 | for l, data in layout_data.items(): 90 | w.writerow((l, *data)) -------------------------------------------------------------------------------- /funny_graphs.py: -------------------------------------------------------------------------------- 1 | # This one uses matplotlib 2 | 3 | from typing import Callable, Container 4 | 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | 8 | import typingdata 9 | import layout 10 | from nstroke import experimental_describe_tristroke 11 | 12 | td = typingdata.TypingData("tanamr") 13 | qwerty = layout.get_layout("qwerty") 14 | all_known = td.exact_tristrokes_for_layout(qwerty) 15 | 16 | def get_medians_of_tristroke_category(conditions: Callable[[Container[str]], bool]): 17 | totals = [] 18 | for ts in all_known: 19 | if conditions(experimental_describe_tristroke(ts)): 20 | totals.append(td.tri_medians[ts][2]) 21 | return totals 22 | 23 | fig, ax = plt.subplots() 24 | redir_nothumb_noscissor = lambda tags: "redir" in tags and "thumb" not in tags and "hsb" not in tags and "fsb" not in tags and "hss" not in tags and "fss" not in tags and "sfs" not in tags 25 | categories = { 26 | # "sfs redir": lambda tags: "sfs" in tags and "redir" in tags, 27 | # "all redir": lambda tags: "redir" in tags, 28 | # "non-scissor\nonehand": lambda tags: "rolll" in tags and "hsb" not in tags and "fsb" not in tags, 29 | # "roll AND NOT\nrowchange": lambda tags: "tutu" in tags and "rcb" not in tags, 30 | # "roll AND\nrowchange": lambda tags: "tutu" in tags and "rcb" in tags, 31 | # "roll AND\nHSB": lambda tags: "tutu" in tags and "hsb" in tags, 32 | # "roll AND\nFSB": lambda tags: "tutu" in tags and "fsb" in tags, 33 | # "non-sfs\nredir": lambda tags: "redir" in tags and "sfs" not in tags, 34 | # "non-sfs\nbad redir": lambda tags: "bad-redir" in tags and "sfs" not in tags, 35 | # "alt AND\nNOT sfs": lambda tags: "alt" in tags and "sfs" not in tags, 36 | # "alt AND sfs": lambda tags: "alt" in tags and "sfs" in tags, 37 | # "sfs": lambda tags: "sfs" in tags, 38 | # "sfb": lambda tags: "sfb" in tags, 39 | # # "sft": lambda tags: "sft" in tags, 40 | # "roll-in": lambda tags: "tutu" in tags and "in" in tags, 41 | # "roll-out": lambda tags: "tutu" in tags and "out" in tags, 42 | 43 | # "redir with\nthumb": lambda tags: "redir" in tags and "thumb" in tags, 44 | # "redir without\nthumb": lambda tags: "redir" in tags and "thumb" not in tags, 45 | # "non-sfs\nredir without\nthumb": lambda tags: "redir" in tags and "thumb" not in tags and "sfs" not in tags, 46 | # "roll with\nthumb": lambda tags: "tutu" in tags and "thumb" in tags, 47 | # "roll without\nthumb": lambda tags: "tutu" in tags and "thumb" not in tags, 48 | 49 | # "roll AND lsb": lambda tags: "tutu" in tags and "lsb" in tags, 50 | # "roll AND lsb\nAND scissor": lambda tags: "tutu" in tags and "lsb" in tags and ("hsb" in tags or "fsb" in tags), 51 | # "roll AND\nNOT lsb": lambda tags: "tutu" in tags and "lsb" not in tags, 52 | 53 | # "thumb redir\nfirst-in\nskip-in": lambda tags: "redir" in tags and "first-in" in tags and "skip-in" in tags and "thumb" in tags, 54 | # "thumb redir\nfirst-in\nskip-out": lambda tags: "redir" in tags and "first-in" in tags and "skip-out" in tags and "thumb" in tags, 55 | # "thumb redir\nfirst-out\nskip-in": lambda tags: "redir" in tags and "first-out" in tags and "skip-in" in tags and "thumb" in tags, 56 | # "thumb redir\nfirst-out\nskip-out": lambda tags: "redir" in tags and "first-out" in tags and "skip-out" in tags and "thumb" in tags, 57 | # "non-thumb redir\nfirst-in\nskip-in": lambda tags: "redir" in tags and "first-in" in tags and "skip-in" in tags and "thumb" not in tags, 58 | # "non-thumb redir\nfirst-in\nskip-out": lambda tags: "redir" in tags and "first-in" in tags and "skip-out" in tags and "thumb" not in tags, 59 | # "non-thumb redir\nfirst-out\nskip-in": lambda tags: "redir" in tags and "first-out" in tags and "skip-in" in tags and "thumb" not in tags, 60 | # "non-thumb redir\nfirst-out\nskip-out": lambda tags: "redir" in tags and "first-out" in tags and "skip-out" in tags and "thumb" not in tags, 61 | 62 | # "middle thumb\nredir": lambda tags: "redir" in tags and "middle-thumb" in tags, 63 | # "end thumb\nredir": lambda tags: "redir" in tags and "middle-thumb" not in tags and "thumb" in tags, 64 | # "redir without\nthumb": lambda tags: "redir" in tags and "thumb" not in tags, 65 | 66 | # "redir\nno thumb\nno scissor": lambda tags: "redir" in tags and "thumb" not in tags and "hsb" not in tags and "fsb" not in tags and "hss" not in tags and "fss" not in tags, 67 | # "redir\nno thumb\nsome scissor": lambda tags: "redir" in tags and "thumb" not in tags and not("hsb" not in tags and "fsb" not in tags and "hss" not in tags and "fss" not in tags), 68 | # "non bad redir\nthumb": lambda tags: "redir" in tags and "bad-redir" not in tags and "thumb" in tags, 69 | # "bad redir\nthumb": lambda tags: "bad-redir" in tags and "thumb" in tags, 70 | # "non bad redir\nno thumb": lambda tags: "redir" in tags and "bad-redir" not in tags and "thumb" not in tags, 71 | # "bad redir\nno thumb": lambda tags: "bad-redir" in tags and "thumb" not in tags, 72 | "no thumb redir\ncenter last": lambda tags: redir_nothumb_noscissor(tags) and (("first-in" in tags and "skip-in" in tags) or ("first-out" in tags and "skip-out" in tags)), 73 | "no thumb redir\nother": lambda tags: redir_nothumb_noscissor(tags) and not(("first-in" in tags and "skip-in" in tags) or ("first-out" in tags and "skip-out" in tags)), 74 | } 75 | datas = [np.array(get_medians_of_tristroke_category(func)) for func in categories.values()] 76 | ax.violinplot(datas, showmedians=True, quantiles=[[0.25, 0.75] for _ in datas]) 77 | ax.set_xticks([y+1 for y in range(len(datas))], 78 | labels=list(categories), rotation=0) 79 | ax.set_ylabel("Trigram times (ms)") 80 | ax.set_title("Using trigrams typed in isolation") 81 | pos = np.arange(len(datas)) + 1 82 | for tick, label in zip(range(len(datas)), ax.get_xticklabels()): 83 | ax.text(pos[tick], .97, f"n={len(datas[tick])}", 84 | transform=ax.get_xaxis_transform(), 85 | horizontalalignment='center', size='x-small') 86 | 87 | plt.show() 88 | 89 | for cat, discriminator in categories.items(): 90 | print(f"\n{cat}") 91 | strokes = {qwerty.to_ngram(ts): td.tri_medians[ts][2] for ts in all_known if discriminator(experimental_describe_tristroke(ts))} 92 | for tg in sorted(strokes, key=lambda tg: strokes[tg]): 93 | print(f"{strokes[tg]:.2f} ms: {' '.join(tg)}") 94 | -------------------------------------------------------------------------------- /gui_util.py: -------------------------------------------------------------------------------- 1 | import curses 2 | from typing import Iterable 3 | import statistics 4 | import math 5 | 6 | def extreme(seq): 7 | return max(abs(val) for val in seq) 8 | def neg_extreme(seq): 9 | return -max(abs(val) for val in seq) 10 | def odd_sqrt(n): 11 | return math.sqrt(n) if n >= 0 else -math.sqrt(-n) 12 | 13 | red = 1 14 | green = 2 15 | blue = 3 16 | gray = 4 17 | 18 | gradient_colors = (196, 202, 208, 214, 220, 226, 190, 154, 118, 82, 46) 19 | 20 | def init_colors(): 21 | curses.start_color() 22 | curses.use_default_colors() 23 | bg = -1 24 | # bg = curses.COLOR_BLACK 25 | curses.init_pair(red, curses.COLOR_RED, bg) 26 | curses.init_pair(green, curses.COLOR_GREEN, bg) 27 | curses.init_pair(blue, curses.COLOR_CYAN, bg) 28 | curses.init_pair(gray, 244, bg) 29 | 30 | # n = gradient_colors + 8 31 | # m = min_gradient_color - 4 32 | # for i in range(n): # colors for worst to best 33 | # curses.init_color( 34 | # m+i, 35 | # int(1000*(1-i/n)), 36 | # int(1000*(1-2*abs(i/n-0.5))), 37 | # int(1000*(i/n)) 38 | # ) 39 | # curses.init_pair(m+i, m+i, curses.COLOR_BLACK) 40 | 41 | for i in range(5, curses.COLOR_PAIRS): 42 | curses.init_pair(i, i, bg) 43 | 44 | def color_scale(worst, best, target, exclude_zeros = False): 45 | """Make sure to run the result through curses.color_pair().""" 46 | if exclude_zeros and not target: return gray 47 | if best == worst: 48 | if target > best: return gradient_colors[-1] 49 | elif target < best: return gradient_colors[0] 50 | else: return gradient_colors[int(len(gradient_colors)/2)] 51 | fraction = (target-worst) / (best-worst) 52 | if fraction < 0: 53 | fraction = 0 54 | elif fraction >= 1.0: 55 | fraction = 0.999 56 | i = int(len(gradient_colors)*fraction) 57 | return gradient_colors[i] 58 | 59 | def apply_scales(rows: dict[str, Iterable], col_settings: Iterable[dict]): 60 | """Applies scale to each column of a table and returns a result, which is 61 | accessed by result[col][rowname], giving the curses color pair for each 62 | entry in the table. 63 | 64 | rows is a dict containing {rowname: (data0, data1, ..., dataN)}. 65 | col_settings is of length N, each entry being a dict with the keywords: 66 | "worst", "best", "scale_filter", "transform", "exclude_zeros". 67 | Defaults to min, max, lambda _: True, lambda x: x, True. 68 | "worst" and "best" are applied before the transform. 69 | 70 | Columns whose col_settings entry is None, or nonexistent, will be skipped 71 | and the corresponding column in the output will be None or nonexistent 72 | respectively.""" 73 | if not rows: 74 | return dict() 75 | pairs = [dict() for _ in col_settings] 76 | defaults = {"worst": min, "best": max, "scale_filter": lambda _: True, 77 | "transform": lambda x: x, "exclude_zeros": True} 78 | for col, settings in enumerate(col_settings): 79 | if settings is None: 80 | pairs[col] = None 81 | continue 82 | for key in defaults: 83 | if key not in settings: 84 | settings[key] = defaults[key] 85 | if settings["exclude_zeros"]: 86 | def zeros_filter(x): return x != 0 87 | else: 88 | def zeros_filter(_): return True 89 | try: 90 | worst = settings["transform"]( 91 | settings["worst"](val[col] for val in rows.values() 92 | if settings["scale_filter"](val[col]) 93 | and zeros_filter(val[col]))) 94 | best = settings["transform"]( 95 | settings["best"](val[col] for val in rows.values() 96 | if settings["scale_filter"](val[col]) 97 | and zeros_filter(val[col]))) 98 | except ValueError: # no valid values 99 | worst = 0.0 100 | best = 0.0 101 | for rowname in rows: 102 | pairs[col][rowname] = curses.color_pair(color_scale( 103 | worst, best, settings["transform"](rows[rowname][col]), 104 | settings["exclude_zeros"])) 105 | return pairs 106 | 107 | def MAD_z(zscore: float, keep_within_data_values: bool = True): 108 | """This is intended as an less outlier-sensitive alternative to 109 | max() and min(). 110 | 111 | Returns a function which, given a distribution, returns the value 112 | that would have the specified "z-score". Not a conventional z-score, as 113 | it uses Median Absolute Deviation (median deviation from the median) 114 | instead of standard deviation.""" 115 | 116 | def func(data: Iterable): 117 | data = tuple(data) 118 | if not data: 119 | return 0.0 120 | median = statistics.median(data) 121 | diffs = tuple(abs(d - median) for d in data) 122 | mad = statistics.median(diffs) 123 | raw = median + zscore*mad 124 | if not keep_within_data_values: 125 | return raw 126 | else: 127 | if zscore > 0: 128 | return min(raw, max(data)) 129 | else: 130 | return max(raw, min(data)) 131 | 132 | return func 133 | 134 | 135 | def insert_line_bottom(text: str, win: curses.window, attr: int = ...): 136 | """Scrolls a line in from the bottom of the window. 137 | Wraps overflow onto subsequent lines. 138 | Does not refresh the window. 139 | """ 140 | if "\n" in text: 141 | for subtext in text.split("\n"): 142 | insert_line_bottom(subtext, win, attr) 143 | return 144 | 145 | ymax, xmax = win.getmaxyx() 146 | 147 | while len(text) > xmax-1: 148 | first_line = text[:xmax-1] 149 | text = text[xmax-1:] 150 | insert_line_bottom(first_line, win, attr) 151 | 152 | # win.scrollok(True) 153 | # win.idlok(True) 154 | win.scroll(1) 155 | 156 | if attr != ...: 157 | win.addstr(ymax-1, 0, text, attr) 158 | else: 159 | win.addstr(ymax-1, 0, text) 160 | 161 | def debug_win(win: curses.window, label: str): 162 | win.border() 163 | for i in range(win.getmaxyx()[0]): 164 | win.addstr(i, 0, str(i) + " ") 165 | win.addstr(0, 0, label + " 0,0") 166 | win.refresh() -------------------------------------------------------------------------------- /remap.py: -------------------------------------------------------------------------------- 1 | # Object to represent a superset of swaps, cycles, row/column swaps, etc 2 | # Remaps can be composed with each other using the + operator, 3 | # and reversed using the - operator. Subtraction is also implemented but 4 | # I have no clue when you would ever use that. 5 | 6 | from __future__ import annotations 7 | from typing import Container, Iterable, Sequence 8 | 9 | from typing import TYPE_CHECKING 10 | 11 | if TYPE_CHECKING: 12 | from layout import Layout 13 | from fingermap import Pos, Row 14 | 15 | def cycle(*args: tuple[str]): 16 | remap = Remap() 17 | for i in range(len(args)): 18 | remap[args[i]] = args[(i + 1) % len(args)] 19 | return remap 20 | 21 | swap = cycle 22 | 23 | def set_swap(first: Sequence[str], second: Sequence[str]): 24 | remap = Remap() 25 | for a, b in zip(first, second): 26 | remap[a] = b 27 | remap[b] = a 28 | return remap 29 | 30 | def row_swap(layout_: Layout, r1: Row, r2: Row, 31 | pins: Container[str] = tuple()): 32 | remap = Remap() 33 | for pos, key in layout_.keys.items(): 34 | if key not in pins and pos.row == r1: 35 | try: 36 | otherkey = layout_.keys[Pos(r2, pos.col)] 37 | except KeyError: 38 | continue 39 | if otherkey in pins: 40 | continue 41 | remap[key] = otherkey 42 | remap[otherkey] = key 43 | return remap 44 | 45 | def col_swap(layout_: Layout, c1: int, c2: int, 46 | pins: Container[str] = tuple()): 47 | remap = Remap() 48 | for pos, key in layout_.keys.items(): 49 | if key not in pins and pos.col == c1: 50 | try: 51 | otherkey = layout_.keys[Pos(pos.row, c2)] 52 | except KeyError: 53 | continue 54 | if otherkey in pins: 55 | continue 56 | remap[key] = otherkey 57 | remap[otherkey] = key 58 | return remap 59 | 60 | def layout_diff(initial: Layout, target: Layout): 61 | """Ignores fingermaps. Skips keys that are present on one layout 62 | but not the other. 63 | """ 64 | remap = Remap() 65 | for ipos, key in initial.keys.items(): 66 | try: 67 | if key in remap: 68 | continue 69 | tpos = target.positions[key] 70 | if ipos == tpos: 71 | continue 72 | dest = initial.keys[tpos] 73 | first = key 74 | while dest != first: 75 | remap[key] = dest 76 | key = dest 77 | dest = initial.keys[target.positions[key]] 78 | except KeyError: 79 | continue 80 | return remap 81 | 82 | class Remap(dict): 83 | """Remap stored as {key: destination for all moved keys}""" 84 | 85 | def translate(self, ngram: Iterable[str]) -> tuple[str, ...]: 86 | return tuple(self.get(key, key) for key in ngram) 87 | 88 | def _parse(self) -> tuple[Iterable, Iterable]: 89 | """Returns (cycles, swaps)""" 90 | 91 | if not self: 92 | return ((),()) 93 | 94 | sequences = [] 95 | consumed = set() 96 | check_for_merging = False 97 | for first_key, next_key in self.items(): 98 | if first_key in consumed: 99 | continue 100 | sequence = [first_key] 101 | while next_key != first_key: 102 | sequence.append(next_key) 103 | try: 104 | next_key = self[next_key] 105 | if next_key in consumed: 106 | break 107 | except KeyError: 108 | sequence.append("unknown") 109 | check_for_merging = True 110 | break 111 | sequences.append(sequence) 112 | consumed.update(sequence) 113 | 114 | while check_for_merging: 115 | remove = [] 116 | for i in range(len(sequences)): 117 | if sequences[i][-1] == "unknown": 118 | for sequence in sequences: 119 | if sequence[-1] == sequences[i][0]: 120 | remove.append(i) 121 | sequence.extend(sequences[i][1:]) 122 | for i in sorted(remove, reverse=True): 123 | sequences.pop(i) 124 | if not remove: 125 | break 126 | remove.clear() 127 | 128 | cycles = [] 129 | swaps = [] 130 | for sequence in sequences: 131 | if len(sequence) > 2: 132 | cycles.append(sequence) 133 | else: 134 | swaps.append(sequence) 135 | return (cycles, swaps) 136 | 137 | def __str__(self) -> str: 138 | if not self: 139 | return "no-op" 140 | 141 | descriptions = [] 142 | cycles, swaps = self._parse() 143 | 144 | if swaps: 145 | src, dest = zip(*swaps) 146 | descriptions.append(f"{' '.join(src)} <-> {' '.join(dest)}") 147 | for cycle_ in cycles: 148 | descriptions.append(f"{' '.join(cycle_)} cycle") 149 | 150 | return ", ".join(descriptions) 151 | 152 | def __repr__(self) -> str: 153 | if not self: 154 | return "Remap()" 155 | 156 | descriptions = [] 157 | cycles, swaps = self._parse() 158 | 159 | if swaps: 160 | src, dest = zip(*swaps) 161 | if len(src) > 1: 162 | descriptions.append(f"set_swap(({', '.join(repr(c) for c in src)}), " 163 | f"({', '.join(repr(c) for c in dest)}))") 164 | else: 165 | descriptions.append(f"swap({repr(src[0])}, {repr(dest[0])})") 166 | for cycle_ in cycles: 167 | descriptions.append(f"cycle({', '.join(repr(c) for c in cycle_)})") 168 | 169 | return " + ".join(descriptions) 170 | 171 | def __add__(self, other: type["Remap"]): 172 | if not isinstance(other, Remap): 173 | return NotImplemented 174 | result = Remap() 175 | for key, dest in other.items(): 176 | result[key] = self.get(dest, dest) 177 | for key in self: 178 | if key not in result: 179 | result[key] = self[key] 180 | return Remap((k, v) for k, v in result.items() if k != v) 181 | 182 | def __neg__(self): 183 | result = Remap() 184 | for src, dest in self.items(): 185 | result[dest] = src 186 | return result 187 | 188 | def __sub__(self, other): 189 | return self + (-other) 190 | 191 | if __name__ == "__main__": # for testing 192 | import layout 193 | 194 | qwerty = layout.get_layout('qwerty') 195 | dv = layout.get_layout('dvorak') 196 | print(layout_diff(qwerty, dv)) 197 | -------------------------------------------------------------------------------- /typingtest.py: -------------------------------------------------------------------------------- 1 | import operator 2 | import time 3 | import queue 4 | import statistics 5 | import curses 6 | from typing import Collection 7 | from pynput import keyboard 8 | 9 | import nstroke 10 | import layout 11 | import gui_util 12 | from corpus import default_lower, default_upper 13 | import session 14 | 15 | key_events = queue.Queue() 16 | 17 | def on_press(key): 18 | global key_events 19 | key_events.put((key, time.perf_counter_ns())) 20 | 21 | def on_release(key): 22 | if key == keyboard.Key.esc: 23 | # Listener stops when False returned 24 | return False 25 | 26 | def wpm(ms) -> int: 27 | """Returns the wpm conversion given the time taken to type a *bigram*. 28 | For a trigram, multiply this result by 2. 29 | For a quadgram, multiply this result by 3, etc. 30 | """ 31 | return int(12000/ms) 32 | 33 | shift_aliases = set( 34 | frozenset(pair) for pair in zip(default_lower, default_upper)) 35 | 36 | def test(s: session.Session, tristroke: nstroke.Tristroke, 37 | estimate: float = None): 38 | """Run a typing test with the specified tristroke. 39 | The new data is saved into s.typingdata_.csv_data. 40 | """ 41 | 42 | curses.curs_set(0) 43 | 44 | s.right_pane.clear() 45 | s.right_pane.addstr(1, 0, "Typing test - Press esc to finish") 46 | 47 | height, width = s.right_pane.getmaxyx() 48 | stats_win_height = 14 49 | if estimate is not None: 50 | stats_win_height += 1 51 | stats_win = s.right_pane.derwin(stats_win_height, width, 2, 0) 52 | message_win = s.right_pane.derwin(stats_win_height, 0) 53 | 54 | def message(msg: str, color: int = 0): # mostly for brevity 55 | gui_util.insert_line_bottom( 56 | msg, message_win, curses.color_pair(color)) 57 | message_win.refresh() 58 | 59 | trigram = s.user_layout.to_ngram(tristroke) 60 | if not trigram: 61 | message("User layout does not have a trigram for the specified " 62 | "tristroke\nExiting!", gui_util.red) 63 | 64 | valid_keys = set(trigram) 65 | all_aliases = shift_aliases.union(s.key_aliases) 66 | for key in trigram: 67 | for set_ in all_aliases: 68 | if key in set_: 69 | valid_keys.update(set_) 70 | 71 | fingers = tuple(f.name for f in tristroke.fingers) 72 | 73 | stats_win.hline(0, 0, "-", width-2) 74 | stats_win.addstr(1, 0, "Bigram {} {} ({}, {}): {}".format( 75 | *trigram[:2], *fingers[:2], nstroke.bistroke_category(tristroke, 0, 1))) 76 | stats_win.addstr(2, 0, "mean / stdev / median") 77 | stats_win.hline(4, 0, "-", width-2) 78 | stats_win.addstr(5, 0, "Bigram {} {} ({}, {}): {}".format( 79 | *trigram[1:], *fingers[1:], nstroke.bistroke_category(tristroke, 1, 2))) 80 | stats_win.addstr(6, 0, "mean / stdev / median") 81 | stats_win.hline(8, 0, "-", width-2) 82 | stats_win.addstr(9, 0, "Trigram " + " ".join(trigram) + 83 | " ({}, {}, {}): {}".format( 84 | *fingers, nstroke.tristroke_category(tristroke))) 85 | stats_win.addstr(10, 0, "mean / stdev / median") 86 | if estimate is not None: 87 | stats_win.addstr(12, 0, f"Previous estimate: {estimate:.2f} ms") 88 | 89 | def format_stats(data: list): 90 | try: 91 | return "{0:^5.1f} {1:^5.1f} {2:^5.1f} ms, n={3}".format( 92 | statistics.fmean(data), 93 | statistics.stdev(data), 94 | statistics.median(data), 95 | len(data) 96 | ) 97 | except statistics.StatisticsError: 98 | return "Not enough data, n={0}".format(len(data)) 99 | 100 | last_time = time.perf_counter_ns() 101 | next_index = 0 102 | if tristroke not in s.typingdata_.csv_data: 103 | s.typingdata_.csv_data[tristroke] = ([], []) 104 | speeds_01 = s.typingdata_.csv_data[tristroke][0] 105 | speeds_12 = s.typingdata_.csv_data[tristroke][1] 106 | speeds_02 = list(map(operator.add, speeds_01, speeds_12)) 107 | 108 | pynput_listener = keyboard.Listener(on_press=on_press, 109 | on_release=on_release) 110 | pynput_listener.start() 111 | pynput_listener.wait() 112 | global key_events 113 | while pynput_listener.running: 114 | time.sleep(0.02) 115 | # process key events 116 | while not key_events.empty(): 117 | key, new_time = key_events.get() 118 | try: 119 | key_name = key.char 120 | except AttributeError: 121 | key_name = key 122 | if str(key_name).startswith("Key."): 123 | key_name = str(key_name)[4:] 124 | 125 | key_correct = False 126 | for set_ in all_aliases: 127 | if trigram[next_index] in set_ and key_name in set_: 128 | key_correct = True 129 | break 130 | 131 | if key_name == trigram[next_index]: 132 | key_correct = True 133 | 134 | if not key_correct: 135 | if key_name == "esc": 136 | message("Finishing test", gui_util.green) 137 | elif key_name in valid_keys: 138 | message("Key " + key_name + 139 | " out of sequence, trigram invalidated", 140 | gui_util.red) 141 | next_index = 0 142 | if len(speeds_01) != len(speeds_12): 143 | speeds_01.pop() 144 | else: 145 | message("Ignoring wrong key " + key_name, gui_util.red) 146 | continue 147 | 148 | # Key is correct, proceed 149 | bigram_ms = (new_time - last_time)/1e6 150 | if next_index == 0: # first key just typed 151 | message("First key detected", gui_util.blue) 152 | elif next_index == 1: # second key just typed 153 | speeds_01.append(bigram_ms) 154 | message("Second key detected after {0:.1f} ms".format(bigram_ms), 155 | gui_util.blue) 156 | else: # trigram just completed 157 | speeds_12.append(bigram_ms) 158 | speeds_02.append(bigram_ms + speeds_01[-1]) 159 | message("Trigram complete, took {0:.1f} ms ({1} wpm)" 160 | .format(speeds_02[-1], 2*wpm(speeds_02[-1])), 161 | gui_util.green) 162 | 163 | next_index = (next_index + 1) % 3 164 | last_time = new_time 165 | key_events.task_done() 166 | 167 | for line in (3, 7, 11): 168 | stats_win.move(line, 0) 169 | stats_win.clrtoeol() 170 | stats_win.addstr(3, 0, format_stats(speeds_01)) 171 | stats_win.addstr(7, 0, format_stats(speeds_12)) 172 | stats_win.addstr(11, 0, format_stats(speeds_02)) 173 | 174 | stats_win.refresh() 175 | s.right_pane.refresh() 176 | s.right_pane.move(height-1, 0) 177 | 178 | if not speeds_01 or not speeds_12: 179 | del s.typingdata_.csv_data[tristroke] 180 | 181 | s.right_pane.refresh() 182 | curses.flushinp() 183 | curses.curs_set(1) -------------------------------------------------------------------------------- /session.py: -------------------------------------------------------------------------------- 1 | import json 2 | import curses 3 | import curses.textpad 4 | import math 5 | 6 | import constraintmap 7 | import gui_util 8 | import layout 9 | from typingdata import TypingData 10 | 11 | 12 | class Session: 13 | """ 14 | Contains trialyzer settings, data, and interface elements--everything 15 | needed for commands to be run. 16 | """ 17 | 18 | def __init__(self, stdscr: curses.window) -> None: 19 | self.startup_messages = [] 20 | 21 | try: 22 | with open("session_settings.json") as settings_file: 23 | settings = json.load(settings_file) 24 | some_default = False 25 | try: 26 | self.analysis_target = layout.get_layout( 27 | settings["analysis_target"]) 28 | except (FileNotFoundError, KeyError): 29 | self.analysis_target = layout.get_layout("qwerty") 30 | some_default = True 31 | try: 32 | self.user_layout = layout.get_layout(settings["user_layout"]) 33 | except (FileNotFoundError, KeyError): 34 | self.user_layout = layout.get_layout("qwerty") 35 | some_default = True 36 | try: 37 | self.speeds_file = settings["active_speeds_file"] 38 | except KeyError: 39 | self.speeds_file = "default" 40 | some_default = True 41 | try: 42 | self.constraintmap_ = constraintmap.get_constraintmap( 43 | settings["constraintmap"]) 44 | except (KeyError, FileNotFoundError): 45 | self.constraintmap_ = constraintmap.get_constraintmap( 46 | "traditional-default") 47 | some_default = True 48 | try: 49 | self.key_aliases = set( 50 | frozenset(keys) for keys in settings["key_aliases"]) 51 | except KeyError: 52 | self.key_aliases = set() 53 | try: 54 | self.corpus_settings = settings["corpus_settings"] 55 | except KeyError: 56 | self.corpus_settings = { 57 | "filename": "tr_quotes.txt", 58 | "space_key": "space", 59 | "shift_key": "shift", 60 | "shift_policy": "once", 61 | "precision": 500, 62 | } 63 | some_default = True 64 | self.startup_messages.append(("Loaded user settings", gui_util.green)) 65 | if some_default: 66 | self.startup_messages.append(( 67 | "Set some missing/bad settings to default", gui_util.blue)) 68 | except (FileNotFoundError, KeyError, json.decoder.JSONDecodeError): 69 | self.speeds_file = "default" 70 | self.analysis_target = layout.get_layout("qwerty") 71 | self.user_layout = layout.get_layout("qwerty") 72 | self.constraintmap_ = constraintmap.get_constraintmap( 73 | "traditional-default") 74 | self.key_aliases = set() 75 | self.corpus_settings = { 76 | "filename": "tr_quotes.txt", 77 | "space_key": "space", 78 | "shift_key": "shift", 79 | "shift_policy": "once", 80 | "precision": 500, 81 | } 82 | self.startup_messages.append( 83 | ("Using default user settings", gui_util.red)) 84 | 85 | self.typingdata_ = TypingData(self.speeds_file) 86 | self.target_corpus = self.analysis_target.get_corpus( 87 | self.corpus_settings) 88 | 89 | self.save_settings() 90 | 91 | curses.curs_set(0) 92 | gui_util.init_colors() 93 | 94 | self.height, self.twidth = stdscr.getmaxyx() 95 | self.titlebar = stdscr.subwin(1,self.twidth,0,0) 96 | self.titlebar.bkgd(" ", curses.A_REVERSE) 97 | self.titlebar.addstr("Trialyzer" + " "*(self.twidth-10)) 98 | self.titlebar.refresh() 99 | self.content_win = stdscr.subwin(1, 0) 100 | 101 | self.height, self.twidth = self.content_win.getmaxyx() 102 | self.header_lines = math.ceil(len(self.header_text())/2) 103 | self.repl_win = self.content_win.derwin( 104 | self.height-self.header_lines-2, int(self.twidth/3), 105 | self.header_lines, 0 106 | ) 107 | self.right_pane = self.content_win.derwin( 108 | self.height-self.header_lines-2, int(self.twidth*2/3), 109 | self.header_lines, int(self.twidth/3) 110 | ) 111 | for win in (self.repl_win, self.right_pane): 112 | win.scrollok(True) 113 | win.idlok(True) 114 | self.input_win = self.content_win.derwin(self.height-2, 2) 115 | self.input_box = curses.textpad.Textbox(self.input_win, True) 116 | 117 | for item in self.startup_messages: 118 | self.say(*item) 119 | 120 | self.last_command = "" 121 | self.last_args = [] 122 | 123 | def prompt_user_command(self): 124 | """Yields (command_name, args). This is a generator because some 125 | user inputs cause commands to be run multiple times. Returns 126 | immediately if the user input is blank.""" 127 | 128 | self.content_win.addstr(self.height-2, 0, "> ") 129 | self.print_header() 130 | 131 | self.input_win.clear() 132 | self.input_win.refresh() 133 | 134 | input_args = self.get_input().split() 135 | if not len(input_args): 136 | return 137 | command = input_args.pop(0).lower() 138 | try: 139 | num_repetitions = int(command) 140 | command = input_args.pop(0).lower() 141 | except ValueError: 142 | num_repetitions = 1 143 | 144 | if command in (".",): 145 | command = self.last_command 146 | input_args = self.last_args 147 | else: 148 | self.last_command = command 149 | self.last_args = input_args.copy() 150 | 151 | if not command: 152 | return 153 | 154 | for _ in range(num_repetitions): 155 | yield command, input_args.copy() 156 | 157 | def save_settings(self): 158 | with open("session_settings.json", "w") as settings_file: 159 | json.dump( 160 | { "analysis_target": self.analysis_target.name, 161 | "user_layout": self.user_layout.name, 162 | "active_speeds_file": self.speeds_file, 163 | "constraintmap": self.constraintmap_.name, 164 | "corpus_settings": self.corpus_settings, 165 | "key_aliases": [tuple(keys) for keys in self.key_aliases] 166 | }, settings_file) 167 | 168 | def header_text(self): 169 | precision_text = ( 170 | f"all ({len(self.target_corpus.top_trigrams)})" 171 | if not self.target_corpus.precision 172 | else f"top {self.target_corpus.precision}" 173 | ) 174 | space_string = (self.corpus_settings['space_key'] 175 | if self.corpus_settings['space_key'] else "[don't analyze spaces]") 176 | shift_string = (self.corpus_settings['shift_key'] 177 | if self.corpus_settings['shift_key'] else "[don't analyze shift]") 178 | text = [ 179 | "\"h\" or \"help\" to show command list", 180 | f"Analysis target: {self.analysis_target}", 181 | f"User layout: {self.user_layout}", 182 | f"Active speeds file: {self.speeds_file}" 183 | f" (/data/{self.speeds_file}.csv)", 184 | f"Generation constraintmap: {self.constraintmap_.name}", 185 | f"Corpus: {self.corpus_settings['filename']}", 186 | f"Default space key: {space_string}", 187 | f"Default shift key: {shift_string}", 188 | ] 189 | if self.corpus_settings["shift_key"]: 190 | text.append("Consecutive capital letters: shift " 191 | f"{self.corpus_settings['shift_policy']}") 192 | text.append( 193 | f"Precision: {precision_text} " 194 | f"({self.target_corpus.trigram_completeness:.3%})" 195 | ) 196 | return text 197 | 198 | def print_header(self): 199 | for i in range(self.header_lines): 200 | self.content_win.move(i, 0) 201 | self.content_win.clrtoeol() 202 | header_text_ = self.header_text() 203 | second_col_start = 3 + max( 204 | len(line) for line in header_text_[:self.header_lines]) 205 | second_col_start = max(second_col_start, int(self.twidth/3)) 206 | for i in range(self.header_lines): 207 | self.content_win.addstr(i, 0, header_text_[i]) 208 | for i in range(self.header_lines, len(header_text_)): 209 | self.content_win.addstr( 210 | i-self.header_lines, second_col_start, header_text_[i]) 211 | 212 | self.content_win.refresh() 213 | 214 | def say(self, msg: str, color: int = 0, 215 | win: curses.window = ...): 216 | if win == ...: 217 | win = self.repl_win 218 | gui_util.insert_line_bottom( 219 | msg, win, curses.color_pair(color)) 220 | win.refresh() 221 | 222 | def output(self, msg: str, color: int = 0): 223 | self.say(msg, color, win=self.right_pane) 224 | 225 | def get_input(self) -> str: 226 | self.input_win.move(0,0) 227 | curses.curs_set(1) 228 | 229 | res = self.input_box.edit() 230 | 231 | self.input_win.clear() 232 | self.input_win.refresh() 233 | self.say("> " + res) 234 | return res 235 | -------------------------------------------------------------------------------- /layout.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import json 3 | import os 4 | from typing import Collection, Iterable, Dict, Tuple, Callable 5 | import threading 6 | import random 7 | import functools 8 | import contextlib 9 | 10 | import board 11 | import fingermap 12 | import corpus 13 | import remap 14 | from nstroke import ( 15 | all_tristroke_categories, Nstroke, applicable_function, tristroke_category 16 | ) 17 | 18 | class Layout: 19 | 20 | loaded = {} # type: Dict[str, Layout] 21 | 22 | def __init__( 23 | self, name: str, preprocess: bool = True, 24 | repr_: str = "") -> None: 25 | """Pass in repr_ to build the layout directly from it. Otherwise, 26 | the layout will be built from the file at layouts/. Raises 27 | FileNotFoundError if no repr is provided and no file is found.""" 28 | self.name = name 29 | self.keys = {} # type: Dict[fingermap.Pos, str] 30 | self.positions = {} # type: Dict[str, fingermap.Pos] 31 | self.fingers = {} # type: Dict[str, fingermap.Finger] 32 | self.coords = {} # type: Dict[str, board.Coord] 33 | self.counts = {category: 0 for category in all_tristroke_categories} 34 | self.preprocessors = {} # type: Dict[str, threading.Thread] 35 | self.nstroke_cache = {} # type: Dict[Tuple[str, ...], Nstroke] 36 | self.special_replacements = {} # type: Dict[str, Tuple[str,...]] 37 | if repr_: 38 | self.build_from_string(repr_) 39 | else: 40 | with open("layouts/" + name) as file: 41 | self.build_from_string(file.read()) 42 | if preprocess: 43 | self.start_preprocessing() 44 | 45 | def build_from_string(self, s: str): 46 | rows = [] 47 | first_row = fingermap.Row.TOP 48 | first_col = 1 49 | fingermap_defined = False 50 | board_defined = False 51 | self.repeat_key = "" 52 | for row in s.splitlines(): 53 | tokens = row.split("//", 1)[0].split(" ") 54 | if tokens[0] == "fingermap:": 55 | if len(tokens) >= 2: 56 | self.fingermap = fingermap.get_fingermap(tokens[1]) 57 | fingermap_defined = True 58 | elif tokens[0] == "board:": 59 | if len(tokens) >= 2: 60 | self.board = board.get_board(tokens[1]) 61 | board_defined = True 62 | elif tokens[0] == "first_pos:": 63 | if len(tokens) >= 3: 64 | try: 65 | first_row = int(tokens[1]) 66 | except ValueError: 67 | first_row = fingermap.Row[tokens[1]] 68 | first_col = int(tokens[2]) 69 | elif tokens[0] == "special:": 70 | if len(tokens) >= 3: 71 | self.special_replacements[tokens[1]] = tuple(tokens[2:]) 72 | elif tokens[0] == "repeat_key:": 73 | if len(tokens) >= 2: 74 | self.repeat_key = tokens[1] 75 | elif len("".join(tokens)): 76 | rows.append(tokens) 77 | if not fingermap_defined: 78 | self.fingermap = fingermap.get_fingermap("traditional") 79 | if not board_defined: 80 | self.board = board.get_board("ansi") 81 | for r, row in enumerate(rows): 82 | for c, key in enumerate(row): 83 | if key: 84 | pos = fingermap.Pos(first_row + r, first_col + c) 85 | self.keys[pos] = key 86 | self.positions[key] = pos 87 | self.fingers[key] = self.fingermap.fingers[ 88 | self.positions[key]] 89 | self.coords[key] = self.board.coords[ 90 | self.positions[key]] 91 | for pos, key in self.board.default_keys.items(): 92 | if pos not in self.keys and key not in self.positions: 93 | self.keys[pos] = key 94 | self.positions[key] = pos 95 | self.fingers[key] = self.fingermap.fingers[ 96 | self.positions[key]] 97 | self.coords[key] = self.board.coords[ 98 | self.positions[key]] 99 | 100 | def calculate_category_counts(self): 101 | for other in Layout.loaded.values(): 102 | if (other is not self and self.has_same_tristrokes(other)): 103 | self.preprocessors["counts"] = other.preprocessors["counts"] 104 | self.counts = other.counts 105 | return 106 | 107 | for tristroke in self.all_nstrokes(3): 108 | self.counts[tristroke_category(tristroke)] += 1 109 | for category in all_tristroke_categories: 110 | if not self.counts[category]: 111 | applicable = applicable_function(category) 112 | for instance in all_tristroke_categories: 113 | if applicable(instance): 114 | self.counts[category] += self.counts[instance] 115 | 116 | def has_same_tristrokes(self, other: "Layout"): 117 | return ( 118 | self.fingermap == other.fingermap and 119 | self.board == other.board and 120 | set(self.coords.values()) == set(other.coords.values()) 121 | ) 122 | 123 | def start_preprocessing(self): 124 | self.preprocessors["counts"] = threading.Thread( 125 | target=_calculate_counts_wrapper, args=(self,), daemon=True) 126 | for name in self.preprocessors: 127 | self.preprocessors[name].start() 128 | 129 | def __str__(self) -> str: 130 | return (self.name + " (" + self.fingermap.name + ", " 131 | + self.board.name + ")") 132 | 133 | def __repr__(self) -> str: 134 | reprkeys = self.get_board_keys()[1] 135 | 136 | first_row = min(pos.row for pos in reprkeys) 137 | first_col = min(pos.col for pos in reprkeys) 138 | last_row = max(pos.row for pos in reprkeys) 139 | last_col = max(pos.col for pos in reprkeys) 140 | rows = [] 141 | if self.fingermap.name != "traditional": 142 | rows.append(f"fingermap: {self.fingermap.name}") 143 | if self.board.name != "ansi": 144 | rows.append(f"board: {self.board.name}") 145 | if first_row != fingermap.Row.TOP.value or first_col != 1: 146 | rows.append( 147 | f"first_pos: {fingermap.Row(first_row).name} {first_col}") 148 | for row in range(first_row, last_row+1): 149 | keys = [] 150 | for col in range(first_col, last_col+1): 151 | try: 152 | keys.append(reprkeys[fingermap.Pos(row, col)]) 153 | except KeyError: 154 | keys.append("") 155 | rows.append(" ".join(keys)) 156 | if bool(self.repeat_key): 157 | rows.append(f"repeat_key: {self.repeat_key}") 158 | for k, v in self.special_replacements.items(): 159 | rows.append(f"special: {k} {' '.join(v)}") 160 | return "\n".join(rows) 161 | 162 | def get_board_keys(self): 163 | """Returns board_keys, non_board_keys as dicts[pos, key] that 164 | are/are not from the default keys of the board. 165 | """ 166 | board_keys = {} 167 | non_board_keys = {} 168 | for pos, key in self.keys.items(): 169 | if (pos, key) in self.board.default_keys.items(): 170 | board_keys[pos] = key 171 | else: 172 | non_board_keys[pos] = key 173 | return board_keys, non_board_keys 174 | 175 | def to_ngram(self, nstroke: Nstroke): 176 | """Returns None if the Nstroke does not have a corresponding 177 | ngram in this layout. Otherwise, returns a tuple of key names 178 | based on the coordinates in the tristroke, disregarding the 179 | fingers and any notes. 180 | """ 181 | ngram = [] 182 | try: 183 | for coord in nstroke.coords: 184 | pos = self.board.positions[coord] 185 | key = self.keys[pos] 186 | ngram.append(key) 187 | except KeyError: 188 | return None 189 | return tuple(ngram) 190 | 191 | #@functools.cache 192 | def to_nstroke(self, ngram: Tuple[str, ...], note: str = "", 193 | fingers: Tuple[fingermap.Finger, ...] = ..., 194 | overwrite_cache: bool = False): 195 | """Converts an ngram into an nstroke. Leave fingers blank 196 | to auto-calculate from the keymap. Since this uses functools.cache, 197 | give immutable arguments only. 198 | 199 | Returns None if a key is not found in the layout. 200 | """ 201 | is_pure_ngram = (note == "" and fingers == ...) 202 | if not overwrite_cache: 203 | try: 204 | if is_pure_ngram: 205 | return self.nstroke_cache[ngram] 206 | else: 207 | args = (ngram, note, fingers) 208 | return self.nstroke_cache[args] 209 | except KeyError: 210 | pass 211 | 212 | if fingers == ...: 213 | fingers = (self.fingers[key] for key in ngram) 214 | try: 215 | result = Nstroke(note, tuple(fingers), 216 | tuple(self.coords[key] for key in ngram)) 217 | except KeyError: 218 | result = None 219 | 220 | if is_pure_ngram: 221 | self.nstroke_cache[ngram] = result 222 | else: 223 | args = (ngram, note, fingers) 224 | self.nstroke_cache[args] = result 225 | return result 226 | 227 | def all_nstrokes(self, n: int = 3): 228 | ngrams = itertools.product(self.keys.values(), repeat=n) 229 | return (self.to_nstroke(ngram) for ngram in ngrams) 230 | 231 | @functools.cache 232 | def nstrokes_with_fingers(self, fingers: Tuple[fingermap.Finger]): 233 | options = [] 234 | for finger in fingers: 235 | options.append(( 236 | self.keys[pos] for pos in self.fingermap.cols[finger] 237 | if pos in self.keys)) 238 | return tuple(self.to_nstroke(item) 239 | for item in itertools.product(*options)) 240 | 241 | def ngrams_with_any_of(self, keys: Iterable[str], n: int = 3, 242 | exclude_keys: Collection[str] = ()): 243 | """Any key in exclude_keys will be excluded from the result.""" 244 | # this method should avoid generating duplicates probably maybe 245 | options = tuple(key for key in keys 246 | if key in self.positions and key not in exclude_keys) 247 | inverse = tuple(key for key in self.positions 248 | if key not in options and key not in exclude_keys) 249 | all = tuple(key for key in self.positions if key not in exclude_keys) 250 | for i in range(n): 251 | by_position = [] 252 | for j in range(n): 253 | if j > i: 254 | by_position.append(all) 255 | elif j < i: 256 | by_position.append(inverse) 257 | else: 258 | by_position.append(options) 259 | for ngram in itertools.product(*by_position): 260 | # print(ngram) 261 | # yield self.to_nstroke(ngram) 262 | yield ngram 263 | 264 | def remap(self, remap: dict[str, str], refresh_cache: bool = True): 265 | k = {self.positions[dest]: key for key, dest in remap.items()} 266 | p = {key: self.positions[dest] for key, dest in remap.items()} 267 | f = {key: self.fingers[dest] for key, dest in remap.items()} 268 | c = {key: self.coords[dest] for key, dest in remap.items()} 269 | self.keys.update(k) 270 | self.positions.update(p) 271 | self.fingers.update(f) 272 | self.coords.update(c) 273 | self.nstrokes_with_fingers.cache_clear() 274 | # self.to_nstroke.cache_clear() 275 | if refresh_cache: 276 | for ngram in self.ngrams_with_any_of(remap): 277 | self.to_nstroke(ngram, overwrite_cache=True) 278 | 279 | def shuffle(self, swaps: int = 100, pins: Iterable[str] = tuple()): 280 | keys = set(self.keys.values()) 281 | for key in pins: 282 | keys.discard(key) 283 | random.seed() 284 | for _ in range(swaps): 285 | self.remap(remap.cycle(*random.sample(keys, k=2)), False) 286 | self.nstroke_cache.clear() 287 | 288 | def constrained_shuffle(self, shuffle_source: Callable, swaps: int = 100): 289 | for _ in range(swaps): 290 | self.remap(shuffle_source(), False) 291 | self.nstroke_cache.clear() 292 | 293 | def frequency_by_finger(self, lfreqs = ...): 294 | if lfreqs == ...: 295 | with open("data/shai.json") as file: 296 | corp_data = json.load(file) 297 | lfreqs = corp_data["letters"] 298 | fing_freqs = {finger: 0.0 for finger in list(fingermap.Finger)} 299 | for finger in self.fingermap.cols: 300 | for pos in self.fingermap.cols[finger]: 301 | try: 302 | key = self.keys[pos] 303 | lfreq = lfreqs[key] 304 | except KeyError: 305 | continue 306 | fing_freqs[finger] += lfreq 307 | total_lfreq = sum(fing_freqs.values()) 308 | if not total_lfreq: 309 | return {finger: 0.0 for finger in fing_freqs} 310 | for finger in fing_freqs: 311 | fing_freqs[finger] /= total_lfreq 312 | return fing_freqs 313 | 314 | def total_trigram_count(self, corpus_settings: dict): 315 | total = 0 316 | trigram_counts = self.get_corpus(corpus_settings).trigram_counts 317 | for trigram, count in trigram_counts.items(): 318 | for key in trigram: 319 | if not key in self.positions: 320 | continue 321 | total += count 322 | return total 323 | 324 | def get_corpus(self, settings: dict): 325 | return corpus.get_corpus( 326 | settings["filename"], 327 | "space" if ("space" in self.keys.values()) and settings["space_key"] 328 | else settings["space_key"], 329 | "shift" if ("shift" in self.keys.values()) and settings["shift_key"] 330 | else settings["shift_key"], 331 | settings["shift_policy"], 332 | self.special_replacements, 333 | self.repeat_key, 334 | settings["precision"] 335 | ) 336 | 337 | def is_saved(self) -> bool: 338 | return os.path.exists(f"layouts/{self.name}") 339 | # check repr? I don't think there should ever be a situation 340 | # where self differs from what's in the file 341 | # So this should be fine for now 342 | 343 | def get_layout(name: str) -> Layout: 344 | """Raises FileNotFoundError if layout does not exist.""" 345 | if name not in Layout.loaded: 346 | Layout.loaded[name] = Layout(name) 347 | return Layout.loaded[name] 348 | 349 | def _calculate_counts_wrapper(*args: Layout): 350 | args[0].calculate_category_counts() 351 | 352 | @contextlib.contextmanager 353 | def make_picklable(layout_: Layout): 354 | preprocessors = layout_.preprocessors 355 | layout_.preprocessors = {} 356 | try: 357 | yield layout_ 358 | finally: 359 | layout_.preprocessors = preprocessors 360 | 361 | 362 | # for testing 363 | if __name__ == "__main__": 364 | qwerty = get_layout("qwerty") 365 | n = 3 366 | keys = ("a", "b", "c") 367 | set_1 = {nstroke for nstroke in qwerty.ngrams_with_any_of(keys, n)} 368 | print(len(set_1)) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # trialyzer 2 | 3 | An idea I had for a keyboard layout analyzer. It includes a specialized trigram typing test to allow users to measure their speed on arbitrary trigrams, and uses that data as the basis for layout analysis. 4 | 5 | If you aren't familiar with the concept of alternate keyboard layouts, the short explanation is this: Imagine shuffling around the keys on the keyboard so they no longer read `QWERTY`. It seems reasonable to say that some of these arrangements are somehow "better" than others--more comfortable, or potentially faster to type on. The most widely known examples of alternate keyboard layouts include [Dvorak](https://en.wikipedia.org/wiki/Dvorak_keyboard_layout) and [Colemak](https://colemak.com), of which the latter is regarded more favorably in modern analysis. Trialyzer is a tool to help analyze and generate such layouts. 6 | 7 | ![Some images of trialyzer in action](/misc/medley_1.png) 8 | 9 | I'm also using this project as an opportunity to learn Python. This is my first time using Python for anything more substantial than CodingBat, and also my first project in VS Code, so things may be somewhat messy. 10 | 11 | Uses Python 3.10.11 with the following packages: 12 | - `pynput` 13 | - `windows-curses` (only needed if you're on Windows) 14 | 15 | Usage: 16 | - Once you have the above packages installed in your environment, use Python to run `trialyzer.py`. 17 | - I'll probably get around to a requirements.txt and other things eventually maybe possibly. 18 | - Trialyzer runs in a terminal window. For best results, use a terminal that is capable of colors. 19 | - On my machines, VSCode's integrated terminal and Windows terminal both work, but I'm pretty bad at coding so trialyzer is notoriously (and [hilariously](https://cdn.discordapp.com/attachments/448292734982422543/937192289854455858/unknown.png)) bad at running on other people's setups. 20 | - A pretty big terminal window is needed for certain features. If you get a curses error, you may have to increase your window size and/or decrease font size. 21 | 22 | Features: 23 | - Layout analysis, comparison, and editing, based on typing data provided by and personally applicable to you 24 | - A large variety of bigram and trigram statistics, accounting for both frequency and your measured ease of typing each 25 | - Autosuggest trigrams to use in the typing test to most effectively characterize a layout's common finger movements 26 | - Estimate the physical typing speed limit of each layout based on your typing data 27 | - Rank layouts by a large variety of different statistics 28 | - Draw layout heatmaps using a large variety of different statistics 29 | - Custom layouts, fingermaps, and even physical board definitions can be added in files 30 | - Layout optimization by simulated annealing and steepest ascent, with key pinning and usage constraints 31 | - Load arbitrary text corpora with rules for shift and space keys 32 | 33 | Planned: 34 | - Further generation/optimization tools 35 | - More fingermaps, boards, layouts 36 | - Alt fingering and sliding, dynamic keys, layers 37 | 38 | ## What makes trialyzer special? 39 | 40 | Recent analyzers such as [genkey](https://github.com/semilin/genkey) have included novel analysis concepts such as fingerspeed, in addition to the common bigram, trigram, and generalized n-gram stats (frequency of same finger bigrams/skipgrams, neighbor finger bigrams, lateral stretch bigrams, alternation, rolls, onehands, and redirects). However, in evaluating the overall fitness of a layout, the relative weights of all these different statistics are generally set arbitrarily. A noteworthy exception is that [semi](https://github.com/semilin) partially determined his weights by [using a script](https://semilin.github.io/semimak/#orgb1cc038) to measure the physical typing speed of each finger on selected same finger bigrams, and used that data to weight how bad sfb's are on each finger. This started me thinking. 41 | 42 | Much discussion has been had about whether different movements are better or worse - for example, some people absolutely despise redirects, whereas others report being able to tolerate them quite well. A complicating factor is that not all redirects are equal; redirects involving ring and pinkies (such as the infamous `you` on Colemak) seem particularly bad. But how bad? Surely it depends on the exact key sequence? Similar questions can be asked about a wide range of other statistics: inward versus outward rolling, neighbor finger rowjumps, rolling versus alternation, and on and on. 43 | 44 | **The goal of this analyzer is to use actual measured typing speed data to answer these questions, and more:** 45 | 46 | - Have you been suspecting that your right ring finger is faster than your left? 47 | - Is QWERTY's `ve` roll tolerable until you add `x` afterward? 48 | - How bad is Colemak's `you` really, compared to the average redirect? Compared to `oyu`? 49 | 50 | ![Trialyzer's typing test in action](/misc/typingtest_image_3.png) 51 | 52 | Other analyzers take same finger bigrams, skipgrams, rolls, and all those other stats, and combine them into an overall layout fitness using arbitrary weights. Trialyzer takes your own *experimentally measured* typing speed data and uses it to build up an overall trigram speed statistic. This naturally incorporates the effects of all these stats into one overall layout speed score, with no arbitrary weight selection required. The predicted maximum typing speed of a layout should, hopefully, also be a good measurement for how comfortable it is to type on. 53 | 54 | Despite only having one main statistic, we don't give up up the granularity of all those more specific stats. In fact, with all this data, we actually obtain *more* insight into, for example, exactly how bad each different sfb is, and how much they slow you down compared to the average non-sfb. Trialyzer contains tools to calculate and display these statistics. 55 | 56 | Of course, this approach comes with some limitations and drawbacks: 57 | 58 | - Speed is an objective measurement, but is it a good heuristic for comfort? It makes sense that they would be correlated, especially considering the results that have come out of the fingerspeed metric, but comfort is notoriously difficult to quantify. 59 | - For example, you might be able to type a lateral stretch bigram quickly, but that doesn't mean it's comfortable. 60 | - Or, how does the workload of each finger weigh in? Fatigue may be reflected in a longer test, or your finger may be slowly strained to unhealthy levels over the course of weeks, but certainly not when typing one trigram at a time. 61 | - Considering just the main 30 keys of the keyboard, the number of possible trigrams is 27,000 - a very tedious number to sit through and test out one at a time, not even considering the number row, thumbs, and modifiers! 62 | - To help mitigate this, trialyzer is able to analyze layouts even with incomplete trigram speed data (by extrapolating from what data it does have), so you won't be forced to test through every single trigram before getting any use out of it. This effect is shown as an "exactness" score that is displayed for certain stats. 63 | - Trialyzer also includes a setting to use only the top *n* most common trigrams rather than the full 27,000, which makes it compute substantially faster at the cost of losing some fidelity. 64 | - Of course, extrapolation is never as good as actual complete data, so the more you test, the better the results will be. 65 | - Trigrams don't capture the entire flow of longer sequences. A quadgram might have an uncomfortable interaction between the first and fourth letters, which won't be captured by trialyzer. 66 | - On the plus side, trigrams are at least much better than the older bigram-only statistics, while remaining within practical limits. Longer sequences quickly become combinatorically problematic to test and calculate, with a much larger number of possibilities than even trigrams. Perhaps trigram data can be split apart and pieced back together to form approximate measurements for longer sequences, which wouldn't require extra data collection but would still be a computational burden... an idea for later, perhaps? 67 | 68 | ![The data trap (from xkcd)](https://imgs.xkcd.com/comics/data_trap_2x.png) 69 | 70 | ## Terminology 71 | 72 | **Layout** 73 | A mapping between key names (`a`, `;`, `shift_l`) and key positions (row, column). A fingermap and board are also specified for each layout. 74 | Examples: `QWERTY`, `colemak_dh`. 75 | 76 | **Fingermap** 77 | A mapping between key positions (row, column) and fingers (`LI` for left index, `RP` for right pinky.) 78 | Possible examples: `traditional`, `iso_angle`. 79 | 80 | **Board** 81 | A mapping between key positions (row, column) and physical locations of the keys (xy coordinates). 82 | Possible examples: `ansi`, `ortho`, `moonlander`. 83 | 84 | **Bigram, trigram, n-gram** 85 | Existing terminology meaning a sequence of characters or key names. These may be called "text n-grams" to further clarify that they refer only to key names, which may vary depending on layout, as opposed to physical keys on the board. 86 | 87 | **Bistroke, tristroke, n-stroke** 88 | A sequence of physical key locations on the board, each associated with the finger used for that key. Trialyzer collects data on the typing speeds of different tristrokes using its built-in typing test, then applies it to analyze a selected layout by associating those tristrokes with text trigrams. 89 | 90 | (Note: Different fingermap-board combinations may have some tristrokes in common; for instance, all tristrokes involving the top and home row are identical between `traditional`-`ansi` and `iso_angle`-`iso`. Moreover, though the right hand is in a different position in `iso_angle` versus `iso_angle_wide`, the shape of each tristroke on the right hand is identical. Trialyzer accounts for these commonalities, and allows the relevant typing speed data to be shared between different boards and fingermaps.) 91 | 92 | For much more terminology and detail, see the [wiki](https://github.com/samuelxyz/trialyzer/wiki)! 93 | 94 | # Some results 95 | 96 | ## General results 97 | 98 | After typing a few thousand trigrams, I had enough typing data to gather some interesting observations. 99 | 100 | ![Trigram speeds plot](https://media.discordapp.net/attachments/798600991221219340/1028750494199468162/medians-2.png) 101 | 102 | In this plot, each point represents the median speed of one trigram, broken down into bigram parts (`the` breaks down into `th` and `he`, which don't necessarily take the same amount of time each). The speeds of the faster and slower parts determine the position of the point on the vertical and horizontal axes. So, faster overall trigrams are to the lower left, and "metronome" trigrams where both bigram parts are the same speed would fall on the main diagonal. 103 | 104 | The full explanation of trigram categories is [here](https://github.com/samuelxyz/trialyzer/wiki/Nstroke-categories). Note that some categories have been lumped together, to avoid cluttering the plot with too many minor categories. 105 | 106 | First, this data seems to confirm the popular opinion that 2-1 or 1-2 rolls are faster than full alternating trigrams. It also raises other observations that are more interesting. 107 | 108 | Notably, redirects are highly variable. There are some very fast ones, and a few very slow ones. The fastest redirects can even be faster than most rolls, while a few slow ones are even worse than SFBs. Consider: QWERTY's `xad` is terrible, but `joi` is actually quite fast and comfortable. This variation stands opposed to the popular opinion that redirects are uniformly worse than alternation, though it should be kept in mind that I can only measure speed, not comfort. 109 | 110 | DSFBs (labeled as "sfs" in the plot) are faster than SFBs, but not twice as fast. They might merit a heavier weighting than the traditional SFB/2. 111 | 112 | Using these measurements as weights for every individual trigram, my analyzer displays some very interesting preferences. It regularly spits out layouts with high rolls and redirects, while maintaining low sfb and very low (near Semimak level) dsfb. It nearly always produces home rows containing at least one lower-frequency letter - most often `c` or `l`. Some of its behavior sketches me out (strange finger usage, off-home pinky redirects etc), but that's to be expected when it's using weightings concerned with pure trigram speeds and nothing else. I generally wouldn't recommend using these layouts exactly as produced without any tweaking, but they are certainly thought-provoking. 113 | 114 | ## Thoughts on same finger skipgrams 115 | 116 | ![Table of trigram categories](https://i.imgur.com/OWzD7JY.png) 117 | 118 | This is a table of trigram categories. For this section, we are interested in the `sfs` subcategories, which is my name for a DSFB in trigram form. `sfs.alt` is an alternating DSFB, the others are various forms of same-hand DSFBs. 119 | 120 | - The `ms` column indicates the average number of milliseconds it took me to type a trigram of that category. Lower is faster. Note that for each trigram, I typed many trials of it and kept the median time. So, each number in the `ms` column is an average of those medians. 121 | 122 | - The `n` column indicates how many distinct trigrams of that category I have typed. The last column indicates how many are possible on the keyboard (at least, when including the keys that I have set up the code for). These two together indicate a sort of completion score, how confident you should be in the `ms` score of a particular trigram category. 123 | 124 | We can see that `sfs.alt` is indeed faster than the same-hand `sfs` categories. However, note that even `sfs.alt` is substantially worse than your average redirect or alternating trigram, and much worse than rolls. In fact, `sfs` overall is the worst trigram category that doesn't contain consecutive same-finger use, by quite a wide margin. 125 | 126 | DSFB is slightly less bad when it's alternating, but even then it's still a significant issue. 127 | 128 | Next, let's apply those speeds to a real layout, in this case Colemak. 129 | 130 | ![Example analysis](https://i.imgur.com/iAunwco.png) 131 | 132 | The main thing that has happened here is that we have taken our median time for each trigram, and weighted it by the frequency of that trigram in the corpus (in this case, the typeracer quotes corpus). Some of the more detailed categories have been hidden for brevity, but we still have our main `sfs` categories visible. 133 | 134 | - The `freq` column shows the total frequency of each trigram category in the corpus. 135 | 136 | - the `exact` column shows the proportion of trigrams in each category (weighted by frequency of each individual trigram) that have exact speed data recorded. The rest have their speeds estimated from existing data by various means. You can think of this as a *lower bound* on how confident the analyzer is about the values in the next two columns. In my experience, once this score passes about 10%, the analyzer's extrapolations for unknown trigrams tend to be surprisingly accurate. 137 | 138 | - The `avg_ms` column lists the average number of milliseconds taken to type a trigram of each category, weighted by frequency of each trigram within the category. You can think of this as a score describing how slow each category tends to be. 139 | 140 | - The `ms` column is `avg_ms` weighted by the frequency of each category in the corpus. You can think of this as a score describing how much each category contributes to the whole layout's overall typing slowness. 141 | 142 | Looking at the `avg_ms` column, we see that the typical `sfs` is worse than any non-same-finger, non-scissoring trigram. Worse than alternating, rolling, and redirecting trigrams. Looking at the `ms` column, we see that it also impacts the total typing time *more* than redirects. It also has more impact than trigrams containing sfbs. This makes sense because it's much more difficult to minimize dsfb than sfb, so there is substantially more dsfb in most layouts, Colemak included. So, decreasing dsfb may be a substantial opportunity to improve a layout's performance overall. 143 | -------------------------------------------------------------------------------- /corpus.py: -------------------------------------------------------------------------------- 1 | # Load and process a corpus into trigram frequencies, subject to certain settings 2 | # Members: 3 | # shift_key: str 4 | # space_key: str 5 | # key_counts: dict[str, int] 6 | # bigram_counts: dict[Bigram, int] 7 | # trigram_counts: dict[Trigram, int] 8 | # trigrams_by_freq: list[Trigram] - possibly just use trigram_counts.most_common() 9 | # precision: int 10 | # trigram_completeness: float 11 | # replacements: dict[str, tuple[str, ...]] 12 | # special_replacements: dict[str, tuple[str, ...]] 13 | # Local vars 14 | # raw: raw text of the corpus, directly from a file 15 | # processed: a list of 1-grams? may not be necessary 16 | 17 | from collections import Counter 18 | import itertools 19 | import json 20 | from typing import Type 21 | 22 | Bigram = tuple[str, str] 23 | Trigram = tuple[str, str, str] 24 | 25 | default_lower = """`1234567890-=qwertyuiop[]\\asdfghjkl;'zxcvbnm,./""" 26 | default_upper = """~!@#$%^&*()_+QWERTYUIOP{}|ASDFGHJKL:"ZXCVBNM<>?""" 27 | 28 | def display_name(key: str, corpus_settings: dict): 29 | if key == corpus_settings.get("space_key", None): 30 | return "space" 31 | elif key == corpus_settings.get("shift_key", None): 32 | return "shift" 33 | elif key == corpus_settings.get("repeat_key", None): 34 | return "repeat" 35 | else: 36 | return key 37 | 38 | def display_str(ngram: tuple[str, ...], corpus_settings: dict): 39 | return " ".join(display_name(key, corpus_settings) for key in ngram) 40 | 41 | def undisplay_name(key: str, corpus_settings: dict): 42 | if key == "space": 43 | return corpus_settings.get("space_key", key) 44 | elif key == "shift": 45 | return corpus_settings.get("shift_key", key) 46 | elif key == "repeat": 47 | return corpus_settings.get("repeat_key", key) 48 | else: 49 | return key 50 | 51 | def create_replacements(space_key: str, shift_key: str, 52 | special_replacements: dict[str, tuple[str,...]]): 53 | """A dict from direct corpus characters to key sequences. 54 | For example, "A" becomes (shift_key, "a") 55 | Also contains all default legal sequences, like {"a": "a"} 56 | """ 57 | if space_key: 58 | replacements = {" ": (space_key,)} 59 | else: 60 | replacements = {" ": ("unknown",)} 61 | 62 | if shift_key: 63 | for l, u in zip(default_lower, default_upper): 64 | replacements[u] = (shift_key, l) 65 | else: 66 | for l, u in zip(default_lower, default_upper): 67 | replacements[u] = ("unknown", l) 68 | 69 | replacements.update(special_replacements) 70 | legal_chars = (set(default_lower) | set(default_upper) 71 | | set(replacements)) 72 | for char in legal_chars: 73 | if char not in replacements: 74 | replacements[char] = (char,) 75 | 76 | return replacements 77 | 78 | class TranslationError(ValueError): 79 | """Attempted to translate two corpuses that are not compatible.""" 80 | 81 | class Corpus: 82 | 83 | def __init__(self, filename: str, 84 | space_key: str = "space", 85 | shift_key: str = "shift", 86 | shift_policy: str = "once", 87 | special_replacements: dict[str, tuple[str,...]] = {}, 88 | precision: int = 500, 89 | repeat_key: str = "", 90 | json_dict: dict = None, 91 | other: Type["Corpus"] = None, 92 | skipgram_weights: tuple[float] = None) -> None: 93 | """To disable a key, set it to `""`. 94 | 95 | `shift_policy` can be "once" or "each". "once" means that when 96 | consecutive capital letters occur, shift is only pressed once before 97 | the first letter. "each" means shift is pressed before each letter. 98 | 99 | `skipgram_weights` contains the weight in its `i`th index for pairs 100 | of the form `pos, pos+i` for any position in the corpus. For 101 | example, regular bigrams would be weighted by the number at index 1. 102 | """ 103 | self.filename = filename 104 | self.space_key = space_key 105 | self.shift_key = shift_key 106 | self.shift_policy = shift_policy 107 | self.special_replacements = special_replacements 108 | self.repeat_key = repeat_key 109 | self.skipgram_weights = skipgram_weights 110 | 111 | # Not necessarily integer, due to skipgram_weights floats 112 | self.skipgram_counts: Counter[tuple[str], float] = None 113 | 114 | if json_dict is not None: 115 | self._json_load(json_dict) 116 | elif other is not None: 117 | self._translate(other) 118 | else: 119 | self._process() 120 | self.precision = precision 121 | self.top_trigrams = () 122 | self.trigram_precision_total = 0 123 | self.trigram_completeness = 0 124 | self.set_precision(precision) 125 | 126 | def _process(self): 127 | self.replacements = create_replacements( 128 | self.space_key, self.shift_key, self.special_replacements 129 | ) 130 | replacee_lengths = sorted(set( 131 | len(key) for key in self.replacements), reverse=True) 132 | 133 | self.key_counts = Counter() 134 | self.bigram_counts = Counter() 135 | self.skip1_counts = Counter() 136 | self.trigram_counts = Counter() 137 | 138 | if self.skipgram_weights: 139 | self.skipgram_counts = Counter() 140 | 141 | def apply_replacements(): 142 | with open("corpus/" + self.filename, errors="ignore") as file: 143 | for raw_line in file: 144 | buffer = [] 145 | 146 | line_length = len(raw_line) - 1 # always ends with newline 147 | i = 0 148 | while i < line_length: 149 | for lookahead in replacee_lengths: # longest first 150 | if i + lookahead > line_length: 151 | continue # remember, this can go to else 152 | if (replacer := self.replacements.get( 153 | raw_line[i:i+lookahead], None)) is not None: 154 | buffer.extend(replacer) 155 | i += lookahead 156 | break # doesn't go to else 157 | else: 158 | # No replacement found 159 | # We denote this key with "unknown" 160 | # The rest of trialyzer knows how to handle this. 161 | # Usually ngrams containing unknown keys are 162 | # discarded. 163 | buffer.add("unknown") 164 | i += 1 165 | yield buffer 166 | 167 | for buffer in apply_replacements(): 168 | if bool(self.shift_key) and self.shift_policy == "once": 169 | i = len(buffer) - 1 170 | while i >= 2: 171 | if (buffer[i] == self.shift_key 172 | and buffer[i-2] == self.shift_key): 173 | buffer.pop(i) 174 | i -= 1 175 | 176 | if bool(self.repeat_key): 177 | for i in range(1, len(buffer)): 178 | if buffer[i] == buffer[i-1]: 179 | buffer[i] = self.repeat_key 180 | 181 | line = tuple(buffer) 182 | self.key_counts.update(line) 183 | self.bigram_counts.update(itertools.pairwise(line)) 184 | self.skip1_counts.update(zip(line, line[2:])) 185 | self.trigram_counts.update( 186 | line[i:i+3] for i in range(len(line)-2)) 187 | 188 | if not self.skipgram_weights: 189 | continue 190 | 191 | for i, l1 in enumerate(line): 192 | for sep, weight in enumerate(self.skipgram_weights): 193 | if i+sep < len(line): 194 | self.skipgram_counts[(l1, line[i+sep])] += weight 195 | 196 | self.key_counts = Counter(dict(self.key_counts.most_common())) 197 | self.bigram_counts = Counter(dict(self.bigram_counts.most_common())) 198 | self.skip1_counts = Counter(dict(self.skip1_counts.most_common())) 199 | self.trigram_counts = Counter(dict(self.trigram_counts.most_common())) 200 | 201 | if self.skipgram_weights: 202 | self.skipgram_counts = Counter(dict( 203 | self.skipgram_counts.most_common())) 204 | 205 | def set_precision(self, precision: int | None): 206 | # if self.trigram_precision_total and precision == self.precision: 207 | # return 208 | if precision <= 0: 209 | self.precision = 0 210 | precision = len(self.top_trigrams) 211 | else: 212 | self.precision = precision 213 | self.top_trigrams = tuple(self.trigram_counts)[:precision] 214 | self.trigram_precision_total = sum(self.trigram_counts[tg] 215 | for tg in self.top_trigrams) 216 | self.trigram_completeness = (self.trigram_precision_total / 217 | self.trigram_counts.total()) 218 | self.filtered_trigram_counts = {t: self.trigram_counts[t] 219 | for t in self.top_trigrams} 220 | 221 | def _json_load(self, json_dict: dict): 222 | self.key_counts = Counter(json_dict["key_counts"]) 223 | self.bigram_counts = eval(json_dict["bigram_counts"]) 224 | self.skip1_counts = eval(json_dict["skip1_counts"]) 225 | self.trigram_counts = eval(json_dict["trigram_counts"]) 226 | self.skipgram_counts = eval(json_dict["skipgram_counts"]) 227 | 228 | def jsonable_export(self): 229 | return { 230 | "filename": self.filename, 231 | "space_key": self.space_key, 232 | "shift_key": self.shift_key, 233 | "shift_policy": self.shift_policy, 234 | "special_replacements": self.special_replacements, 235 | "repeat_key": self.repeat_key, 236 | "key_counts": self.key_counts, 237 | "bigram_counts": repr(self.bigram_counts), 238 | "skip1_counts": repr(self.skip1_counts), 239 | "trigram_counts": repr(self.trigram_counts), 240 | "skipgram_weights": self.skipgram_weights, 241 | "skipgram_counts": repr(self.skipgram_counts) 242 | } 243 | 244 | def _translate(self, other: Type["Corpus"]): 245 | if self.shift_policy != other.shift_policy: 246 | raise TranslationError("Mismatched shifting policies") 247 | if bool(self.space_key) != bool(other.space_key): 248 | raise TranslationError(f"Cannot translate missing space key") 249 | if bool(self.shift_key) != bool(other.shift_key): 250 | raise TranslationError(f"Cannot translate missing shift key") 251 | if self.special_replacements != other.special_replacements: 252 | raise TranslationError("Cannot translate differing special_replacements") 253 | if bool(self.repeat_key) != bool(other.repeat_key): 254 | raise TranslationError("Cannot translate missing repeat key") 255 | if self.skipgram_weights != other.skipgram_weights: 256 | raise TranslationError("Cannot translate differing skipgram weights") 257 | 258 | self.replacements = create_replacements( 259 | self.space_key, self.shift_key, self.special_replacements 260 | ) 261 | conversion: dict[str, str] = {} 262 | conversion[other.space_key] = self.space_key 263 | conversion[other.shift_key] = self.shift_key 264 | conversion[other.repeat_key] = self.repeat_key 265 | self.key_counts = Counter() 266 | for ko, count in other.key_counts.items(): 267 | self.key_counts[conversion.get(ko, ko)] = count 268 | self.bigram_counts = Counter() 269 | for bo, count in other.bigram_counts.items(): 270 | self.bigram_counts[ 271 | tuple(conversion.get(ko, ko) for ko in bo)] = count 272 | self.trigram_counts = Counter() 273 | for to, count in other.trigram_counts.items(): 274 | self.trigram_counts[ 275 | tuple(conversion.get(ko, ko) for ko in to)] = count 276 | self.skipgram_counts = Counter() 277 | for so, count in other.skipgram_counts.items(): 278 | self.skipgram_counts[ 279 | tuple(conversion.get(ko, ko) for ko in so)] = count 280 | 281 | # All corpuses, including translations 282 | loaded = [] # type: list[Corpus] 283 | # Exclude translations 284 | disk_list = [] # type: list[Corpus] 285 | 286 | def get_corpus(filename: str, 287 | space_key: str = "space", 288 | shift_key: str = "shift", 289 | shift_policy: str = "once", 290 | special_replacements: dict[str, tuple[str,...]] = {}, 291 | repeat_key: str = "", 292 | precision: int = 500, 293 | skipgram_weights: tuple[float] = None): 294 | 295 | any_loaded = False 296 | for c in loaded: 297 | if c.filename == filename: 298 | any_loaded = True 299 | break 300 | if not any_loaded: 301 | _load_corpus_list(filename, precision) 302 | 303 | # find exact match 304 | for corpus_ in loaded: 305 | if ( 306 | corpus_.filename == filename and 307 | corpus_.space_key == space_key and 308 | corpus_.shift_key == shift_key and 309 | corpus_.shift_policy == shift_policy and 310 | corpus_.special_replacements == special_replacements and 311 | corpus_.repeat_key == repeat_key and 312 | corpus_.skipgram_weights == skipgram_weights 313 | ): 314 | corpus_.set_precision(precision) 315 | return corpus_ 316 | 317 | # try translation 318 | for corpus_ in loaded: 319 | try: 320 | new_ = Corpus(filename, space_key, shift_key, shift_policy, 321 | special_replacements, precision, repeat_key, None, corpus_, 322 | skipgram_weights) 323 | except TranslationError: 324 | continue # translation unsuccessful 325 | loaded.append(new_) 326 | return new_ 327 | 328 | # create entire new one 329 | new_ = Corpus(filename, space_key, shift_key, shift_policy, 330 | special_replacements, precision, repeat_key, 331 | skipgram_weights=skipgram_weights) 332 | loaded.append(new_) 333 | disk_list.append(new_) 334 | _save_corpus_list(filename) 335 | return new_ 336 | 337 | def _load_corpus_list(filename: str, precision: int = 500): 338 | try: 339 | with open(f"corpus/{filename}.json") as file: 340 | json_list: list[dict] = json.load(file) 341 | except FileNotFoundError: 342 | return 343 | result = [] 344 | for c in json_list: 345 | filename = c["filename"] 346 | space_key = c.get("space_key", "") 347 | shift_key = c.get("shift_key", "") 348 | shift_policy = c["shift_policy"] 349 | special_replacements = c.get("special_replacements", {}) 350 | repeat_key = c.get("repeat_key", "") 351 | skipgram_weights = c.get("skipgram_weights", None) 352 | result.append(Corpus( 353 | filename, space_key, shift_key, shift_policy, 354 | special_replacements, precision, repeat_key, json_dict=c, 355 | skipgram_weights=skipgram_weights 356 | )) 357 | loaded.extend(result) 358 | disk_list.extend(result) 359 | 360 | def _save_corpus_list(filename: str): 361 | with open(f"corpus/{filename}.json", "w") as file: 362 | json.dump( 363 | [c.jsonable_export() for c in disk_list 364 | if c.filename == filename], 365 | file 366 | ) 367 | 368 | if __name__ == "__main__": 369 | print("Corpus test") 370 | corp = Corpus("tr_quotes.txt") 371 | print(corp.key_counts) 372 | print(corp.trigram_counts.most_common(20)) 373 | print(len(corp.trigram_counts)) -------------------------------------------------------------------------------- /nstroke.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from typing import Sequence, Callable, NamedTuple, Tuple 3 | import operator 4 | import functools 5 | 6 | from board import Coord 7 | from fingermap import Finger 8 | 9 | # Tristroke = collections.namedtuple("Tristroke", "note fingers coords") 10 | class Tristroke(NamedTuple): 11 | note: str 12 | fingers: Tuple[Finger, ...] 13 | coords: Tuple[Coord, ...] 14 | Nstroke = Tristroke 15 | 16 | def bistroke(tristroke: Tristroke, index0: int, index1: int): 17 | # TODO: special handling for slides, altfingers etc 18 | return Nstroke(tristroke.note, 19 | (tristroke.fingers[index0], tristroke.fingers[index1]), 20 | (tristroke.coords[index0], tristroke.coords[index1])) 21 | 22 | # If category starts or ends with ".", it's purely a sum of others 23 | # Note that order is important; supercategories before their subcategories 24 | all_bistroke_categories = [ 25 | "", 26 | "alt", 27 | "roll.", 28 | "roll.in", 29 | "roll.in.scissor", 30 | "roll.out", 31 | "roll.out.scissor", 32 | "sfb", 33 | "sfr", 34 | "unknown" 35 | ] 36 | all_tristroke_categories = [ 37 | "", 38 | ".scissor", 39 | ".scissor.twice", 40 | ".scissor_and_skip", 41 | ".scissor_skip", 42 | "alt.", 43 | "alt.in", 44 | "alt.in.scissor_skip", 45 | "alt.out", 46 | "alt.out.scissor_skip", 47 | "onehand.", 48 | "onehand.in", 49 | "onehand.in.scissor", 50 | "onehand.in.scissor.twice", 51 | "onehand.out", 52 | "onehand.out.scissor", 53 | "onehand.out.scissor.twice", 54 | "redirect", 55 | "redirect.scissor", 56 | "redirect.scissor_and_skip", 57 | "redirect.scissor_skip", 58 | "roll.", 59 | "roll.in", 60 | "roll.in.scissor", 61 | "roll.out", 62 | "roll.out.scissor", 63 | "sfb.", 64 | "sfb.alt", 65 | "sfb.roll.in", 66 | "sfb.roll.in.scissor", 67 | "sfb.roll.out", 68 | "sfb.roll.out.scissor", 69 | "sfr.", 70 | "sfr.alt", 71 | "sfr.roll.in", 72 | "sfr.roll.in.scissor", 73 | "sfr.roll.out", 74 | "sfr.roll.out.scissor", 75 | "sfs.", 76 | "sfs.alt", 77 | "sfs.redirect", 78 | "sfs.redirect.scissor", 79 | "sfs.redirect.scissor.twice", 80 | "sfs.trill", 81 | "sfs.trill.scissor.twice", 82 | "sft", 83 | "unknown" 84 | ] 85 | category_display_names = { 86 | "": "total", 87 | "alt.": "alt", 88 | "onehand.": "onehand", 89 | "roll.": "roll", 90 | ".scissor": "*.scissor", 91 | ".scissor.twice": "*.scissor.twice", 92 | ".scissor_and_skip": "*.scissor_and_skip", 93 | ".scissor_skip": "*.scissor_skip", 94 | "sfb.": "sfb", 95 | "sfr.": "sfr", 96 | "sfs.": "sfs" 97 | } 98 | hand_names = { 99 | "R": "right hand total", 100 | "L": "left hand total", 101 | } 102 | finger_names = { 103 | "T": "thumb total", 104 | "I": "index total", 105 | "M": "middle total", 106 | "R": "ring total", 107 | "P": "pinky total" 108 | } 109 | 110 | def parse_category(user_input: str = ""): 111 | """Returns None if category not found.""" 112 | if not user_input: 113 | category_name = "" 114 | else: 115 | category_name = user_input.lower().strip() 116 | 117 | if "(" in category_name: 118 | category_name = category_name[:category_name.find("(")].strip() 119 | if category_name in all_tristroke_categories: 120 | return category_name 121 | elif category_name in category_display_names.values(): 122 | for cat in category_display_names: 123 | if category_display_names[cat] == category_name: 124 | return cat 125 | else: 126 | return None 127 | 128 | def category_display_name(category: str): 129 | category_name = (category_display_names[category] 130 | if category in category_display_names else category) 131 | if category.endswith(".") or not category: 132 | category_name += " (total)" 133 | return category_name 134 | 135 | def applicable_function(target_category: str) -> Callable[[str], bool]: 136 | """Given a target category, returns a function(category: str) which tells 137 | whether category is applicable to target_category. 138 | """ 139 | if target_category.endswith("."): 140 | return lambda cat: cat.startswith(target_category) 141 | elif target_category.startswith("."): 142 | return lambda cat: cat.endswith(target_category) 143 | elif not target_category: 144 | return lambda _: True 145 | else: 146 | return lambda cat: cat == target_category 147 | 148 | @functools.cache 149 | def compatible(a: Tristroke, b: Tristroke): 150 | """Assumes it is already known that a.fingers == b.fingers. 151 | 152 | Tristrokes are compatible if they are equal, or if 153 | there exists a pair of floats c1 and c2, which when added 154 | to the x-coords of the left and right hands respectively, 155 | cause the tristrokes to become equal.""" 156 | if a == b: 157 | return True 158 | for ac, bc in zip(a.coords, b.coords): 159 | if ac.y != bc.y: 160 | return False 161 | for i, j in itertools.combinations(range(3), 2): 162 | if (a.fingers[i] > 0) == (a.fingers[j] > 0): 163 | if ((a.coords[i].x - a.coords[j].x) != 164 | (b.coords[i].x - b.coords[j].x)): 165 | return False 166 | return True 167 | 168 | @functools.cache 169 | def bifinger_category(fingers: Sequence[Finger], coords: Sequence[Coord]): 170 | # Used by both bistroke_category() and tristroke_category() 171 | if Finger.UNKNOWN in fingers: 172 | return "unknown" 173 | elif (fingers[0] > 0) != (fingers[1] > 0): 174 | return "alt" 175 | 176 | delta = abs(fingers[1]) - abs(fingers[0]) 177 | if delta == 0: 178 | return "sfr" if coords[1] == coords[0] else "sfb" 179 | else: 180 | return "roll.out" if delta > 0 else "roll.in" 181 | 182 | @functools.cache 183 | def bistroke_category(nstroke: Nstroke, 184 | index0: int = 0, index1: int = 1): 185 | category = bifinger_category( 186 | (nstroke.fingers[index0], nstroke.fingers[index1]), 187 | (nstroke.coords[index0], nstroke.coords[index1])) 188 | if category.startswith("roll"): 189 | category += detect_scissor(nstroke, index0, index1) 190 | return category 191 | 192 | @functools.cache 193 | def tristroke_category(tristroke: Tristroke): 194 | if Finger.UNKNOWN in tristroke.fingers: 195 | return "unknown" 196 | first, skip, second = map( 197 | bifinger_category, 198 | itertools.combinations(tristroke.fingers, 2), 199 | itertools.combinations(tristroke.coords, 2)) 200 | if skip in ("sfb", "sfr"): 201 | if first in ("sfb", "sfr"): 202 | return "sft" 203 | if first.startswith("roll"): 204 | if skip == "sfr": 205 | return "sfs.trill" + detect_scissor_roll(tristroke) 206 | else: 207 | return "sfs.redirect" + detect_scissor_roll(tristroke) 208 | else: 209 | return "sfs.alt" + detect_scissor_skip(tristroke) 210 | elif first in ("sfb", "sfr"): 211 | return first + "." + second + detect_scissor(tristroke, 1, 2) 212 | elif second in ("sfb", "sfr"): 213 | return second + "." + first + detect_scissor(tristroke, 0, 1) 214 | elif first == "alt" and second == "alt": 215 | return "alt" + skip[4:] + detect_scissor_skip(tristroke) 216 | elif first.startswith("roll"): 217 | if second.startswith("roll"): 218 | if first == second: 219 | return "onehand" + first[4:] + detect_scissor_roll(tristroke) 220 | else: 221 | return "redirect" + detect_scissor_any(tristroke) 222 | else: 223 | return first + detect_scissor(tristroke, 0, 1) # roll 224 | else: # second.startswith("roll") 225 | return second + detect_scissor(tristroke, 1, 2) # roll 226 | 227 | @functools.cache 228 | def detect_scissor(nstroke: Nstroke, index0: int = 0, index1: int = 1): 229 | """Given that the keys (optionally specified by index) are typed with the 230 | same hand, return \".scissor\" if neighboring fingers must reach coords 231 | that are a distance of 2.0 apart or farther. Return an empty string 232 | otherwise.""" 233 | if abs(nstroke.fingers[index0] - nstroke.fingers[index1]) != 1: 234 | return "" 235 | thumbs = (Finger.LT, Finger.RT) 236 | if nstroke.fingers[index0] in thumbs or nstroke.fingers[index1] in thumbs: 237 | return "" 238 | vec = map(operator.sub, nstroke.coords[index0], nstroke.coords[index1]) 239 | dist_sq = sum((n**2 for n in vec)) 240 | return ".scissor" if dist_sq >= 4 else "" 241 | 242 | @functools.cache 243 | def detect_scissor_roll(tristroke: Tristroke): 244 | if detect_scissor(tristroke, 0, 1): 245 | if detect_scissor(tristroke, 1, 2): 246 | return ".scissor.twice" 247 | else: 248 | return ".scissor" 249 | elif detect_scissor(tristroke, 1, 2): 250 | return ".scissor" 251 | else: 252 | return "" 253 | 254 | @functools.cache 255 | def detect_scissor_skip(tristroke: Tristroke): 256 | if detect_scissor(tristroke, 0, 2): 257 | return ".scissor_skip" 258 | else: 259 | return "" 260 | 261 | @functools.cache 262 | def detect_scissor_any(tristroke: Tristroke): 263 | cat = detect_scissor_roll(tristroke) + detect_scissor_skip(tristroke) 264 | return ".scissor_and_skip" if cat == ".scissor.scissor_skip" else cat 265 | 266 | 267 | def new_bifinger_category(fingers: Sequence[Finger], coords: Sequence[Coord]): 268 | if Finger.UNKNOWN in fingers: 269 | return "unknown" 270 | if (fingers[0] > 0) != (fingers[1] > 0): 271 | return "alt" 272 | delta = abs(fingers[1]) - abs(fingers[0]) 273 | if delta == 0: 274 | return "sfr" if coords[1] == coords[0] else "sfb" 275 | else: 276 | return "out" if delta > 0 else "in" 277 | 278 | def new_detect_scissor(ts: Tristroke, i: int, j: int): 279 | HALF_SCISSOR_THRESHOLD = 0.5 280 | FULL_SCISSOR_THRESHOLD = 1.5 281 | 282 | if (ts.fingers[i] > 0) != (ts.fingers[j] > 0): 283 | return False 284 | if abs(ts.fingers[i] - ts.fingers[j]) != 1: 285 | return False 286 | dy = ts.coords[i].y - ts.coords[j].y 287 | if abs(dy) < HALF_SCISSOR_THRESHOLD: 288 | return False 289 | lower = j if dy > 0 else i 290 | if ts.fingers[lower].name[1] == "P" and abs(dy) > FULL_SCISSOR_THRESHOLD: 291 | return "fsb" 292 | if ts.fingers[lower].name[1] not in ("M", "R"): 293 | return False 294 | if abs(dy) < FULL_SCISSOR_THRESHOLD: 295 | return "hsb" 296 | return "fsb" 297 | 298 | def new_detect_lateral_stretch(ts: Tristroke, i: int, j: int): 299 | """Must be adjacent fingers and not thumb.""" 300 | LATERAL_STRETCH_THRESHOLD = 2.0 301 | 302 | if (ts.fingers[i] > 0) != (ts.fingers[j] > 0): 303 | return False 304 | if abs(ts.fingers[i] - ts.fingers[j]) != 1: 305 | return False 306 | if abs(ts.fingers[i]) == 1 or abs(ts.fingers[j]) == 1: 307 | return False 308 | if abs(ts.coords[i].x - ts.coords[j].x) < LATERAL_STRETCH_THRESHOLD: 309 | return False 310 | return True 311 | 312 | def new_detect_row_change(ts: Tristroke, i: int, j: int): 313 | """Must be same hand and not same finger.""" 314 | ROW_CHANGE_THRESHOLD = 0.5 315 | if (ts.fingers[i] > 0) != (ts.fingers[j] > 0): 316 | return False 317 | if (ts.fingers[i] == ts.fingers[j]): 318 | return False 319 | if abs(ts.coords[i].y - ts.coords[j].y) < ROW_CHANGE_THRESHOLD: 320 | return False 321 | return True 322 | 323 | def any_stretch(ts: Tristroke, *pairs: tuple[tuple[int]]): 324 | for pair in pairs: 325 | if new_detect_lateral_stretch(ts, *pair): 326 | return True 327 | if new_detect_scissor(ts, *pair) == "fsb": 328 | return True 329 | return False 330 | 331 | def akl_bistroke_tags(bs: Nstroke) -> Sequence[str]: 332 | """ 333 | Returns any of the following tags: 334 | * sfr (same finger) 335 | * sfb (same finger) 336 | * asb (any stretch) 337 | * vsb (vertical stretch) 338 | * lsb (lateral stretch) 339 | * ahb (alternate hand) 340 | * shb (same hand, other finger) 341 | * shb-in""" 342 | 343 | bt = new_bifinger_category(bs.fingers, bs.coords) 344 | match bt: 345 | case "alt": 346 | return ("ahb",) 347 | case "sfb" | "sfr": 348 | return (bt,) 349 | case "unknown": 350 | return () 351 | case _: 352 | tags = ["shb"] 353 | if bt == "in": 354 | tags.append("shb-in") 355 | stretch = False 356 | if new_detect_lateral_stretch(bs, 0, 1): 357 | tags.append("lsb") 358 | stretch = True 359 | if new_detect_scissor(bs, 0, 1) == "fsb": 360 | tags.append("vsb") 361 | stretch = True 362 | if stretch: 363 | tags.append("asb") 364 | return tags 365 | 366 | 367 | def akl_tristroke_tags(ts: Tristroke) -> Sequence[str]: 368 | """ 369 | Returns any of the following tags: 370 | * sft 371 | * has-sfb (only if not sft) 372 | 373 | * redir-best 374 | * redir-stretch 375 | * redir-sfs 376 | * redir-weak 377 | * redir-total 378 | 379 | * alt-best 380 | * alt-stretch 381 | * alt-sfs 382 | * alt-total 383 | 384 | * oneh-best 385 | * oneh-stretch 386 | * oneh-in 387 | * oneh-total 388 | 389 | * roll-best 390 | * roll-stretch 391 | * roll-in 392 | * roll-total""" 393 | 394 | if Finger.UNKNOWN in ts.fingers: 395 | return () 396 | 397 | first, skip, second = map( 398 | new_bifinger_category, 399 | itertools.combinations(ts.fingers, 2), 400 | itertools.combinations(ts.coords, 2)) 401 | 402 | sfs = False 403 | 404 | if skip in ("sfb", "sfr"): 405 | if first in ("sfb", "sfr"): 406 | return ("sft",) 407 | else: 408 | sfs = True 409 | elif first in ("sfb", "sfr") or second in ("sfb", "sfr"): 410 | return ("has-sfb",) 411 | 412 | # sfb, sft eliminated by this point. sfs noted 413 | 414 | if skip == "alt": 415 | tags = ["roll-total"] 416 | if "in" in (first, second): 417 | tags.append("roll-in") 418 | if any_stretch(ts, (0, 1), (1, 2)): 419 | tags.append("roll-stretch") 420 | else: 421 | tags.append("roll-best") 422 | return tags 423 | # rolls eliminated by this point 424 | 425 | if first == "alt": 426 | tags = ["alt-total"] 427 | if sfs: 428 | tags.append("alt-sfs") 429 | if any_stretch(ts, (0, 2)): 430 | tags.append("alt-stretch") 431 | elif not sfs: 432 | tags.append("alt-best") 433 | return tags 434 | # alt eliminated by this point 435 | if first == second: 436 | tags = ["oneh-total"] 437 | if first == "in": 438 | tags.append("oneh-in") 439 | if any_stretch(ts, (0, 1), (1, 2)): 440 | tags.append("oneh-stretch") 441 | else: 442 | tags.append("oneh-best") 443 | return tags 444 | # redir only at this point 445 | tags = ["redir-total"] 446 | best = True 447 | if all(abs(f.value) in range(3, 6) for f in ts.fingers): 448 | best = False 449 | tags.append("redir-weak") 450 | if any_stretch(ts, (0, 1), (1, 2), (0, 2)): 451 | best = False 452 | tags.append("redir-stretch") 453 | if sfs: 454 | tags.append("redir-sfs") 455 | elif best: 456 | tags.append("redir-best") 457 | return tags 458 | 459 | 460 | def experimental_describe_tristroke(ts: Tristroke): 461 | """ 462 | All tags always appear if they apply, except where noted with "Only for ...". 463 | 464 | Tags considering whole trigram: 465 | - sft 466 | - sfb (contains sfb, not sft) 467 | - sfr (contains sfr, not sft) 468 | - sfs (not sft) 469 | - fsb (contains fsb) 470 | - hsb (contains hsb) 471 | - lsb (contains lsb) 472 | - hand-change (reflecting one bigram of sfb. Only for sfb) 473 | - in, out (reflecting one bigram of sfb, sfr, tutu; or both bigrams of rolll. Only for those) 474 | - tutu 475 | - alt 476 | - rolll 477 | - redir 478 | - bad-redir (Only for redir) 479 | 480 | Tags considering bigrams: 481 | - (first, second)-(alt, in, out, sfb, sfr, fsb, hsb, lsb, rcb) 482 | - skip-(in, out) 483 | - fss (contains fss) 484 | - hss (contains hss) 485 | - rcs (contains rcs) 486 | """ 487 | 488 | tags = set() 489 | if Finger.UNKNOWN in ts.fingers: 490 | return ("unknown",) 491 | 492 | first, skip, second = map( 493 | new_bifinger_category, 494 | itertools.combinations(ts.fingers, 2), 495 | itertools.combinations(ts.coords, 2)) 496 | 497 | tags.add(f"first-{first}") 498 | tags.add(f"second-{second}") 499 | if skip in ("in", "out"): 500 | tags.add(f"skip-{skip}") 501 | 502 | if skip in ("sfb", "sfr"): 503 | if first in ("sfb", "sfr"): 504 | tags.add("sft") 505 | else: 506 | tags.add("sfs") 507 | elif first in ("sfb", "sfr"): 508 | tags.add(first) 509 | if second in ("in", "out"): 510 | tags.add(second) 511 | elif second in ("sfb", "sfr"): 512 | tags.add(second) 513 | if first in ("in", "out"): 514 | tags.add(first) 515 | 516 | if skip == "alt": 517 | if "sfb" not in (first, second) and "sfr" not in (first, second): 518 | tags.add("tutu") 519 | if "in" in (first, second): 520 | tags.add("in") 521 | else: 522 | tags.add("out") 523 | else: 524 | tags.add("hand-change") 525 | else: 526 | if first in ("in, out") and second in ("in, out"): 527 | if first == second: 528 | tags.add("rolll") 529 | tags.add(first) 530 | else: 531 | tags.add("redir") 532 | if Finger.LI not in ts.fingers and Finger.RI not in ts.fingers: 533 | tags.add("bad-redir") 534 | elif first == "alt" and second == "alt": 535 | tags.add("alt") # includes sfs 536 | # else it's sfb or sfr 537 | 538 | if (s1 := new_detect_scissor(ts, 0, 1)): 539 | tags.add(s1) 540 | tags.add(f"first-{s1}") 541 | if (s2 := new_detect_scissor(ts, 1, 2)): 542 | tags.add(s2) 543 | tags.add(f"second-{s2}") 544 | if (ss := new_detect_scissor(ts, 0, 2)): 545 | if "hsb" in ss: 546 | tags.add("hss") 547 | else: 548 | tags.add("fss") 549 | 550 | if new_detect_lateral_stretch(ts, 0, 1): 551 | tags.add("first-lsb") 552 | tags.add("lsb") 553 | if new_detect_lateral_stretch(ts, 1, 2): 554 | tags.add("second-lsb") 555 | tags.add("lsb") 556 | if new_detect_lateral_stretch(ts, 0, 2): 557 | tags.add("lss") 558 | 559 | if new_detect_row_change(ts, 0, 1): 560 | tags.add("first-rcb") 561 | tags.add("rcb") 562 | if new_detect_row_change(ts, 1, 2): 563 | tags.add("second-rcb") 564 | tags.add("rcb") 565 | if new_detect_row_change(ts, 0, 2): 566 | tags.add("rcs") 567 | 568 | if Finger.LT in ts.fingers or Finger.RT in ts.fingers: 569 | tags.add("thumb") 570 | 571 | if abs(ts.fingers[1]) == 1: 572 | tags.add("middle-thumb") 573 | 574 | return tags 575 | 576 | if __name__ == "__main__": 577 | 578 | from collections import defaultdict 579 | from statistics import mean 580 | 581 | import layout 582 | from typingdata import TypingData 583 | 584 | qwerty = layout.get_layout("qwerty") 585 | td = TypingData("tanamr") 586 | all_known = td.exact_tristrokes_for_layout(qwerty) 587 | sf = td.tristroke_speed_calculator(qwerty) 588 | ts_to_cat = {} 589 | cat_to_ts = defaultdict(list) 590 | for ts in qwerty.all_nstrokes(): 591 | cat = frozenset(experimental_describe_tristroke(ts)) 592 | ts_to_cat[ts] = cat 593 | cat_to_ts[cat].append(ts) 594 | cat_times = {cat: mean(sf(ts)[0] for ts in strokes) for cat, strokes in cat_to_ts.items()} 595 | cat_samples = {cat: [0, len(cat_to_ts[cat])] for cat in cat_times} 596 | for ts in all_known: 597 | cat_samples[frozenset(experimental_describe_tristroke(ts))][0] += 1 598 | for cat in sorted(cat_times, key=lambda c: cat_times[c]): 599 | print(f'{cat_times[cat]:.2f} ms from {cat_samples[cat][0]}/{cat_samples[cat][1]} samples: {", ".join(sorted(list(cat)))}') -------------------------------------------------------------------------------- /typingdata.py: -------------------------------------------------------------------------------- 1 | # Planned as a way to consolidate medians, bistroke/tristroke speed data, etc 2 | # Goals: 3 | # - Cache all tristroke medians/other data in one place 4 | # - Compute new ones when needed 5 | # - Update when csvdata is updated 6 | # - dedicated add/remove functions clear affected data from caches 7 | # - or mutate just the relevant data? 8 | # - nah too much work for now, just rudimentary cache clearing 9 | # - may do more fancy things later 10 | 11 | import csv 12 | import functools 13 | import itertools 14 | import operator 15 | import statistics 16 | from collections import defaultdict 17 | from typing import Callable 18 | 19 | import layout 20 | import nstroke 21 | from board import Coord 22 | from fingermap import Finger 23 | from layout import Layout 24 | from nstroke import Nstroke, Tristroke 25 | 26 | def _find_existing_cached(cache, layout_: Layout): 27 | """Finds a cached property by looking up cache[layout_.name]. Will 28 | alternatively find cache[other_layout.name] if other_layout has the same 29 | tristrokes as layout_. Returns None if none found.""" 30 | if layout_.name in cache: 31 | return cache[layout_.name] 32 | else: 33 | for other_name in cache: 34 | try: 35 | other_layout = layout.get_layout(other_name) 36 | except FileNotFoundError: 37 | # temporary layouts may exist from si command, etc 38 | continue 39 | if layout_.has_same_tristrokes(other_layout): 40 | return cache[other_name] 41 | return None 42 | 43 | class TypingData: 44 | def __init__(self, csv_filename: str) -> None: 45 | """Raises FileNotFoundError if data/csv_filename.csv not found.""" 46 | self.csv_filename = csv_filename 47 | 48 | # csv_data[tristroke] -> ([speeds_01], [speeds_12]) 49 | self.csv_data: dict[Tristroke, tuple[list, list]] 50 | self.csv_data = defaultdict(lambda: ([], [])) 51 | 52 | try: 53 | self.load_csv() 54 | except FileNotFoundError: 55 | pass # just let the data be written upon save 56 | 57 | # medians[tristroke] -> (speed_01, speed_12, speed_02) 58 | self.tri_medians: dict[Tristroke, tuple[float, float, float]] 59 | self.tri_medians = {} 60 | 61 | # exact_tristrokes[layout_name] -> set of tristrokes 62 | self.exact_tristrokes: dict[str, set[Tristroke]] = {} 63 | 64 | # bicatdata[layout_name][category] -> (speed, num_samples) 65 | self.bicatdata: dict[str, dict[str, tuple[float, int]]] = {} 66 | 67 | # tricatdata[layout_name][category] -> (speed, num_samples) 68 | self.tricatdata: dict[str, dict[str, tuple[float, int]]] = {} 69 | 70 | # tribreakdowns[layout_name][category][bistroke] 71 | # -> (speed, num_samples) 72 | self.tribreakdowns: dict[str, dict[str, dict[str, tuple(float, int)]]] 73 | self.tribreakdowns = {} 74 | 75 | # speed_funcs[layout_name] -> speed_func(tristroke) -> (speed, exact) 76 | self.speed_funcs: dict[str, Callable[[Tristroke], tuple[float, bool]]] 77 | self.speed_funcs = {} 78 | 79 | def refresh(self): 80 | """Clears caches; they will be repopulated when needed""" 81 | for cache in ( 82 | self.tri_medians, self.exact_tristrokes, self.bicatdata, 83 | self.tricatdata, self.tribreakdowns, self.speed_funcs): 84 | cache.clear() 85 | 86 | def load_csv(self): 87 | with open(f"data/{self.csv_filename}.csv", "r", 88 | newline="") as csvfile: 89 | reader = csv.DictReader(csvfile, restkey="speeds") 90 | for row in reader: 91 | if "speeds" not in row: 92 | continue 93 | fingers = tuple( 94 | Finger[row[f"finger{n}"]] for n in range(3)) 95 | coords = tuple( 96 | (Coord(float(row[f"x{n}"]), float(row[f"y{n}"])) 97 | for n in range(3))) 98 | tristroke = Tristroke(row["note"], fingers, coords) 99 | # there may be multiple rows for the same tristroke 100 | for i, time in enumerate(row["speeds"]): 101 | self.csv_data[tristroke][i%2].append(float(time)) 102 | 103 | def save_csv(self): 104 | header = [ 105 | "note", "finger0", "finger1", "finger2", 106 | "x0", "y0", "x1", "y1", "x2", "y2" 107 | ] 108 | with open(f"data/{self.csv_filename}.csv", "w", newline="") as csvfile: 109 | w = csv.writer(csvfile) 110 | w.writerow(header) 111 | for tristroke in self.csv_data: 112 | if (not self.csv_data[tristroke] 113 | or not self.csv_data[tristroke][0]): 114 | continue 115 | row: list = self._start_csv_row(tristroke) 116 | row.extend(itertools.chain.from_iterable( 117 | zip(self.csv_data[tristroke][0], 118 | self.csv_data[tristroke][1]))) 119 | w.writerow(row) 120 | 121 | def _start_csv_row(self, tristroke: Tristroke): 122 | """Order of returned data: note, fingers, coords""" 123 | 124 | result = [tristroke.note] 125 | result.extend(f.name for f in tristroke.fingers) 126 | result.extend(itertools.chain.from_iterable(tristroke.coords)) 127 | return result 128 | 129 | def calc_medians_for_tristroke(self, tristroke: Tristroke): 130 | """Returns (speed_01, speed_12, speed_02) if relevant csv data exists, 131 | otherwise returns None. 132 | """ 133 | if tristroke in self.tri_medians: 134 | return self.tri_medians[tristroke] 135 | 136 | speeds_01: list[float] = [] 137 | speeds_12: list[float] = [] 138 | if tristroke in self.csv_data: 139 | speeds_01.extend(self.csv_data[tristroke][0]) 140 | speeds_12.extend(self.csv_data[tristroke][1]) 141 | else: 142 | for csv_tristroke in self.csv_data: 143 | if csv_tristroke.fingers == tristroke.fingers: 144 | if nstroke.compatible(tristroke, csv_tristroke): 145 | speeds_01.extend(self.csv_data[csv_tristroke][0]) 146 | speeds_12.extend(self.csv_data[csv_tristroke][1]) 147 | speeds_02: list[float] = map(operator.add, speeds_01, speeds_12) 148 | try: 149 | data = ( 150 | statistics.median(speeds_01), 151 | statistics.median(speeds_12), 152 | statistics.median(speeds_02) 153 | ) 154 | self.tri_medians[tristroke] = data 155 | return data 156 | except statistics.StatisticsError: 157 | return None 158 | 159 | def exact_tristrokes_for_layout(self, layout_: Layout): 160 | """Uses cached set if it exists. Otherwise, builds the set from 161 | typing data and ensures the corresponding medians are precalculated 162 | in self.medians. 163 | """ 164 | existing = _find_existing_cached(self.exact_tristrokes, layout_) 165 | if existing is not None: 166 | return existing 167 | 168 | result: set[Tristroke] = set() 169 | for csv_tristroke in self.csv_data: 170 | for layout_tristroke in layout_.nstrokes_with_fingers( 171 | csv_tristroke.fingers): 172 | if nstroke.compatible(csv_tristroke, layout_tristroke): 173 | self.calc_medians_for_tristroke(layout_tristroke) # cache 174 | result.add(layout_tristroke) 175 | 176 | self.exact_tristrokes[layout_.name] = result 177 | return result 178 | 179 | # May cache this eventually but it's probably not worth 180 | def amalgamated_bistroke_medians(self, layout_: Layout): 181 | """Note that the returned dict is a defaultdict.""" 182 | bi_medians: dict[Nstroke, float] = defaultdict(list) 183 | for tristroke in self.exact_tristrokes_for_layout(layout_): 184 | bi0 = ( 185 | Nstroke( 186 | tristroke.note, tristroke.fingers[:2], 187 | tristroke.coords[:2] 188 | ), 189 | self.tri_medians[tristroke][0] 190 | ) 191 | bi1 = ( 192 | Nstroke( 193 | tristroke.note, tristroke.fingers[1:], 194 | tristroke.coords[1:] 195 | ), 196 | self.tri_medians[tristroke][1] 197 | ) 198 | for bi_tuple in (bi0, bi1): 199 | bi_medians[bi_tuple[0]].append(bi_tuple[1]) 200 | for bistroke in bi_medians: 201 | bi_medians[bistroke] = statistics.fmean(bi_medians[bistroke]) 202 | 203 | return bi_medians 204 | 205 | def bistroke_category_data(self, layout_: Layout): 206 | """Returns a 207 | dict[category: string, (speed: float, num_samples: int)] 208 | where num_samples is the number of unique bistroke median speeds that 209 | have been averaged to obtain the speed stat. num_samples is positive 210 | if speed is obtained from known data, and negative if speed is 211 | estimated from related data, which occurs if no known data is 212 | directly applicable. 213 | """ 214 | existing = _find_existing_cached(self.bicatdata, layout_) 215 | if existing is not None: 216 | return existing 217 | 218 | known_medians: dict[str, list[float]] 219 | known_medians = defaultdict(list) # cat -> [speeds] 220 | total = [] # list[median] 221 | for tristroke in self.exact_tristrokes_for_layout(layout_): 222 | for indices in ((0, 1), (1, 2)): 223 | data = self.tri_medians[tristroke][indices[0]] 224 | total.append(data) 225 | category = nstroke.bistroke_category(tristroke, *indices) 226 | known_medians[category].append(data) 227 | 228 | # now estimate missing data 229 | all_medians: dict[str, list[float]] = {} # cat -> [speeds] 230 | is_estimate: dict[str, bool] = {} # cat -> bool 231 | 232 | all_categories = nstroke.all_bistroke_categories.copy() 233 | 234 | for category in all_categories: # sorted general -> specific 235 | if category in known_medians: 236 | all_medians[category] = known_medians[category] 237 | is_estimate[category] = False 238 | else: 239 | is_estimate[category] = True 240 | if not category: 241 | is_estimate[category] = total 242 | all_medians[category] = [] 243 | for subcategory in known_medians: 244 | if subcategory.startswith(category): 245 | all_medians[category].extend( 246 | known_medians[subcategory]) 247 | # There may be no subcategories with known data either. 248 | # Hence the next stages 249 | 250 | # Assuming sfs is the limiting factor in a trigram, this may help fill 251 | # sfb speeds 252 | if not all_medians["sfb"]: 253 | for tristroke in self.exact_tristrokes_for_layout(layout_): 254 | if nstroke.tristroke_category(tristroke).startswith("sfs"): 255 | all_medians["sfb"].append(self.tri_medians[tristroke][2]) 256 | 257 | all_categories.reverse() # most specific first 258 | for category in all_categories: 259 | if not all_medians[category]: # data needed 260 | for supercategory in all_categories: # most specific first 261 | if (category.startswith(supercategory) and 262 | bool(all_medians[supercategory])): 263 | all_medians[category] = all_medians[supercategory] 264 | break 265 | # If there is still any category with no data at this point, that 266 | # means there was literally no data in ANY category. that's just 267 | # a bruh moment 268 | 269 | result = {} 270 | for category in all_medians: 271 | try: 272 | mean = statistics.fmean(all_medians[category]) 273 | except statistics.StatisticsError: 274 | mean = 0.0 # bruh 275 | result[category] = ( 276 | mean, 277 | -len(all_medians[category]) if is_estimate[category] 278 | else len(all_medians[category]) 279 | ) 280 | self.bicatdata[layout_.name] = result 281 | return result 282 | 283 | def tristroke_category_data(self, layout_: Layout): 284 | """Returns a 285 | dict[category: string, (speed: float, num_samples: int)] 286 | where num_samples is the number of unique bistroke/tristroke median 287 | speeds that have been combined to obtain the speed stat. num_samples 288 | is positive if speed is obtained from known data, and negative if 289 | speed is estimated from related data, which occurs if no known data 290 | is directly applicable.""" 291 | 292 | existing = _find_existing_cached(self.tricatdata, layout_) 293 | if existing is not None: 294 | return existing 295 | 296 | known_medians: dict[str, list[float]] 297 | known_medians = defaultdict(list) # cat -> [speeds] 298 | total = [] # list[speeds] 299 | for tristroke in self.exact_tristrokes_for_layout(layout_): 300 | data = self.tri_medians[tristroke][2] 301 | total.append(data) 302 | category = nstroke.tristroke_category(tristroke) 303 | known_medians[category].append(data) 304 | 305 | # now estimate missing data 306 | all_medians: dict[str, list[float]] = {} # cat -> [speeds] 307 | is_estimate: dict[str, bool] = {} # cat -> bool 308 | 309 | all_categories = nstroke.all_tristroke_categories.copy() 310 | 311 | # Initial transfer 312 | for category in all_categories: # sorted general -> specific 313 | is_estimate[category] = False 314 | if category in known_medians: 315 | all_medians[category] = known_medians[category] 316 | else: # fill in from subcategories 317 | if not category: 318 | all_medians[category] = total 319 | continue 320 | all_medians[category] = [] 321 | if category.startswith("."): 322 | for instance in known_medians: 323 | if instance.endswith(category): 324 | all_medians[category].extend(known_medians[instance]) 325 | else: 326 | if not category.endswith("."): 327 | is_estimate[category] = True 328 | for subcategory in known_medians: 329 | if subcategory.startswith(category): 330 | all_medians[category].extend(known_medians[subcategory]) 331 | # There may be no subcategories with known data either. 332 | # Hence the next stages 333 | 334 | # Fill from other categories 335 | if not all_medians["sfb."]: 336 | for tristroke in self.tri_medians: 337 | if nstroke.tristroke_category(tristroke).startswith("sfr"): 338 | all_medians["sfb."].append(self.tri_medians[tristroke][2]) 339 | 340 | all_categories.reverse() # most specific first 341 | 342 | # fill in from supercategory 343 | for category in all_categories: 344 | if not all_medians[category] and not category.startswith("."): 345 | for supercategory in all_categories: 346 | if (category.startswith(supercategory) and 347 | bool(all_medians[supercategory]) and 348 | category != supercategory): 349 | all_medians[category] = all_medians[supercategory] 350 | break 351 | # fill in scissors from subcategories 352 | for category in all_categories: 353 | if not all_medians[category] and category.startswith("."): 354 | is_estimate[category] = True # the subcategory is an estimate 355 | for instance in all_categories: 356 | if (instance.endswith(category) and instance != category): 357 | all_medians[category].extend(all_medians[instance]) 358 | # If there is still any category with no data at this point, that means 359 | # there was literally no data in ANY category. that's just a bruh moment 360 | 361 | result = {} 362 | for category in all_medians: 363 | try: 364 | mean = statistics.fmean(all_medians[category]) 365 | except statistics.StatisticsError: 366 | mean = 0.0 # bruh 367 | result[category] = ( 368 | mean, 369 | -len(all_medians[category]) if is_estimate[category] 370 | else len(all_medians[category]) 371 | ) 372 | 373 | self.tricatdata[layout_.name] = result 374 | return result 375 | 376 | def tristroke_breakdowns(self, layout_: Layout): 377 | """Returns a result such that result[category][bistroke] gives 378 | (speed, num_samples) for bistrokes obtained by breaking down tristrokes 379 | in that category. 380 | 381 | This data is useful to estimate the speed of an unknown tristroke by 382 | piecing together its component bistrokes, since those may be known. 383 | """ 384 | existing = _find_existing_cached(self.tribreakdowns, layout_) 385 | if existing is not None: 386 | return existing 387 | 388 | samples: dict[str, dict[Nstroke, list[float]]] 389 | samples = {cat: defaultdict(list) for cat in nstroke.all_tristroke_categories} 390 | for ts in self.exact_tristrokes_for_layout(layout_): # ts is tristroke 391 | cat = nstroke.tristroke_category(ts) 392 | bistrokes = ( 393 | Nstroke(ts.note, ts.fingers[:2], ts.coords[:2]), 394 | Nstroke(ts.note, ts.fingers[1:], ts.coords[1:]) 395 | ) 396 | for i, b in enumerate(bistrokes): 397 | speed = self.tri_medians[ts][i] 398 | samples[cat][b].append(speed) 399 | result: dict[str, dict[str, tuple(float, int)]] 400 | result = {cat: dict() for cat in samples} 401 | for cat in samples: 402 | for bs in samples[cat]: # bs is bistroke 403 | mean = statistics.fmean(samples[cat][bs]) 404 | count = len(samples[cat][bs]) 405 | result[cat][bs] = (mean, count) 406 | return result 407 | 408 | def tristroke_speed_calculator(self, layout_: Layout, 409 | ms_floor = 60, stretch_point = 100, 410 | stretch_factor = 2): 411 | """Returns a function speed(ts) which determines the speed of the 412 | tristroke ts. Uses data from medians if it exists; if not, uses 413 | tribreakdowns as a fallback, and if that still fails then 414 | uses the average speed of the category from tricatdata. 415 | Caching is used for additional speed. 416 | 417 | ms_floor provides a speed limiter to hopefully prevent the analyzer 418 | from focusing so much on onehands. 419 | 420 | stretch_point and stretch_factor specify a millisecond threshold 421 | at which to start exaggerating further increases in ms. This 422 | penalizes bad trigrams more heavily. 423 | 424 | The function returns (duration in ms, is_exact)""" 425 | 426 | existing = _find_existing_cached(self.speed_funcs, layout_) 427 | if existing is not None: 428 | return existing 429 | 430 | tribreakdowns = self.tristroke_breakdowns(layout_) 431 | tricatdata = self.tristroke_category_data(layout_) 432 | 433 | @functools.cache 434 | def speed_func(ts: Tristroke): 435 | cat = nstroke.tristroke_category(ts) 436 | try: 437 | speed = self.tri_medians[ts][2] 438 | is_exact = True 439 | except KeyError: # Use breakdown data instead 440 | is_exact = False 441 | try: 442 | speed = 0.0 443 | bs1 = Nstroke(ts.note, ts.fingers[:2], ts.coords[:2]) 444 | speed += tribreakdowns[cat][bs1][0] 445 | bs2 = Nstroke(ts.note, ts.fingers[1:], ts.coords[1:]) 446 | speed += tribreakdowns[cat][bs2][0] 447 | except KeyError: # Use general category speed 448 | speed = tricatdata[cat][0] 449 | # return (speed, is_exact) 450 | return ( 451 | max(speed, ms_floor, 452 | (speed - stretch_point) * stretch_factor + stretch_point 453 | ), 454 | is_exact 455 | ) 456 | 457 | return speed_func 458 | -------------------------------------------------------------------------------- /analysis.py: -------------------------------------------------------------------------------- 1 | # Contains analysis functionality accessed by commands. 2 | # See commands.py for those commands, as well as the more REPL-oriented code. 3 | 4 | from collections import Counter, defaultdict 5 | import itertools 6 | import math 7 | import multiprocessing 8 | import os 9 | import random 10 | import statistics 11 | import typing 12 | from typing import Callable, Collection, Iterable 13 | 14 | from fingermap import Finger 15 | import layout 16 | import nstroke 17 | import remap 18 | from session import Session 19 | from typingdata import TypingData 20 | 21 | def data_for_tristroke_category(category: str, layout_: layout.Layout, 22 | typingdata_: TypingData): 23 | """Returns (speed: float, num_samples: int, 24 | with_fingers: dict[Finger, (speed: float, num_samples: int)], 25 | without_fingers: dict[Finger, (speed: float, num_samples: int)]) 26 | using the *known* medians in the given tristroke category. 27 | 28 | Note that medians is the output of get_medians_for_layout().""" 29 | 30 | all_samples = [] 31 | speeds_with_fingers = {finger: [] for finger in list(Finger)} 32 | speeds_without_fingers = {finger: [] for finger in list(Finger)} 33 | 34 | applicable = nstroke.applicable_function(category) 35 | 36 | for tristroke in typingdata_.exact_tristrokes_for_layout(layout_): 37 | cat = nstroke.tristroke_category(tristroke) 38 | if not applicable(cat): 39 | continue 40 | speed = typingdata_.tri_medians[tristroke][2] 41 | used_fingers = {finger for finger in tristroke.fingers} 42 | all_samples.append(speed) 43 | for finger in list(Finger): 44 | if finger in used_fingers: 45 | speeds_with_fingers[finger].append(speed) 46 | else: 47 | speeds_without_fingers[finger].append(speed) 48 | 49 | num_samples = len(all_samples) 50 | speed = statistics.fmean(all_samples) if num_samples else 0.0 51 | with_fingers = {} 52 | without_fingers = {} 53 | for speeds_l, output_l in zip( 54 | (speeds_with_fingers, speeds_without_fingers), 55 | (with_fingers, without_fingers)): 56 | for finger in list(Finger): 57 | n = len(speeds_l[finger]) 58 | speed = statistics.fmean(speeds_l[finger]) if n else 0.0 59 | output_l[finger] = (speed, n) 60 | 61 | return (speed, num_samples, with_fingers, without_fingers) 62 | 63 | def trigrams_in_list( 64 | trigrams: Iterable, typingdata_: TypingData, layout_: layout.Layout, 65 | corpus_settings: dict): 66 | """Returns dict[trigram_tuple, (freq, avg_ms, ms, is_exact)], 67 | except for the key \"\" which gives (freq, avg_ms, ms, exact_percent) 68 | for the entire given list.""" 69 | raw = {"": [0, 0, 0]} # total_freq, total_time, known_freq for list 70 | speed_calc = typingdata_.tristroke_speed_calculator(layout_) 71 | corpus_ = layout_.get_corpus(corpus_settings) 72 | for trigram in trigrams: 73 | if (tristroke := layout_.to_nstroke(trigram)) is None: 74 | continue 75 | try: 76 | count = corpus_.trigram_counts[trigram] 77 | except KeyError: 78 | continue 79 | speed, exact = speed_calc(tristroke) 80 | raw[""][0] += count 81 | raw[""][1] += speed*count 82 | if exact: 83 | raw[""][2] += count 84 | raw[trigram] = [count, speed*count, exact] 85 | raw[""][2] = raw[""][2]/raw[""][0] if raw[""][0] else 0 86 | result = dict() 87 | total_count = layout_.total_trigram_count(corpus_settings) 88 | for key in raw: 89 | freq = raw[key][0]/total_count if total_count else 0 90 | avg_ms = raw[key][1]/raw[key][0] if raw[key][0] else 0 91 | ms = raw[key][1]/total_count if total_count else 0 92 | result[key] = (freq, avg_ms, ms, raw[key][2]) 93 | return result 94 | 95 | def trigrams_with_specifications_raw( 96 | typingdata_: TypingData, corpus_settings: dict, 97 | layout_: layout.Layout, category: str, 98 | with_fingers: set[Finger] = set(Finger), 99 | without_fingers: set[Finger] = set(), 100 | with_keys: set[str] = set(), 101 | without_keys: set[str] = set()): 102 | """Returns total_layout_count and a 103 | dict[trigram_tuple, (count, total_time, is_exact)]. 104 | In the dict, the \"\" key gives the total 105 | (count, total_time, exact_count) for the entire given category. 106 | """ 107 | if not with_fingers: 108 | with_fingers.update(Finger) 109 | with_fingers.difference_update(without_fingers) 110 | if not with_keys: 111 | with_keys.update(layout_.positions) 112 | with_keys.difference_update(without_keys) 113 | applicable = nstroke.pplicable_function(category) 114 | result = {"": [0, 0, 0]} # total_count, total_time, known_count for category 115 | total_count = 0 # for all trigrams 116 | speed_calc = typingdata_.tristroke_speed_calculator(layout_) 117 | for trigram, count in layout_.get_corpus( 118 | corpus_settings).trigram_counts.items(): 119 | if (tristroke := layout_.to_nstroke(trigram)) is None: 120 | continue 121 | total_count += count 122 | if (with_keys.isdisjoint(trigram) 123 | or not without_keys.isdisjoint(trigram)): 124 | continue 125 | if (with_fingers.isdisjoint(tristroke.fingers) 126 | or not without_fingers.isdisjoint(tristroke.fingers)): 127 | continue 128 | cat = nstroke.tristroke_category(tristroke) 129 | if not applicable(cat): 130 | continue 131 | speed, exact = speed_calc(tristroke) 132 | result[""][0] += count 133 | result[""][1] += speed*count 134 | if exact: 135 | result[""][2] += count 136 | result[tuple(trigram)] = [count, speed*count, exact] 137 | return total_count, result 138 | 139 | def trigrams_with_specifications( 140 | typingdata_: TypingData, corpus_settings: dict, 141 | layout_: layout.Layout, category: str, 142 | with_fingers: set[Finger] = set(Finger), 143 | without_fingers: set[Finger] = set(), 144 | with_keys: set[str] = set(), 145 | without_keys: set[str] = set()): 146 | """Returns dict[trigram_tuple, (freq, avg_ms, ms, is_exact)], 147 | except for the key \"\" which gives (freq, avg_ms, ms, exact_percent) 148 | for the entire given category.""" 149 | layout_count, raw = trigrams_with_specifications_raw( 150 | typingdata_, corpus_settings, layout_, category, 151 | with_fingers, without_fingers, with_keys, without_keys) 152 | raw[""][2] = raw[""][2]/raw[""][0] if raw[""][0] else 0 153 | result = dict() 154 | for key in raw: 155 | freq = raw[key][0]/layout_count if layout_count else 0 156 | avg_ms = raw[key][1]/raw[key][0] if raw[key][0] else 0 157 | ms = raw[key][1]/layout_count if layout_count else 0 158 | result[key] = (freq, avg_ms, ms, raw[key][2]) 159 | return result 160 | 161 | def tristroke_breakdowns(medians: dict): 162 | """Returns a result such that result[category][bistroke] gives 163 | (speed, num_samples) for bistrokes obtained by breaking down tristrokes 164 | in that category. 165 | 166 | This data is useful to estimate the speed of an unknown tristroke by 167 | piecing together its component bistrokes, since those may be known. 168 | """ 169 | samples = {cat: dict() for cat in nstroke.all_tristroke_categories} 170 | for ts in medians: # ts is tristroke 171 | cat = nstroke.tristroke_category(ts) 172 | bistrokes = ( 173 | nstroke.Nstroke(ts.note, ts.fingers[:2], ts.coords[:2]), 174 | nstroke.Nstroke(ts.note, ts.fingers[1:], ts.coords[1:]) 175 | ) 176 | for i, b in enumerate(bistrokes): 177 | speed = medians[ts][i] 178 | try: 179 | samples[cat][b].append(speed) 180 | except KeyError: 181 | samples[cat][b] = [speed] 182 | result = {cat: dict() for cat in samples} 183 | for cat in samples: 184 | for bs in samples[cat]: # bs is bistroke 185 | mean = statistics.fmean(samples[cat][bs]) 186 | count = len(samples[cat][bs]) 187 | result[cat][bs] = (mean, count) 188 | return result 189 | 190 | def layout_brief_analysis(layout_: layout.Layout, corpus_settings: dict, 191 | use_thumbs: bool = False): 192 | """ 193 | Returns dict[stat_name, percentage] 194 | 195 | BAD BIGRAMS 196 | sfb (same finger) 197 | sfs 198 | vsb (vertical stretch) 199 | vss 200 | lsb (lateral stretch) 201 | lss 202 | asb (any stretch) 203 | ass 204 | 205 | GOOD BIGRAMS 206 | 2roll-in 207 | 2roll-out 208 | 2roll-total 209 | in-out-ratio 210 | 2alt 211 | 212 | ahs 213 | shs-best (shs-total - sfs - fss) 214 | shs-total 215 | 216 | TRIGRAMS 217 | redir-best 218 | redir-stretch 219 | redir-weak 220 | redir-sfs 221 | redir-total 222 | 223 | oneh-best 224 | oneh-stretch 225 | oneh-total 226 | 227 | alt-best (calculated from shs-best, redir-best, onehand-best) 228 | alt-sfs (calculated from sfs, sft, redirects-sfs) 229 | 230 | tutu-approx (2*2roll-total) 231 | 232 | sft 233 | """ 234 | 235 | # any 2roll-total bigram can be part of 236 | # redir-total 237 | # oneh-total 238 | # 3sfb-samehand (approximate as 3sfb/2 = 2sfb?) 239 | # tutu 240 | 241 | # normally with a lone fsb, your trigram-incl-fsb category (call it 3fsb) 242 | # increases by 2. (2 3fsb per 1 2fsb). But when fsb chain into a fst, 243 | # your 3fsb increases by 3 (fst in the middle), so 3 3fsb per 2 2fsb. 244 | # so the ratio goes off. but you can fix it by adding an extra fst, 245 | # so 2*fsb = 3fsb + fst. however in most cases fst is negligible 246 | # 247 | # Additionally, when you have a longer chain like fsq, it goes to 248 | # 4 3fsb per 3 2fsb. so when you add back the 2 3fsb, it should also 249 | # restore things to the right numbers. 250 | # 251 | # And if 2 2fsb right next to each other NOT overlapping, you 252 | # get 4 3fsb per 2 2fsb which is correct. so thats good 253 | # 254 | # But if you have a 2fsb at the start or end of a line, then 255 | # you only get 1 3fsb for that 2fsb, and there's no good way 256 | # to correct that. So that has to be accepted as inaccuracy. 257 | # Overall 3fsb should therefore turn out to be lower than the fst 258 | # correction formula predicts. 259 | # 260 | # checking this with trialyzer sfb counts: 261 | # trigram(sfb+sfr) = 4.28%, bigram(sfb+sfr) = 2.21%, 262 | # sft = 0.05%. 263 | # Formula predicts 264 | # 2*sfb = 3sfb + sft 265 | # 4.42% > 4.33% 266 | # 3sfb is indeed lower than the formula predicts. 267 | # Checking again with scissors: 268 | # trigram with scissor bigram = 1.85%, bigram scissors = 0.96%, 269 | # scissor_twice = 0.03% 270 | # Formula predicts 271 | # 2*sbigram = 3scissor + scissor_twice 272 | # 1.92% > 1.85 + 0.03% 273 | # 3scissor is indeed lower than the formula predicts. 274 | # Could correct further by using the average length of a line in the corpus 275 | # but ehhhhhh 276 | # 277 | # ohhhh great what about skipgrams. they dont have the same problem 278 | # because there is no simple bigram thing we're trying to extrapolate from 279 | 280 | # the trigram-incl-fsb category (i think =2*fsb) is composed of 281 | # redir-scissor (includes redir-sfs-scissor) 282 | # oneh-scissor 283 | # double-scissor versions of the above 284 | # tutu-scissor -> this is 3fsb - redir-scissor - oneh-scissor. 285 | # -> trigram-incl-fsb is 2*fsb + *-double-scissor 286 | 287 | # ahs trigrams break down into 288 | # 3sfb-handswitch (approximate as 3sfb/2 = 2sfb?) 289 | # tutu-scissor 290 | # tutu-best 291 | 292 | 293 | corpus_ = layout_.get_corpus(corpus_settings) 294 | pass # TODO 295 | 296 | def layout_stats_analysis(layout_: layout.Layout, corpus_settings: dict, 297 | use_thumbs: bool = False): 298 | """ 299 | Returns a tuple containing, in this order: 300 | * three Counters: one each for bigrams, skipgrams, and trigrams. Each is 301 | of the form dict[stat_name, percentage] 302 | * two dicts: one each for bigrams and skipgrams, listing the top three 303 | bigrams in each category. Each is of the form dict[str, list[(str, str)]] 304 | 305 | BIGRAMS (skipgrams are the same, with same names. No parens) 306 | * sfb (same finger) 307 | * asb (any stretch) 308 | * vsb (vertical stretch) 309 | * lsb (lateral stretch) 310 | * ahb (alternate hand) 311 | * shb (same hand, other finger) 312 | * inratio 313 | 314 | TRIGRAMS 315 | * sft 316 | * inratio-trigram 317 | 318 | * redir-best 319 | * redir-stretch 320 | * redir-sfs 321 | * redir-weak 322 | * redir-total 323 | 324 | * alt-best 325 | * alt-stretch 326 | * alt-sfs 327 | * alt-total 328 | 329 | * oneh-best 330 | * oneh-stretch 331 | * oneh-total 332 | * inratio-oneh 333 | 334 | * roll-best 335 | * roll-stretch 336 | * roll-total 337 | * inratio-roll 338 | 339 | """ 340 | corpus_ = layout_.get_corpus(corpus_settings) 341 | bstats = Counter() 342 | sstats = Counter() 343 | tstats = Counter() 344 | 345 | btop = defaultdict(list) 346 | stop = defaultdict(list) 347 | 348 | for dest, src, top in ( 349 | (bstats, corpus_.bigram_counts, btop), 350 | (sstats, corpus_.skip1_counts, stop) 351 | ): 352 | bcount = 0 353 | for bg, count in src.items(): 354 | if (bs := layout_.to_nstroke(bg)) is None: 355 | continue 356 | if (not use_thumbs) and any( 357 | f in (Finger.RT, Finger.LT) for f in bs.fingers): 358 | continue 359 | tags = nstroke.akl_bistroke_tags(bs) 360 | if not tags: 361 | continue # unknown bigram 362 | bcount += count 363 | for tag in tags: 364 | dest[tag] += count 365 | if len(top[tag]) < 3: 366 | top[tag].append(bg) 367 | for label in dest: 368 | dest[label] /= bcount 369 | dest["inratio"] = dest["shb-in"]/(dest["shb"] - dest["shb-in"]) 370 | 371 | tcount = 0 372 | for tg in corpus_.top_trigrams: 373 | if (ts := layout_.to_nstroke(tg)) is None: 374 | continue 375 | if (not use_thumbs) and any( 376 | f in (Finger.RT, Finger.LT) for f in ts.fingers): 377 | continue 378 | tags = nstroke.akl_tristroke_tags(ts) 379 | if not tags: 380 | continue # unknown trigram 381 | count = corpus_.trigram_counts[tg] 382 | tcount += count 383 | for tag in tags: 384 | tstats[tag] += count 385 | for label in tstats: 386 | tstats[label] /= tcount 387 | roll_out = tstats["roll-total"]-tstats["roll-in"] 388 | oneh_out = tstats["oneh-total"]-tstats["oneh-in"] 389 | tstats["inratio-roll"] = tstats["roll-in"]/roll_out 390 | tstats["inratio-oneh"] = tstats["oneh-in"]/oneh_out 391 | tstats["inratio-trigram"] = (tstats["oneh-in"] + 392 | tstats["roll-in"])/(roll_out + oneh_out) 393 | 394 | return (bstats, sstats, tstats, btop, stop) 395 | 396 | def layout_bistroke_analysis(layout_: layout.Layout, typingdata_: TypingData, 397 | corpus_settings: dict): 398 | """Returns dict[category, (freq_prop, known_prop, speed, contribution)] 399 | 400 | bicatdata is the output of bistroke_category_data(). That is, 401 | dict[category: string, (speed: float, num_samples: int)]""" 402 | 403 | bigram_counts = layout_.get_corpus(corpus_settings).bigram_counts 404 | # {category: [total_time, exact_count, total_count]} 405 | by_category = { 406 | category: [0.0,0,0] for category in nstroke.all_bistroke_categories} 407 | bi_medians = typingdata_.amalgamated_bistroke_medians(layout_) 408 | bicatdata = typingdata_.bistroke_category_data(layout_) 409 | for bigram in bigram_counts: 410 | if (bistroke := layout_.to_nstroke(bigram)) is None: 411 | continue 412 | cat = nstroke.bistroke_category(bistroke) 413 | count = bigram_counts[bigram] 414 | if bistroke in bi_medians: 415 | speed = bi_medians[bistroke] 416 | by_category[cat][1] += count 417 | else: 418 | speed = bicatdata[cat][0] 419 | by_category[cat][0] += speed * count 420 | by_category[cat][2] += count 421 | 422 | # fill in sum categories 423 | for cat in nstroke.all_bistroke_categories: 424 | if not by_category[cat][2]: 425 | applicable = nstroke.applicable_function(cat) 426 | for othercat in nstroke.all_bistroke_categories: 427 | if by_category[othercat][2] and applicable(othercat): 428 | for i in range(3): 429 | by_category[cat][i] += by_category[othercat][i] 430 | 431 | total_count = by_category[""][2] 432 | if not total_count: 433 | total_count = 1 434 | stats = {} 435 | for cat in nstroke.all_bistroke_categories: 436 | cat_count = by_category[cat][2] 437 | if not cat_count: 438 | cat_count = 1 439 | freq_prop = by_category[cat][2] / total_count 440 | known_prop = by_category[cat][1] / cat_count 441 | cat_speed = by_category[cat][0] / cat_count 442 | contribution = by_category[cat][0] / total_count 443 | stats[cat] = (freq_prop, known_prop, cat_speed, contribution) 444 | 445 | return stats 446 | 447 | def layout_tristroke_analysis(layout_: layout.Layout, typingdata_: TypingData, 448 | corpus_settings: dict): 449 | """Returns dict[category, (freq_prop, known_prop, speed, contribution)] 450 | 451 | tricatdata is the output of tristroke_category_data(). That is, 452 | dict[category: string, (speed: float, num_samples: int)] 453 | 454 | medians is the output of get_medians_for_layout(). That is, 455 | dict[Tristroke, (float, float, float)]""" 456 | # {category: [total_time, exact_count, total_count]} 457 | by_category = { 458 | category: [0,0,0] for category in nstroke.all_tristroke_categories} 459 | speed_func = typingdata_.tristroke_speed_calculator(layout_) 460 | corpus_ = layout_.get_corpus(corpus_settings) 461 | for trigram in corpus_.top_trigrams: 462 | if (ts := layout_.to_nstroke(trigram)) is None: 463 | continue 464 | cat = nstroke.tristroke_category(ts) 465 | count = corpus_.trigram_counts[trigram] 466 | speed, is_exact = speed_func(ts) 467 | if is_exact: 468 | by_category[cat][1] += count 469 | by_category[cat][0] += speed * count 470 | by_category[cat][2] += count 471 | 472 | # fill in sum categories 473 | for cat in nstroke.all_tristroke_categories: 474 | if not by_category[cat][2]: 475 | applicable = nstroke.applicable_function(cat) 476 | for othercat in nstroke.all_tristroke_categories: 477 | if by_category[othercat][2] and applicable(othercat): 478 | for i in range(3): 479 | by_category[cat][i] += by_category[othercat][i] 480 | 481 | total_count = by_category[""][2] 482 | if not total_count: 483 | total_count = 1 484 | stats = {} 485 | for cat in nstroke.all_tristroke_categories: 486 | cat_count = by_category[cat][2] 487 | if not cat_count: 488 | cat_count = 1 489 | freq_prop = by_category[cat][2] / total_count 490 | known_prop = by_category[cat][1] / cat_count 491 | cat_speed = by_category[cat][0] / cat_count 492 | contribution = by_category[cat][0] / total_count 493 | stats[cat] = (freq_prop, known_prop, cat_speed, contribution) 494 | 495 | return stats 496 | 497 | def layout_speed( 498 | layout_: layout.Layout, typingdata_: TypingData, 499 | corpus_settings: dict): 500 | """Like tristroke_analysis but instead of breaking down by category, only 501 | calculates stats for the "total" category. 502 | 503 | Returns (speed, known_prop)""" 504 | 505 | total_count, known_count, total_time = layout_speed_raw( 506 | layout_, typingdata_, corpus_settings) 507 | 508 | return (total_time/total_count, known_count/total_count) 509 | 510 | def layout_speed_raw( 511 | layout_: layout.Layout, typingdata_: TypingData, corpus_settings: dict): 512 | """Returns (total_count, known_count, total_time)""" 513 | total_count = 0 514 | known_count = 0 515 | total_time = 0 516 | speed_func = typingdata_.tristroke_speed_calculator(layout_) 517 | corpus_ = layout_.get_corpus(corpus_settings) 518 | for trigram in corpus_.top_trigrams: 519 | if (ts := layout_.to_nstroke(trigram)) is None: 520 | continue 521 | count = corpus_.trigram_counts[trigram] 522 | speed, is_exact = speed_func(ts) 523 | if is_exact: 524 | known_count += count 525 | total_time += speed * count 526 | total_count += count 527 | return (total_count, known_count, total_time) 528 | 529 | def finger_analysis(layout_: layout.Layout, typingdata_: TypingData, 530 | corpus_settings: dict): 531 | """Returns dict[finger, (freq, exact, avg_ms, ms)] 532 | 533 | finger has possible values including anything in Finger.names, 534 | finger_names.values(), and hand_names.values()""" 535 | # {category: [cat_tcount, known_tcount, cat_ttime, lcount]} 536 | corpus_ = layout_.get_corpus(corpus_settings) 537 | letter_counts = corpus_.key_counts 538 | total_lcount = 0 539 | raw_stats = {finger.name: [0,0,0,0] for finger in Finger} 540 | raw_stats.update({ 541 | nstroke.hand_names[hand]: [0,0,0,0] for hand in nstroke.hand_names}) 542 | raw_stats.update( 543 | {nstroke.finger_names[fingcat]: [0,0,0,0] 544 | for fingcat in nstroke.finger_names}) 545 | speed_func = typingdata_.tristroke_speed_calculator(layout_) 546 | for key in layout_.keys.values(): 547 | total_lcount += letter_counts[key] 548 | if total_lcount == 0: 549 | continue 550 | finger = layout_.fingers[key].name 551 | raw_stats[finger][3] += letter_counts[key] 552 | if finger == Finger.UNKNOWN.name: 553 | continue 554 | raw_stats[nstroke.hand_names[finger[0]]][3] += letter_counts[key] 555 | raw_stats[nstroke.finger_names[finger[1]]][3] += letter_counts[key] 556 | total_tcount = 0 557 | for trigram in corpus_.top_trigrams: 558 | if (tristroke := layout_.to_nstroke(trigram)) is None: 559 | continue 560 | tcount = corpus_.trigram_counts[trigram] 561 | total_tcount += tcount 562 | cats = set() 563 | for finger in tristroke.fingers: 564 | cats.add(finger.name) 565 | if finger != Finger.UNKNOWN: 566 | cats.add(nstroke.hand_names[finger.name[0]]) 567 | cats.add(nstroke.finger_names[finger.name[1]]) 568 | speed, is_exact = speed_func(tristroke) 569 | for cat in cats: 570 | if is_exact: 571 | raw_stats[cat][1] += tcount 572 | raw_stats[cat][2] += speed * tcount 573 | raw_stats[cat][0] += tcount 574 | processed = {} 575 | for cat in raw_stats: 576 | processed[cat] = ( 577 | raw_stats[cat][3]/total_lcount if total_lcount else 0, 578 | raw_stats[cat][0]/total_tcount if total_tcount else 0, 579 | raw_stats[cat][1]/raw_stats[cat][0] if raw_stats[cat][0] else 0, 580 | raw_stats[cat][2]/raw_stats[cat][0] if raw_stats[cat][0] else 0, 581 | raw_stats[cat][2]/total_tcount if total_tcount else 0, 582 | ) 583 | return processed 584 | 585 | def key_analysis(layout_: layout.Layout, typingdata_: TypingData, 586 | corpus_settings: dict): 587 | """Like layout_tristroke_analysis but divided up by key. 588 | Each key only has data for trigrams that contain that key. 589 | 590 | Returns a result such that result[key][category] gives 591 | (freq_prop, known_prop, speed, contribution)""" 592 | # {category: [total_time, exact_freq, total_freq]} 593 | raw = {key: {category: [0,0,0] for category in nstroke.all_tristroke_categories} 594 | for key in layout_.keys.values()} 595 | 596 | total_count = 0 597 | 598 | speed_func = typingdata_.tristroke_speed_calculator(layout_) 599 | corpus_ = layout_.get_corpus(corpus_settings) 600 | 601 | for trigram in corpus_.top_trigrams: 602 | if (ts := layout_.to_nstroke(trigram)) is None: 603 | continue 604 | cat = nstroke.tristroke_category(ts) 605 | count = corpus_.trigram_counts[trigram] 606 | speed, is_exact = speed_func(ts) 607 | for key in set(trigram): 608 | if is_exact: 609 | raw[key][cat][1] += count 610 | raw[key][cat][0] += speed * count 611 | raw[key][cat][2] += count 612 | total_count += count 613 | if not total_count: 614 | total_count = 1 615 | stats = {key: dict() for key in raw} 616 | for key in raw: 617 | # fill in sum categories 618 | for cat in nstroke.all_tristroke_categories: 619 | if not raw[key][cat][2]: 620 | applicable = nstroke.applicable_function(cat) 621 | for othercat in nstroke.all_tristroke_categories: 622 | if raw[key][othercat][2] and applicable(othercat): 623 | for i in range(3): 624 | raw[key][cat][i] += raw[key][othercat][i] 625 | # process stats 626 | for cat in nstroke.all_tristroke_categories: 627 | cat_count = raw[key][cat][2] 628 | if not cat_count: 629 | cat_count = 1 630 | freq_prop = raw[key][cat][2] / total_count 631 | known_prop = raw[key][cat][1] / cat_count 632 | cat_speed = raw[key][cat][0] / cat_count 633 | contribution = raw[key][cat][0] / total_count 634 | stats[key][cat] = (freq_prop, known_prop, cat_speed, contribution) 635 | 636 | return stats 637 | 638 | def steepest_ascent(layout_: layout.Layout, s: Session, 639 | pins: Iterable[str] = tuple(), suffix: str = "-ascended"): 640 | """Yields (newlayout, score, swap_made) after each step. 641 | """ 642 | lay = layout.Layout(layout_.name, False, repr(layout_)) 643 | if not lay.name.endswith(suffix): 644 | lay.name += suffix 645 | lay.name = find_free_filename(lay.name, prefix="layouts/") 646 | 647 | swappable = set(lay.keys.values()) 648 | for key in pins: 649 | swappable.discard(key) 650 | 651 | total_count, known_count, total_time = layout_speed_raw( 652 | lay, s.typingdata_, s.corpus_settings 653 | ) 654 | 655 | speed_func = s.typingdata_.tristroke_speed_calculator(layout_) 656 | speed_dict = {ts: speed_func(ts) for ts in lay.all_nstrokes()} 657 | 658 | lfreqs = layout_.get_corpus(s.corpus_settings).key_counts.copy() 659 | total_lcount = sum(lfreqs[key] for key in layout_.positions 660 | if key in lfreqs) 661 | for key in lfreqs: 662 | lfreqs[key] /= total_lcount 663 | 664 | unused_keys = set(key for key in lay.positions 665 | if key not in lfreqs or not bool(lfreqs[key])) 666 | 667 | scores = [total_time/total_count] 668 | rows = tuple({pos.row for pos in lay.keys}) 669 | cols = tuple({pos.col for pos in lay.keys}) 670 | swaps = tuple(remap.swap(*pair) 671 | for pair in itertools.combinations(swappable, 2)) 672 | trigram_counts = lay.get_corpus(s.corpus_settings).filtered_trigram_counts 673 | with multiprocessing.Pool(4) as pool: 674 | while True: 675 | row_swaps = (remap.row_swap(lay, r1, r2, pins) 676 | for r1, r2 in itertools.combinations(rows, 2)) 677 | col_swaps = (remap.col_swap(lay, c1, c2, pins) 678 | for c1, c2 in itertools.combinations(cols, 2)) 679 | 680 | args = ( 681 | (remap, total_count, known_count, total_time, lay, 682 | trigram_counts, speed_dict, unused_keys) 683 | for remap in itertools.chain(swaps, row_swaps, col_swaps) 684 | if s.constraintmap_.is_remap_legal(lay, lfreqs, remap)) 685 | datas = pool.starmap(remapped_score, args, 200) 686 | try: 687 | best = min(datas, key=lambda d: d[2]/d[0]) 688 | except ValueError: 689 | return # no swaps exist 690 | best_remap = best[3] 691 | best_score = best[2]/best[0] 692 | 693 | if best_score < scores[-1]: 694 | total_count, known_count, total_time = best[:3] 695 | scores.append(best_score) 696 | lay.remap(best_remap) 697 | 698 | yield lay, scores[-1], best_remap 699 | else: 700 | return # no swaps are good 701 | 702 | def remapped_score( 703 | remap_: remap.Remap, total_count, known_count, total_time, 704 | lay: layout.Layout, trigram_counts: dict, 705 | speed_func: typing.Union[Callable, dict], 706 | exclude_keys: Collection[str] = ()): 707 | """ 708 | For extra performance, filter trigram_counts by corpus precision. 709 | 710 | Returns: 711 | (total_count, known_count, total_time, remap)""" 712 | 713 | # for ngram in lay.ngrams_with_any_of(remap_, exclude_keys=exclude_keys): 714 | for ngram in trigram_counts: # probably faster 715 | # try: 716 | # tcount = trigram_counts[ngram] 717 | # except KeyError: # contains key not in corpus 718 | # continue 719 | affected_by_remap = False 720 | for key in ngram: 721 | if key in remap_: 722 | affected_by_remap = True 723 | break 724 | if not affected_by_remap: 725 | continue 726 | 727 | if (ts := lay.to_nstroke(ngram)) is None: 728 | continue 729 | tcount = trigram_counts[ngram] 730 | 731 | # remove effect of original tristroke 732 | try: 733 | speed, is_known = speed_func(ts) 734 | except TypeError: 735 | speed, is_known = speed_func[ts] 736 | if is_known: 737 | known_count -= tcount 738 | total_time -= speed * tcount 739 | total_count -= tcount 740 | 741 | # add effect of swapped tristroke 742 | ts = lay.to_nstroke(remap_.translate(ngram)) 743 | try: 744 | speed, is_known = speed_func(ts) 745 | except TypeError: 746 | speed, is_known = speed_func[ts] 747 | if is_known: 748 | known_count += tcount 749 | total_time += speed * tcount 750 | total_count += tcount 751 | 752 | return (total_count, known_count, total_time, remap_) 753 | 754 | def per_ngram_deltas( 755 | remap_: remap.Remap, 756 | lay: layout.Layout, trigram_counts: dict, 757 | speed_func: typing.Union[Callable, dict], 758 | exclude_keys: Collection[str] = ()): 759 | """Calculates the stat deltas by ngram for the given remap. 760 | Returns {ngram: delta_total_count, delta_known_count, delta_total_time} 761 | """ 762 | 763 | result = {} 764 | 765 | for ngram in lay.ngrams_with_any_of(remap_, exclude_keys=exclude_keys): 766 | # deltas for the ngram 767 | known_count = 0 768 | total_time = 0 769 | total_count = 0 770 | 771 | try: 772 | tcount = trigram_counts[ngram] 773 | except KeyError: # contains key not in corpus 774 | continue 775 | 776 | # remove effect of original tristroke 777 | ts = lay.to_nstroke(ngram) 778 | try: 779 | speed, is_known = speed_func(ts) 780 | except TypeError: 781 | speed, is_known = speed_func[ts] 782 | if is_known: 783 | known_count -= tcount 784 | total_time -= speed * tcount 785 | total_count -= tcount 786 | 787 | # add effect of swapped tristroke 788 | ts = lay.to_nstroke(remap_.translate(ngram)) 789 | try: 790 | speed, is_known = speed_func(ts) 791 | except TypeError: 792 | speed, is_known = speed_func[ts] 793 | if is_known: 794 | known_count += tcount 795 | total_time += speed * tcount 796 | total_count += tcount 797 | 798 | result[ngram] = (total_count, known_count, total_time) 799 | 800 | return result 801 | 802 | def anneal(layout_: layout.Layout, s: Session, 803 | pins: Iterable[str] = tuple(), suffix: str = "-annealed", 804 | iterations: int = 10000): 805 | """Yields (layout, i, temperature, delta, score, remap) 806 | when a remap is successful.""" 807 | lay = layout.Layout(layout_.name, False, repr(layout_)) 808 | if not lay.name.endswith(suffix): 809 | lay.name += suffix 810 | 811 | total_count, known_count, total_time = layout_speed_raw( 812 | lay, s.typingdata_, s.corpus_settings 813 | ) 814 | 815 | speed_func = s.typingdata_.tristroke_speed_calculator(layout_) 816 | 817 | corpus_ = lay.get_corpus(s.corpus_settings) 818 | lfreqs = corpus_.key_counts.copy() 819 | total_lcount = sum(lfreqs[key] for key in layout_.positions 820 | if key in lfreqs) 821 | for key in lfreqs: 822 | lfreqs[key] /= total_lcount 823 | 824 | unused_keys = set(key for key in lay.positions 825 | if key not in lfreqs or not bool(lfreqs[key])) 826 | 827 | scores = [total_time/total_count] 828 | T0 = 10 829 | Tf = 1e-3 830 | k = math.log(T0/Tf) 831 | 832 | rows = tuple({pos.row for pos in lay.keys}) 833 | cols = tuple({pos.col for pos in lay.keys}) 834 | remap_ = remap.Remap() # initialize in case needed for is_remap_legal() below 835 | 836 | random.seed() 837 | for i in range(iterations): 838 | temperature = T0*math.exp(-k*i/iterations) 839 | try_rowswap = i % 100 == 0 840 | if try_rowswap: 841 | remap_ = remap.row_swap(lay, *random.sample(rows, 2), pins) 842 | try_colswap = ((not try_rowswap) and i % 10 == 0 843 | or try_rowswap and not s.constraintmap_.is_remap_legal( 844 | lay, lfreqs, remap_)) 845 | if try_colswap: 846 | remap_ = remap.col_swap(lay, *random.sample(cols, 2), pins) 847 | if ( 848 | not (try_colswap or try_rowswap) or 849 | (try_colswap or try_rowswap) and not 850 | s.constraintmap_.is_remap_legal(lay, lfreqs, remap_)): 851 | remap_ = s.constraintmap_.random_legal_swap(lay, lfreqs, pins) 852 | data = remapped_score(remap_, total_count, known_count, total_time, 853 | lay, corpus_.filtered_trigram_counts, speed_func, unused_keys) 854 | score = data[2]/data[0] 855 | delta = score - scores[-1] 856 | 857 | if score > scores[-1]: 858 | p = math.exp(-delta/temperature) 859 | if random.random() > p: 860 | continue 861 | 862 | total_count, known_count, total_time = data[:3] 863 | scores.append(score) 864 | lay.remap(remap_) 865 | 866 | yield lay, i, temperature, delta, scores[-1], remap_ 867 | return 868 | 869 | def find_free_filename(before_number: str, after_number: str = "", 870 | prefix = ""): 871 | """Returns the filename {before_number}{after_number} if not already taken, 872 | or else returns the filename {before_number}-{i}{after_number} with the 873 | smallest i that results in a filename not already taken. 874 | 875 | prefix is used to specify a prefix that is applied to the filename 876 | but is not part of the returned value, used for directory things.""" 877 | incl_number = before_number 878 | i = 1 879 | while os.path.exists(prefix + incl_number + after_number): 880 | incl_number = f"{before_number}-{i}" 881 | i += 1 882 | return incl_number + after_number --------------------------------------------------------------------------------