gsimplex 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,805 @@
1
+ """
2
+ netlib_emps.py - Expand compressed LP programs (netlib format) to MPS format.
3
+
4
+ Python port of emps.c by David M. Gay (AT&T Bell Laboratories).
5
+
6
+ See the original c version at https://www.netlib.org/lp/data/emps.c
7
+
8
+ The netlib LP archive stores problems in a compressed ASCII encoding.
9
+ This module decodes that format and writes standard MPS output.
10
+
11
+ Public API
12
+ ----------
13
+ expand_mps(input_file, output_file=None, *, keepmyst, blanksubst, just1)
14
+ Convert one compressed LP file. Returns MPS text when output_file
15
+ is None; otherwise writes to output_file and returns None.
16
+
17
+ expand_mps_string(text, ...)
18
+ Accepts the compressed text as a str; returns MPS text as a str.
19
+
20
+ Command line usage
21
+ ------------------
22
+ This file can be used as a command-line tool as well. The options mirror the original C program.
23
+ See also `gsimplex-emps --help` for usage information.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import io
29
+ import sys
30
+ import argparse
31
+ from typing import List, Optional, Tuple, TextIO
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Translation table (identical to the C original)
35
+ # ---------------------------------------------------------------------------
36
+ TRTAB: str = (
37
+ "!\"#$%&'()*+,-./0123456789;<=>?@"
38
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`"
39
+ "abcdefghijklmnopqrstuvwxyz{|}~"
40
+ )
41
+
42
+ _INVTRTAB: List[int] = [92] * 256
43
+ for _i, _ch in enumerate(TRTAB):
44
+ _INVTRTAB[ord(_ch)] = _i
45
+
46
+
47
+ def _tr(c: str) -> int:
48
+ """Return the inverse-translation-table value for a character."""
49
+
50
+ return _INVTRTAB[ord(c) & 0xFF]
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Low-level decoders (stateless)
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def _exindx(z: str, zi: int) -> Tuple[int, int]:
58
+ """Decode one variable-length supersparse index.
59
+
60
+ Mirrors exindx() in the C source. Characters with tr-value 23-45
61
+ encode a single-character terminal (value = k-23). Characters with
62
+ tr-value 0-22 start a multi-character base-46 sequence that ends when
63
+ a character with tr-value >= 46 is encountered (the terminal 'bit').
64
+
65
+ Parameters
66
+ ----------
67
+ z : current data string (one stripped line from the compressed file)
68
+ zi : current read offset within *z*
69
+
70
+ Returns
71
+ -------
72
+ (index_value, new_zi)
73
+ """
74
+
75
+ k = _tr(z[zi]); zi += 1
76
+ if k >= 46:
77
+ raise ValueError(f"exindx: bad encoded byte at offset {zi - 1}")
78
+ if k >= 23:
79
+ return k - 23, zi
80
+
81
+ # Multi-character base-46 variable-length encoding
82
+ x = k
83
+ while True:
84
+ k = _tr(z[zi]); zi += 1
85
+ x = x * 46 + k
86
+ if k >= 46:
87
+ x -= 46
88
+ break
89
+
90
+ return x, zi
91
+
92
+
93
+ def _exform(
94
+ z: str, zi: int, ss: List[str], kmax: int
95
+ ) -> Tuple[str, int]:
96
+ """Decode one compressed floating-point number.
97
+
98
+ Mirrors exform() in the C source.
99
+
100
+ When the first encoded character has tr-value < 46 the number is a
101
+ supersparse reference into the pre-computed table *ss*. Otherwise the
102
+ number is encoded inline as either integer-float (k >= 11 after sign
103
+ adjustment) or general float.
104
+
105
+ Parameters
106
+ ----------
107
+ z : current data string
108
+ zi : current read offset within *z*
109
+ ss : pre-computed number table, **1-based** (ss[0] unused)
110
+ kmax : number of valid entries in *ss* (== len(ss) - 1)
111
+
112
+ Returns
113
+ -------
114
+ (formatted_string, new_zi)
115
+ The string is right-justified in a field of at least 12 characters,
116
+ matching the C padding logic ``while(k++ < 12) *s++ = ' '``.
117
+ """
118
+ y: int = 0
119
+ k: int = _tr(z[zi]); zi += 1
120
+
121
+ # Supersparse table reference
122
+ if k < 46:
123
+ # The index encoding starts at the character we already read;
124
+ # back up one so _exindx can re-consume the full encoding.
125
+ idx, zi = _exindx(z, zi - 1)
126
+ if idx > kmax:
127
+ raise ValueError(
128
+ f"exform: table reference {idx} exceeds kmax {kmax}"
129
+ )
130
+ return ss[idx], zi
131
+
132
+ # Inline encoded number
133
+ k -= 46
134
+ neg = k >= 23
135
+ if neg:
136
+ k -= 23
137
+ nelim = 11
138
+ else:
139
+ nelim = 12
140
+
141
+ # d[] holds decimal digit characters, least-significant first
142
+ # (mirrors the db[] buffer + d pointer pattern in the C source).
143
+ d: List[str] = []
144
+
145
+ if k >= 11:
146
+ # Integer floating-point
147
+ # '.' is stored first so that reversing d places it after the
148
+ # digits, yielding output like "123." (integer with trailing point).
149
+ k -= 11
150
+ d.append('.')
151
+ if k >= 6:
152
+ x: int = k - 6
153
+ else:
154
+ x = k
155
+ while True:
156
+ k = _tr(z[zi]); zi += 1
157
+ x = x * 46 + k
158
+ if k >= 46:
159
+ x -= 46
160
+ break
161
+ if x == 0:
162
+ d.append('0')
163
+ else:
164
+ while x:
165
+ d.append(str(x % 10))
166
+ x //= 10
167
+ sbuf = list(reversed(d)) # MSB-first digits then '.'
168
+
169
+ else:
170
+ # General floating-point
171
+ ex: int = _tr(z[zi]) - 50; zi += 1 # decimal exponent
172
+ x = _tr(z[zi]); zi += 1 # start of base-92 mantissa
173
+ kk = k
174
+ while kk > 0:
175
+ kk -= 1
176
+ if x >= 100_000_000:
177
+ # High-part overflow: save x, restart x from new character
178
+ y = x
179
+ x = _tr(z[zi]); zi += 1
180
+ else:
181
+ x = x * 92 + _tr(z[zi]); zi += 1
182
+
183
+ # Collect decimal digits of the mantissa (LSB first)
184
+ if y:
185
+ # y holds the high part; x holds the low extension.
186
+ # C loop: while(x > 1) drain x digits, then drain y digits.
187
+ while x > 1:
188
+ d.append(str(x % 10))
189
+ x //= 10
190
+ yy = y
191
+ while True:
192
+ d.append(str(yy % 10))
193
+ if yy < 10:
194
+ break
195
+ yy //= 10
196
+ elif x:
197
+ xx = x
198
+ while True:
199
+ d.append(str(xx % 10))
200
+ if xx < 10:
201
+ break
202
+ xx //= 10
203
+ else:
204
+ d.append('0')
205
+
206
+ # nd = number of digits that belong before the decimal point
207
+ nd = len(d) + ex
208
+ sbuf = []
209
+ eout = False
210
+
211
+ if ex > 0:
212
+ if nd < nelim or ex < 3:
213
+ # All mantissa digits, trailing zeros, then '.'
214
+ sbuf.extend(reversed(d))
215
+ sbuf.extend(['0'] * ex)
216
+ sbuf.append('.')
217
+ else:
218
+ eout = True
219
+ elif nd >= 0:
220
+ rev = list(reversed(d))
221
+ sbuf.extend(rev[:nd])
222
+ sbuf.append('.')
223
+ sbuf.extend(rev[nd:])
224
+ elif ex > -nelim:
225
+ sbuf.append('.')
226
+ sbuf.extend(['0'] * (-nd))
227
+ sbuf.extend(reversed(d))
228
+ else:
229
+ eout = True
230
+
231
+ if eout:
232
+ # Scientific notation (mirrors the Eout: label in the C source)
233
+ ex += len(d) - 1
234
+ rev = list(reversed(d))
235
+ ridx = 0
236
+ if ex == -10:
237
+ # Special case: avoid a 3-digit exponent; accept slight
238
+ # rounding error (mirrors the C original).
239
+ ex = -9
240
+ # No digits before '.'; fall through to the '.' append below
241
+ else:
242
+ # Optionally emit leading digits to reduce the exponent
243
+ if ex > 9 and ex <= len(d) + 8:
244
+ while ex > 9:
245
+ sbuf.append(rev[ridx]); ridx += 1
246
+ ex -= 1
247
+ sbuf.append(rev[ridx]); ridx += 1
248
+ sbuf.append('.')
249
+ sbuf.extend(rev[ridx:])
250
+ sbuf.append('E')
251
+ if ex < 0:
252
+ sbuf.append('-')
253
+ ex = -ex
254
+ ed: List[str] = []
255
+ while ex:
256
+ ed.append(str(ex % 10))
257
+ ex //= 10
258
+ sbuf.extend(reversed(ed))
259
+
260
+ result = ('-' if neg else '') + ''.join(sbuf)
261
+ # Right-justify in a field of at least 12 characters
262
+ # (matches C: while(k++ < 12) *s++ = ' '; strcpy(s, sbuf);)
263
+ return result.rjust(12), zi
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Converter class
268
+ # ---------------------------------------------------------------------------
269
+
270
+ class EMPSConverter:
271
+ """Expand one or more compressed netlib LP problems to MPS format."""
272
+
273
+ _BOUND_TYPES = ("UP", "LO", "FX", "FR", "MI", "PL")
274
+
275
+ def __init__(
276
+ self,
277
+ *,
278
+ keepmyst: bool = True,
279
+ blanksubst: Optional[str] = None,
280
+ just1: bool = False,
281
+ ) -> None:
282
+ """
283
+ Parameters
284
+ ----------
285
+ keepmyst
286
+ When True (default), "mystery lines" (lines starting with ':'
287
+ in the source) are included in the output.
288
+ blanksubst
289
+ If given (e.g. '_'), blanks within names are replaced with
290
+ this character.
291
+ just1
292
+ If True, emit only one nonzero per output line.
293
+ """
294
+
295
+ self.keepmyst = keepmyst
296
+ self.blanksubst = blanksubst
297
+ self.just1 = just1
298
+
299
+ # Public interface
300
+
301
+ def convert(
302
+ self,
303
+ input_file: "str | TextIO",
304
+ output_file: "str | TextIO | None" = None,
305
+ ) -> Optional[str]:
306
+ """Expand *input_file* and write the MPS result to *output_file*.
307
+
308
+ Parameters
309
+ ----------
310
+ input_file
311
+ A filesystem path or an already-opened readable text file.
312
+ output_file
313
+ A filesystem path, a writable text file, or None.
314
+ When None the MPS text is returned as a string.
315
+
316
+ Returns
317
+ -------
318
+ MPS text (str) when output_file is None; otherwise None.
319
+ """
320
+
321
+ return_str = output_file is None
322
+
323
+ if isinstance(input_file, str):
324
+ with open(input_file) as fh:
325
+ result = self._run(fh, input_file)
326
+ else:
327
+ name = getattr(input_file, 'name', '<stream>')
328
+ result = self._run(input_file, name)
329
+
330
+ if return_str:
331
+ return result
332
+ if isinstance(output_file, str):
333
+ with open(output_file, 'w') as fh:
334
+ fh.write(result)
335
+ else:
336
+ output_file.write(result)
337
+ return None
338
+
339
+ # Internal driver
340
+
341
+ def _run(self, inf: TextIO, infile_name: str) -> str:
342
+ """Core loop; returns the complete MPS text as a single string."""
343
+ # Per-run state
344
+ self._inf = inf
345
+ self._infile = infile_name
346
+ self._nline = 0
347
+ self._ncs = 1 # next slot in the rolling checksum buffer
348
+ self._canend = False # True -> EOF is acceptable
349
+ self._kmax = -1 # entries currently in the number table
350
+ self._cn = 0 # column name counter
351
+ self._nrow = 0
352
+ self._ss: List[str] = [] # pre-computed number table (1-based)
353
+ self._bs: List[str] = [] # name store (1-based via index-1)
354
+ self._bsz = 0 # capacity of _bs
355
+ self._lastl = ''
356
+ # Checksum buffer: _chkbuf[0]=' ', checksum chars go into [1..71],
357
+ # a '\n' is appended when reading the checksum verification line.
358
+ self._chkbuf: List[str] = [' '] + ['\x00'] * 75
359
+ self._cfn: List[str] = [''] * 72 # filename per checksum slot
360
+ self._cfl: List[int] = [0] * 72 # line no. per checksum slot
361
+ self._out: List[str] = []
362
+
363
+ buf = self._rdline()
364
+ while True:
365
+ self._kmax = -1
366
+ self._ncs = 1
367
+
368
+ # Skip lines until we find "NAME"
369
+ while buf is not None and not buf.startswith('NAME'):
370
+ buf = self._rdline()
371
+ if buf is None:
372
+ break
373
+
374
+ self._canend = False
375
+ self._emit(buf)
376
+ self._ncs = 1
377
+
378
+ # Problem statistics: two encoded lines
379
+ s1 = (self._rdline() or '').split()
380
+ s2 = (self._rdline() or '').split()
381
+ try:
382
+ nrow = int(s1[0]); ncol = int(s1[1])
383
+ # s1[2] = colmx (unused)
384
+ nz = int(s1[3])
385
+ # s1[4] = nrhs (unused)
386
+ rhsnz = int(s1[5])
387
+ # s1[6] = nran (unused)
388
+ ranz = int(s1[7])
389
+ # s2[0] = nbd (unused)
390
+ bdnz = int(s2[1])
391
+ ns = int(s2[2])
392
+ except (IndexError, ValueError) as exc:
393
+ raise RuntimeError(
394
+ f"Bad statistics lines in {self._infile}"
395
+ ) from exc
396
+
397
+ self._nrow = nrow
398
+ self._ncs = 1
399
+ self._cn = nrow # columns will be stored after rows
400
+ self._bsz = nrow + ncol
401
+ self._bs = [' '] * self._bsz
402
+
403
+ # Number table: ns pre-formatted floating-point strings
404
+ # Indexed 1-based; ss[0] is a placeholder that is never read.
405
+ self._ss = [''] * (ns + 1)
406
+ z, zi = '', 0
407
+ for i in range(1, ns + 1):
408
+ if zi >= len(z):
409
+ b = self._rdline()
410
+ z = b if b is not None else ''
411
+ zi = 0
412
+ val, zi = _exform(z, zi, self._ss, self._kmax)
413
+ self._ss[i] = val
414
+ self._kmax = ns
415
+
416
+ # ROWS section
417
+ for i in range(1, nrow + 1):
418
+ buf = self._rdline() or ''
419
+ if i == 1:
420
+ self._emit('ROWS')
421
+ row_type = buf[0] if buf else '?'
422
+ row_name = buf[1:] if len(buf) > 1 else ''
423
+ if self.blanksubst:
424
+ row_name = row_name.replace(' ', self.blanksubst)
425
+ self._emit(f' {row_type} {row_name}')
426
+ self._namstore(i, row_name)
427
+
428
+ # Data sections
429
+ self._colout('COLUMNS', nz, 1)
430
+ self._colout('RHS', rhsnz, 2)
431
+ self._colout('RANGES', ranz, 3)
432
+ self._colout('BOUNDS', bdnz, 4)
433
+
434
+ if self._ncs > 1:
435
+ self._checkline()
436
+
437
+ self._emit('ENDATA')
438
+
439
+ # Look for another LP in the same file
440
+ self._canend = True
441
+ self._ncs = 1
442
+ buf = self._rdline()
443
+ if buf is None:
444
+ break
445
+
446
+ return ''.join(self._out)
447
+
448
+ def _emit(self, s: str) -> None:
449
+ self._out.append(s if s.endswith('\n') else s + '\n')
450
+
451
+ def _checkchar(self, s: str) -> None:
452
+ """Update the rolling checksum with all characters of line *s*.
453
+
454
+ Mirrors checkchar() in the C source. The hash accumulates one
455
+ character at a time; the final hash character (mod 92) is stored
456
+ in _chkbuf[_ncs].
457
+ """
458
+
459
+ x = 0
460
+ for c in s:
461
+ if c == '\n':
462
+ break
463
+ cv = _tr(c)
464
+ if x & 1:
465
+ x = (x >> 1) + cv + 16384
466
+ else:
467
+ x = (x >> 1) + cv
468
+ self._cfn[self._ncs] = self._infile
469
+ self._cfl[self._ncs] = self._nline
470
+ self._chkbuf[self._ncs] = TRTAB[x % 92]
471
+ self._ncs += 1
472
+
473
+ def _checkline(self) -> None:
474
+ """Read and verify the next checksum line from the input.
475
+
476
+ Mirrors checkline() in the C source. The expected checksum line
477
+ is _chkbuf[0.._ncs] = ' ' + (ncs-1 hash chars) + '\\n'.
478
+ """
479
+
480
+ self._canend = False
481
+ while True:
482
+ self._chkbuf[self._ncs] = '\n'
483
+ expected = ''.join(self._chkbuf[:self._ncs + 1])
484
+ self._nline += 1
485
+ raw = self._inf.readline()
486
+ if not raw:
487
+ self._early_eof()
488
+ if raw != expected:
489
+ if raw.startswith(':') and self._ncs <= 72:
490
+
491
+ # Mystery line embedded at a checksum position
492
+ self._ncs -= 1
493
+ self._checkchar(raw)
494
+ if self.keepmyst:
495
+ self._emit(raw[1:].rstrip('\n'))
496
+ continue
497
+ self._badchk(raw)
498
+ break
499
+ self._ncs = 1
500
+
501
+ def _early_eof(self) -> None:
502
+ self._lastl = ''
503
+ raise RuntimeError(
504
+ f"Premature end of file at line {self._nline} of {self._infile}"
505
+ )
506
+
507
+ def _badchk(self, got: str) -> None:
508
+ expected = ''.join(self._chkbuf[:self._ncs + 1])
509
+ # Identify the first mismatching slot to report the corrupt data line
510
+ for i in range(1, self._ncs + 1):
511
+ if i >= len(got) or got[i] != expected[i]:
512
+ raise RuntimeError(
513
+ f"Checksum error: data line {self._cfl[i]} of "
514
+ f"{self._cfn[i]} appears corrupt\n"
515
+ f" expected checksum line: {expected!r}\n"
516
+ f" got: {got!r}"
517
+ )
518
+ raise RuntimeError(
519
+ f"Checksum error:\n expected: {expected!r}\n got: {got!r}"
520
+ )
521
+
522
+ def _rdline(self) -> Optional[str]:
523
+ """Read one data line; return None at EOF when _canend is True."""
524
+
525
+ self._nline += 1
526
+ raw = self._inf.readline()
527
+ if not raw:
528
+ if self._canend:
529
+ return None
530
+ self._early_eof()
531
+
532
+ # Strip newline for processing (checkchar handles chars up to '\n')
533
+ s = raw.rstrip('\n')
534
+ self._checkchar(s)
535
+ if self._ncs >= 72:
536
+ self._checkline()
537
+
538
+ # Mystery line: starts with ':'
539
+ if s.startswith(':'):
540
+ if self.keepmyst:
541
+ self._emit(s[1:])
542
+ return self._rdline()
543
+
544
+ self._lastl = s
545
+ return s
546
+
547
+ def _namstore(self, i: int, s: str) -> None:
548
+ """Store up to 8 characters of *s* at position *i* (1-based)."""
549
+
550
+ if not (1 <= i <= self._bsz):
551
+ raise RuntimeError(
552
+ f"namstore: index {i} out of range [1, {self._bsz}]"
553
+ )
554
+ self._bs[i - 1] = (s + ' ')[:8]
555
+
556
+ def _namfetch(self, i: int) -> str:
557
+ """Retrieve the 8-character name stored at position *i* (1-based)."""
558
+
559
+ if not (1 <= i <= self._bsz):
560
+ raise RuntimeError(
561
+ f"namfetch: index {i} out of range [1, {self._bsz}]"
562
+ )
563
+ return self._bs[i - 1]
564
+
565
+ # Section output (COLUMNS / RHS / RANGES / BOUNDS)
566
+
567
+ def _colout(self, head: str, nz: int, what: int) -> None:
568
+ """Output one MPS data section.
569
+
570
+ Parameters
571
+ ----------
572
+ head : section header keyword
573
+ nz : number of nonzero/bound entries to process
574
+ what : 1=COLUMNS, 2=RHS, 3=RANGES, 4=BOUNDS
575
+ """
576
+
577
+ BT = self._BOUND_TYPES
578
+
579
+ if not nz:
580
+ # Empty section: COLUMNS and RHS still print the header line
581
+ if what <= 2:
582
+ self._emit(head)
583
+ return
584
+
585
+ first = True
586
+ k = 0 # pending nonzero counter (0 or 1)
587
+ z, zi = '', 0 # current data line and read position
588
+ curcol = ''
589
+ rc1 = rc2 = ''
590
+ rownm = ['', '']
591
+
592
+ while nz > 0:
593
+ nz -= 1
594
+
595
+ # Refill the line buffer when exhausted
596
+ if zi >= len(z):
597
+ b = self._rdline()
598
+ z = b if b is not None else ''
599
+ zi = 0
600
+
601
+ if first:
602
+ self._emit(head)
603
+ first = False
604
+
605
+ # Consume new-column markers (index == 0)
606
+ # A zero index signals that a column name follows inline in z.
607
+ n, zi = _exindx(z, zi)
608
+ while n == 0:
609
+ # Flush any pending single-nonzero output
610
+ if k:
611
+ self._emit(
612
+ f' {curcol:<8.8} {rownm[0]:<8.8} {rc1:.15}'
613
+ )
614
+ k = 0
615
+ # The column name is the remaining data in z after the index
616
+ seg = z[zi:] if zi < len(z) else head
617
+ if self.blanksubst:
618
+ seg = seg.replace(' ', self.blanksubst)
619
+ curcol = (seg + ' ')[:8]
620
+ if what == 1:
621
+ self._cn += 1
622
+ self._namstore(self._cn, seg)
623
+ # Advance to the next line for the nonzero data
624
+ b = self._rdline()
625
+ z = b if b is not None else ''
626
+ zi = 0
627
+ n, zi = _exindx(z, zi)
628
+
629
+ # Process the entry
630
+ if what >= 4:
631
+ # BOUNDS section: n is the bound-type index (1-based, 1..6)
632
+ if n >= 7:
633
+ raise RuntimeError(f"bad bound type index {n}")
634
+ if zi >= len(z):
635
+ b = self._rdline()
636
+ z = b if b is not None else ''
637
+ zi = 0
638
+ col_idx, zi = _exindx(z, zi)
639
+ rownm[0] = self._namfetch(self._nrow + col_idx)
640
+ if n >= 4:
641
+ # FR / MI / PL: no numeric value needed
642
+ # C: n-- >= 4 -> post-decrement; use n-1 as BT index
643
+ self._emit(
644
+ f' {BT[n - 1]} {curcol:<8.8} {rownm[0]:.8}'
645
+ )
646
+ continue
647
+ # UP / LO / FX: n decremented to 0-based BT index
648
+ n -= 1
649
+ else:
650
+ # COLUMNS / RHS / RANGES: n is the row index
651
+ rownm[k] = self._namfetch(n)
652
+
653
+ # Read the numeric value
654
+ if zi >= len(z):
655
+ b = self._rdline()
656
+ z = b if b is not None else ''
657
+ zi = 0
658
+ if k:
659
+ rc2, zi = _exform(z, zi, self._ss, self._kmax)
660
+ else:
661
+ rc1, zi = _exform(z, zi, self._ss, self._kmax)
662
+
663
+ # Emit the output line(s)
664
+ if what <= 3:
665
+ if self.just1:
666
+ self._emit(
667
+ f' {curcol:<8.8} {rownm[0]:<8.8} {rc1:.15}'
668
+ )
669
+ else:
670
+ k += 1
671
+ if k == 1:
672
+ continue # wait for a second nonzero on the same line
673
+ # Two nonzeros on one line (standard MPS compact format)
674
+ self._emit(
675
+ f' {curcol:<8.8} {rownm[0]:<8.8} '
676
+ f'{rc1:<15.15}{rownm[1]:<8.8} {rc2:.15}'
677
+ )
678
+ k = 0
679
+ else:
680
+ # BOUNDS with a value (UP / LO / FX)
681
+ self._emit(
682
+ f' {BT[n]} {curcol:<8.8} {rownm[0]:<8.8} {rc1:.15}'
683
+ )
684
+
685
+ # Flush any unpaired pending nonzero
686
+ if k:
687
+ self._emit(
688
+ f' {curcol:<8.8} {rownm[0]:<8.8} {rc1:.15}'
689
+ )
690
+
691
+
692
+ # ---------------------------------------------------------------------------
693
+ # Convenience wrappers
694
+ # ---------------------------------------------------------------------------
695
+
696
+ def expand_mps(
697
+ input_file: "str | TextIO",
698
+ output_file: "str | TextIO | None" = None,
699
+ *,
700
+ keepmyst: bool = True,
701
+ blanksubst: Optional[str] = None,
702
+ just1: bool = False,
703
+ ) -> Optional[str]:
704
+ """Expand a compressed netlib LP file to MPS format.
705
+
706
+ Parameters
707
+ ----------
708
+ input_file
709
+ Filesystem path (str) or readable file object.
710
+ output_file
711
+ Filesystem path, writable file object, or None.
712
+ When None the MPS text is returned as a string.
713
+ keepmyst
714
+ Include mystery-line extensions in output (default True).
715
+ blanksubst
716
+ Replace blanks in names with this character (e.g. '_').
717
+ just1
718
+ Output at most one nonzero per line.
719
+
720
+ Returns
721
+ -------
722
+ MPS text string when output_file is None; otherwise None.
723
+
724
+ Examples
725
+ --------
726
+ >>> mps = expand_mps("afiro.mps.netlib") # returns str
727
+ >>> expand_mps("afiro.mps.netlib", "afiro.mps") # writes file
728
+ >>> with open("afiro.mps.netlib") as f:
729
+ ... mps = expand_mps(f)
730
+ """
731
+
732
+ return EMPSConverter(
733
+ keepmyst=keepmyst,
734
+ blanksubst=blanksubst,
735
+ just1=just1,
736
+ ).convert(input_file, output_file)
737
+
738
+
739
+ def expand_mps_string(
740
+ compressed_text: str,
741
+ *,
742
+ keepmyst: bool = True,
743
+ blanksubst: Optional[str] = None,
744
+ just1: bool = False,
745
+ ) -> str:
746
+ """Expand compressed LP text supplied as a string; return MPS text.
747
+
748
+ Examples
749
+ --------
750
+ >>> with open("afiro.mps.netlib") as f:
751
+ ... raw = f.read()
752
+ >>> mps = expand_mps_string(raw)
753
+ """
754
+
755
+ result = expand_mps(
756
+ io.StringIO(compressed_text),
757
+ keepmyst=keepmyst,
758
+ blanksubst=blanksubst,
759
+ just1=just1,
760
+ )
761
+ assert result is not None
762
+ return result
763
+
764
+ def __main():
765
+
766
+ parser = argparse.ArgumentParser(
767
+ prog='gsimplex-emps',
768
+ description='Expand compressed netlib LP files to MPS format. See also: https://www.netlib.org/lp/data',
769
+ )
770
+ parser.add_argument(
771
+ 'files', nargs='*', metavar='FILE',
772
+ help='Compressed input file(s); reads stdin when none given.',
773
+ )
774
+ parser.add_argument(
775
+ '-1', dest='just1', action='store_true',
776
+ help='Output only one nonzero per line.',
777
+ )
778
+ parser.add_argument(
779
+ '-b', dest='blanksubst', action='store_const', const='_', default=None,
780
+ help="Replace blanks within names with '_'.",
781
+ )
782
+ parser.add_argument(
783
+ '-m', dest='keepmyst', action='store_false',
784
+ help='Skip mystery lines.',
785
+ )
786
+ args = parser.parse_args()
787
+
788
+ conv = EMPSConverter(
789
+ keepmyst=args.keepmyst,
790
+ blanksubst=args.blanksubst,
791
+ just1=args.just1,
792
+ )
793
+
794
+ if not args.files:
795
+ sys.stdout.write(conv.convert(sys.stdin)) # type: ignore[arg-type]
796
+ else:
797
+ for path in args.files:
798
+ result = conv.convert(path)
799
+ assert result is not None
800
+ sys.stdout.write(result)
801
+
802
+ return 0
803
+
804
+ if __name__ == '__main__':
805
+ sys.exit(__main())