Skip to content

Commit 304cc64

Browse files
FBumanncoroa
andauthored
Fix -0.0 handling in lp writer (#544)
* Fix -0.0 handling in lp writer * Normalize -0.0 to +0.0 using when(abs(x) == 0).then(0.0).otherwise(x) before formatting * Add release notes * Cast to Float64 first to handle columns that are entirely null (dtype `null`) * fix: make signed_number work on expressions (#9) --------- Co-authored-by: Jonas Hörsch <coroa@posteo.de>
1 parent a7efe18 commit 304cc64

3 files changed

Lines changed: 146 additions & 10 deletions

File tree

doc/release_notes.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ Release Notes
33

44
.. Upcoming Version
55
6+
* Fix LP file writing for negative zero (-0.0) values that produced invalid syntax like "+-0.0" rejected by Gurobi
7+
68
Version 0.6.0
79
--------------
810

linopy/io.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,26 @@ def clean_name(name: str) -> str:
5454
coord_sanitizer = str.maketrans("[,]", "(,)", " ")
5555

5656

57+
def signed_number(expr: pl.Expr) -> tuple[pl.Expr, pl.Expr]:
58+
"""
59+
Return polars expressions for a signed number string, handling -0.0 correctly.
60+
61+
Parameters
62+
----------
63+
expr : pl.Expr
64+
Numeric value
65+
66+
Returns
67+
-------
68+
tuple[pl.Expr, pl.Expr]
69+
value_string with sign
70+
"""
71+
return (
72+
pl.when(expr >= 0).then(pl.lit("+")).otherwise(pl.lit("")),
73+
pl.when(expr == 0).then(pl.lit("0.0")).otherwise(expr.cast(pl.String)),
74+
)
75+
76+
5777
def print_coord(coord: str) -> str:
5878
from linopy.common import print_coord
5979

@@ -132,8 +152,7 @@ def objective_write_linear_terms(
132152
f: BufferedWriter, df: pl.DataFrame, print_variable: Callable
133153
) -> None:
134154
cols = [
135-
pl.when(pl.col("coeffs") >= 0).then(pl.lit("+")).otherwise(pl.lit("")),
136-
pl.col("coeffs").cast(pl.String),
155+
*signed_number(pl.col("coeffs")),
137156
*print_variable(pl.col("vars")),
138157
]
139158
df = df.select(pl.concat_str(cols, ignore_nulls=True))
@@ -146,8 +165,7 @@ def objective_write_quadratic_terms(
146165
f: BufferedWriter, df: pl.DataFrame, print_variable: Callable
147166
) -> None:
148167
cols = [
149-
pl.when(pl.col("coeffs") >= 0).then(pl.lit("+")).otherwise(pl.lit("")),
150-
pl.col("coeffs").mul(2).cast(pl.String),
168+
*signed_number(pl.col("coeffs").mul(2)),
151169
*print_variable(pl.col("vars1")),
152170
pl.lit(" *"),
153171
*print_variable(pl.col("vars2")),
@@ -229,13 +247,11 @@ def bounds_to_file(
229247
df = var_slice.to_polars()
230248

231249
columns = [
232-
pl.when(pl.col("lower") >= 0).then(pl.lit("+")).otherwise(pl.lit("")),
233-
pl.col("lower").cast(pl.String),
250+
*signed_number(pl.col("lower")),
234251
pl.lit(" <= "),
235252
*print_variable(pl.col("labels")),
236253
pl.lit(" <= "),
237-
pl.when(pl.col("upper") >= 0).then(pl.lit("+")).otherwise(pl.lit("")),
238-
pl.col("upper").cast(pl.String),
254+
*signed_number(pl.col("upper")),
239255
]
240256

241257
kwargs: Any = dict(
@@ -463,8 +479,7 @@ def constraints_to_file(
463479
pl.when(pl.col("labels_first").is_not_null())
464480
.then(pl.lit(":\n"))
465481
.alias(":"),
466-
pl.when(pl.col("coeffs") >= 0).then(pl.lit("+")),
467-
pl.col("coeffs").cast(pl.String),
482+
*signed_number(pl.col("coeffs")),
468483
pl.when(pl.col("vars").is_not_null()).then(col_labels[0]),
469484
pl.when(pl.col("vars").is_not_null()).then(col_labels[1]),
470485
pl.when(pl.col("is_last_in_group")).then(pl.col("sign")),

test/test_io.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@
88
import pickle
99
from pathlib import Path
1010

11+
import numpy as np
1112
import pandas as pd
13+
import polars as pl
1214
import pytest
1315
import xarray as xr
1416

1517
from linopy import LESS_EQUAL, Model, available_solvers, read_netcdf
18+
from linopy.io import signed_number
1619
from linopy.testing import assert_model_equal
1720

1821

@@ -217,3 +220,119 @@ def test_to_blocks(tmp_path: Path) -> None:
217220

218221
with pytest.raises(NotImplementedError):
219222
m.to_block_files(tmp_path)
223+
224+
225+
class TestSignedNumberExpr:
226+
"""Test the signed_number helper function for LP file formatting."""
227+
228+
def test_positive_numbers(self) -> None:
229+
"""Positive numbers should get a '+' prefix."""
230+
df = pl.DataFrame({"value": [1.0, 2.5, 100.0]})
231+
result = df.select(pl.concat_str(signed_number(pl.col("value"))))
232+
values = result.to_series().to_list()
233+
assert values == ["+1.0", "+2.5", "+100.0"]
234+
235+
def test_negative_numbers(self) -> None:
236+
"""Negative numbers should not get a '+' prefix (already have '-')."""
237+
df = pl.DataFrame({"value": [-1.0, -2.5, -100.0]})
238+
result = df.select(pl.concat_str(signed_number(pl.col("value"))))
239+
values = result.to_series().to_list()
240+
assert values == ["-1.0", "-2.5", "-100.0"]
241+
242+
def test_positive_zero(self) -> None:
243+
"""Positive zero should get a '+' prefix."""
244+
df = pl.DataFrame({"value": [0.0]})
245+
result = df.select(pl.concat_str(signed_number(pl.col("value"))))
246+
values = result.to_series().to_list()
247+
assert values == ["+0.0"]
248+
249+
def test_negative_zero(self) -> None:
250+
"""Negative zero is normalized to +0.0 - this is the bug fix."""
251+
# Create negative zero using numpy
252+
neg_zero = np.float64(-0.0)
253+
df = pl.DataFrame({"value": [neg_zero]})
254+
result = df.select(pl.concat_str(signed_number(pl.col("value"))))
255+
values = result.to_series().to_list()
256+
# The key assertion: should NOT be "+-0.0", -0.0 is normalized to +0.0
257+
assert values == ["+0.0"]
258+
assert "+-" not in values[0]
259+
260+
def test_mixed_values_including_negative_zero(self) -> None:
261+
"""Test a mix of positive, negative, and zero values."""
262+
neg_zero = np.float64(-0.0)
263+
df = pl.DataFrame({"value": [1.0, -1.0, 0.0, neg_zero, 2.5, -2.5]})
264+
result = df.select(pl.concat_str(signed_number(pl.col("value"))))
265+
values = result.to_series().to_list()
266+
# -0.0 is normalized to +0.0
267+
assert values == ["+1.0", "-1.0", "+0.0", "+0.0", "+2.5", "-2.5"]
268+
# No value should contain "+-"
269+
for v in values:
270+
assert "+-" not in v
271+
272+
273+
@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed")
274+
def test_to_file_lp_with_negative_zero_bounds(tmp_path: Path) -> None:
275+
"""
276+
Test that LP files with negative zero bounds are valid.
277+
278+
This is a regression test for the bug where -0.0 bounds would produce
279+
invalid LP file syntax like "+-0.0 <= x1 <= +0.0".
280+
281+
See: https://github.com/PyPSA/linopy/issues/XXX
282+
"""
283+
import gurobipy
284+
285+
m = Model()
286+
287+
# Create bounds that could produce -0.0
288+
# Using numpy to ensure we can create actual negative zeros
289+
lower = pd.Series([np.float64(-0.0), np.float64(0.0), np.float64(-0.0)])
290+
upper = pd.Series([np.float64(0.0), np.float64(-0.0), np.float64(1.0)])
291+
292+
m.add_variables(lower, upper, name="x")
293+
m.add_objective(m.variables["x"].sum())
294+
295+
fn = tmp_path / "test_neg_zero.lp"
296+
m.to_file(fn)
297+
298+
# Read the LP file content and verify no "+-" appears
299+
with open(fn) as f:
300+
content = f.read()
301+
assert "+-" not in content, f"Found invalid '+-' in LP file: {content}"
302+
303+
# Verify Gurobi can read it without errors
304+
gurobipy.read(str(fn))
305+
306+
307+
@pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed")
308+
def test_to_file_lp_with_negative_zero_coefficients(tmp_path: Path) -> None:
309+
"""
310+
Test that LP files with negative zero coefficients are valid.
311+
312+
Coefficients can also potentially be -0.0 due to floating point arithmetic.
313+
"""
314+
import gurobipy
315+
316+
m = Model()
317+
318+
x = m.add_variables(name="x", lower=0, upper=10)
319+
y = m.add_variables(name="y", lower=0, upper=10)
320+
321+
# Create an expression where coefficients could become -0.0
322+
# through arithmetic operations
323+
coeff = np.float64(-0.0)
324+
expr = coeff * x + 1 * y
325+
326+
m.add_constraints(expr <= 5)
327+
m.add_objective(x + y)
328+
329+
fn = tmp_path / "test_neg_zero_coeffs.lp"
330+
m.to_file(fn)
331+
332+
# Read the LP file content and verify no "+-" appears
333+
with open(fn) as f:
334+
content = f.read()
335+
assert "+-" not in content, f"Found invalid '+-' in LP file: {content}"
336+
337+
# Verify Gurobi can read it without errors
338+
gurobipy.read(str(fn))

0 commit comments

Comments
 (0)