diff --git a/src/mps_parser.c b/src/mps_parser.c index 6119429..6b4bd2e 100644 --- a/src/mps_parser.c +++ b/src/mps_parser.c @@ -796,10 +796,26 @@ static int parse_columns_section(MpsParserState *state, char **tokens, int n_tok return 0; } +// The set-name field of RHS, RANGES and BOUNDS lines is optional (Netlib and +// CUTEst/SIF files often omit it). If the first data field already names a +// known row (or column, for BOUNDS), there is no set name; otherwise the +// first field is the set name and is skipped. +static bool is_known_row(const MpsParserState *state, const char *name) +{ + if (state->objective_row_name && strcmp(name, state->objective_row_name) == 0) + return true; + return namemap_get(&state->row_map, name) != -1; +} + +static int row_data_start(const MpsParserState *state, char **tokens, int n_tokens) +{ + return (n_tokens > 0 && is_known_row(state, tokens[0])) ? 0 : 1; +} + static int parse_rhs_section(MpsParserState *state, char **tokens, int n_tokens) { - for (int i = 1; i + 1 < n_tokens; i += 2) + for (int i = row_data_start(state, tokens, n_tokens); i + 1 < n_tokens; i += 2) { const char *row_name = tokens[i]; double value = atof(tokens[i + 1]); @@ -832,7 +848,7 @@ static int parse_rhs_section(MpsParserState *state, char **tokens, int n_tokens) static int parse_ranges_section(MpsParserState *state, char **tokens, int n_tokens) { - for (int i = 1; i + 1 < n_tokens; i += 2) + for (int i = row_data_start(state, tokens, n_tokens); i + 1 < n_tokens; i += 2) { const char *row_name = tokens[i]; double range_val = atof(tokens[i + 1]); @@ -870,13 +886,17 @@ static int parse_ranges_section(MpsParserState *state, char **tokens, int n_toke static int parse_bounds_section(MpsParserState *state, char **tokens, int n_tokens) { - if (n_tokens < 3) + if (n_tokens < 2) return 0; const char *bound_type = tokens[0]; - const char *col_name = tokens[2]; - double value = (n_tokens > 3) ? atof(tokens[3]) : 0.0; + // "type [set_name] column [value]" + int col_pos = (namemap_get(&state->col_map, tokens[1]) != -1) ? 1 : 2; + if (col_pos >= n_tokens) + return 0; + const char *col_name = tokens[col_pos]; + double value = (col_pos + 1 < n_tokens) ? atof(tokens[col_pos + 1]) : 0.0; int col_idx = namemap_get(&state->col_map, col_name); if (col_idx == -1) diff --git a/test/test_read_mps.py b/test/test_read_mps.py index aa003cb..856e6af 100644 --- a/test/test_read_mps.py +++ b/test/test_read_mps.py @@ -51,6 +51,32 @@ "OBJSENSE\n MAXIMIZE\nROWS\n", ) +# MPS_MIN plus a RANGES entry, explicit bounds and an objective constant, +# with the optional set-name field omitted in every RHS / RANGES / BOUNDS +# line (as in Netlib / CUTEst files such as BLEND, SIERRA, GFRD-PNC, DFL001). +MPS_NAMELESS = """NAME TESTLP +ROWS + N COST + E R1 + L R2 + L R3 +COLUMNS + X1 COST 1.0 R1 1.0 + X1 R3 3.0 + X2 COST 1.0 R1 2.0 + X2 R2 1.0 R3 2.0 +RHS + R1 5.0 R2 2.0 + R3 8.0 COST -1.5 +RANGES + R3 6.0 +BOUNDS + UP X1 4.0 + LO X2 0.5 + MI X1 +ENDATA +""" + @pytest.fixture def mps_min_file(tmp_path): @@ -130,6 +156,26 @@ def test_read_objsense_maximize(mps_max_file): assert model.ModelSense == PDLP.MAXIMIZE +def test_read_sections_without_set_name(tmp_path): + """ + Entries without a set name must still reach the right rows/columns + (the reader used to drop the whole RHS section in this case). + """ + path = tmp_path / "nameless.mps" + path.write_text(MPS_NAMELESS) + model = cupdlpx.read(str(path)) + assert model.num_vars == 2 + assert model.num_constrs == 3 + assert np.allclose(model.c, [1.0, 1.0]) + # RHS on the objective row is the negated objective constant + assert np.isclose(model.c0, 1.5) + # RHS and RANGES: R3 <= 8 with range 6 becomes 2 <= R3 <= 8 + assert np.allclose(model.constr_lb, [5.0, -np.inf, 2.0]) + assert np.allclose(model.constr_ub, [5.0, 2.0, 8.0]) + assert np.allclose(model.lb, [-np.inf, 0.5]) + assert np.allclose(model.ub, [4.0, np.inf]) + + def test_read_missing_file_raises(tmp_path): """ Reading a nonexistent file should raise FileNotFoundError.