Skip to content

Conversation

@FBumann
Copy link
Contributor

@FBumann FBumann commented Jan 3, 2026

Closes #541

Changes proposed in this Pull Request

Bug: LP file writer produced invalid syntax +-0.0 when bounds/coefficients were -0.0, causing Gurobi to reject the file.

Root cause: -0.0 >= 0 is True, so a + prefix was added, but str(-0.0) is "-0.0", producing +-0.0.

Fix: Normalize -0.0 to +0.0 using when(abs(x) == 0).then(0.0).otherwise(x) before formatting.

Performance: ~4% overhead on 10M rows

Changes:

  • linopy/io.py: Added signed_number_expr() helper, applied to 4 locations
  • test/test_io.py: Added 7 test cases

Benchmark script

#!/usr/bin/env python3
"""
Benchmark script for LP file writing performance.

Can be used ti check the impact of the negative zero fix on LP file generation speed.
"""

import tempfile
import time
from pathlib import Path

import numpy as np

from linopy import Model


def create_model(n: int) -> Model:
    """Create a test model with n^2 variables."""
    m = Model()
    N = np.arange(n)
    x = m.add_variables(coords=[N, N], name="x")
    y = m.add_variables(coords=[N, N], name="y")
    m.add_constraints(x - y >= N, name="c1")
    m.add_constraints(x + y >= 0, name="c2")
    m.add_objective((2 * x).sum() + y.sum())
    return m


def benchmark_lp_writing(sizes: list[int], iterations: int = 10) -> None:
    """Benchmark LP file writing for different model sizes."""
    print(f"Benchmarking LP file writing ({iterations} iterations each)")
    print("-" * 60)

    with tempfile.TemporaryDirectory() as tmpdir:
        for n in sizes:
            m = create_model(n)
            nvars = m.nvars
            ncons = m.ncons

            # Warmup
            fn = Path(tmpdir) / f"warmup_{n}.lp"
            m.to_file(fn, progress=False)

            # Benchmark
            times = []
            for i in range(iterations):
                fn = Path(tmpdir) / f"bench_{n}_{i}.lp"
                start = time.perf_counter()
                m.to_file(fn, progress=False)
                elapsed = time.perf_counter() - start
                times.append(elapsed)

            avg = np.mean(times)
            std = np.std(times)
            print(
                f"N={n:4d} ({nvars:>8,} vars, {ncons:>8,} cons): "
                f"{avg*1000:7.1f}ms ± {std*1000:5.1f}ms"
            )


if __name__ == "__main__":
    # Test with various model sizes
    sizes = [50, 100, 200, 500, 1000]
    benchmark_lp_writing(sizes, iterations=10)

Checklist

  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in doc.
  • Unit tests for new features were added (if applicable).
  • A note for the release notes doc/release_notes.rst of the upcoming release is included.
  • I consent to the release of this PR's code under the MIT license.

@FBumann FBumann marked this pull request as ready for review January 3, 2026 18:09
Copy link
Member

@coroa coroa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of a signed_number function, but i would let it directly act on expressions and return a simple expression.

This expression could then be a simple case statement.

Comment on lines +55 to +79
def signed_number_expr(col_name: str) -> list[pl.Expr]:
"""
Return polars expressions for a signed number string, handling -0.0 correctly.
This function returns two expressions: a sign prefix ('+' or '') and the
number cast to string. It normalizes -0.0 to +0.0 to avoid producing invalid
LP file syntax like "+-0.0" (which occurs because -0.0 >= 0 is True but
str(-0.0) is "-0.0").
Parameters
----------
col_name : str
Name of the column containing the numeric value.
Returns
-------
list[pl.Expr]
Two polars expressions: [sign_prefix, value_string]
"""
# Cast to Float64 first to handle columns that are entirely null (dtype `null`)
value = pl.col(col_name).cast(pl.Float64)
# Normalize -0.0 to +0.0: if abs(x) == 0 then 0.0 else x
normalized = pl.when(value.abs() == 0).then(pl.lit(0.0)).otherwise(value)
sign_prefix = pl.when(normalized >= 0).then(pl.lit("+")).otherwise(pl.lit(""))
return [sign_prefix, normalized.cast(pl.String)]
Copy link
Member

@coroa coroa Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about making it a simple case statement?

Suggested change
def signed_number_expr(col_name: str) -> list[pl.Expr]:
"""
Return polars expressions for a signed number string, handling -0.0 correctly.
This function returns two expressions: a sign prefix ('+' or '') and the
number cast to string. It normalizes -0.0 to +0.0 to avoid producing invalid
LP file syntax like "+-0.0" (which occurs because -0.0 >= 0 is True but
str(-0.0) is "-0.0").
Parameters
----------
col_name : str
Name of the column containing the numeric value.
Returns
-------
list[pl.Expr]
Two polars expressions: [sign_prefix, value_string]
"""
# Cast to Float64 first to handle columns that are entirely null (dtype `null`)
value = pl.col(col_name).cast(pl.Float64)
# Normalize -0.0 to +0.0: if abs(x) == 0 then 0.0 else x
normalized = pl.when(value.abs() == 0).then(pl.lit(0.0)).otherwise(value)
sign_prefix = pl.when(normalized >= 0).then(pl.lit("+")).otherwise(pl.lit(""))
return [sign_prefix, normalized.cast(pl.String)]
def signed_number(expr: pl.Expr) -> pl.Expr:
"""
Return polar expressions for a signed number string, handling -0.0 correctly.
Parameters
----------
expr : pl.Expr
Numeric value
Returns
-------
pl.Expr
value_string with sign
"""
return (
pl.when(expr > 0)
.then(pl.format("+{}", expr)
.when(expr < 0)
.then(pl.format("{}", expr))
.when(expr.is_not_null())
.then(pl.lit("+0.0"))
.otherwise(None)
)

cols = [
pl.when(pl.col("coeffs") >= 0).then(pl.lit("+")).otherwise(pl.lit("")),
pl.col("coeffs").cast(pl.String),
*signed_number_expr("coeffs"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
*signed_number_expr("coeffs"),
signed_number(pl.col("coeffs")),

and equivalent down below.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: lp-file error: Unrecognized variable bound

2 participants