open-standards/aac/aac-rs/tools/pagesets/pslib/costs.py
Transaction Science abbe4becb6 feat(aac): two open page sets — Growing Core and Fixed Paths — as Open Board Format
Our own vocabularies in the styles of a growing core and a motor-plan set,
built from cited open sources, never from a vendor's words, positions or names.

Growing Core: one core board through eleven sizes (1x1 to 8x10). Every item
lives at one point of the screen and is drawn, at every size, in the cell that
contains it, so a word learned at 3x3 is in the same region at 8x10. The board
is full at every size and nothing that appeared ever goes. The first boards are
designed by hand; a solver places the rest by Fitzgerald region. Ten pages of
more words, each with Back in the top-left corner; social messages with
repairs; a Word forms page; hide-and-reveal at a fixed size from each word's
stage.

Fixed Paths: one meaning per location and one path per word, of 1 to 3 hits,
at 30, 60 and 84 locations. The heaviest words (spoken frequency, more for
Universal Core and Banajee 2003 words) take one hit in the easiest places for
a blend of touch, scanning and gaze; a page is closed by the place of the key
that opened it. No multi-meaning icon sequences. A vocabulary filter
(starter) hides the rest in place.

tools/pagesets: design sources (YAML), the solver (run once, committed as
layouts), and a reproducible build that writes 19 .obz packages, an index,
metrics and a per-language report to data/pagesets. aac-core tests/pagesets.rs
imports every package with the app's own importer and checks positions,
actions, word classes, that every symbol is the one the app draws, that each
button says what the app's own export would, the growth and path rules, and
language honesty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 11:23:41 -04:00

134 lines
5 KiB
Python

"""Screens, target sizes, and what "easy to reach" means for each access method.
These are models, stated so they can be argued with; none is a measurement of
a person. DESIGN-motor.md gives the reasoning and shows the trade-offs.
Screens (CSS pixels and millimetres per CSS pixel, from the devices' published
resolutions):
tablet: a 10.2-inch tablet (iPad 9th generation class), 2160x1620 px at
264 ppi, 1080x810 CSS px -> 0.1924 mm per CSS px.
phone: a 6.1-inch phone (iPhone 14 class), 1170x2532 px at 460 ppi,
390x844 CSS px -> 0.1657 mm per CSS px; safe areas 47 px top and
34 px bottom in portrait, 47 px each side and 21 px bottom in
landscape.
The app's chrome (aac-web style/main.css): message bar 66 px, suggestion row
52 px (3.25em), navigation bar 54 px; board padding 4 px; 4 px between cells
(the default cell_spacing_px). Text mode and the word-finding bar are off.
Target sizes are compared with the app's own recommendations
(access-core input.rs recommended_target_mm): touch 10 mm, switch 15 mm,
head 18 mm, eye gaze 20 mm.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from .grid import Size
CHROME_PX = 66 + 52 + 54
PAD_PX = 4
GAP_PX = 4
RECOMMENDED_MM = {"touch": 10.0, "switch": 15.0, "head": 18.0, "gaze": 20.0}
@dataclass(frozen=True)
class Screen:
name: str
width_px: int
height_px: int
mm_per_px: float
safe_x: int = 0 # both sides together
safe_y: int = 0 # top and bottom together
def board_px(self) -> tuple[float, float]:
w = self.width_px - self.safe_x - 2 * PAD_PX
h = self.height_px - self.safe_y - CHROME_PX - 2 * PAD_PX
return w, h
def cell_mm(self, size: Size) -> tuple[float, float]:
w, h = self.board_px()
cw = (w - (size.cols - 1) * GAP_PX) / size.cols
ch = (h - (size.rows - 1) * GAP_PX) / size.rows
return cw * self.mm_per_px, ch * self.mm_per_px
SCREENS = {
"tablet-landscape": Screen("10.2-inch tablet, landscape", 1080, 810, 0.1924),
"tablet-portrait": Screen("10.2-inch tablet, portrait", 810, 1080, 0.1924),
"phone-portrait": Screen("6.1-inch phone, portrait", 390, 844, 0.1657, safe_y=47 + 34),
"phone-landscape": Screen("6.1-inch phone, landscape", 844, 390, 0.1657, safe_x=47 + 47, safe_y=21),
}
# The screen the motor costs are computed on.
DESIGN_SCREEN = SCREENS["tablet-landscape"]
def touch_cost(cell: tuple[int, int], size: Size, screen: Screen = DESIGN_SCREEN) -> float:
"""Fitts' index of difficulty, log2(1 + D/W), from a hand resting at the
bottom centre below the board (the navigation bar's height under it) to
the cell's centre; W is the smaller side of the cell."""
w_px, h_px = screen.board_px()
cw = (w_px - (size.cols - 1) * GAP_PX) / size.cols
ch = (h_px - (size.rows - 1) * GAP_PX) / size.rows
r, c = cell
x = c * (cw + GAP_PX) + cw / 2
y = r * (ch + GAP_PX) + ch / 2
rest_x, rest_y = w_px / 2, h_px + 54
d = math.hypot(x - rest_x, y - rest_y)
return math.log2(1 + d / min(cw, ch))
def scan_steps(cell: tuple[int, int], size: Size) -> int:
"""Row-column scanning from the top left: highlights until the cell is
chosen (rows stepped through, then cells in the row), the selection
presses included. The app's default places the bar and the navigation
after the board (SPEC §5.5, §5.6), so they add nothing before it."""
r, c = cell
return (r + 1) + (c + 1)
def gaze_cost(cell: tuple[int, int], size: Size) -> float:
"""Distance of the cell's centre from the middle of the board, 0 at the
centre and 1 at a corner: gaze estimates (a webcam's above all) are least
accurate towards the edges, and a target at the edge has nowhere to put
the overshoot."""
r, c = cell
y = (r + 0.5) / size.rows - 0.5
x = (c + 0.5) / size.cols - 0.5
return math.hypot(x, y) / math.hypot(0.5, 0.5)
METHODS = {"touch": touch_cost, "scan": scan_steps, "gaze": gaze_cost}
def cost_table(size: Size, method: str) -> dict[tuple[int, int], float]:
fn = METHODS[method]
return {(r, c): float(fn((r, c), size)) for r in range(size.rows) for c in range(size.cols)}
def blended(size: Size, weights: dict[str, float]) -> dict[tuple[int, int], float]:
"""Each method's cost scaled to 0..1 over the grid, then weighted."""
out = {(r, c): 0.0 for r in range(size.rows) for c in range(size.cols)}
for m, w in weights.items():
t = cost_table(size, m)
lo, hi = min(t.values()), max(t.values())
for k, v in t.items():
out[k] += w * ((v - lo) / (hi - lo) if hi > lo else 0.0)
return out
def target_sizes(size: Size) -> dict[str, dict]:
out = {}
for key, screen in SCREENS.items():
w, h = screen.cell_mm(size)
smaller = min(w, h)
out[key] = {
"width_mm": round(w, 1),
"height_mm": round(h, 1),
"smaller_mm": round(smaller, 1),
"meets": [m for m, mm in RECOMMENDED_MM.items() if smaller >= mm],
}
return out