linopy
Advanced tools
@@ -5,12 +5,38 @@ Release Notes | ||
| .. Upcoming Version | ||
| * Add ``mock_solve`` option to model.solve for quick testing without actual solving | ||
| * Bugfix for missing dependency for jupyter notebook example in documentation | ||
| Version 0.6.1 | ||
| -------------- | ||
| * Avoid Gurobi initialization on linopy import. | ||
| * Fix LP file writing for negative zero (-0.0) values that produced invalid syntax like "+-0.0" rejected by Gurobi | ||
| Version 0.6.0 | ||
| -------------- | ||
| **Features** | ||
| * Add ``mock_solve`` option to ``Model.solve()`` for quick testing without actual solving | ||
| * Add support for SOS1 and SOS2 (Special Ordered Sets) constraints via ``Model.add_sos_constraints()`` and ``Model.remove_sos_constraints()`` | ||
| * Add simplify method to LinearExpression to combine duplicate terms | ||
| * Add convenience function to create LinearExpression from constant | ||
| * Fix compatibility for xpress versions below 9.6 (regression) | ||
| * Performance: Up to 50x faster ``repr()`` for variables/constraints via O(log n) label lookup and direct numpy indexing | ||
| * Performance: Up to 46x faster ``ncons`` property by replacing ``.flat.labels.unique()`` with direct counting | ||
| * Add support for GPU-accelerated solver [cuPDLPx](https://github.com/MIT-Lu-Lab/cuPDLPx) | ||
| * Add ``simplify`` method to ``LinearExpression`` to combine duplicate terms | ||
| * Add convenience function to create ``LinearExpression`` from constant | ||
| * Add support for GPU-accelerated solver `cuPDLPx <https://github.com/MIT-Lu-Lab/cuPDLPx>`_ | ||
| * Add solver features registry for introspection of solver capabilities | ||
| **Performance** | ||
| * Up to 50x faster ``repr()`` for variables/constraints via O(log n) label lookup and direct numpy indexing | ||
| * Up to 46x faster ``ncons`` property by replacing ``.flat.labels.unique()`` with direct counting | ||
| **Bug Fixes** | ||
| * Fix HiGHS solver to properly stop on Ctrl-C keyboard interrupt | ||
| * Fix CBC solver to correctly parse negative objective values | ||
| * Fix Xpress compatibility for versions below 9.6 (regression from namespace change) | ||
| * Fix Xpress ``getDual()`` fallback for older versions | ||
| * Fix missing dependency for jupyter notebook example in documentation | ||
| **Solver Updates** | ||
| * Add Xpress 9.8+ API support with full backward compatibility to 9.6+ | ||
| Version 0.5.8 | ||
@@ -17,0 +43,0 @@ -------------- |
| Metadata-Version: 2.4 | ||
| Name: linopy | ||
| Version: 0.6.0 | ||
| Version: 0.6.1 | ||
| Summary: Linear optimization with N-D labeled arrays in Python | ||
@@ -5,0 +5,0 @@ Author-email: Fabian Hofmann <fabianmarikhofmann@gmail.com> |
+25
-10
@@ -57,2 +57,22 @@ #!/usr/bin/env python3 | ||
| def signed_number(expr: pl.Expr) -> tuple[pl.Expr, pl.Expr]: | ||
| """ | ||
| Return polars expressions for a signed number string, handling -0.0 correctly. | ||
| Parameters | ||
| ---------- | ||
| expr : pl.Expr | ||
| Numeric value | ||
| Returns | ||
| ------- | ||
| tuple[pl.Expr, pl.Expr] | ||
| value_string with sign | ||
| """ | ||
| return ( | ||
| pl.when(expr >= 0).then(pl.lit("+")).otherwise(pl.lit("")), | ||
| pl.when(expr == 0).then(pl.lit("0.0")).otherwise(expr.cast(pl.String)), | ||
| ) | ||
| def print_coord(coord: str) -> str: | ||
@@ -136,4 +156,3 @@ from linopy.common import print_coord | ||
| cols = [ | ||
| pl.when(pl.col("coeffs") >= 0).then(pl.lit("+")).otherwise(pl.lit("")), | ||
| pl.col("coeffs").cast(pl.String), | ||
| *signed_number(pl.col("coeffs")), | ||
| *print_variable(pl.col("vars")), | ||
@@ -151,4 +170,3 @@ ] | ||
| cols = [ | ||
| pl.when(pl.col("coeffs") >= 0).then(pl.lit("+")).otherwise(pl.lit("")), | ||
| pl.col("coeffs").mul(2).cast(pl.String), | ||
| *signed_number(pl.col("coeffs").mul(2)), | ||
| *print_variable(pl.col("vars1")), | ||
@@ -235,9 +253,7 @@ pl.lit(" *"), | ||
| columns = [ | ||
| pl.when(pl.col("lower") >= 0).then(pl.lit("+")).otherwise(pl.lit("")), | ||
| pl.col("lower").cast(pl.String), | ||
| *signed_number(pl.col("lower")), | ||
| pl.lit(" <= "), | ||
| *print_variable(pl.col("labels")), | ||
| pl.lit(" <= "), | ||
| pl.when(pl.col("upper") >= 0).then(pl.lit("+")).otherwise(pl.lit("")), | ||
| pl.col("upper").cast(pl.String), | ||
| *signed_number(pl.col("upper")), | ||
| ] | ||
@@ -470,4 +486,3 @@ | ||
| .alias(":"), | ||
| pl.when(pl.col("coeffs") >= 0).then(pl.lit("+")), | ||
| pl.col("coeffs").cast(pl.String), | ||
| *signed_number(pl.col("coeffs")), | ||
| pl.when(pl.col("vars").is_not_null()).then(col_labels[0]), | ||
@@ -474,0 +489,0 @@ pl.when(pl.col("vars").is_not_null()).then(col_labels[1]), |
@@ -31,5 +31,5 @@ # file generated by setuptools-scm | ||
| __version__ = version = '0.6.0' | ||
| __version_tuple__ = version_tuple = (0, 6, 0) | ||
| __version__ = version = '0.6.1' | ||
| __version_tuple__ = version_tuple = (0, 6, 1) | ||
| __commit_id__ = commit_id = 'ge698fb25f' | ||
| __commit_id__ = commit_id = 'g67e3484dc' |
+1
-1
| Metadata-Version: 2.4 | ||
| Name: linopy | ||
| Version: 0.6.0 | ||
| Version: 0.6.1 | ||
| Summary: Linear optimization with N-D labeled arrays in Python | ||
@@ -5,0 +5,0 @@ Author-email: Fabian Hofmann <fabianmarikhofmann@gmail.com> |
+119
-0
@@ -11,3 +11,5 @@ #!/usr/bin/env python3 | ||
| import numpy as np | ||
| import pandas as pd | ||
| import polars as pl | ||
| import pytest | ||
@@ -17,2 +19,3 @@ import xarray as xr | ||
| from linopy import LESS_EQUAL, Model, available_solvers, read_netcdf | ||
| from linopy.io import signed_number | ||
| from linopy.testing import assert_model_equal | ||
@@ -222,1 +225,117 @@ | ||
| m.to_block_files(tmp_path) | ||
| class TestSignedNumberExpr: | ||
| """Test the signed_number helper function for LP file formatting.""" | ||
| def test_positive_numbers(self) -> None: | ||
| """Positive numbers should get a '+' prefix.""" | ||
| df = pl.DataFrame({"value": [1.0, 2.5, 100.0]}) | ||
| result = df.select(pl.concat_str(signed_number(pl.col("value")))) | ||
| values = result.to_series().to_list() | ||
| assert values == ["+1.0", "+2.5", "+100.0"] | ||
| def test_negative_numbers(self) -> None: | ||
| """Negative numbers should not get a '+' prefix (already have '-').""" | ||
| df = pl.DataFrame({"value": [-1.0, -2.5, -100.0]}) | ||
| result = df.select(pl.concat_str(signed_number(pl.col("value")))) | ||
| values = result.to_series().to_list() | ||
| assert values == ["-1.0", "-2.5", "-100.0"] | ||
| def test_positive_zero(self) -> None: | ||
| """Positive zero should get a '+' prefix.""" | ||
| df = pl.DataFrame({"value": [0.0]}) | ||
| result = df.select(pl.concat_str(signed_number(pl.col("value")))) | ||
| values = result.to_series().to_list() | ||
| assert values == ["+0.0"] | ||
| def test_negative_zero(self) -> None: | ||
| """Negative zero is normalized to +0.0 - this is the bug fix.""" | ||
| # Create negative zero using numpy | ||
| neg_zero = np.float64(-0.0) | ||
| df = pl.DataFrame({"value": [neg_zero]}) | ||
| result = df.select(pl.concat_str(signed_number(pl.col("value")))) | ||
| values = result.to_series().to_list() | ||
| # The key assertion: should NOT be "+-0.0", -0.0 is normalized to +0.0 | ||
| assert values == ["+0.0"] | ||
| assert "+-" not in values[0] | ||
| def test_mixed_values_including_negative_zero(self) -> None: | ||
| """Test a mix of positive, negative, and zero values.""" | ||
| neg_zero = np.float64(-0.0) | ||
| df = pl.DataFrame({"value": [1.0, -1.0, 0.0, neg_zero, 2.5, -2.5]}) | ||
| result = df.select(pl.concat_str(signed_number(pl.col("value")))) | ||
| values = result.to_series().to_list() | ||
| # -0.0 is normalized to +0.0 | ||
| assert values == ["+1.0", "-1.0", "+0.0", "+0.0", "+2.5", "-2.5"] | ||
| # No value should contain "+-" | ||
| for v in values: | ||
| assert "+-" not in v | ||
| @pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") | ||
| def test_to_file_lp_with_negative_zero_bounds(tmp_path: Path) -> None: | ||
| """ | ||
| Test that LP files with negative zero bounds are valid. | ||
| This is a regression test for the bug where -0.0 bounds would produce | ||
| invalid LP file syntax like "+-0.0 <= x1 <= +0.0". | ||
| See: https://github.com/PyPSA/linopy/issues/XXX | ||
| """ | ||
| import gurobipy | ||
| m = Model() | ||
| # Create bounds that could produce -0.0 | ||
| # Using numpy to ensure we can create actual negative zeros | ||
| lower = pd.Series([np.float64(-0.0), np.float64(0.0), np.float64(-0.0)]) | ||
| upper = pd.Series([np.float64(0.0), np.float64(-0.0), np.float64(1.0)]) | ||
| m.add_variables(lower, upper, name="x") | ||
| m.add_objective(m.variables["x"].sum()) | ||
| fn = tmp_path / "test_neg_zero.lp" | ||
| m.to_file(fn) | ||
| # Read the LP file content and verify no "+-" appears | ||
| with open(fn) as f: | ||
| content = f.read() | ||
| assert "+-" not in content, f"Found invalid '+-' in LP file: {content}" | ||
| # Verify Gurobi can read it without errors | ||
| gurobipy.read(str(fn)) | ||
| @pytest.mark.skipif("gurobi" not in available_solvers, reason="Gurobipy not installed") | ||
| def test_to_file_lp_with_negative_zero_coefficients(tmp_path: Path) -> None: | ||
| """ | ||
| Test that LP files with negative zero coefficients are valid. | ||
| Coefficients can also potentially be -0.0 due to floating point arithmetic. | ||
| """ | ||
| import gurobipy | ||
| m = Model() | ||
| x = m.add_variables(name="x", lower=0, upper=10) | ||
| y = m.add_variables(name="y", lower=0, upper=10) | ||
| # Create an expression where coefficients could become -0.0 | ||
| # through arithmetic operations | ||
| coeff = np.float64(-0.0) | ||
| expr = coeff * x + 1 * y | ||
| m.add_constraints(expr <= 5) | ||
| m.add_objective(x + y) | ||
| fn = tmp_path / "test_neg_zero_coeffs.lp" | ||
| m.to_file(fn) | ||
| # Read the LP file content and verify no "+-" appears | ||
| with open(fn) as f: | ||
| content = f.read() | ||
| assert "+-" not in content, f"Found invalid '+-' in LP file: {content}" | ||
| # Verify Gurobi can read it without errors | ||
| gurobipy.read(str(fn)) |
Sorry, the diff of this file is too big to display
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
2636507
0.19%20441
0.49%