|
8 | 8 | import pickle |
9 | 9 | from pathlib import Path |
10 | 10 |
|
| 11 | +import numpy as np |
11 | 12 | import pandas as pd |
| 13 | +import polars as pl |
12 | 14 | import pytest |
13 | 15 | import xarray as xr |
14 | 16 |
|
15 | 17 | from linopy import LESS_EQUAL, Model, available_solvers, read_netcdf |
| 18 | +from linopy.io import signed_number |
16 | 19 | from linopy.testing import assert_model_equal |
17 | 20 |
|
18 | 21 |
|
@@ -217,3 +220,119 @@ def test_to_blocks(tmp_path: Path) -> None: |
217 | 220 |
|
218 | 221 | with pytest.raises(NotImplementedError): |
219 | 222 | 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