1use std::collections::HashSet;
15use std::fs::File;
16use std::io::{BufRead, BufReader, Read, Write};
17use std::path::Path;
18
19use oximo_core::{
20 Constraint, Domain, Model, ModelKind, ObjectiveSense, Relate, Sense, SosConstraint, SosType,
21 var_name,
22};
23use oximo_expr::{Expr, QuadraticTerms, VarId, describe_nonlinear_term, extract_quadratic};
24use rustc_hash::FxHashMap;
25
26use crate::error::IoError;
27
28#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
35pub enum MpsQuadraticFormat {
36 #[default]
38 Gurobi,
39 Cplex,
41 Mosek,
43}
44
45#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
47pub struct MpsReadOptions {
48 pub quadratic_format: MpsQuadraticFormat,
50}
51
52#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
54pub struct MpsWriteOptions {
55 pub quadratic_format: MpsQuadraticFormat,
57}
58
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60enum RowKind {
61 Free,
62 Greater,
63 Less,
64 Equal,
65}
66
67struct ParsedRow {
68 name: String,
69 kind: RowKind,
70 lower: f64,
71 upper: f64,
72 linear: FxHashMap<usize, f64>,
73 quadratic: FxHashMap<(usize, usize), f64>,
74}
75
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77enum ColumnKind {
78 Continuous,
79 Integer,
80 Binary,
81 SemiContinuous,
82 SemiInteger,
83}
84
85struct ParsedColumn {
86 name: String,
87 lower: f64,
88 upper: f64,
89 kind: ColumnKind,
90 default_bounds: bool,
91 lower_explicit: bool,
92 marker_placement: MarkerPlacement,
93}
94
95#[derive(Default)]
96struct QuadraticTriangle {
97 upper: Option<f64>,
98 lower: Option<f64>,
99}
100
101#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
102enum MarkerPlacement {
103 #[default]
104 Unseen,
105 Outside,
106 Inside,
107 Mixed,
108}
109
110#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)]
111enum SectionRank {
112 Name,
113 ObjSense,
114 Rows,
115 Columns,
116 Rhs,
117 Ranges,
118 Bounds,
119 Quadratic,
120 Sos,
121 Indicators,
122 End,
123}
124
125#[derive(Clone, Debug)]
126enum Section {
127 Name,
128 ObjSense,
129 Rows,
130 Columns,
131 Rhs,
132 Ranges,
133 Bounds,
134 QuadObj,
135 QMatrix,
136 QcMatrix(String),
137 QSec(String),
138 Sos,
139 Indicators,
140 End,
141}
142
143impl Section {
144 fn rank(&self) -> SectionRank {
145 match self {
146 Self::Name => SectionRank::Name,
147 Self::ObjSense => SectionRank::ObjSense,
148 Self::Rows => SectionRank::Rows,
149 Self::Columns => SectionRank::Columns,
150 Self::Rhs => SectionRank::Rhs,
151 Self::Ranges => SectionRank::Ranges,
152 Self::Bounds => SectionRank::Bounds,
153 Self::QuadObj | Self::QMatrix | Self::QcMatrix(_) | Self::QSec(_) => {
154 SectionRank::Quadratic
155 }
156 Self::Sos => SectionRank::Sos,
157 Self::Indicators => SectionRank::Indicators,
158 Self::End => SectionRank::End,
159 }
160 }
161
162 fn name(&self) -> &'static str {
163 match self {
164 Self::Name => "NAME",
165 Self::ObjSense => "OBJSENSE",
166 Self::Rows => "ROWS",
167 Self::Columns => "COLUMNS",
168 Self::Rhs => "RHS",
169 Self::Ranges => "RANGES",
170 Self::Bounds => "BOUNDS",
171 Self::QuadObj => "QUADOBJ",
172 Self::QMatrix => "QMATRIX",
173 Self::QcMatrix(_) => "QCMATRIX",
174 Self::QSec(_) => "QSECTION",
175 Self::Sos => "SOS",
176 Self::Indicators => "INDICATORS",
177 Self::End => "ENDATA",
178 }
179 }
180}
181
182struct Field<'a> {
183 text: &'a str,
184 column: usize,
185}
186
187struct ParsedMps {
188 name: String,
189 objective_row: Option<String>,
190 sense: Option<ObjectiveSense>,
191 legacy_sense: Option<ObjectiveSense>,
192 rows: Vec<ParsedRow>,
193 row_index: FxHashMap<String, usize>,
194 columns: Vec<ParsedColumn>,
195 column_index: FxHashMap<String, usize>,
196 objective_linear: Vec<f64>,
197 objective_quadratic: FxHashMap<(usize, usize), f64>,
198 quadratic_triangles: FxHashMap<(Option<usize>, usize, usize), QuadraticTriangle>,
199 objective_constant: f64,
200 intorg: bool,
201 rhs_vector: Option<String>,
202 range_vector: Option<String>,
203 bounds_vector: Option<String>,
204 objective_quadratic_source: Option<&'static str>,
205 quadratic_rows: HashSet<String>,
206 sos: Vec<ParsedSos>,
207 current_sos: Option<usize>,
208 indicators: Vec<(String, String, bool)>,
209 seen_sections: u8,
210}
211
212struct ParsedSos {
213 name: String,
214 sos_type: SosType,
215 members: Vec<(String, f64)>,
216}
217
218const MPS_INFINITY_SENTINEL: f64 = 1e30;
219const SEEN_ROWS: u8 = 1;
220const SEEN_COLUMNS: u8 = 2;
221const SEEN_END: u8 = 4;
222
223impl ParsedMps {
224 fn new(fallback_name: &str) -> Self {
225 Self {
226 name: fallback_name.to_owned(),
227 objective_row: None,
228 sense: None,
229 legacy_sense: None,
230 rows: Vec::new(),
231 row_index: FxHashMap::default(),
232 columns: Vec::new(),
233 column_index: FxHashMap::default(),
234 objective_linear: Vec::new(),
235 objective_quadratic: FxHashMap::default(),
236 quadratic_triangles: FxHashMap::default(),
237 objective_constant: 0.0,
238 intorg: false,
239 rhs_vector: None,
240 range_vector: None,
241 bounds_vector: None,
242 objective_quadratic_source: None,
243 quadratic_rows: HashSet::new(),
244 sos: Vec::new(),
245 current_sos: None,
246 indicators: Vec::new(),
247 seen_sections: 0,
248 }
249 }
250
251 fn add_column(&mut self, name: &str, inside_marker: bool) -> usize {
252 let index = if let Some(index) = self.column_index.get(name) {
253 *index
254 } else {
255 let index = self.columns.len();
256 self.columns.push(ParsedColumn {
257 name: name.to_owned(),
258 lower: 0.0,
259 upper: f64::INFINITY,
260 kind: ColumnKind::Continuous,
261 default_bounds: true,
262 lower_explicit: false,
263 marker_placement: MarkerPlacement::Unseen,
264 });
265 self.column_index.insert(name.to_owned(), index);
266 self.objective_linear.push(0.0);
267 index
268 };
269 let column = &mut self.columns[index];
270 if inside_marker {
271 column.marker_placement = match column.marker_placement {
272 MarkerPlacement::Unseen | MarkerPlacement::Inside => MarkerPlacement::Inside,
273 MarkerPlacement::Outside | MarkerPlacement::Mixed => MarkerPlacement::Mixed,
274 };
275 column.kind = ColumnKind::Integer;
276 if column.default_bounds {
277 column.upper = 1.0;
278 }
279 } else {
280 column.marker_placement = match column.marker_placement {
281 MarkerPlacement::Unseen | MarkerPlacement::Outside => MarkerPlacement::Outside,
282 MarkerPlacement::Inside | MarkerPlacement::Mixed => MarkerPlacement::Mixed,
283 };
284 }
285 index
286 }
287}
288
289fn invalid_mps(line: usize, column: usize, message: impl Into<String>) -> IoError {
290 IoError::InvalidMps { line, column, message: message.into() }
291}
292
293fn fields(line: &str) -> Vec<Field<'_>> {
294 let mut out = Vec::new();
295 let mut start = None;
296 for (offset, ch) in line.char_indices() {
297 if ch.is_whitespace() {
298 if let Some(begin) = start.take() {
299 out.push(Field { text: &line[begin..offset], column: begin + 1 });
300 }
301 } else if start.is_none() {
302 start = Some(offset);
303 }
304 }
305 if let Some(begin) = start {
306 out.push(Field { text: &line[begin..], column: begin + 1 });
307 }
308 out
309}
310
311fn parse_number(field: &Field<'_>, line: usize) -> Result<f64, IoError> {
312 let normalized;
313 let text = if field.text.contains(['d', 'D']) {
314 normalized = field.text.replace(['d', 'D'], "E");
315 normalized.as_str()
316 } else {
317 field.text
318 };
319 let value = text
320 .parse::<f64>()
321 .map_err(|_| invalid_mps(line, field.column, format!("invalid number {:?}", field.text)))?;
322 if !value.is_finite() {
323 return Err(invalid_mps(line, field.column, "numeric fields must be finite"));
324 }
325 Ok(value)
326}
327
328fn looks_like_number(text: &str) -> bool {
329 text.replace(['d', 'D'], "E").parse::<f64>().is_ok()
330}
331
332fn objective_sense(field: &Field<'_>, line: usize) -> Result<ObjectiveSense, IoError> {
333 match field.text.to_ascii_uppercase().as_str() {
334 "MIN" | "MINIMIZE" => Ok(ObjectiveSense::Minimize),
335 "MAX" | "MAXIMIZE" => Ok(ObjectiveSense::Maximize),
336 _ => Err(invalid_mps(line, field.column, "objective sense must be MIN or MAX")),
337 }
338}
339
340fn parse_legacy_sense(line: &str) -> Option<ObjectiveSense> {
341 let comment = line.trim_start_matches('*').trim();
342 let value = comment.strip_prefix("sense:").or_else(|| comment.strip_prefix("SENSE:"))?;
343 match value.trim().to_ascii_lowercase().as_str() {
344 "minimize" | "min" => Some(ObjectiveSense::Minimize),
345 "maximize" | "max" => Some(ObjectiveSense::Maximize),
346 _ => None,
347 }
348}
349
350fn header(items: &[Field<'_>], current: &Section) -> Option<Section> {
351 let first = items.first()?.text.to_ascii_uppercase();
352 match (first.as_str(), items.len()) {
353 ("NAME", _) if matches!(current, Section::Name) => Some(Section::Name),
354 ("OBJSENSE", 1 | 2) => Some(Section::ObjSense),
355 ("ROWS", 1) => Some(Section::Rows),
356 ("COLUMNS", 1) => Some(Section::Columns),
357 ("RHS", 1) => Some(Section::Rhs),
358 ("RANGES", 1) => Some(Section::Ranges),
359 ("BOUNDS", 1) => Some(Section::Bounds),
360 ("QUADOBJ", 1) => Some(Section::QuadObj),
361 ("QMATRIX", 1) => Some(Section::QMatrix),
362 ("QCMATRIX", 2) => Some(Section::QcMatrix(items[1].text.to_owned())),
363 ("QSECTION", 2) => Some(Section::QSec(items[1].text.to_owned())),
364 ("SOS", 1) => Some(Section::Sos),
365 ("INDICATORS", 1) => Some(Section::Indicators),
366 ("ENDATA", 1) => Some(Section::End),
367 _ => None,
368 }
369}
370
371fn select_vector(
372 selected: &mut Option<String>,
373 candidate: Option<&Field<'_>>,
374 section: &str,
375) -> Result<(), IoError> {
376 let Some(candidate) = candidate else { return Ok(()) };
377 if let Some(existing) = selected {
378 if existing != candidate.text {
379 return Err(IoError::UnsupportedMps {
380 section: section.into(),
381 feature: format!(
382 "multiple data vectors ({existing:?} and {:?}) are not supported",
383 candidate.text
384 ),
385 });
386 }
387 } else {
388 *selected = Some(candidate.text.to_owned());
389 }
390 Ok(())
391}
392
393fn parse_row(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
394 if items.len() < 2 {
395 return Err(invalid_mps(line, 1, "ROWS records require a sense and row name"));
396 }
397 let name = items[1].text;
398 if data.objective_row.as_deref() == Some(name) || data.row_index.contains_key(name) {
399 return Err(invalid_mps(line, items[1].column, format!("duplicate row name {name:?}")));
400 }
401 let kind = match items[0].text.to_ascii_uppercase().as_str() {
402 "N" => RowKind::Free,
403 "G" => RowKind::Greater,
404 "L" => RowKind::Less,
405 "E" => RowKind::Equal,
406 _ => {
407 return Err(invalid_mps(line, items[0].column, "row sense must be N, G, L, or E"));
408 }
409 };
410 if kind == RowKind::Free && data.objective_row.is_none() {
411 data.objective_row = Some(name.to_owned());
412 return Ok(());
413 }
414 let (lower, upper) = match kind {
415 RowKind::Free => (f64::NEG_INFINITY, f64::INFINITY),
416 RowKind::Greater => (0.0, f64::INFINITY),
417 RowKind::Less => (f64::NEG_INFINITY, 0.0),
418 RowKind::Equal => (0.0, 0.0),
419 };
420 let index = data.rows.len();
421 data.rows.push(ParsedRow {
422 name: name.to_owned(),
423 kind,
424 lower,
425 upper,
426 linear: FxHashMap::default(),
427 quadratic: FxHashMap::default(),
428 });
429 data.row_index.insert(name.to_owned(), index);
430 Ok(())
431}
432
433fn parse_coefficient(
434 data: &mut ParsedMps,
435 column: usize,
436 row: &Field<'_>,
437 value: &Field<'_>,
438 line: usize,
439) -> Result<(), IoError> {
440 let value = parse_number(value, line)?;
441 if data.objective_row.as_deref() == Some(row.text) {
442 data.objective_linear[column] += value;
443 return Ok(());
444 }
445 let row_index = data.row_index.get(row.text).copied().ok_or_else(|| {
446 invalid_mps(line, row.column, format!("unknown ROWS name {:?}", row.text))
447 })?;
448 *data.rows[row_index].linear.entry(column).or_insert(0.0) += value;
449 Ok(())
450}
451
452fn unquote_marker(value: &str) -> String {
453 value.trim_matches(|c| c == '\'' || c == '"').to_ascii_uppercase()
454}
455
456fn parse_columns(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
457 if items.len() == 3 && unquote_marker(items[1].text) == "MARKER" {
458 match unquote_marker(items[2].text).as_str() {
459 "INTORG" if !data.intorg => data.intorg = true,
460 "INTEND" if data.intorg => data.intorg = false,
461 "INTORG" => return Err(invalid_mps(line, items[2].column, "nested INTORG marker")),
462 "INTEND" => {
463 return Err(invalid_mps(line, items[2].column, "INTEND without INTORG"));
464 }
465 _ => return Err(invalid_mps(line, items[2].column, "unknown MARKER value")),
466 }
467 return Ok(());
468 }
469 if items.len() != 3 && items.len() != 5 {
470 return Err(invalid_mps(line, 1, "COLUMNS records require three or five fields"));
471 }
472 let column = data.add_column(items[0].text, data.intorg);
473 let column_data = &data.columns[column];
474 if column_data.marker_placement == MarkerPlacement::Mixed {
475 return Err(invalid_mps(
476 line,
477 items[0].column,
478 format!("integer column {:?} also appears outside INTORG/INTEND", items[0].text),
479 ));
480 }
481 parse_coefficient(data, column, &items[1], &items[2], line)?;
482 if items.len() == 5 {
483 parse_coefficient(data, column, &items[3], &items[4], line)?;
484 }
485 Ok(())
486}
487
488fn parse_rhs_value(
489 data: &mut ParsedMps,
490 row: &Field<'_>,
491 value: &Field<'_>,
492 line: usize,
493) -> Result<(), IoError> {
494 let value = parse_number(value, line)?;
495 if data.objective_row.as_deref() == Some(row.text) {
496 data.objective_constant = -value;
497 return Ok(());
498 }
499 let index = data.row_index.get(row.text).copied().ok_or_else(|| {
500 invalid_mps(line, row.column, format!("unknown ROWS name {:?}", row.text))
501 })?;
502 let parsed_row = &mut data.rows[index];
503 match parsed_row.kind {
504 RowKind::Greater => parsed_row.lower = value,
505 RowKind::Less => parsed_row.upper = value,
506 RowKind::Equal => {
507 parsed_row.lower = value;
508 parsed_row.upper = value;
509 }
510 RowKind::Free => {
511 return Err(invalid_mps(line, row.column, "a free N row cannot have an RHS"));
512 }
513 }
514 Ok(())
515}
516
517fn parse_rhs(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
518 let (vector, pairs): (Option<&Field<'_>>, &[Field<'_>]) = match items.len() {
519 2 | 4 => (None, items),
520 3 | 5 => (Some(&items[0]), &items[1..]),
521 _ => return Err(invalid_mps(line, 1, "RHS records require two to five fields")),
522 };
523 select_vector(&mut data.rhs_vector, vector, "RHS")?;
524 for pair in pairs.as_chunks::<2>().0 {
525 parse_rhs_value(data, &pair[0], &pair[1], line)?;
526 }
527 Ok(())
528}
529
530fn parse_range_value(
531 data: &mut ParsedMps,
532 row: &Field<'_>,
533 value: &Field<'_>,
534 line: usize,
535) -> Result<(), IoError> {
536 let value = parse_number(value, line)?;
537 let index = data.row_index.get(row.text).copied().ok_or_else(|| {
538 invalid_mps(line, row.column, format!("unknown ROWS name {:?}", row.text))
539 })?;
540 let parsed_row = &mut data.rows[index];
541 match parsed_row.kind {
542 RowKind::Greater => parsed_row.upper = parsed_row.lower + value.abs(),
543 RowKind::Less => parsed_row.lower = parsed_row.upper - value.abs(),
544 RowKind::Equal if value >= 0.0 => parsed_row.upper = parsed_row.lower + value,
545 RowKind::Equal => parsed_row.lower = parsed_row.upper + value,
546 RowKind::Free => {
547 return Err(invalid_mps(line, row.column, "a free N row cannot have a range"));
548 }
549 }
550 Ok(())
551}
552
553fn parse_ranges(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
554 let (vector, pairs): (Option<&Field<'_>>, &[Field<'_>]) = match items.len() {
555 2 | 4 => (None, items),
556 3 | 5 => (Some(&items[0]), &items[1..]),
557 _ => return Err(invalid_mps(line, 1, "RANGES records require two to five fields")),
558 };
559 select_vector(&mut data.range_vector, vector, "RANGES")?;
560 for pair in pairs.as_chunks::<2>().0 {
561 parse_range_value(data, &pair[0], &pair[1], line)?;
562 }
563 Ok(())
564}
565
566fn parse_bound(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
567 if !(2..=4).contains(&items.len()) {
568 return Err(invalid_mps(line, 1, "BOUNDS records require two to four fields"));
569 }
570 let bound_type = items[0].text.to_ascii_uppercase();
571 let requires_value =
572 matches!(bound_type.as_str(), "FX" | "UP" | "LO" | "LI" | "UI" | "SC" | "SI");
573 let (vector, column_field, value_field) = match (items.len(), requires_value) {
574 (2, _) => (None, &items[1], None),
575 (3, true) => (None, &items[1], Some(&items[2])),
576 (3, false) => (Some(&items[1]), &items[2], None),
577 (4, _) => (Some(&items[1]), &items[2], Some(&items[3])),
578 _ => unreachable!(),
579 };
580 select_vector(&mut data.bounds_vector, vector, "BOUNDS")?;
581 let column_index = data.column_index.get(column_field.text).copied().ok_or_else(|| {
582 invalid_mps(line, column_field.column, format!("unknown column {:?}", column_field.text))
583 })?;
584 let value = value_field
585 .map(|field| {
586 parse_number(field, line)
587 .map(|value| if value >= MPS_INFINITY_SENTINEL { f64::INFINITY } else { value })
588 })
589 .transpose()?;
590 let column = &mut data.columns[column_index];
591 if column.default_bounds && column.kind == ColumnKind::Integer {
592 column.upper = f64::INFINITY;
593 }
594 column.default_bounds = false;
595 match (bound_type.as_str(), value) {
596 ("PL", None) => column.upper = f64::INFINITY,
597 ("MI", None) => {
598 column.lower = f64::NEG_INFINITY;
599 column.lower_explicit = true;
600 }
601 ("FR", None | Some(_)) => {
602 column.lower = f64::NEG_INFINITY;
603 column.upper = f64::INFINITY;
604 column.lower_explicit = true;
605 }
606 ("BV", None | Some(_)) => {
607 column.lower = 0.0;
608 column.upper = 1.0;
609 column.kind = ColumnKind::Binary;
610 column.lower_explicit = true;
611 }
612 ("FX", Some(value)) => {
613 column.lower = value;
614 column.upper = value;
615 column.lower_explicit = true;
616 }
617 ("UP", Some(value)) => {
618 if value < 0.0 && !column.lower_explicit {
619 column.lower = f64::NEG_INFINITY;
620 }
621 column.upper = value;
622 }
623 ("LO", Some(value)) => {
624 column.lower = value;
625 column.lower_explicit = true;
626 }
627 ("LI", Some(value)) => {
628 column.lower = value;
629 column.kind = ColumnKind::Integer;
630 column.lower_explicit = true;
631 }
632 ("UI", Some(value)) => {
633 column.upper = value;
634 column.kind = ColumnKind::Integer;
635 }
636 ("SC", Some(value)) => {
637 if !column.lower_explicit {
638 column.lower = 1.0;
639 }
640 column.upper = value;
641 column.kind = ColumnKind::SemiContinuous;
642 }
643 ("SI", Some(value)) => {
644 if !column.lower_explicit {
645 column.lower = 1.0;
646 }
647 column.upper = value;
648 column.kind = ColumnKind::SemiInteger;
649 }
650 _ => {
651 return Err(invalid_mps(
652 line,
653 items[0].column,
654 format!("invalid {bound_type} bound record"),
655 ));
656 }
657 }
658 Ok(())
659}
660
661fn quadratic_coefficient(
662 format: MpsQuadraticFormat,
663 diagonal: bool,
664 objective: bool,
665 value: f64,
666) -> f64 {
667 if objective || format != MpsQuadraticFormat::Gurobi {
668 if diagonal { value / 2.0 } else { value }
669 } else if diagonal {
670 value
671 } else {
672 2.0 * value
673 }
674}
675
676fn qsection_targets_objective(data: &ParsedMps, name: &str, line: usize) -> Result<bool, IoError> {
677 if name == "OBJ" {
678 if data.objective_row.as_deref() != Some("OBJ") && data.row_index.contains_key("OBJ") {
679 return Err(invalid_mps(
680 line,
681 1,
682 "QSECTION OBJ is ambiguous because OBJ is also a constraint row",
683 ));
684 }
685 return Ok(true);
686 }
687 Ok(data.objective_row.as_deref() == Some(name))
688}
689
690fn parse_quadratic_record(
691 data: &mut ParsedMps,
692 section: &Section,
693 items: &[Field<'_>],
694 line: usize,
695 options: MpsReadOptions,
696) -> Result<(), IoError> {
697 if items.len() != 3 {
698 return Err(invalid_mps(line, 1, "quadratic records require three fields"));
699 }
700 let left = data.column_index.get(items[0].text).copied().ok_or_else(|| {
701 invalid_mps(line, items[0].column, format!("unknown column {:?}", items[0].text))
702 })?;
703 let right = data.column_index.get(items[1].text).copied().ok_or_else(|| {
704 invalid_mps(line, items[1].column, format!("unknown column {:?}", items[1].text))
705 })?;
706 let pair = if left <= right { (left, right) } else { (right, left) };
707 let value = parse_number(&items[2], line)?;
708 let objective = match section {
709 Section::QuadObj | Section::QMatrix => true,
710 Section::QSec(name) => qsection_targets_objective(data, name, line)?,
711 Section::QcMatrix(_) => false,
712 _ => unreachable!("quadratic parser called outside quadratic section"),
713 };
714 let row = if objective {
715 None
716 } else {
717 let (Section::QcMatrix(row_name) | Section::QSec(row_name)) = section else {
718 unreachable!()
719 };
720 Some(data.row_index.get(row_name).copied().ok_or_else(|| {
721 invalid_mps(line, 1, format!("quadratic section names unknown row {row_name:?}"))
722 })?)
723 };
724 let coefficient =
725 quadratic_coefficient(options.quadratic_format, left == right, objective, value);
726 let mut store_coefficient = true;
727 if matches!(section, Section::QMatrix | Section::QcMatrix(_)) && left != right {
728 let triangle = data.quadratic_triangles.entry((row, pair.0, pair.1)).or_default();
729 let first_record = triangle.upper.is_none() && triangle.lower.is_none();
730 let side = if left < right { &mut triangle.upper } else { &mut triangle.lower };
731 let same_side = side.is_some();
732 *side = Some(side.take().unwrap_or(0.0) + coefficient);
733 store_coefficient = first_record || same_side;
734 }
735 if !store_coefficient {
736 return Ok(());
737 }
738 if objective {
739 *data.objective_quadratic.entry(pair).or_insert(0.0) += coefficient;
740 return Ok(());
741 }
742 *data.rows[row.expect("quadratic constraint row")].quadratic.entry(pair).or_insert(0.0) +=
743 coefficient;
744 Ok(())
745}
746
747fn validate_quadratic_triangles(data: &ParsedMps, line: usize) -> Result<(), IoError> {
748 for ((_, left, right), triangle) in &data.quadratic_triangles {
749 if let (Some(upper), Some(lower)) = (triangle.upper, triangle.lower)
750 && upper.total_cmp(&lower).is_ne()
751 {
752 return Err(invalid_mps(
753 line,
754 1,
755 format!(
756 "asymmetric quadratic matrix entries for columns {:?} and {:?}",
757 data.columns[*left].name, data.columns[*right].name
758 ),
759 ));
760 }
761 }
762 Ok(())
763}
764
765fn begin_quadratic_section(
766 data: &mut ParsedMps,
767 section: &Section,
768 line: usize,
769) -> Result<(), IoError> {
770 let source = section.name();
771 let objective = match section {
772 Section::QuadObj | Section::QMatrix => true,
773 Section::QSec(name) => qsection_targets_objective(data, name, line)?,
774 Section::QcMatrix(_) => false,
775 _ => return Ok(()),
776 };
777 if objective {
778 if let Some(existing) = data.objective_quadratic_source {
779 return Err(invalid_mps(
780 line,
781 1,
782 format!("objective quadratic data already supplied by {existing}"),
783 ));
784 }
785 data.objective_quadratic_source = Some(source);
786 return Ok(());
787 }
788 let (Section::QcMatrix(row_name) | Section::QSec(row_name)) = section else { unreachable!() };
789 if !data.row_index.contains_key(row_name) {
790 return Err(invalid_mps(
791 line,
792 1,
793 format!("quadratic section names unknown row {row_name:?}"),
794 ));
795 }
796 if !data.quadratic_rows.insert(row_name.clone()) {
797 return Err(invalid_mps(
798 line,
799 1,
800 format!("duplicate quadratic section for row {row_name:?}"),
801 ));
802 }
803 Ok(())
804}
805
806fn check_section_transition(
807 previous: &Section,
808 next: &Section,
809 line: usize,
810) -> Result<(), IoError> {
811 let quadratic_after_sos = matches!(previous, Section::Sos)
812 && matches!(
813 next,
814 Section::QuadObj | Section::QMatrix | Section::QcMatrix(_) | Section::QSec(_)
815 );
816 if next.rank() < previous.rank() && !quadratic_after_sos {
817 return Err(invalid_mps(
818 line,
819 1,
820 format!("{} section appears after {}", next.name(), previous.name()),
821 ));
822 }
823 if next.rank() == previous.rank()
824 && !matches!(next.rank(), SectionRank::Quadratic)
825 && !matches!((previous, next), (Section::Name, Section::Name))
826 {
827 return Err(invalid_mps(line, 1, format!("duplicate {} section", next.name())));
828 }
829 Ok(())
830}
831
832fn parse_mps_line(
833 data: &mut ParsedMps,
834 section: &mut Section,
835 saw_name: &mut bool,
836 line: &str,
837 line_no: usize,
838 options: MpsReadOptions,
839) -> Result<(), IoError> {
840 let trimmed = line.trim();
841 if trimmed.is_empty() {
842 return Ok(());
843 }
844 if trimmed.starts_with('*') {
845 if data.sense.is_none() {
846 data.legacy_sense = parse_legacy_sense(trimmed).or(data.legacy_sense);
847 }
848 return Ok(());
849 }
850 if data.seen_sections & SEEN_END != 0 {
851 return Err(invalid_mps(line_no, 1, "content after ENDATA"));
852 }
853 let items = fields(line);
854 if let Some(next) = header(&items, section) {
855 if matches!(next, Section::Name) {
856 if *saw_name {
857 return Err(invalid_mps(line_no, 1, "duplicate NAME section"));
858 }
859 *saw_name = true;
860 if items.len() > 1 {
861 data.name = items[1..].iter().map(|field| field.text).collect::<Vec<_>>().join(" ");
862 }
863 return Ok(());
864 }
865 if !*saw_name {
866 return Err(invalid_mps(line_no, 1, "the first data line must be NAME"));
867 }
868 check_section_transition(section, &next, line_no)?;
869 if data.intorg && !matches!(next, Section::Columns) {
870 return Err(invalid_mps(line_no, 1, "missing INTEND marker before COLUMNS ends"));
871 }
872 match &next {
873 Section::ObjSense if items.len() == 2 => {
874 data.sense = Some(objective_sense(&items[1], line_no)?);
875 }
876 Section::Rows => data.seen_sections |= SEEN_ROWS,
877 Section::Columns => data.seen_sections |= SEEN_COLUMNS,
878 Section::QuadObj | Section::QMatrix | Section::QcMatrix(_) | Section::QSec(_) => {
879 begin_quadratic_section(data, &next, line_no)?;
880 }
881 Section::Sos => data.current_sos = None,
882 Section::End => data.seen_sections |= SEEN_END,
883 _ => {}
884 }
885 *section = next;
886 return Ok(());
887 }
888 if !*saw_name {
889 return Err(invalid_mps(line_no, 1, "the first data line must be NAME"));
890 }
891 match section {
892 Section::ObjSense => {
893 if items.len() != 1 {
894 return Err(invalid_mps(line_no, 1, "OBJSENSE data requires one field"));
895 }
896 data.sense = Some(objective_sense(&items[0], line_no)?);
897 }
898 Section::Rows => parse_row(data, &items, line_no)?,
899 Section::Columns => parse_columns(data, &items, line_no)?,
900 Section::Rhs => parse_rhs(data, &items, line_no)?,
901 Section::Ranges => parse_ranges(data, &items, line_no)?,
902 Section::Bounds => parse_bound(data, &items, line_no)?,
903 Section::QuadObj | Section::QMatrix | Section::QcMatrix(_) | Section::QSec(_) => {
904 parse_quadratic_record(data, section, &items, line_no, options)?;
905 }
906 Section::Sos => parse_sos_record(data, &items, line_no)?,
907 Section::Indicators => parse_indicator_record(data, &items, line_no)?,
908 Section::Name => return Err(invalid_mps(line_no, 1, "expected NAME header")),
909 Section::End => unreachable!(),
910 }
911 Ok(())
912}
913
914fn parse_indicator_record(
915 data: &mut ParsedMps,
916 items: &[Field<'_>],
917 line: usize,
918) -> Result<(), IoError> {
919 if items.len() != 4 || !items[0].text.eq_ignore_ascii_case("IF") {
920 return Err(invalid_mps(line, 1, "indicator record must be `IF row binary 0|1`"));
921 }
922 let active = match items[3].text {
923 "0" => false,
924 "1" => true,
925 _ => return Err(invalid_mps(line, items[3].column, "indicator value must be 0 or 1")),
926 };
927 data.indicators.push((items[1].text.to_owned(), items[2].text.to_owned(), active));
928 Ok(())
929}
930
931fn parse_sos_record(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
932 let is_header = matches!(items.first().map(|item| item.text.to_ascii_uppercase()), Some(ty) if matches!(ty.as_str(), "S1" | "S2"))
935 && (items.len() != 2 || !looks_like_number(items[1].text));
936 if is_header {
937 if items.len() != 2 {
938 return Err(invalid_mps(line, 1, "SOS set headers require type and name"));
939 }
940 let sos_type = match items[0].text.to_ascii_uppercase().as_str() {
941 "S1" => SosType::Sos1,
942 _ => SosType::Sos2,
943 };
944 if items[1].text.is_empty() {
945 return Err(invalid_mps(line, items[1].column, "SOS set needs a name"));
946 }
947 data.sos.push(ParsedSos { name: items[1].text.to_owned(), sos_type, members: Vec::new() });
948 data.current_sos = Some(data.sos.len() - 1);
949 return Ok(());
950 }
951 let Some(index) = data.current_sos else {
952 return Err(invalid_mps(line, 1, "SOS member appears before an S1/S2 header"));
953 };
954 if items.len() != 2 {
955 return Err(invalid_mps(line, 1, "SOS member needs variable name and weight"));
956 }
957 let weight = parse_number(&items[1], line)?;
958 data.sos[index].members.push((items[0].text.to_owned(), weight));
959 Ok(())
960}
961
962fn parse_mps<R: BufRead>(
963 mut input: R,
964 fallback_name: &str,
965 options: MpsReadOptions,
966) -> Result<Model, IoError> {
967 let mut data = ParsedMps::new(fallback_name);
968 let mut section = Section::Name;
969 let mut saw_name = false;
970 let mut line_buffer = String::new();
971 let mut last_line = 0;
972 loop {
973 line_buffer.clear();
974 if input.read_line(&mut line_buffer)? == 0 {
975 break;
976 }
977 last_line += 1;
978 let line = line_buffer.trim_end_matches(['\r', '\n']);
979 parse_mps_line(&mut data, &mut section, &mut saw_name, line, last_line, options)?;
980 }
981 let last_line = last_line.max(1);
982 if data.intorg {
983 return Err(invalid_mps(last_line, 1, "missing INTEND marker"));
984 }
985 if data.seen_sections & SEEN_ROWS == 0 {
986 return Err(invalid_mps(last_line, 1, "missing ROWS section"));
987 }
988 if data.seen_sections & SEEN_COLUMNS == 0 {
989 return Err(invalid_mps(last_line, 1, "missing COLUMNS section"));
990 }
991 if data.seen_sections & SEEN_END == 0 {
992 return Err(invalid_mps(last_line, 1, "missing ENDATA"));
993 }
994 validate_quadratic_triangles(&data, last_line)?;
995 build_mps_model(data)
996}
997
998fn expression<'a>(
999 model: &'a Model,
1000 variables: &[Expr<'a>],
1001 linear: impl IntoIterator<Item = (usize, f64)>,
1002 quadratic: impl IntoIterator<Item = ((usize, usize), f64)>,
1003 constant: f64,
1004) -> Expr<'a> {
1005 let mut expr = model.__constant(constant);
1006 for (column, coefficient) in linear {
1007 if coefficient != 0.0 {
1008 expr = expr + coefficient * variables[column];
1009 }
1010 }
1011 let mut quadratic: Vec<_> =
1012 quadratic.into_iter().filter(|(_, coefficient)| *coefficient != 0.0).collect();
1013 quadratic.sort_unstable_by_key(|((left, right), _)| (*left, *right));
1014 for ((left, right), coefficient) in quadratic {
1015 expr = expr + coefficient * variables[left] * variables[right];
1016 }
1017 expr
1018}
1019
1020fn unique_mps_names<'a>(
1021 names: impl IntoIterator<Item = &'a str>,
1022 fallback_prefix: &str,
1023 reserved: impl IntoIterator<Item = &'a str>,
1024) -> Vec<String> {
1025 let mut used: HashSet<String> = reserved.into_iter().map(str::to_owned).collect();
1026 names
1027 .into_iter()
1028 .enumerate()
1029 .map(|(index, name)| {
1030 let base: String = name
1031 .chars()
1032 .map(|ch| {
1033 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-') {
1034 ch
1035 } else {
1036 '_'
1037 }
1038 })
1039 .collect();
1040 let base =
1041 if base.is_empty() { format!("{fallback_prefix}{}", index + 1) } else { base };
1042 let mut candidate = base.clone();
1043 let mut suffix = 1;
1044 while used.contains(&candidate) {
1045 candidate = format!("{base}_{suffix}");
1046 suffix += 1;
1047 }
1048 used.insert(candidate.clone());
1049 candidate
1050 })
1051 .collect()
1052}
1053
1054fn write_quadratic_section<W: Write>(
1055 out: &mut W,
1056 header: &str,
1057 terms: &QuadraticTerms,
1058 variable_names: &[String],
1059 format: MpsQuadraticFormat,
1060 objective: bool,
1061) -> Result<(), IoError> {
1062 writeln!(out, "{header}")?;
1063 for &(left, right, hessian) in &terms.hessian {
1064 let (first, second) = if objective {
1065 if format == MpsQuadraticFormat::Cplex && left.index() > right.index() {
1068 (right, left)
1069 } else {
1070 (left, right)
1071 }
1072 } else if left.index() <= right.index() {
1073 (left, right)
1074 } else {
1075 (right, left)
1076 };
1077 let left_name = &variable_names[first.index()];
1078 let right_name = &variable_names[second.index()];
1079 let coefficient =
1080 if objective || format != MpsQuadraticFormat::Gurobi { hessian } else { hessian / 2.0 };
1081 writeln!(out, " {left_name:<10} {right_name:<10} {coefficient}")?;
1082 if !objective && format != MpsQuadraticFormat::Mosek && first != second {
1083 writeln!(out, " {right_name:<10} {left_name:<10} {coefficient}")?;
1085 }
1086 }
1087 Ok(())
1088}
1089
1090fn write_sos_section<W: Write>(
1091 out: &mut W,
1092 constraints: &[SosConstraint],
1093 variable_names: &[String],
1094) -> Result<(), IoError> {
1095 writeln!(out, "SOS")?;
1096 let active = || constraints.iter().filter(|constraint| constraint.active);
1097 let sos_names =
1098 unique_mps_names(active().map(|s| s.name.as_str()), "S", std::iter::empty::<&str>());
1099 for (set, set_name) in active().zip(sos_names.iter()) {
1100 writeln!(
1101 out,
1102 " S{} {}",
1103 match set.sos_type {
1104 SosType::Sos1 => 1,
1105 SosType::Sos2 => 2,
1106 },
1107 set_name
1108 )?;
1109 for member in &set.members {
1110 writeln!(out, " {:<10} {}", variable_names[member.variable.index()], member.weight)?;
1111 }
1112 }
1113 Ok(())
1114}
1115
1116#[expect(clippy::too_many_lines)]
1117fn build_mps_model(data: ParsedMps) -> Result<Model, IoError> {
1118 for column in &data.columns {
1119 if column.lower > column.upper {
1120 return Err(invalid_mps(
1121 1,
1122 1,
1123 format!("inconsistent bounds for column {:?}", column.name),
1124 ));
1125 }
1126 if matches!(column.kind, ColumnKind::SemiContinuous | ColumnKind::SemiInteger)
1127 && (!column.lower.is_finite() || column.lower < 0.0)
1128 {
1129 return Err(invalid_mps(
1130 1,
1131 1,
1132 format!("invalid semi-domain threshold for column {:?}", column.name),
1133 ));
1134 }
1135 }
1136 for row in &data.rows {
1137 if row.lower > row.upper {
1138 return Err(invalid_mps(1, 1, format!("inconsistent range for row {:?}", row.name)));
1139 }
1140 }
1141 let mut indicator_rows: FxHashMap<usize, (usize, bool)> = FxHashMap::default();
1142 for (row_name, trigger_name, active) in &data.indicators {
1143 let row = *data.row_index.get(row_name).ok_or_else(|| {
1144 invalid_mps(1, 1, format!("indicator references unknown row {row_name:?}"))
1145 })?;
1146 let trigger = *data.column_index.get(trigger_name).ok_or_else(|| {
1147 invalid_mps(1, 1, format!("indicator references unknown variable {trigger_name:?}"))
1148 })?;
1149 if data.columns[trigger].kind != ColumnKind::Binary {
1150 return Err(invalid_mps(
1151 1,
1152 1,
1153 format!("indicator trigger {trigger_name:?} is not binary"),
1154 ));
1155 }
1156 let body = &data.rows[row];
1157 if body.lower.is_finite()
1158 && body.upper.is_finite()
1159 && !body.lower.total_cmp(&body.upper).is_eq()
1160 {
1161 return Err(IoError::UnsupportedMps {
1162 section: "INDICATORS".into(),
1163 feature: "ranged indicator row".into(),
1164 });
1165 }
1166 if !body.quadratic.is_empty() {
1167 return Err(IoError::UnsupportedMps {
1168 section: "INDICATORS".into(),
1169 feature: "quadratic indicator row".into(),
1170 });
1171 }
1172 if indicator_rows.insert(row, (trigger, *active)).is_some() {
1173 return Err(invalid_mps(1, 1, format!("row {row_name:?} has multiple indicators")));
1174 }
1175 }
1176 let mut sos_names = HashSet::new();
1177 for set in &data.sos {
1178 if !sos_names.insert(set.name.clone()) {
1179 return Err(invalid_mps(1, 1, format!("duplicate SOS name {:?}", set.name)));
1180 }
1181 if set.members.is_empty() {
1182 return Err(invalid_mps(1, 1, format!("SOS {:?} has no members", set.name)));
1183 }
1184 let mut members = HashSet::new();
1185 let mut weights = Vec::new();
1186 for (var, weight) in &set.members {
1187 if !data.column_index.contains_key(var) {
1188 return Err(invalid_mps(1, 1, format!("unknown SOS variable {var:?}")));
1189 }
1190 if !members.insert(var) || weights.contains(weight) {
1191 return Err(invalid_mps(
1192 1,
1193 1,
1194 format!("duplicate SOS member or weight in {:?}", set.name),
1195 ));
1196 }
1197 weights.push(*weight);
1198 }
1199 }
1200 let model = Model::new(data.name);
1201 let mut variables = Vec::with_capacity(data.columns.len());
1202 for column in &data.columns {
1203 let domain = match column.kind {
1204 ColumnKind::Continuous => Domain::Real,
1205 ColumnKind::Integer => Domain::Integer,
1206 ColumnKind::Binary => Domain::Binary,
1207 ColumnKind::SemiContinuous => Domain::SemiContinuous { threshold: column.lower },
1208 ColumnKind::SemiInteger => Domain::SemiInteger { threshold: column.lower },
1209 };
1210 let model_lower = match column.kind {
1211 ColumnKind::SemiContinuous | ColumnKind::SemiInteger => 0.0,
1212 _ => column.lower,
1213 };
1214 variables.push(
1215 model
1216 .__var(column.name.clone())
1217 .bounds(model_lower, column.upper)
1218 .domain(domain)
1219 .build(),
1220 );
1221 }
1222 let mut pending_indicators = Vec::new();
1223 for (row_index, row) in data.rows.into_iter().enumerate() {
1224 let expr = expression(&model, &variables, row.linear, row.quadratic, 0.0);
1225 if let Some((trigger, active)) = indicator_rows.get(&row_index).copied() {
1226 pending_indicators.push((row.name, trigger, active, expr, row.lower, row.upper));
1227 } else {
1228 model.__add_constraint_interval(row.name, expr, row.lower, row.upper);
1229 }
1230 }
1231 for (name, trigger, active, expr, lower, upper) in pending_indicators {
1232 model.__add_indicator_interval(name, variables[trigger], active, expr, lower, upper);
1233 }
1234 for set in data.sos {
1235 let members = set.members.into_iter().map(|(name, weight)| {
1236 let index = *data.column_index.get(&name).expect("validated SOS variable");
1237 (variables[index], weight)
1238 });
1239 model.add_sos_constraint(set.name, set.sos_type, members);
1240 }
1241 let objective = expression(
1242 &model,
1243 &variables,
1244 data.objective_linear.into_iter().enumerate(),
1245 data.objective_quadratic,
1246 data.objective_constant,
1247 );
1248 match data.sense.or(data.legacy_sense).unwrap_or(ObjectiveSense::Minimize) {
1249 ObjectiveSense::Minimize => model.__minimize(objective),
1250 ObjectiveSense::Maximize => model.__maximize(objective),
1251 }
1252 Ok(model)
1253}
1254
1255pub fn read_mps<R: Read>(input: R) -> Result<Model, IoError> {
1261 read_mps_with(input, &MpsReadOptions::default())
1262}
1263
1264pub fn read_mps_with<R: Read>(input: R, options: &MpsReadOptions) -> Result<Model, IoError> {
1270 parse_mps(BufReader::new(input), "mps_model", *options)
1271}
1272
1273pub fn read_mps_file(path: impl AsRef<Path>) -> Result<Model, IoError> {
1279 read_mps_file_with(path, &MpsReadOptions::default())
1280}
1281
1282pub fn read_mps_file_with(
1288 path: impl AsRef<Path>,
1289 options: &MpsReadOptions,
1290) -> Result<Model, IoError> {
1291 let path = path.as_ref();
1292 let fallback = path.file_stem().and_then(|name| name.to_str()).unwrap_or("mps_model");
1293 parse_mps(BufReader::new(File::open(path)?), fallback, *options)
1294}
1295
1296pub fn write_mps<W: Write>(model: &Model, out: &mut W) -> Result<(), IoError> {
1313 write_mps_with(model, out, &MpsWriteOptions::default())
1314}
1315
1316#[expect(clippy::too_many_lines)]
1326pub fn write_mps_with<W: Write>(
1327 model: &Model,
1328 out: &mut W,
1329 options: &MpsWriteOptions,
1330) -> Result<(), IoError> {
1331 if model.num_soc_constraints() > 0
1332 || matches!(model.kind(), ModelKind::SOCP | ModelKind::MISOCP)
1333 {
1334 return Err(IoError::Conic);
1335 }
1336 if model.has_active_indicator_constraints() {
1337 return write_mps_with_indicators(model, out, *options);
1338 }
1339 if model.has_active_sos_constraints() && options.quadratic_format == MpsQuadraticFormat::Mosek {
1340 return Err(IoError::UnsupportedMps {
1341 section: "SOS".into(),
1342 feature: "SOS is not supported by the MOSEK MPS dialect".into(),
1343 });
1344 }
1345 let arena = model.arena();
1346 let vars = model.variables();
1347 let model_constraints = model.constraints();
1348 let constraints = model_constraints.algebraic();
1349 let (obj_sense, obj_terms) = crate::objective::export_terms(model, &arena, &vars)?;
1350 let variable_names = unique_mps_names(vars.iter().map(|v| v.name.as_str()), "C", []);
1351 let row_names = unique_mps_names(constraints.iter().map(|c| c.name.as_str()), "R", ["OBJ"]);
1352
1353 let con_terms: Vec<QuadraticTerms> = constraints
1355 .iter()
1356 .map(|c| {
1357 extract_quadratic(&arena, c.lhs).ok_or_else(|| IoError::Nonlinear {
1358 location: format!("constraint {:?}", c.name),
1359 term: describe_nonlinear_term(&arena, c.lhs, &|v| var_name(&vars, v))
1360 .unwrap_or_else(|| "<nonlinear>".into()),
1361 })
1362 })
1363 .collect::<Result<_, _>>()?;
1364
1365 let mut col_index: FxHashMap<VarId, Vec<(&str, f64)>> = FxHashMap::default();
1367 for (v, c) in &obj_terms.linear {
1368 col_index.entry(*v).or_default().push(("OBJ", *c));
1369 }
1370 for (row_name, terms) in row_names.iter().zip(con_terms.iter()) {
1371 for (v, coef) in &terms.linear {
1372 col_index.entry(*v).or_default().push((row_name.as_str(), *coef));
1373 }
1374 }
1375
1376 writeln!(out, "* OXIMO MPS export")?;
1377 writeln!(
1378 out,
1379 "* sense: {}",
1380 match obj_sense {
1381 ObjectiveSense::Minimize => "minimize",
1382 ObjectiveSense::Maximize => "maximize",
1383 }
1384 )?;
1385 writeln!(out, "NAME {}", model.name)?;
1386 writeln!(out, "OBJSENSE")?;
1387 writeln!(
1388 out,
1389 " {}",
1390 match obj_sense {
1391 ObjectiveSense::Minimize => "MIN",
1392 ObjectiveSense::Maximize => "MAX",
1393 }
1394 )?;
1395
1396 writeln!(out, "ROWS")?;
1397 writeln!(out, " N OBJ")?;
1398 for (c, row_name) in constraints.iter().zip(row_names.iter()) {
1399 let tag = match c.as_single() {
1400 Some((Sense::Le, _)) => 'L',
1401 Some((Sense::Ge, _)) => 'G',
1402 Some((Sense::Eq, _)) => 'E',
1403 None if c.is_range() => 'L',
1405 None => 'N',
1408 };
1409 writeln!(out, " {tag} {row_name}")?;
1410 }
1411
1412 writeln!(out, "COLUMNS")?;
1413 let mut int_open = false;
1414 for (v, column_name) in vars.iter().zip(variable_names.iter()) {
1415 let needs_marker = matches!(v.domain, Domain::Integer);
1417 if needs_marker && !int_open {
1418 writeln!(out, " MARKER 'MARKER' 'INTORG'")?;
1419 int_open = true;
1420 } else if !needs_marker && int_open {
1421 writeln!(out, " MARKER 'MARKER' 'INTEND'")?;
1422 int_open = false;
1423 }
1424 if let Some(entries) = col_index.get(&v.id) {
1425 for (row_name, coef) in entries {
1426 writeln!(out, " {column_name:<10} {row_name:<10} {coef}")?;
1427 }
1428 } else {
1429 writeln!(out, " {column_name:<10} {:<10} 0", "OBJ")?;
1430 }
1431 }
1432 if int_open {
1433 writeln!(out, " MARKER 'MARKER' 'INTEND'")?;
1434 }
1435
1436 writeln!(out, "RHS")?;
1437 let obj_constant = obj_terms.constant;
1438 if obj_constant != 0.0 {
1439 writeln!(out, " RHS OBJ {}", -obj_constant)?;
1440 }
1441 for ((c, row_name), t) in constraints.iter().zip(row_names.iter()).zip(con_terms.iter()) {
1442 let rhs = match c.as_single() {
1445 Some((_, rhs)) => rhs,
1446 None if c.is_range() => c.upper,
1447 None => continue,
1449 };
1450 let adjusted = rhs - t.constant;
1451 if adjusted != 0.0 {
1452 writeln!(out, " RHS {row_name:<10} {adjusted}")?;
1453 }
1454 }
1455
1456 if constraints.iter().any(Constraint::is_range) {
1457 writeln!(out, "RANGES")?;
1458 for (c, row_name) in constraints.iter().zip(row_names.iter()) {
1459 if c.is_range() {
1460 writeln!(out, " RNG {row_name:<10} {}", c.upper - c.lower)?;
1461 }
1462 }
1463 }
1464
1465 writeln!(out, "BOUNDS")?;
1466 for (v, column_name) in vars.iter().zip(variable_names.iter()) {
1467 let lb = v.lb;
1468 let ub = v.ub;
1469 if matches!(v.domain, Domain::Binary) {
1470 writeln!(out, " BV BND {column_name}")?;
1471 if lb != 0.0 {
1472 writeln!(out, " LO BND {column_name:<10} {lb}")?;
1473 }
1474 if (ub - 1.0).abs() >= f64::EPSILON {
1475 writeln!(out, " UP BND {column_name:<10} {ub}")?;
1476 }
1477 continue;
1478 }
1479 if let Some(thr) = v.domain.semi_threshold() {
1480 writeln!(out, " LO BND {column_name:<10} {thr}")?;
1481 let semi_ub = if ub.is_finite() { ub } else { MPS_INFINITY_SENTINEL };
1482 let code = if v.domain.is_integer() { "SI" } else { "SC" };
1484 writeln!(out, " {code} BND {column_name:<10} {semi_ub}")?;
1485 continue;
1486 }
1487 if lb.is_finite() && (lb - ub).abs() < f64::EPSILON {
1488 writeln!(out, " FX BND {column_name:<10} {lb}")?;
1489 continue;
1490 }
1491 let infinite_lo = lb == f64::NEG_INFINITY;
1492 let infinite_hi = ub == f64::INFINITY;
1493 match (infinite_lo, infinite_hi) {
1494 (true, true) => writeln!(out, " FR BND {column_name}")?,
1495 (true, false) => {
1496 writeln!(out, " MI BND {column_name}")?;
1497 writeln!(out, " UP BND {column_name:<10} {ub}")?;
1498 }
1499 (false, true) => {
1500 if lb != 0.0 {
1501 writeln!(out, " LO BND {column_name:<10} {lb}")?;
1502 }
1503 }
1504 (false, false) => {
1505 if lb != 0.0 {
1506 writeln!(out, " LO BND {column_name:<10} {lb}")?;
1507 }
1508 writeln!(out, " UP BND {column_name:<10} {ub}")?;
1509 }
1510 }
1511 }
1512
1513 let sos = model.sos_constraints();
1514 let has_active_sos = sos.iter().any(|constraint| constraint.active);
1515 if options.quadratic_format == MpsQuadraticFormat::Cplex && has_active_sos {
1516 write_sos_section(out, &sos, &variable_names)?;
1517 }
1518
1519 if !obj_terms.hessian.is_empty() {
1520 let header = if options.quadratic_format == MpsQuadraticFormat::Mosek {
1521 "QSECTION OBJ"
1522 } else {
1523 "QUADOBJ"
1524 };
1525 write_quadratic_section(
1526 out,
1527 header,
1528 &obj_terms,
1529 &variable_names,
1530 options.quadratic_format,
1531 true,
1532 )?;
1533 }
1534 for (row_name, terms) in row_names.iter().zip(con_terms.iter()) {
1535 if terms.hessian.is_empty() {
1536 continue;
1537 }
1538 let header = match options.quadratic_format {
1539 MpsQuadraticFormat::Mosek => format!("QSECTION {row_name}"),
1540 MpsQuadraticFormat::Gurobi | MpsQuadraticFormat::Cplex => {
1541 format!("QCMATRIX {row_name}")
1542 }
1543 };
1544 write_quadratic_section(
1545 out,
1546 &header,
1547 terms,
1548 &variable_names,
1549 options.quadratic_format,
1550 false,
1551 )?;
1552 }
1553
1554 if options.quadratic_format != MpsQuadraticFormat::Cplex && has_active_sos {
1555 write_sos_section(out, &sos, &variable_names)?;
1556 }
1557
1558 writeln!(out, "ENDATA")?;
1559 Ok(())
1560}
1561
1562fn rebuild_quadratic<'a>(
1563 model: &'a Model,
1564 variables: &[Expr<'a>],
1565 terms: &QuadraticTerms,
1566) -> Expr<'a> {
1567 let mut expr = model.__constant(terms.constant);
1568 for &(var, coefficient) in &terms.linear {
1569 expr = expr + coefficient * variables[var.index()];
1570 }
1571 for &(left, right, hessian) in &terms.hessian {
1572 let coefficient = if left == right { hessian / 2.0 } else { hessian };
1573 expr = expr + coefficient * variables[left.index()] * variables[right.index()];
1574 }
1575 expr
1576}
1577
1578fn write_mps_with_indicators<W: Write>(
1581 model: &Model,
1582 out: &mut W,
1583 options: MpsWriteOptions,
1584) -> Result<(), IoError> {
1585 let source_vars = model.variables();
1586 let arena = model.arena();
1587 let temporary = Model::new(model.name.clone());
1588 let variables: Vec<_> = source_vars
1589 .iter()
1590 .map(|v| temporary.__var(v.name.clone()).bounds(v.lb, v.ub).domain(v.domain).build())
1591 .collect();
1592 let mut raw_row_names = Vec::new();
1593 let mut used: HashSet<String> =
1594 model.constraints().algebraic().iter().map(|c| c.name.to_string()).collect();
1595 for c in model.constraints().algebraic() {
1596 let terms = extract_quadratic(&arena, c.lhs).ok_or_else(|| IoError::Nonlinear {
1597 location: format!("constraint {:?}", c.name),
1598 term: "nonlinear expression".into(),
1599 })?;
1600 let expr = rebuild_quadratic(&temporary, &variables, &terms);
1601 temporary.__add_constraint_interval(c.name.clone(), expr, c.lower, c.upper);
1602 raw_row_names.push(c.name.to_string());
1603 }
1604 let mut records: Vec<(usize, VarId, bool)> = Vec::new();
1605 for indicator in model.indicator_constraints().iter().filter(|c| c.active) {
1606 let terms = extract_quadratic(&arena, indicator.lhs).expect("validated affine indicator");
1607 if !terms.hessian.is_empty() {
1608 return Err(IoError::UnsupportedMps {
1609 section: "INDICATORS".into(),
1610 feature: "quadratic indicator row".into(),
1611 });
1612 }
1613 let mut add_body = |suffix: &str, sense: Sense, rhs: f64| {
1614 let base = if suffix.is_empty() {
1615 indicator.name.to_string()
1616 } else {
1617 format!("{}_{suffix}", indicator.name)
1618 };
1619 let mut name = base.clone();
1620 let mut suffix = 1usize;
1621 while !used.insert(name.clone()) {
1622 name = format!("{base}_{suffix}");
1623 suffix += 1;
1624 }
1625 let expr = rebuild_quadratic(&temporary, &variables, &terms);
1626 let row = match sense {
1627 Sense::Le => expr.le(rhs),
1628 Sense::Ge => expr.ge(rhs),
1629 Sense::Eq => expr.eq(rhs),
1630 };
1631 temporary.__add_constraint(name.clone(), row);
1632 raw_row_names.push(name);
1633 records.push((raw_row_names.len() - 1, indicator.trigger, indicator.active_value));
1634 };
1635 if let Some((sense, rhs)) = indicator.as_single() {
1636 add_body("", sense, rhs);
1637 } else if indicator.is_range() {
1638 add_body("lo", Sense::Ge, indicator.lower);
1639 add_body("hi", Sense::Le, indicator.upper);
1640 }
1641 }
1642 for set in model.sos_constraints().iter().filter(|s| s.active) {
1643 temporary.add_sos_constraint(
1644 set.name.clone(),
1645 set.sos_type,
1646 set.members.iter().map(|member| (variables[member.variable.index()], member.weight)),
1647 );
1648 }
1649 if model.is_feasibility() {
1650 temporary.__feasibility();
1651 } else if let Some(objective) = model.objective().as_ref() {
1652 let terms = extract_quadratic(&arena, objective.expr).ok_or_else(|| {
1653 IoError::Nonlinear { location: "objective".into(), term: "nonlinear expression".into() }
1654 })?;
1655 let expr = rebuild_quadratic(&temporary, &variables, &terms);
1656 match objective.sense {
1657 ObjectiveSense::Minimize => temporary.__minimize(expr),
1658 ObjectiveSense::Maximize => temporary.__maximize(expr),
1659 }
1660 }
1661 let mut bytes = Vec::new();
1662 write_mps_with(&temporary, &mut bytes, &options)?;
1663 let text = String::from_utf8(bytes).expect("MPS writer emits ASCII");
1664 let end = text.rfind("ENDATA").expect("writer emits ENDATA");
1665 out.write_all(&text.as_bytes()[..end])?;
1666 let row_names = unique_mps_names(raw_row_names.iter().map(String::as_str), "R", ["OBJ"]);
1667 let variable_names = unique_mps_names(source_vars.iter().map(|v| v.name.as_str()), "C", []);
1668 writeln!(out, "INDICATORS")?;
1669 for (row, trigger, value) in records {
1670 writeln!(
1671 out,
1672 " IF {} {} {}",
1673 row_names[row],
1674 variable_names[trigger.index()],
1675 u8::from(value)
1676 )?;
1677 }
1678 out.write_all(&text.as_bytes()[end..])?;
1679 Ok(())
1680}
1681
1682pub fn to_mps_string(model: &Model) -> Result<String, IoError> {
1692 to_mps_string_with(model, &MpsWriteOptions::default())
1693}
1694
1695pub fn to_mps_string_with(model: &Model, options: &MpsWriteOptions) -> Result<String, IoError> {
1706 let mut buf = Vec::new();
1707 write_mps_with(model, &mut buf, options)?;
1708 Ok(String::from_utf8(buf).expect("MPS writer emits ASCII"))
1709}