diff --git a/src/lemke/bimatrix.py b/src/lemke/bimatrix.py index 268ce7b..20c4a47 100644 --- a/src/lemke/bimatrix.py +++ b/src/lemke/bimatrix.py @@ -9,7 +9,6 @@ from . import columnprint, lemke, randomstart, utils from .randomstart import MAX_ACCURACY -from .utils import MAXDECIMALS # file format: # @@ -45,14 +44,13 @@ def rangesplit(s, endrange=50): class payoffmatrix: # create matrix from any numerical matrix def __init__(self, A): - AA = np.array(A) + AA = np.array(A, dtype=object) + if not all(isinstance(x, fractions.Fraction) for x in AA.flat): + raise TypeError("matrix must contain only Fraction values") m, n = AA.shape self.numrows = m self.numcolumns = n - self.matrix = np.zeros((m, n), dtype=fractions.Fraction) - for i in range(m): - for j in range(n): - self.matrix[i][j] = utils.tofraction(AA[i][j]) + self.matrix = AA self.fullmaxmin() def __str__(self): @@ -83,12 +81,16 @@ def fullmaxmin(self): # add full row, row must be of size n def addrow(self, row): + if not all(isinstance(x, fractions.Fraction) for x in row): + raise TypeError("New row must contain only Fraction values") self.matrix = np.vstack([self.matrix, row]) self.numrows += 1 self.updatemaxmin(self.numrows - 1, 0) # add full column, col must be of size m def addcolumn(self, col): + if not all(isinstance(x, fractions.Fraction) for x in col): + raise TypeError("New column must contain only Fraction values") self.matrix = np.column_stack([self.matrix, col]) self.numcolumns += 1 self.updatemaxmin(0, self.numcolumns - 1) @@ -101,7 +103,8 @@ def __init__(self, A, B): # create A,B from file @classmethod - def from_file(cls, filename): + def from_file(cls, filename, decimals=utils.DEFAULT_DECIMALS): + utils.validate_decimals(decimals) lines = utils.stripcomments(filename) # flatten into words words = utils.towords(lines) @@ -113,10 +116,10 @@ def from_file(cls, filename): print("m=", m, ", n=", n, ", need", needfracs, "payoffs, got", len(words) - 2) exit(1) k = 2 - C = utils.tomatrix(m, n, words, k) + C = utils.tomatrix(m, n, words, k, decimals) A = payoffmatrix(C) k += m * n - C = utils.tomatrix(m, n, words, k) + C = utils.tomatrix(m, n, words, k, decimals) B = payoffmatrix(C) return cls(A, B) @@ -300,9 +303,9 @@ def common_options(f): ) @click.option( "--decimals", - default=4, + default=utils.DEFAULT_DECIMALS, show_default=True, - type=click.IntRange(min=0, max=MAXDECIMALS), + type=click.IntRange(min=0, max=utils.MAXDECIMALS), metavar="INTEGER", help="Allowed payoff digits in input after decimal point", ) @@ -312,8 +315,7 @@ def common_options(f): # help="Show value of z0 at each step", # ) @wraps(f) - def wrapper(*args, decimals, **kwargs): - utils.setdecimals(decimals) + def wrapper(*args, **kwargs): return f(*args, **kwargs) return wrapper @@ -326,10 +328,10 @@ def wrapper(*args, decimals, **kwargs): help="Missing labels, e.g. 1,3-5,7- " "[default: all labels]", ) -def lh(filename, labels): +def lh(filename, decimals, labels): """Find equilibria using the Lemke-Howson algorithm.""" - G = bimatrix.from_file(filename) + G = bimatrix.from_file(filename, decimals) G.LH(labels) @@ -341,10 +343,10 @@ def trace(): @trace.command(name="uniform") @common_options -def trace_uniform_cmd(filename): +def trace_uniform_cmd(filename, decimals): """Trace using a uniform prior.""" - G = bimatrix.from_file(filename) + G = bimatrix.from_file(filename, decimals) G.trace_uniform_prior() @@ -371,8 +373,8 @@ def trace_uniform_cmd(filename): metavar="INTEGER", help="Denominator x: each coordinate of the prior is rounded to the nearest 1/x", ) -def trace_random_cmd(filename, priors, seed, accuracy): +def trace_random_cmd(filename, decimals, priors, seed, accuracy): """Trace using random prior(s).""" - G = bimatrix.from_file(filename) + G = bimatrix.from_file(filename, decimals) G.trace_random_priors(priors, seed, accuracy) diff --git a/src/lemke/lemke.py b/src/lemke/lemke.py index 5735c3d..4fdca2c 100644 --- a/src/lemke/lemke.py +++ b/src/lemke/lemke.py @@ -18,7 +18,8 @@ def __init__(self, M, q, d): self.n = len(d) @classmethod - def from_file(cls, filename): + def from_file(cls, filename, decimals=utils.DEFAULT_DECIMALS): + utils.validate_decimals(decimals) # create LCP from file lines = utils.stripcomments(filename) # flatten into words @@ -42,15 +43,15 @@ def from_file(cls, filename): while k < len(words): if words[k] == "M=": k += 1 - M = utils.tomatrix(n, n, words, k) + M = utils.tomatrix(n, n, words, k, decimals) k += n * n elif words[k] == "q=": k += 1 - q = utils.tovector(n, words, k) + q = utils.tovector(n, words, k, decimals) k += n elif words[k] == "d=": k += 1 - d = utils.tovector(n, words, k) + d = utils.tovector(n, words, k, decimals) k += n else: raise ValueError( @@ -557,18 +558,26 @@ def runlemke(*, lcp, callback=None): is_flag=True, help="Show value of z0 at each step", ) +@click.option( + "--decimals", + default=utils.DEFAULT_DECIMALS, + show_default=True, + type=click.IntRange(min=0, max=utils.MAXDECIMALS), + metavar="INTEGER", + help="Allowed payoff digits in input after decimal point", +) @click.argument( "lcpfilename", type=click.Path(exists=True, readable=True, file_okay=True, dir_okay=False), ) -def main(verbose, z0, lcpfilename): +def main(verbose, z0, decimals, lcpfilename): """ Tool for solving linear complementarity problems using Lemke's algorithm. LCPFILENAME is the path to the input file. """ - m = lcp.from_file(lcpfilename) + m = lcp.from_file(lcpfilename, decimals) result = runlemke( lcp=m, diff --git a/src/lemke/utils.py b/src/lemke/utils.py index cc4adf9..394da0b 100644 --- a/src/lemke/utils.py +++ b/src/lemke/utils.py @@ -1,30 +1,31 @@ # file utilities -# tofraction utilities with global decimals import fractions +from decimal import ( + ROUND_HALF_UP, + Decimal, + InvalidOperation, +) import numpy as np # global constants, mutable # https://stackoverflow.com/questions/1977362/how-to-create-module-wide-variables-in-python -decimals = 4 -deciDenom = 10 ** decimals +DEFAULT_DECIMALS = 4 MAXDECIMALS = 20 # roundingwarn = False -def setdecimals(n): - global decimals, deciDenom - if 0 <= n <= MAXDECIMALS: - decimals = n - deciDenom = 10 ** decimals - else: - # if roundingwarn: - print(n, "as number of decimals not in allowed range 0 to", MAXDECIMALS) - return +commentchars = "#%*" # lines starting with these are ignored -commentchars = "#%*" # lines starting with these are ignored +def validate_decimals(decimals): + if not isinstance(decimals, int): + raise TypeError("decimals must be an integer") + if decimals < 0 or decimals > MAXDECIMALS: + raise ValueError( + f"{decimals} as number of decimals not in allowed range 0 to {MAXDECIMALS}" + ) # read file into list of line-strings @@ -53,35 +54,47 @@ def towords(lines): return words -# convert s to fraction -# if s contains ".": convert to decimal fraction -# (numerator deciDenom) -def tofraction(s): - if isinstance(s, str) and "." in s: - s = float(s) - if isinstance(s, float): - num = int(abs(s) * deciDenom + 0.5) # round .5 away from zero - if s < 0: - num = -num - return fractions.Fraction(num, deciDenom) - # any other s than a float or string containing '.': - return fractions.Fraction(s) +def tofraction(s: str, decimals: int) -> fractions.Fraction: + """Convert a string to an exact Fraction. + + If `s` contains '.', it's treated as a decimal literal and + rounded to `decimals` places (half away from zero). + Otherwise, it's parsed as an integer or 'p/q' fraction string. + """ + if not isinstance(s, str): + raise TypeError( + f"tofraction expects a string, got {type(s).__name__}: {s!r}" + ) + + if "." in s: + try: + d = Decimal(s) + except InvalidOperation as e: + raise ValueError(f"{s!r} is not a valid decimal number") from e + denominator = 10 ** decimals + scaled = (d * denominator).to_integral_value(rounding=ROUND_HALF_UP) + return fractions.Fraction(int(scaled), denominator) + + try: + return fractions.Fraction(s) + except (ValueError, ZeroDivisionError) as e: + raise ValueError(f"{s!r} is not a valid number or fraction: {e}") from e # create n-vector of fractions from words[start,start+n) -def tovector(n, words, start): +def tovector(n, words, start, decimals): vector = np.zeros(n, dtype=fractions.Fraction) for i in range(n): - vector[i] = tofraction(words[start + i]) + vector[i] = tofraction(words[start + i], decimals) return vector # create (m,n)-matrix of fractions from words[start,start+m*n) -def tomatrix(m, n, words, start): +def tomatrix(m, n, words, start, decimals): C = np.zeros((m, n), dtype=fractions.Fraction) k = start for i in range(m): for j in range(n): - C[i][j] = tofraction(words[k]) + C[i][j] = tofraction(words[k], decimals) k += 1 return C diff --git a/tests/test_bimatrix_units.py b/tests/test_bimatrix_units.py index 130573e..d17c4fb 100644 --- a/tests/test_bimatrix_units.py +++ b/tests/test_bimatrix_units.py @@ -4,7 +4,6 @@ import pytest from click.testing import CliRunner -from lemke import utils from lemke.bimatrix import ( bimatrix, lh, @@ -24,8 +23,8 @@ @pytest.fixture def small_payoff_matrix(): return payoffmatrix([ - [1, 2], - [3, 4], + [Fraction(1), Fraction(2)], + [Fraction(3), Fraction(4)], ]) @@ -64,7 +63,7 @@ def test_payoff_matrix_negshift_negmatrix(small_payoff_matrix): def test_addrow_updates_shape_max_min(small_payoff_matrix): pm = small_payoff_matrix - pm.addrow([10, -10]) + pm.addrow([Fraction(10), Fraction(-10)]) assert pm.numrows == 3 assert pm.matrix[2][0] == Fraction(10) assert pm.max == Fraction(10) @@ -73,7 +72,7 @@ def test_addrow_updates_shape_max_min(small_payoff_matrix): def test_addcolumn_updates_shape_max_min(small_payoff_matrix): pm = small_payoff_matrix - pm.addcolumn([-5, 20]) + pm.addcolumn([Fraction(-5), Fraction(20)]) assert pm.numcolumns == 3 assert pm.matrix[0][2] == Fraction(-5) assert pm.max == Fraction(20) @@ -83,7 +82,6 @@ def test_addcolumn_updates_shape_max_min(small_payoff_matrix): # --- BIMATRIX INIT -------------------------------------------------- @pytest.fixture def small_game_file(tmp_path): - utils.setdecimals(4) content = textwrap.dedent(""" 2 2 diff --git a/tests/test_lcp_units.py b/tests/test_lcp_units.py index 4d6bd3c..4513e6b 100644 --- a/tests/test_lcp_units.py +++ b/tests/test_lcp_units.py @@ -35,6 +35,24 @@ def test_lcp_valid_file(tmp_path, raw, expected): assert m.M[0][0] == expected +@pytest.mark.parametrize( + "raw, expected, decimals", + [ + ("0.00015", Fraction(2, 10000), 4), + ("0.145", Fraction(3, 20), 2), + ("9999999999999991.1", Fraction(9999999999999991, 1), 0), + ("9999999999999999.1", Fraction(99999999999999991, 10), 4), + ] +) +def test_lcp_file_parsing_in_fractions(tmp_path, raw, expected, decimals): + content = f"n= 1\nM= {raw}\nq= 1\nd= 1\n" + file_path = tmp_path / "lcp" + file_path.write_text(content) + + m = lcp.from_file(str(file_path), decimals) + assert m.M[0][0] == expected + + @pytest.mark.parametrize("content", [ "M= 1 0 0 1 q= 1 1 d= 1 1\n", # missing n= "n= 2\nM= 1 0 0 1\nq= 1 1\nd= 1\n", # wrong number of values