diff --git a/scalcs/mechanism.py b/scalcs/mechanism.py index 03adde4..d5f31ca 100644 --- a/scalcs/mechanism.py +++ b/scalcs/mechanism.py @@ -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: diff --git a/scalcs/scalcsio.py b/scalcs/scalcsio.py index b19d757..63c27f1 100644 --- a/scalcs/scalcsio.py +++ b/scalcs/scalcsio.py @@ -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()} # --------------------------------------------------------------------------- @@ -430,8 +493,10 @@ 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 = [] @@ -439,11 +504,12 @@ def mec_load(mecfile, start): 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 = [] @@ -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') diff --git a/tests/test_mec_layout.py b/tests/test_mec_layout.py new file mode 100644 index 0000000..624a0d5 --- /dev/null +++ b/tests/test_mec_layout.py @@ -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*'}