Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions scalcs/mechanism.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,13 @@ def __init__(
self.rtitle = rtitle
self.verbose = verbose

# Where each state sits when the mechanism is drawn:
# {state name: (column, row)}, row 0 at the top. Filled in by
# scalcsio.mec_load from the drawing a .mec file carries; None for a
# mechanism that has none, such as one defined in Python. It says
# nothing about which states are connected - that is the Rates.
self.layout = None

self._set_fastKB(fastKB)

# construct States end effectors from Rates:
Expand Down
80 changes: 75 additions & 5 deletions scalcs/scalcsio.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,74 @@ def _mec_record_to_mechanism(rec):
for idx in rec.cycle_state_indices[ci]]
CycleList.append(mechanism.Cycle(states_in_cycle))

return mechanism.Mechanism(
mec = mechanism.Mechanism(
RateList, CycleList,
mtitle=rec.mec_title,
rtitle=rec.rate_title,
)
# a record that carries the stored drawing passes it straight through
mec.layout = getattr(rec, 'layout', None)
return mec


# ---------------------------------------------------------------------------
# The mechanism drawing a .mec file carries
# ---------------------------------------------------------------------------

def _layout_from_board(board, statenames):
"""Positions of the states on the "chess board" stored with a mechanism.

The DOS programs drew a mechanism on a grid of two-character cells and
saved the grid in the .mec file. A cell holds a state - its number,
usually after a C or an O for shut or open: ``C9``, ``O1``, ``10``,
``03`` - or a piece of the line drawing, or nothing.

Parameters
----------
board : list of rows, each a list of two-byte cells
statenames : list of str
Names of the states in the file's own order, so that the state
numbered *n* on the board is ``statenames[n - 1]``.

Returns
-------
dict or None
``{name: (column, row)}`` in cell units, row 0 at the top and shifted
so the left-most and top-most states are at 0. None unless the board
places every state: many files store no drawing at all, and the cube
mechanisms are drawn in an oblique projection that overwrites some of
the state cells. Part of a layout is no use to anything that draws.
None, too, when states share a name - some older files store no
state names, and positions keyed by name cannot tell them apart.

Only positions are read. The lines are deliberately not parsed: which
states are connected is given by the rates, and boards in circulation
draw their connections with corner pieces, long runs and that oblique
projection, none of which can be read back reliably.
A number with no corresponding state is skipped rather than raised on;
a drawing is not worth failing to load a mechanism over.
"""
placed = {}
for row, cells in enumerate(board):
for column, cell in enumerate(cells):
text = bytes(cell).strip(b'\x00 ')
lettered = text[:1].isalpha()
if lettered:
text = text[1:]
if not text.isdigit():
continue
number = int(text)
if lettered and number == 0:
number = 10 # one digit after the letter: C0 is 10
if 1 <= number <= len(statenames):
placed.setdefault(statenames[number - 1], (column, row))

if len(placed) != len(statenames) or not placed:
return None
left = min(column for column, _ in placed.values())
top = min(row for _, row in placed.values())
return {name: (column - left, row - top)
for name, (column, row) in placed.items()}


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -430,20 +493,23 @@ def mec_load(mecfile, start):
ints.fromfile(f,1)
kstat = ints.pop()

# Output of mechanism in characters
# TODO clean characters
# The "chess board" the DOS programs drew the mechanism on: a grid of
# two-character cells, stored column by column. Kept, row by row, so the
# positions of the states can be recovered once their names are known;
# see _layout_from_board.
Jch = []
for j in range(0, jlast):
Ich = []
for i in range(0, ilast): # 500 is max
charmod = f.read(2)#.decode("utf-8")
Ich.append(charmod)
Jch.append(Ich)
board = []
for i in range(0,ilast):
IIch = []
for j in range(0, jlast):
IIch.append(Jch[j][i])
#print (''.join(IIch))
board.append(IIch)

# Read rate constants.
irate = []
Expand Down Expand Up @@ -639,8 +705,12 @@ def mec_load(mecfile, start):
CycleStates.append(statenames[im[i, j]-1])
CycleList.append(mechanism.Cycle(CycleStates))

return mechanism.Mechanism(RateList, CycleList,
mec = mechanism.Mechanism(RateList, CycleList,
mtitle=mectitle, rtitle=ratetitle)
# the file can list more names than the mechanism has states (kstat > k);
# only the first k are states, and only they have to be on the board
mec.layout = _layout_from_board(board, statenames[:len(StateList)])
return mec

def load_mec_from_prt(filename, verbose=False):
f = open(filename, 'r')
Expand Down
180 changes: 180 additions & 0 deletions tests/test_mec_layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""Tests for the mechanism drawing stored in a .mec file.

A .mec file carries, with each mechanism, the "chess board" on which the DOS
programs drew it: a grid of two-character cells holding state numbers and
line-drawing characters. The reader used to read it and throw it away. It is
now kept as ``Mechanism.layout``, the position of each state on that grid.

Only the positions are taken. The lines on the board are not parsed: the
transitions of a mechanism are its rates, and the boards in circulation draw
their connections with corner pieces, long runs and, for the cube mechanisms,
an oblique projection that cannot be read back reliably.

The boards below are copied from real files, so the tests need no .mec file.
"""
import os

import pytest

from scalcs import scalcsio
from scalcs.samples.samples import CH82


def board(*lines):
"""Text rows -> rows of two-byte cells, as they are stored in the file.

The files are written in code page 437, where the horizontal and vertical
line-drawing characters are 0xC4 and 0xB3.
"""
width = max(len(line) for line in lines)
width += width % 2
rows = []
for line in lines:
raw = line.ljust(width).encode('cp437')
rows.append([raw[i:i + 2] for i in range(0, width, 2)])
return rows


# bgtv2a.mec: the flip mechanism of Burzomato et al. (2004)
FLIP_BOARD = board('10──C9──C8──C7',
' │ │ │',
' C6──C5──C4',
' │ │ │',
' O1 O2 O3')
FLIP_NAMES = ['AF*', 'A2F*', 'A3F*', 'A3F', 'A2F', 'AF',
'A3R', 'A2R', 'AR', 'R'] # state 1 first


class TestLayoutFromBoard:

def test_every_state_is_placed(self):
layout = scalcsio._layout_from_board(FLIP_BOARD, FLIP_NAMES)
assert set(layout) == set(FLIP_NAMES)

def test_positions_are_column_then_row_with_the_top_row_zero(self):
layout = scalcsio._layout_from_board(FLIP_BOARD, FLIP_NAMES)
assert layout['R'] == (0, 0) # state 10, top left
assert layout['A3R'] == (6, 0) # state 7, top right
assert layout['AF'] == (2, 2) # state 6, under AR
assert layout['A3F*'] == (6, 4) # state 3, bottom right

def test_states_in_one_conformation_share_a_row(self):
layout = scalcsio._layout_from_board(FLIP_BOARD, FLIP_NAMES)
assert {layout[n][1] for n in ('R', 'AR', 'A2R', 'A3R')} == {0}
assert {layout[n][1] for n in ('AF', 'A2F', 'A3F')} == {2}
assert {layout[n][1] for n in ('AF*', 'A2F*', 'A3F*')} == {4}

def test_a_two_digit_number_written_with_a_leading_zero(self):
"""Auerbach.mec writes state 3 as '03'."""
layout = scalcsio._layout_from_board(
board('03──O2──C1'), ['s1', 's2', 's3'])
assert layout == {'s3': (0, 0), 's2': (2, 0), 's1': (4, 0)}

def test_two_digit_numbers_written_bare(self):
"""SSNMDA.MEC: fifteen states, 10 to 15 with no letter."""
names = ['s%d' % n for n in range(1, 16)]
layout = scalcsio._layout_from_board(
board('15──14──13',
' │ │ │',
'12──11──10',
' │ │ │',
'C9──C8──C7──C6──C3',
' │ │',
' C5──C4',
' │ │',
' O2 O1'), names)
assert len(layout) == 15
assert layout['s15'] == (0, 0)
assert layout['s10'] == (4, 2)
assert layout['s1'] == (6, 8)

def test_state_ten_written_as_a_zero(self):
"""SCH5S15.MEC has ten states and writes the tenth as 'C0'."""
names = ['s%d' % n for n in range(1, 11)]
layout = scalcsio._layout_from_board(
board('C0──C9──C8──C7──O1',
' │ │ │ │ │',
'C6──C5──C4──C3──C2'), names)
assert len(layout) == 10
assert layout['s10'] == (0, 0)

def test_a_drawing_that_does_not_place_every_state_gives_no_layout(self):
"""The cube mechanisms are drawn in an oblique projection that
overwrites some of the state cells. Part of a layout is no use to
anything that draws, so it is all of the states or None.
"""
assert scalcsio._layout_from_board(
board('O1──C2'), ['s1', 's2', 's3']) is None

def test_corner_pieces_and_long_runs_are_not_states(self):
"""remis.MEC joins C7 to C5 with a line that turns a corner."""
names = ['s%d' % n for n in range(1, 12)]
layout = scalcsio._layout_from_board(
board('11──C9──C6──O2',
' │ │ │',
'10──C8──C5──O1──C4',
' │ │',
'C7───────┘',
' │',
'O3'), names)
assert len(layout) == 11
assert layout['s7'] == (0, 4)
assert layout['s5'] == (4, 2)

def test_positions_start_at_the_origin(self):
"""A board with blank columns to the left is shifted, so layouts from
different files are comparable."""
names = ['s1', 's2']
layout = scalcsio._layout_from_board(
board(' O1──C2'), names)
assert layout == {'s1': (0, 0), 's2': (2, 0)}

def test_a_blank_board_gives_no_layout(self):
assert scalcsio._layout_from_board(board(' ', ' '),
['s1']) is None
assert scalcsio._layout_from_board([], ['s1']) is None

def test_states_that_share_a_name_give_no_layout(self):
"""Some older files store no state names, so every state has the
same blank one. Positions keyed by name cannot tell them apart."""
assert scalcsio._layout_from_board(
board('C3──C2──O1'), ['', '', '']) is None

def test_a_number_with_no_such_state_is_skipped(self):
"""A drawing is not worth failing to load a mechanism over."""
layout = scalcsio._layout_from_board(board('O1──C2──C9'),
['s1', 's2'])
assert layout == {'s1': (0, 0), 's2': (2, 0)}


class TestMechanismLayout:

def test_a_mechanism_defined_in_python_has_no_layout(self):
assert CH82().layout is None


MEC_DIR = os.environ.get('SCALCS_MEC_DIR', '')
BGTV2A = os.path.join(MEC_DIR, 'bgtv2a.mec')


@pytest.mark.skipif(not os.path.isfile(BGTV2A),
reason='set SCALCS_MEC_DIR to a folder holding bgtv2a.mec')
class TestLayoutFromARealFile:

@pytest.fixture(scope='class')
def mec(self):
_, meclist, _ = scalcsio.mec_get_list(BGTV2A)
return scalcsio.mec_load(BGTV2A, meclist[0][0])

def test_every_state_of_the_mechanism_is_placed(self, mec):
assert set(mec.layout) == {s.name for s in mec.States}

def test_it_is_the_layout_of_the_published_figure(self, mec):
"""Three rows: resting, flipped, open. The file calls the flipped
states AP, A2P, A3P and the open ones AR*, A2R*, A3R*."""
rows = {}
for name, (_, row) in mec.layout.items():
rows.setdefault(row, set()).add(name)
assert rows[0] == {'R', 'AR', 'A2R', 'A3R'}
assert rows[2] == {'AP', 'A2P', 'A3P'}
assert rows[4] == {'AR*', 'A2R*', 'A3R*'}
Loading