From 44b2c6d9768059cf1d265196e4d56664dd3cdb9d Mon Sep 17 00:00:00 2001 From: Remis Lape Date: Thu, 12 Nov 2026 13:18:57 +0000 Subject: [PATCH] Keep the mechanism drawing a .mec file carries A .mec file stores, with each mechanism, the "chess board" the DOS programs drew it on: a grid of two-character cells holding state numbers and line drawing. mec_load read the board and discarded it. It is now kept as Mechanism.layout, {state name: (column, row)}, row 0 at the top. For bgtv2a.mec that is the arrangement of Fig. 3 of Burzomato et al. (2004). Only the positions are taken. Which states are connected is given by the rates, and the lines on the boards cannot be read back reliably: they use corner pieces and long runs, and the cube mechanisms are drawn in an oblique projection. layout is every state or None, since part of a layout is no use to anything that draws. Checked against the 85 mechanisms that load from a collection of 29 files: 47 give a complete layout. The other 38 give None, for one of three reasons - the file stores no drawing (17), an oblique drawing has overwritten some state cells (3), or the file stores no state names, so every state has the same blank one and positions keyed by name cannot tell them apart (18). Conventions met in those files and covered by tests: a state number after C or O, bare two-digit numbers, a leading zero ("03"), and "C0" for state ten. The tests build their boards from text, so they need no .mec file. Two more run against a real bgtv2a.mec when SCALCS_MEC_DIR points at one. A Mechanism defined in Python has layout None. A record from dcio.formats.mec that carries a layout has it passed through. Co-Authored-By: Claude Fable 5.2 --- scalcs/mechanism.py | 7 ++ scalcs/scalcsio.py | 80 +++++++++++++++-- tests/test_mec_layout.py | 180 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 tests/test_mec_layout.py 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*'}