1use std::collections::HashSet;
15use std::fs::File;
16use std::io::{BufRead, BufReader, Read, Write};
17use std::path::Path;
18
19use oximo_core::{Constraint, Domain, Model, ModelKind, ObjectiveSense, Sense, var_name};
20use oximo_expr::{Expr, QuadraticTerms, VarId, describe_nonlinear_term, extract_quadratic};
21use rustc_hash::FxHashMap;
22
23use crate::error::IoError;
24
25#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
32pub enum MpsQuadraticFormat {
33 #[default]
35 Gurobi,
36 Cplex,
38 Mosek,
40}
41
42#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
44pub struct MpsReadOptions {
45 pub quadratic_format: MpsQuadraticFormat,
47}
48
49#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
51pub struct MpsWriteOptions {
52 pub quadratic_format: MpsQuadraticFormat,
54}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57enum RowKind {
58 Free,
59 Greater,
60 Less,
61 Equal,
62}
63
64struct ParsedRow {
65 name: String,
66 kind: RowKind,
67 lower: f64,
68 upper: f64,
69 linear: FxHashMap<usize, f64>,
70 quadratic: FxHashMap<(usize, usize), f64>,
71}
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74enum ColumnKind {
75 Continuous,
76 Integer,
77 Binary,
78 SemiContinuous,
79 SemiInteger,
80}
81
82struct ParsedColumn {
83 name: String,
84 lower: f64,
85 upper: f64,
86 kind: ColumnKind,
87 default_bounds: bool,
88 lower_explicit: bool,
89 marker_placement: MarkerPlacement,
90}
91
92#[derive(Default)]
93struct QuadraticTriangle {
94 upper: Option<f64>,
95 lower: Option<f64>,
96}
97
98#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
99enum MarkerPlacement {
100 #[default]
101 Unseen,
102 Outside,
103 Inside,
104 Mixed,
105}
106
107#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)]
108enum SectionRank {
109 Name,
110 ObjSense,
111 Rows,
112 Columns,
113 Rhs,
114 Ranges,
115 Bounds,
116 Quadratic,
117 End,
118}
119
120#[derive(Clone, Debug)]
121enum Section {
122 Name,
123 ObjSense,
124 Rows,
125 Columns,
126 Rhs,
127 Ranges,
128 Bounds,
129 QuadObj,
130 QMatrix,
131 QcMatrix(String),
132 QSec(String),
133 End,
134}
135
136impl Section {
137 fn rank(&self) -> SectionRank {
138 match self {
139 Self::Name => SectionRank::Name,
140 Self::ObjSense => SectionRank::ObjSense,
141 Self::Rows => SectionRank::Rows,
142 Self::Columns => SectionRank::Columns,
143 Self::Rhs => SectionRank::Rhs,
144 Self::Ranges => SectionRank::Ranges,
145 Self::Bounds => SectionRank::Bounds,
146 Self::QuadObj | Self::QMatrix | Self::QcMatrix(_) | Self::QSec(_) => {
147 SectionRank::Quadratic
148 }
149 Self::End => SectionRank::End,
150 }
151 }
152
153 fn name(&self) -> &'static str {
154 match self {
155 Self::Name => "NAME",
156 Self::ObjSense => "OBJSENSE",
157 Self::Rows => "ROWS",
158 Self::Columns => "COLUMNS",
159 Self::Rhs => "RHS",
160 Self::Ranges => "RANGES",
161 Self::Bounds => "BOUNDS",
162 Self::QuadObj => "QUADOBJ",
163 Self::QMatrix => "QMATRIX",
164 Self::QcMatrix(_) => "QCMATRIX",
165 Self::QSec(_) => "QSECTION",
166 Self::End => "ENDATA",
167 }
168 }
169}
170
171struct Field<'a> {
172 text: &'a str,
173 column: usize,
174}
175
176struct ParsedMps {
177 name: String,
178 objective_row: Option<String>,
179 sense: Option<ObjectiveSense>,
180 legacy_sense: Option<ObjectiveSense>,
181 rows: Vec<ParsedRow>,
182 row_index: FxHashMap<String, usize>,
183 columns: Vec<ParsedColumn>,
184 column_index: FxHashMap<String, usize>,
185 objective_linear: Vec<f64>,
186 objective_quadratic: FxHashMap<(usize, usize), f64>,
187 quadratic_triangles: FxHashMap<(Option<usize>, usize, usize), QuadraticTriangle>,
188 objective_constant: f64,
189 intorg: bool,
190 rhs_vector: Option<String>,
191 range_vector: Option<String>,
192 bounds_vector: Option<String>,
193 objective_quadratic_source: Option<&'static str>,
194 quadratic_rows: HashSet<String>,
195 seen_sections: u8,
196}
197
198const MPS_INFINITY_SENTINEL: f64 = 1e30;
199const SEEN_ROWS: u8 = 1;
200const SEEN_COLUMNS: u8 = 2;
201const SEEN_END: u8 = 4;
202
203impl ParsedMps {
204 fn new(fallback_name: &str) -> Self {
205 Self {
206 name: fallback_name.to_owned(),
207 objective_row: None,
208 sense: None,
209 legacy_sense: None,
210 rows: Vec::new(),
211 row_index: FxHashMap::default(),
212 columns: Vec::new(),
213 column_index: FxHashMap::default(),
214 objective_linear: Vec::new(),
215 objective_quadratic: FxHashMap::default(),
216 quadratic_triangles: FxHashMap::default(),
217 objective_constant: 0.0,
218 intorg: false,
219 rhs_vector: None,
220 range_vector: None,
221 bounds_vector: None,
222 objective_quadratic_source: None,
223 quadratic_rows: HashSet::new(),
224 seen_sections: 0,
225 }
226 }
227
228 fn add_column(&mut self, name: &str, inside_marker: bool) -> usize {
229 let index = if let Some(index) = self.column_index.get(name) {
230 *index
231 } else {
232 let index = self.columns.len();
233 self.columns.push(ParsedColumn {
234 name: name.to_owned(),
235 lower: 0.0,
236 upper: f64::INFINITY,
237 kind: ColumnKind::Continuous,
238 default_bounds: true,
239 lower_explicit: false,
240 marker_placement: MarkerPlacement::Unseen,
241 });
242 self.column_index.insert(name.to_owned(), index);
243 self.objective_linear.push(0.0);
244 index
245 };
246 let column = &mut self.columns[index];
247 if inside_marker {
248 column.marker_placement = match column.marker_placement {
249 MarkerPlacement::Unseen | MarkerPlacement::Inside => MarkerPlacement::Inside,
250 MarkerPlacement::Outside | MarkerPlacement::Mixed => MarkerPlacement::Mixed,
251 };
252 column.kind = ColumnKind::Integer;
253 if column.default_bounds {
254 column.upper = 1.0;
255 }
256 } else {
257 column.marker_placement = match column.marker_placement {
258 MarkerPlacement::Unseen | MarkerPlacement::Outside => MarkerPlacement::Outside,
259 MarkerPlacement::Inside | MarkerPlacement::Mixed => MarkerPlacement::Mixed,
260 };
261 }
262 index
263 }
264}
265
266fn invalid_mps(line: usize, column: usize, message: impl Into<String>) -> IoError {
267 IoError::InvalidMps { line, column, message: message.into() }
268}
269
270fn fields(line: &str) -> Vec<Field<'_>> {
271 let mut out = Vec::new();
272 let mut start = None;
273 for (offset, ch) in line.char_indices() {
274 if ch.is_whitespace() {
275 if let Some(begin) = start.take() {
276 out.push(Field { text: &line[begin..offset], column: begin + 1 });
277 }
278 } else if start.is_none() {
279 start = Some(offset);
280 }
281 }
282 if let Some(begin) = start {
283 out.push(Field { text: &line[begin..], column: begin + 1 });
284 }
285 out
286}
287
288fn parse_number(field: &Field<'_>, line: usize) -> Result<f64, IoError> {
289 let normalized;
290 let text = if field.text.contains(['d', 'D']) {
291 normalized = field.text.replace(['d', 'D'], "E");
292 normalized.as_str()
293 } else {
294 field.text
295 };
296 let value = text
297 .parse::<f64>()
298 .map_err(|_| invalid_mps(line, field.column, format!("invalid number {:?}", field.text)))?;
299 if !value.is_finite() {
300 return Err(invalid_mps(line, field.column, "numeric fields must be finite"));
301 }
302 Ok(value)
303}
304
305fn objective_sense(field: &Field<'_>, line: usize) -> Result<ObjectiveSense, IoError> {
306 match field.text.to_ascii_uppercase().as_str() {
307 "MIN" | "MINIMIZE" => Ok(ObjectiveSense::Minimize),
308 "MAX" | "MAXIMIZE" => Ok(ObjectiveSense::Maximize),
309 _ => Err(invalid_mps(line, field.column, "objective sense must be MIN or MAX")),
310 }
311}
312
313fn parse_legacy_sense(line: &str) -> Option<ObjectiveSense> {
314 let comment = line.trim_start_matches('*').trim();
315 let value = comment.strip_prefix("sense:").or_else(|| comment.strip_prefix("SENSE:"))?;
316 match value.trim().to_ascii_lowercase().as_str() {
317 "minimize" | "min" => Some(ObjectiveSense::Minimize),
318 "maximize" | "max" => Some(ObjectiveSense::Maximize),
319 _ => None,
320 }
321}
322
323fn header(items: &[Field<'_>], current: &Section) -> Option<Section> {
324 let first = items.first()?.text.to_ascii_uppercase();
325 match (first.as_str(), items.len()) {
326 ("NAME", _) if matches!(current, Section::Name) => Some(Section::Name),
327 ("OBJSENSE", 1 | 2) => Some(Section::ObjSense),
328 ("ROWS", 1) => Some(Section::Rows),
329 ("COLUMNS", 1) => Some(Section::Columns),
330 ("RHS", 1) => Some(Section::Rhs),
331 ("RANGES", 1) => Some(Section::Ranges),
332 ("BOUNDS", 1) => Some(Section::Bounds),
333 ("QUADOBJ", 1) => Some(Section::QuadObj),
334 ("QMATRIX", 1) => Some(Section::QMatrix),
335 ("QCMATRIX", 2) => Some(Section::QcMatrix(items[1].text.to_owned())),
336 ("QSECTION", 2) => Some(Section::QSec(items[1].text.to_owned())),
337 ("ENDATA", 1) => Some(Section::End),
338 _ => None,
339 }
340}
341
342fn select_vector(
343 selected: &mut Option<String>,
344 candidate: Option<&Field<'_>>,
345 section: &str,
346) -> Result<(), IoError> {
347 let Some(candidate) = candidate else { return Ok(()) };
348 if let Some(existing) = selected {
349 if existing != candidate.text {
350 return Err(IoError::UnsupportedMps {
351 section: section.into(),
352 feature: format!(
353 "multiple data vectors ({existing:?} and {:?}) are not supported",
354 candidate.text
355 ),
356 });
357 }
358 } else {
359 *selected = Some(candidate.text.to_owned());
360 }
361 Ok(())
362}
363
364fn parse_row(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
365 if items.len() < 2 {
366 return Err(invalid_mps(line, 1, "ROWS records require a sense and row name"));
367 }
368 let name = items[1].text;
369 if data.objective_row.as_deref() == Some(name) || data.row_index.contains_key(name) {
370 return Err(invalid_mps(line, items[1].column, format!("duplicate row name {name:?}")));
371 }
372 let kind = match items[0].text.to_ascii_uppercase().as_str() {
373 "N" => RowKind::Free,
374 "G" => RowKind::Greater,
375 "L" => RowKind::Less,
376 "E" => RowKind::Equal,
377 _ => {
378 return Err(invalid_mps(line, items[0].column, "row sense must be N, G, L, or E"));
379 }
380 };
381 if kind == RowKind::Free && data.objective_row.is_none() {
382 data.objective_row = Some(name.to_owned());
383 return Ok(());
384 }
385 let (lower, upper) = match kind {
386 RowKind::Free => (f64::NEG_INFINITY, f64::INFINITY),
387 RowKind::Greater => (0.0, f64::INFINITY),
388 RowKind::Less => (f64::NEG_INFINITY, 0.0),
389 RowKind::Equal => (0.0, 0.0),
390 };
391 let index = data.rows.len();
392 data.rows.push(ParsedRow {
393 name: name.to_owned(),
394 kind,
395 lower,
396 upper,
397 linear: FxHashMap::default(),
398 quadratic: FxHashMap::default(),
399 });
400 data.row_index.insert(name.to_owned(), index);
401 Ok(())
402}
403
404fn parse_coefficient(
405 data: &mut ParsedMps,
406 column: usize,
407 row: &Field<'_>,
408 value: &Field<'_>,
409 line: usize,
410) -> Result<(), IoError> {
411 let value = parse_number(value, line)?;
412 if data.objective_row.as_deref() == Some(row.text) {
413 data.objective_linear[column] += value;
414 return Ok(());
415 }
416 let row_index = data.row_index.get(row.text).copied().ok_or_else(|| {
417 invalid_mps(line, row.column, format!("unknown ROWS name {:?}", row.text))
418 })?;
419 *data.rows[row_index].linear.entry(column).or_insert(0.0) += value;
420 Ok(())
421}
422
423fn unquote_marker(value: &str) -> String {
424 value.trim_matches(|c| c == '\'' || c == '"').to_ascii_uppercase()
425}
426
427fn parse_columns(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
428 if items.len() == 3 && unquote_marker(items[1].text) == "MARKER" {
429 match unquote_marker(items[2].text).as_str() {
430 "INTORG" if !data.intorg => data.intorg = true,
431 "INTEND" if data.intorg => data.intorg = false,
432 "INTORG" => return Err(invalid_mps(line, items[2].column, "nested INTORG marker")),
433 "INTEND" => {
434 return Err(invalid_mps(line, items[2].column, "INTEND without INTORG"));
435 }
436 _ => return Err(invalid_mps(line, items[2].column, "unknown MARKER value")),
437 }
438 return Ok(());
439 }
440 if items.len() != 3 && items.len() != 5 {
441 return Err(invalid_mps(line, 1, "COLUMNS records require three or five fields"));
442 }
443 let column = data.add_column(items[0].text, data.intorg);
444 let column_data = &data.columns[column];
445 if column_data.marker_placement == MarkerPlacement::Mixed {
446 return Err(invalid_mps(
447 line,
448 items[0].column,
449 format!("integer column {:?} also appears outside INTORG/INTEND", items[0].text),
450 ));
451 }
452 parse_coefficient(data, column, &items[1], &items[2], line)?;
453 if items.len() == 5 {
454 parse_coefficient(data, column, &items[3], &items[4], line)?;
455 }
456 Ok(())
457}
458
459fn parse_rhs_value(
460 data: &mut ParsedMps,
461 row: &Field<'_>,
462 value: &Field<'_>,
463 line: usize,
464) -> Result<(), IoError> {
465 let value = parse_number(value, line)?;
466 if data.objective_row.as_deref() == Some(row.text) {
467 data.objective_constant = -value;
468 return Ok(());
469 }
470 let index = data.row_index.get(row.text).copied().ok_or_else(|| {
471 invalid_mps(line, row.column, format!("unknown ROWS name {:?}", row.text))
472 })?;
473 let parsed_row = &mut data.rows[index];
474 match parsed_row.kind {
475 RowKind::Greater => parsed_row.lower = value,
476 RowKind::Less => parsed_row.upper = value,
477 RowKind::Equal => {
478 parsed_row.lower = value;
479 parsed_row.upper = value;
480 }
481 RowKind::Free => {
482 return Err(invalid_mps(line, row.column, "a free N row cannot have an RHS"));
483 }
484 }
485 Ok(())
486}
487
488fn parse_rhs(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
489 let (vector, pairs): (Option<&Field<'_>>, &[Field<'_>]) = match items.len() {
490 2 | 4 => (None, items),
491 3 | 5 => (Some(&items[0]), &items[1..]),
492 _ => return Err(invalid_mps(line, 1, "RHS records require two to five fields")),
493 };
494 select_vector(&mut data.rhs_vector, vector, "RHS")?;
495 for pair in pairs.chunks_exact(2) {
496 parse_rhs_value(data, &pair[0], &pair[1], line)?;
497 }
498 Ok(())
499}
500
501fn parse_range_value(
502 data: &mut ParsedMps,
503 row: &Field<'_>,
504 value: &Field<'_>,
505 line: usize,
506) -> Result<(), IoError> {
507 let value = parse_number(value, line)?;
508 let index = data.row_index.get(row.text).copied().ok_or_else(|| {
509 invalid_mps(line, row.column, format!("unknown ROWS name {:?}", row.text))
510 })?;
511 let parsed_row = &mut data.rows[index];
512 match parsed_row.kind {
513 RowKind::Greater => parsed_row.upper = parsed_row.lower + value.abs(),
514 RowKind::Less => parsed_row.lower = parsed_row.upper - value.abs(),
515 RowKind::Equal if value >= 0.0 => parsed_row.upper = parsed_row.lower + value,
516 RowKind::Equal => parsed_row.lower = parsed_row.upper + value,
517 RowKind::Free => {
518 return Err(invalid_mps(line, row.column, "a free N row cannot have a range"));
519 }
520 }
521 Ok(())
522}
523
524fn parse_ranges(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
525 let (vector, pairs): (Option<&Field<'_>>, &[Field<'_>]) = match items.len() {
526 2 | 4 => (None, items),
527 3 | 5 => (Some(&items[0]), &items[1..]),
528 _ => return Err(invalid_mps(line, 1, "RANGES records require two to five fields")),
529 };
530 select_vector(&mut data.range_vector, vector, "RANGES")?;
531 for pair in pairs.chunks_exact(2) {
532 parse_range_value(data, &pair[0], &pair[1], line)?;
533 }
534 Ok(())
535}
536
537fn parse_bound(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
538 if !(2..=4).contains(&items.len()) {
539 return Err(invalid_mps(line, 1, "BOUNDS records require two to four fields"));
540 }
541 let bound_type = items[0].text.to_ascii_uppercase();
542 let requires_value =
543 matches!(bound_type.as_str(), "FX" | "UP" | "LO" | "LI" | "UI" | "SC" | "SI");
544 let (vector, column_field, value_field) = match (items.len(), requires_value) {
545 (2, _) => (None, &items[1], None),
546 (3, true) => (None, &items[1], Some(&items[2])),
547 (3, false) => (Some(&items[1]), &items[2], None),
548 (4, _) => (Some(&items[1]), &items[2], Some(&items[3])),
549 _ => unreachable!(),
550 };
551 select_vector(&mut data.bounds_vector, vector, "BOUNDS")?;
552 let column_index = data.column_index.get(column_field.text).copied().ok_or_else(|| {
553 invalid_mps(line, column_field.column, format!("unknown column {:?}", column_field.text))
554 })?;
555 let value = value_field
556 .map(|field| {
557 parse_number(field, line)
558 .map(|value| if value >= MPS_INFINITY_SENTINEL { f64::INFINITY } else { value })
559 })
560 .transpose()?;
561 let column = &mut data.columns[column_index];
562 if column.default_bounds && column.kind == ColumnKind::Integer {
563 column.upper = f64::INFINITY;
564 }
565 column.default_bounds = false;
566 match (bound_type.as_str(), value) {
567 ("PL", None) => column.upper = f64::INFINITY,
568 ("MI", None) => {
569 column.lower = f64::NEG_INFINITY;
570 column.lower_explicit = true;
571 }
572 ("FR", None | Some(_)) => {
573 column.lower = f64::NEG_INFINITY;
574 column.upper = f64::INFINITY;
575 column.lower_explicit = true;
576 }
577 ("BV", None | Some(_)) => {
578 column.lower = 0.0;
579 column.upper = 1.0;
580 column.kind = ColumnKind::Binary;
581 column.lower_explicit = true;
582 }
583 ("FX", Some(value)) => {
584 column.lower = value;
585 column.upper = value;
586 column.lower_explicit = true;
587 }
588 ("UP", Some(value)) => {
589 if value < 0.0 && !column.lower_explicit {
590 column.lower = f64::NEG_INFINITY;
591 }
592 column.upper = value;
593 }
594 ("LO", Some(value)) => {
595 column.lower = value;
596 column.lower_explicit = true;
597 }
598 ("LI", Some(value)) => {
599 column.lower = value;
600 column.kind = ColumnKind::Integer;
601 column.lower_explicit = true;
602 }
603 ("UI", Some(value)) => {
604 column.upper = value;
605 column.kind = ColumnKind::Integer;
606 }
607 ("SC", Some(value)) => {
608 if !column.lower_explicit {
609 column.lower = 1.0;
610 }
611 column.upper = value;
612 column.kind = ColumnKind::SemiContinuous;
613 }
614 ("SI", Some(value)) => {
615 if !column.lower_explicit {
616 column.lower = 1.0;
617 }
618 column.upper = value;
619 column.kind = ColumnKind::SemiInteger;
620 }
621 _ => {
622 return Err(invalid_mps(
623 line,
624 items[0].column,
625 format!("invalid {bound_type} bound record"),
626 ));
627 }
628 }
629 Ok(())
630}
631
632fn quadratic_coefficient(
633 format: MpsQuadraticFormat,
634 diagonal: bool,
635 objective: bool,
636 value: f64,
637) -> f64 {
638 if objective || format != MpsQuadraticFormat::Gurobi {
639 if diagonal { value / 2.0 } else { value }
640 } else if diagonal {
641 value
642 } else {
643 2.0 * value
644 }
645}
646
647fn qsection_targets_objective(data: &ParsedMps, name: &str, line: usize) -> Result<bool, IoError> {
648 if name == "OBJ" {
649 if data.objective_row.as_deref() != Some("OBJ") && data.row_index.contains_key("OBJ") {
650 return Err(invalid_mps(
651 line,
652 1,
653 "QSECTION OBJ is ambiguous because OBJ is also a constraint row",
654 ));
655 }
656 return Ok(true);
657 }
658 Ok(data.objective_row.as_deref() == Some(name))
659}
660
661fn parse_quadratic_record(
662 data: &mut ParsedMps,
663 section: &Section,
664 items: &[Field<'_>],
665 line: usize,
666 options: MpsReadOptions,
667) -> Result<(), IoError> {
668 if items.len() != 3 {
669 return Err(invalid_mps(line, 1, "quadratic records require three fields"));
670 }
671 let left = data.column_index.get(items[0].text).copied().ok_or_else(|| {
672 invalid_mps(line, items[0].column, format!("unknown column {:?}", items[0].text))
673 })?;
674 let right = data.column_index.get(items[1].text).copied().ok_or_else(|| {
675 invalid_mps(line, items[1].column, format!("unknown column {:?}", items[1].text))
676 })?;
677 let pair = if left <= right { (left, right) } else { (right, left) };
678 let value = parse_number(&items[2], line)?;
679 let objective = match section {
680 Section::QuadObj | Section::QMatrix => true,
681 Section::QSec(name) => qsection_targets_objective(data, name, line)?,
682 Section::QcMatrix(_) => false,
683 _ => unreachable!("quadratic parser called outside quadratic section"),
684 };
685 let row = if objective {
686 None
687 } else {
688 let (Section::QcMatrix(row_name) | Section::QSec(row_name)) = section else {
689 unreachable!()
690 };
691 Some(data.row_index.get(row_name).copied().ok_or_else(|| {
692 invalid_mps(line, 1, format!("quadratic section names unknown row {row_name:?}"))
693 })?)
694 };
695 let coefficient =
696 quadratic_coefficient(options.quadratic_format, left == right, objective, value);
697 let mut store_coefficient = true;
698 if matches!(section, Section::QMatrix | Section::QcMatrix(_)) && left != right {
699 let triangle = data.quadratic_triangles.entry((row, pair.0, pair.1)).or_default();
700 let first_record = triangle.upper.is_none() && triangle.lower.is_none();
701 let side = if left < right { &mut triangle.upper } else { &mut triangle.lower };
702 let same_side = side.is_some();
703 *side = Some(side.take().unwrap_or(0.0) + coefficient);
704 store_coefficient = first_record || same_side;
705 }
706 if !store_coefficient {
707 return Ok(());
708 }
709 if objective {
710 *data.objective_quadratic.entry(pair).or_insert(0.0) += coefficient;
711 return Ok(());
712 }
713 *data.rows[row.expect("quadratic constraint row")].quadratic.entry(pair).or_insert(0.0) +=
714 coefficient;
715 Ok(())
716}
717
718fn validate_quadratic_triangles(data: &ParsedMps, line: usize) -> Result<(), IoError> {
719 for ((_, left, right), triangle) in &data.quadratic_triangles {
720 if let (Some(upper), Some(lower)) = (triangle.upper, triangle.lower)
721 && upper.total_cmp(&lower).is_ne()
722 {
723 return Err(invalid_mps(
724 line,
725 1,
726 format!(
727 "asymmetric quadratic matrix entries for columns {:?} and {:?}",
728 data.columns[*left].name, data.columns[*right].name
729 ),
730 ));
731 }
732 }
733 Ok(())
734}
735
736fn begin_quadratic_section(
737 data: &mut ParsedMps,
738 section: &Section,
739 line: usize,
740) -> Result<(), IoError> {
741 let source = section.name();
742 let objective = match section {
743 Section::QuadObj | Section::QMatrix => true,
744 Section::QSec(name) => qsection_targets_objective(data, name, line)?,
745 Section::QcMatrix(_) => false,
746 _ => return Ok(()),
747 };
748 if objective {
749 if let Some(existing) = data.objective_quadratic_source {
750 return Err(invalid_mps(
751 line,
752 1,
753 format!("objective quadratic data already supplied by {existing}"),
754 ));
755 }
756 data.objective_quadratic_source = Some(source);
757 return Ok(());
758 }
759 let (Section::QcMatrix(row_name) | Section::QSec(row_name)) = section else { unreachable!() };
760 if !data.row_index.contains_key(row_name) {
761 return Err(invalid_mps(
762 line,
763 1,
764 format!("quadratic section names unknown row {row_name:?}"),
765 ));
766 }
767 if !data.quadratic_rows.insert(row_name.clone()) {
768 return Err(invalid_mps(
769 line,
770 1,
771 format!("duplicate quadratic section for row {row_name:?}"),
772 ));
773 }
774 Ok(())
775}
776
777fn check_section_transition(
778 previous: &Section,
779 next: &Section,
780 line: usize,
781) -> Result<(), IoError> {
782 if next.rank() < previous.rank() {
783 return Err(invalid_mps(
784 line,
785 1,
786 format!("{} section appears after {}", next.name(), previous.name()),
787 ));
788 }
789 if next.rank() == previous.rank()
790 && !matches!(next.rank(), SectionRank::Quadratic)
791 && !matches!((previous, next), (Section::Name, Section::Name))
792 {
793 return Err(invalid_mps(line, 1, format!("duplicate {} section", next.name())));
794 }
795 Ok(())
796}
797
798fn parse_mps_line(
799 data: &mut ParsedMps,
800 section: &mut Section,
801 saw_name: &mut bool,
802 line: &str,
803 line_no: usize,
804 options: MpsReadOptions,
805) -> Result<(), IoError> {
806 let trimmed = line.trim();
807 if trimmed.is_empty() {
808 return Ok(());
809 }
810 if trimmed.starts_with('*') {
811 if data.sense.is_none() {
812 data.legacy_sense = parse_legacy_sense(trimmed).or(data.legacy_sense);
813 }
814 return Ok(());
815 }
816 if data.seen_sections & SEEN_END != 0 {
817 return Err(invalid_mps(line_no, 1, "content after ENDATA"));
818 }
819 let items = fields(line);
820 if let Some(first) = items.first() {
821 let keyword = first.text.to_ascii_uppercase();
822 if items.len() == 1 && matches!(keyword.as_str(), "SOS" | "INDICATORS") {
823 return Err(IoError::UnsupportedMps {
824 section: keyword,
825 feature: "not represented by oximo-core".into(),
826 });
827 }
828 }
829 if let Some(next) = header(&items, section) {
830 if matches!(next, Section::Name) {
831 if *saw_name {
832 return Err(invalid_mps(line_no, 1, "duplicate NAME section"));
833 }
834 *saw_name = true;
835 if items.len() > 1 {
836 data.name = items[1..].iter().map(|field| field.text).collect::<Vec<_>>().join(" ");
837 }
838 return Ok(());
839 }
840 if !*saw_name {
841 return Err(invalid_mps(line_no, 1, "the first data line must be NAME"));
842 }
843 check_section_transition(section, &next, line_no)?;
844 if data.intorg && !matches!(next, Section::Columns) {
845 return Err(invalid_mps(line_no, 1, "missing INTEND marker before COLUMNS ends"));
846 }
847 match &next {
848 Section::ObjSense if items.len() == 2 => {
849 data.sense = Some(objective_sense(&items[1], line_no)?);
850 }
851 Section::Rows => data.seen_sections |= SEEN_ROWS,
852 Section::Columns => data.seen_sections |= SEEN_COLUMNS,
853 Section::QuadObj | Section::QMatrix | Section::QcMatrix(_) | Section::QSec(_) => {
854 begin_quadratic_section(data, &next, line_no)?;
855 }
856 Section::End => data.seen_sections |= SEEN_END,
857 _ => {}
858 }
859 *section = next;
860 return Ok(());
861 }
862 if !*saw_name {
863 return Err(invalid_mps(line_no, 1, "the first data line must be NAME"));
864 }
865 match section {
866 Section::ObjSense => {
867 if items.len() != 1 {
868 return Err(invalid_mps(line_no, 1, "OBJSENSE data requires one field"));
869 }
870 data.sense = Some(objective_sense(&items[0], line_no)?);
871 }
872 Section::Rows => parse_row(data, &items, line_no)?,
873 Section::Columns => parse_columns(data, &items, line_no)?,
874 Section::Rhs => parse_rhs(data, &items, line_no)?,
875 Section::Ranges => parse_ranges(data, &items, line_no)?,
876 Section::Bounds => parse_bound(data, &items, line_no)?,
877 Section::QuadObj | Section::QMatrix | Section::QcMatrix(_) | Section::QSec(_) => {
878 parse_quadratic_record(data, section, &items, line_no, options)?;
879 }
880 Section::Name => return Err(invalid_mps(line_no, 1, "expected NAME header")),
881 Section::End => unreachable!(),
882 }
883 Ok(())
884}
885
886fn parse_mps<R: BufRead>(
887 mut input: R,
888 fallback_name: &str,
889 options: MpsReadOptions,
890) -> Result<Model, IoError> {
891 let mut data = ParsedMps::new(fallback_name);
892 let mut section = Section::Name;
893 let mut saw_name = false;
894 let mut line_buffer = String::new();
895 let mut last_line = 0;
896 loop {
897 line_buffer.clear();
898 if input.read_line(&mut line_buffer)? == 0 {
899 break;
900 }
901 last_line += 1;
902 let line = line_buffer.trim_end_matches(['\r', '\n']);
903 parse_mps_line(&mut data, &mut section, &mut saw_name, line, last_line, options)?;
904 }
905 let last_line = last_line.max(1);
906 if data.intorg {
907 return Err(invalid_mps(last_line, 1, "missing INTEND marker"));
908 }
909 if data.seen_sections & SEEN_ROWS == 0 {
910 return Err(invalid_mps(last_line, 1, "missing ROWS section"));
911 }
912 if data.seen_sections & SEEN_COLUMNS == 0 {
913 return Err(invalid_mps(last_line, 1, "missing COLUMNS section"));
914 }
915 if data.seen_sections & SEEN_END == 0 {
916 return Err(invalid_mps(last_line, 1, "missing ENDATA"));
917 }
918 validate_quadratic_triangles(&data, last_line)?;
919 build_mps_model(data)
920}
921
922fn expression<'a>(
923 model: &'a Model,
924 variables: &[Expr<'a>],
925 linear: impl IntoIterator<Item = (usize, f64)>,
926 quadratic: impl IntoIterator<Item = ((usize, usize), f64)>,
927 constant: f64,
928) -> Expr<'a> {
929 let mut expr = model.__constant(constant);
930 for (column, coefficient) in linear {
931 if coefficient != 0.0 {
932 expr = expr + coefficient * variables[column];
933 }
934 }
935 let mut quadratic: Vec<_> =
936 quadratic.into_iter().filter(|(_, coefficient)| *coefficient != 0.0).collect();
937 quadratic.sort_unstable_by_key(|((left, right), _)| (*left, *right));
938 for ((left, right), coefficient) in quadratic {
939 expr = expr + coefficient * variables[left] * variables[right];
940 }
941 expr
942}
943
944fn unique_mps_names<'a>(
945 names: impl IntoIterator<Item = &'a str>,
946 fallback_prefix: &str,
947 reserved: impl IntoIterator<Item = &'a str>,
948) -> Vec<String> {
949 let mut used: HashSet<String> = reserved.into_iter().map(str::to_owned).collect();
950 names
951 .into_iter()
952 .enumerate()
953 .map(|(index, name)| {
954 let base: String = name
955 .chars()
956 .map(|ch| {
957 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-') {
958 ch
959 } else {
960 '_'
961 }
962 })
963 .collect();
964 let base =
965 if base.is_empty() { format!("{fallback_prefix}{}", index + 1) } else { base };
966 let mut candidate = base.clone();
967 let mut suffix = 1;
968 while used.contains(&candidate) {
969 candidate = format!("{base}_{suffix}");
970 suffix += 1;
971 }
972 used.insert(candidate.clone());
973 candidate
974 })
975 .collect()
976}
977
978fn write_quadratic_section<W: Write>(
979 out: &mut W,
980 header: &str,
981 terms: &QuadraticTerms,
982 variable_names: &[String],
983 format: MpsQuadraticFormat,
984 objective: bool,
985) -> Result<(), IoError> {
986 writeln!(out, "{header}")?;
987 for &(left, right, hessian) in &terms.hessian {
988 let (first, second) = if objective {
989 if format == MpsQuadraticFormat::Cplex && left.index() > right.index() {
992 (right, left)
993 } else {
994 (left, right)
995 }
996 } else if left.index() <= right.index() {
997 (left, right)
998 } else {
999 (right, left)
1000 };
1001 let left_name = &variable_names[first.index()];
1002 let right_name = &variable_names[second.index()];
1003 let coefficient =
1004 if objective || format != MpsQuadraticFormat::Gurobi { hessian } else { hessian / 2.0 };
1005 writeln!(out, " {left_name:<10} {right_name:<10} {coefficient}")?;
1006 if !objective && format != MpsQuadraticFormat::Mosek && first != second {
1007 writeln!(out, " {right_name:<10} {left_name:<10} {coefficient}")?;
1009 }
1010 }
1011 Ok(())
1012}
1013
1014fn build_mps_model(data: ParsedMps) -> Result<Model, IoError> {
1015 for column in &data.columns {
1016 if column.lower > column.upper {
1017 return Err(invalid_mps(
1018 1,
1019 1,
1020 format!("inconsistent bounds for column {:?}", column.name),
1021 ));
1022 }
1023 if matches!(column.kind, ColumnKind::SemiContinuous | ColumnKind::SemiInteger)
1024 && (!column.lower.is_finite() || column.lower < 0.0)
1025 {
1026 return Err(invalid_mps(
1027 1,
1028 1,
1029 format!("invalid semi-domain threshold for column {:?}", column.name),
1030 ));
1031 }
1032 }
1033 for row in &data.rows {
1034 if row.lower > row.upper {
1035 return Err(invalid_mps(1, 1, format!("inconsistent range for row {:?}", row.name)));
1036 }
1037 }
1038 let model = Model::new(data.name);
1039 let mut variables = Vec::with_capacity(data.columns.len());
1040 for column in &data.columns {
1041 let domain = match column.kind {
1042 ColumnKind::Continuous => Domain::Real,
1043 ColumnKind::Integer => Domain::Integer,
1044 ColumnKind::Binary => Domain::Binary,
1045 ColumnKind::SemiContinuous => Domain::SemiContinuous { threshold: column.lower },
1046 ColumnKind::SemiInteger => Domain::SemiInteger { threshold: column.lower },
1047 };
1048 let model_lower = match column.kind {
1049 ColumnKind::SemiContinuous | ColumnKind::SemiInteger => 0.0,
1050 _ => column.lower,
1051 };
1052 variables.push(
1053 model
1054 .__var(column.name.clone())
1055 .bounds(model_lower, column.upper)
1056 .domain(domain)
1057 .build(),
1058 );
1059 }
1060 for row in data.rows {
1061 let expr = expression(&model, &variables, row.linear, row.quadratic, 0.0);
1062 model.__add_constraint_interval(row.name, expr, row.lower, row.upper);
1063 }
1064 let objective = expression(
1065 &model,
1066 &variables,
1067 data.objective_linear.into_iter().enumerate(),
1068 data.objective_quadratic,
1069 data.objective_constant,
1070 );
1071 match data.sense.or(data.legacy_sense).unwrap_or(ObjectiveSense::Minimize) {
1072 ObjectiveSense::Minimize => model.__minimize(objective),
1073 ObjectiveSense::Maximize => model.__maximize(objective),
1074 }
1075 Ok(model)
1076}
1077
1078pub fn read_mps<R: Read>(input: R) -> Result<Model, IoError> {
1084 read_mps_with(input, &MpsReadOptions::default())
1085}
1086
1087pub fn read_mps_with<R: Read>(input: R, options: &MpsReadOptions) -> Result<Model, IoError> {
1093 parse_mps(BufReader::new(input), "mps_model", *options)
1094}
1095
1096pub fn read_mps_file(path: impl AsRef<Path>) -> Result<Model, IoError> {
1102 read_mps_file_with(path, &MpsReadOptions::default())
1103}
1104
1105pub fn read_mps_file_with(
1111 path: impl AsRef<Path>,
1112 options: &MpsReadOptions,
1113) -> Result<Model, IoError> {
1114 let path = path.as_ref();
1115 let fallback = path.file_stem().and_then(|name| name.to_str()).unwrap_or("mps_model");
1116 parse_mps(BufReader::new(File::open(path)?), fallback, *options)
1117}
1118
1119pub fn write_mps<W: Write>(model: &Model, out: &mut W) -> Result<(), IoError> {
1134 write_mps_with(model, out, &MpsWriteOptions::default())
1135}
1136
1137#[expect(clippy::too_many_lines)]
1147pub fn write_mps_with<W: Write>(
1148 model: &Model,
1149 out: &mut W,
1150 options: &MpsWriteOptions,
1151) -> Result<(), IoError> {
1152 if model.num_soc_constraints() > 0
1153 || matches!(model.kind(), ModelKind::SOCP | ModelKind::MISOCP)
1154 {
1155 return Err(IoError::Conic);
1156 }
1157 let arena = model.arena();
1158 let vars = model.variables();
1159 let model_constraints = model.constraints();
1160 let constraints = model_constraints.algebraic();
1161 let objective = model.try_objective().map_err(|_| IoError::NoObjective)?;
1162 let variable_names = unique_mps_names(vars.iter().map(|v| v.name.as_str()), "C", []);
1163 let row_names = unique_mps_names(constraints.iter().map(|c| c.name.as_str()), "R", ["OBJ"]);
1164
1165 let obj_terms =
1166 extract_quadratic(&arena, objective.expr).ok_or_else(|| IoError::Nonlinear {
1167 location: "the objective".into(),
1168 term: describe_nonlinear_term(&arena, objective.expr, &|v| var_name(&vars, v))
1169 .unwrap_or_else(|| "<nonlinear>".into()),
1170 })?;
1171
1172 let con_terms: Vec<QuadraticTerms> = constraints
1174 .iter()
1175 .map(|c| {
1176 extract_quadratic(&arena, c.lhs).ok_or_else(|| IoError::Nonlinear {
1177 location: format!("constraint {:?}", c.name),
1178 term: describe_nonlinear_term(&arena, c.lhs, &|v| var_name(&vars, v))
1179 .unwrap_or_else(|| "<nonlinear>".into()),
1180 })
1181 })
1182 .collect::<Result<_, _>>()?;
1183
1184 let mut col_index: FxHashMap<VarId, Vec<(&str, f64)>> = FxHashMap::default();
1186 for (v, c) in &obj_terms.linear {
1187 col_index.entry(*v).or_default().push(("OBJ", *c));
1188 }
1189 for (row_name, terms) in row_names.iter().zip(con_terms.iter()) {
1190 for (v, coef) in &terms.linear {
1191 col_index.entry(*v).or_default().push((row_name.as_str(), *coef));
1192 }
1193 }
1194
1195 writeln!(out, "* OXIMO MPS export")?;
1196 writeln!(
1197 out,
1198 "* sense: {}",
1199 match objective.sense {
1200 ObjectiveSense::Minimize => "minimize",
1201 ObjectiveSense::Maximize => "maximize",
1202 }
1203 )?;
1204 writeln!(out, "NAME {}", model.name)?;
1205 writeln!(out, "OBJSENSE")?;
1206 writeln!(
1207 out,
1208 " {}",
1209 match objective.sense {
1210 ObjectiveSense::Minimize => "MIN",
1211 ObjectiveSense::Maximize => "MAX",
1212 }
1213 )?;
1214
1215 writeln!(out, "ROWS")?;
1216 writeln!(out, " N OBJ")?;
1217 for (c, row_name) in constraints.iter().zip(row_names.iter()) {
1218 let tag = match c.as_single() {
1219 Some((Sense::Le, _)) => 'L',
1220 Some((Sense::Ge, _)) => 'G',
1221 Some((Sense::Eq, _)) => 'E',
1222 None if c.is_range() => 'L',
1224 None => 'N',
1227 };
1228 writeln!(out, " {tag} {row_name}")?;
1229 }
1230
1231 writeln!(out, "COLUMNS")?;
1232 let mut int_open = false;
1233 for (v, column_name) in vars.iter().zip(variable_names.iter()) {
1234 let needs_marker = matches!(v.domain, Domain::Integer);
1236 if needs_marker && !int_open {
1237 writeln!(out, " MARKER 'MARKER' 'INTORG'")?;
1238 int_open = true;
1239 } else if !needs_marker && int_open {
1240 writeln!(out, " MARKER 'MARKER' 'INTEND'")?;
1241 int_open = false;
1242 }
1243 if let Some(entries) = col_index.get(&v.id) {
1244 for (row_name, coef) in entries {
1245 writeln!(out, " {column_name:<10} {row_name:<10} {coef}")?;
1246 }
1247 } else {
1248 writeln!(out, " {column_name:<10} {:<10} 0", "OBJ")?;
1249 }
1250 }
1251 if int_open {
1252 writeln!(out, " MARKER 'MARKER' 'INTEND'")?;
1253 }
1254
1255 writeln!(out, "RHS")?;
1256 let obj_constant = obj_terms.constant;
1257 if obj_constant != 0.0 {
1258 writeln!(out, " RHS OBJ {}", -obj_constant)?;
1259 }
1260 for ((c, row_name), t) in constraints.iter().zip(row_names.iter()).zip(con_terms.iter()) {
1261 let rhs = match c.as_single() {
1264 Some((_, rhs)) => rhs,
1265 None if c.is_range() => c.upper,
1266 None => continue,
1268 };
1269 let adjusted = rhs - t.constant;
1270 if adjusted != 0.0 {
1271 writeln!(out, " RHS {row_name:<10} {adjusted}")?;
1272 }
1273 }
1274
1275 if constraints.iter().any(Constraint::is_range) {
1276 writeln!(out, "RANGES")?;
1277 for (c, row_name) in constraints.iter().zip(row_names.iter()) {
1278 if c.is_range() {
1279 writeln!(out, " RNG {row_name:<10} {}", c.upper - c.lower)?;
1280 }
1281 }
1282 }
1283
1284 writeln!(out, "BOUNDS")?;
1285 for (v, column_name) in vars.iter().zip(variable_names.iter()) {
1286 let lb = v.lb;
1287 let ub = v.ub;
1288 if matches!(v.domain, Domain::Binary) {
1289 writeln!(out, " BV BND {column_name}")?;
1290 if lb != 0.0 {
1291 writeln!(out, " LO BND {column_name:<10} {lb}")?;
1292 }
1293 if (ub - 1.0).abs() >= f64::EPSILON {
1294 writeln!(out, " UP BND {column_name:<10} {ub}")?;
1295 }
1296 continue;
1297 }
1298 if let Some(thr) = v.domain.semi_threshold() {
1299 writeln!(out, " LO BND {column_name:<10} {thr}")?;
1300 let semi_ub = if ub.is_finite() { ub } else { MPS_INFINITY_SENTINEL };
1301 let code = if v.domain.is_integer() { "SI" } else { "SC" };
1303 writeln!(out, " {code} BND {column_name:<10} {semi_ub}")?;
1304 continue;
1305 }
1306 if lb.is_finite() && (lb - ub).abs() < f64::EPSILON {
1307 writeln!(out, " FX BND {column_name:<10} {lb}")?;
1308 continue;
1309 }
1310 let infinite_lo = lb == f64::NEG_INFINITY;
1311 let infinite_hi = ub == f64::INFINITY;
1312 match (infinite_lo, infinite_hi) {
1313 (true, true) => writeln!(out, " FR BND {column_name}")?,
1314 (true, false) => {
1315 writeln!(out, " MI BND {column_name}")?;
1316 writeln!(out, " UP BND {column_name:<10} {ub}")?;
1317 }
1318 (false, true) => {
1319 if lb != 0.0 {
1320 writeln!(out, " LO BND {column_name:<10} {lb}")?;
1321 }
1322 }
1323 (false, false) => {
1324 if lb != 0.0 {
1325 writeln!(out, " LO BND {column_name:<10} {lb}")?;
1326 }
1327 writeln!(out, " UP BND {column_name:<10} {ub}")?;
1328 }
1329 }
1330 }
1331
1332 if !obj_terms.hessian.is_empty() {
1333 let header = if options.quadratic_format == MpsQuadraticFormat::Mosek {
1334 "QSECTION OBJ"
1335 } else {
1336 "QUADOBJ"
1337 };
1338 write_quadratic_section(
1339 out,
1340 header,
1341 &obj_terms,
1342 &variable_names,
1343 options.quadratic_format,
1344 true,
1345 )?;
1346 }
1347 for (row_name, terms) in row_names.iter().zip(con_terms.iter()) {
1348 if terms.hessian.is_empty() {
1349 continue;
1350 }
1351 let header = match options.quadratic_format {
1352 MpsQuadraticFormat::Mosek => format!("QSECTION {row_name}"),
1353 MpsQuadraticFormat::Gurobi | MpsQuadraticFormat::Cplex => {
1354 format!("QCMATRIX {row_name}")
1355 }
1356 };
1357 write_quadratic_section(
1358 out,
1359 &header,
1360 terms,
1361 &variable_names,
1362 options.quadratic_format,
1363 false,
1364 )?;
1365 }
1366
1367 writeln!(out, "ENDATA")?;
1368 Ok(())
1369}
1370
1371pub fn to_mps_string(model: &Model) -> Result<String, IoError> {
1381 to_mps_string_with(model, &MpsWriteOptions::default())
1382}
1383
1384pub fn to_mps_string_with(model: &Model, options: &MpsWriteOptions) -> Result<String, IoError> {
1395 let mut buf = Vec::new();
1396 write_mps_with(model, &mut buf, options)?;
1397 Ok(String::from_utf8(buf).expect("MPS writer emits ASCII"))
1398}