From 4c3a302d3ffbfc6c860eeed4627ad7e2bd868d6e Mon Sep 17 00:00:00 2001 From: mymissuniverse Date: Fri, 16 Jan 2026 02:23:24 +0900 Subject: [PATCH 01/10] Add setTracefile stub to Model class --- src/pyscipopt/scip.pxi | 15 +++++++++++++++ src/pyscipopt/scip.pyi | 1 + 2 files changed, 16 insertions(+) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index da23028f9..09abf3218 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -11267,6 +11267,21 @@ cdef class Model: SCIPsetMessagehdlrLogfile(self._scip, c_path) else: SCIPsetMessagehdlrLogfile(self._scip, NULL) + + def setTracefile(self, path, mode="a"): + """ + Enable or disable structured trace output to a file. + + Trace output is a machine-readable JSONL format, separate from the human-readable log controlled by setLogfile(). + + Parameters + ---------- + path : str or None + Path to trace file, or None to disable tracing. + mode : str + "a" (append, default) or "w" (overwrite). + """ + pass # Parameter Methods diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index 831dd02ed..d26f249e8 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1472,6 +1472,7 @@ class Model: self, solution: Incomplete, var: Incomplete, val: Incomplete ) -> Incomplete: ... def setStringParam(self, name: Incomplete, value: Incomplete) -> Incomplete: ... + def setTracefile(self, path: Incomplete, mode: Incomplete) -> Incomplete: ... def setupBendersSubproblem( self, probnumber: Incomplete, From 30bb05bc6c4bc8c97818f9edfb6e915d63933fd8 Mon Sep 17 00:00:00 2001 From: mymissuniverse Date: Sat, 17 Jan 2026 02:52:01 +0900 Subject: [PATCH 02/10] Add setTracefile with internal state storage --- src/pyscipopt/scip.pxd | 2 ++ src/pyscipopt/scip.pxi | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index b9bffc1d6..354fcb5e9 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -2228,6 +2228,8 @@ cdef class Model: cdef _benders_subproblems # store iis, if found cdef SCIP_IIS* _iis + cdef public _tracefile_path + cdef public _tracefile_mode @staticmethod cdef create(SCIP* scip) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 09abf3218..6c69f0626 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -2816,6 +2816,8 @@ cdef class Model: self._generated_event_handlers_count = 0 self._benders_subproblems = [] # Keep references to Benders subproblem Models self._iis = NULL + self._tracefile_path = None + self._tracefile_mode = "a" if not createscip: # if no SCIP instance should be created, then an empty Model object is created. @@ -11281,7 +11283,8 @@ cdef class Model: mode : str "a" (append, default) or "w" (overwrite). """ - pass + self._tracefile_path = path + self._tracefile_mode = mode # Parameter Methods From 203fcc5acd7e896c5bf1223fab1ebe3d3a4b006b Mon Sep 17 00:00:00 2001 From: mymissuniverse Date: Sat, 17 Jan 2026 03:20:10 +0900 Subject: [PATCH 03/10] Add setTracefile with solve_finish event recording --- src/pyscipopt/scip.pxi | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 6c69f0626..c53501114 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -8494,6 +8494,11 @@ cdef class Model: """Optimize the problem.""" PY_SCIP_CALL(SCIPsolve(self._scip)) self._bestSol = Solution.create(self._scip, SCIPgetBestSol(self._scip)) + if self._tracefile_path: + import json + with open(self._tracefile_path, self._tracefile_mode) as f: + event = {"type": "solve_finish"} + f.write(json.dumps(event) + "\n") def optimizeNogil(self): """Optimize the problem without GIL.""" From 48619708709e331123523653f100320ca221f959 Mon Sep 17 00:00:00 2001 From: mymissuniverse Date: Sat, 17 Jan 2026 04:05:04 +0900 Subject: [PATCH 04/10] Add solve_finish event with statistics fields --- src/pyscipopt/scip.pxi | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index c53501114..cd5eb4e87 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -7,6 +7,7 @@ import os import sys import warnings import locale +import json cimport cython from cpython cimport Py_INCREF, Py_DECREF @@ -8495,9 +8496,15 @@ cdef class Model: PY_SCIP_CALL(SCIPsolve(self._scip)) self._bestSol = Solution.create(self._scip, SCIPgetBestSol(self._scip)) if self._tracefile_path: - import json with open(self._tracefile_path, self._tracefile_mode) as f: - event = {"type": "solve_finish"} + event = { + "type": "solve_finish", + "best_primal": self.getObjVal() if self.getNSols() > 0 else None, + "best_dual": self.getDualbound(), + "gap": self.getGap(), + "nnodes": self.getNNodes(), + "nsol": self.getNSols(), + } f.write(json.dumps(event) + "\n") def optimizeNogil(self): From b06fd118c9cae98797b325293b4147d48d285ebc Mon Sep 17 00:00:00 2001 From: mymissuniverse Date: Sat, 17 Jan 2026 05:13:08 +0900 Subject: [PATCH 05/10] Add t (solving time) and best_dual fields to trace events --- src/pyscipopt/scip.pxd | 1 + src/pyscipopt/scip.pxi | 59 +++++++++++++++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 354fcb5e9..1bd1b7f3e 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -2230,6 +2230,7 @@ cdef class Model: cdef SCIP_IIS* _iis cdef public _tracefile_path cdef public _tracefile_mode + cdef public _tracefile_handle @staticmethod cdef create(SCIP* scip) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index cd5eb4e87..a814fd25e 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -2772,6 +2772,27 @@ cdef class IIS: PY_SCIP_CALL(SCIPiisGreedyMakeIrreducible(self._iis)) +class _TraceEventHandler(Eventhdlr): + """Internal event handler for trace output.""" + + def eventinit(self): + self.model.catchEvent(SCIP_EVENTTYPE_BESTSOLFOUND, self) + + def eventexit(self): + self.model.dropEvent(SCIP_EVENTTYPE_BESTSOLFOUND, self) + + def eventexec(self, event): + if event.getType() == SCIP_EVENTTYPE_BESTSOLFOUND: + f = self.model._tracefile_handle + if f: + ev = { + "type": "solution_update", + "t": self.model.getSolvingTime(), + "best_primal": self.model.getPrimalbound(), + "best_dual": self.model.getDualbound(), + } + f.write(json.dumps(ev) + "\n") + # - remove create(), includeDefaultPlugins(), createProbBasic() methods # - replace free() by "destructor" @@ -2819,6 +2840,7 @@ cdef class Model: self._iis = NULL self._tracefile_path = None self._tracefile_mode = "a" + self._tracefile_handle = None if not createscip: # if no SCIP instance should be created, then an empty Model object is created. @@ -8493,19 +8515,32 @@ cdef class Model: def optimize(self): """Optimize the problem.""" - PY_SCIP_CALL(SCIPsolve(self._scip)) - self._bestSol = Solution.create(self._scip, SCIPgetBestSol(self._scip)) + tracefile = None if self._tracefile_path: - with open(self._tracefile_path, self._tracefile_mode) as f: - event = { - "type": "solve_finish", - "best_primal": self.getObjVal() if self.getNSols() > 0 else None, - "best_dual": self.getDualbound(), - "gap": self.getGap(), - "nnodes": self.getNNodes(), - "nsol": self.getNSols(), - } - f.write(json.dumps(event) + "\n") + tracefile = open(self._tracefile_path, self._tracefile_mode) + self._tracefile_handle = tracefile + handler = _TraceEventHandler() + self.includeEventhdlr(handler, "trace_handler", "Trace event handler") + + try: + PY_SCIP_CALL(SCIPsolve(self._scip)) + finally: + if tracefile: + event = { + "type": "solve_finish", + "t": self.getSolvingTime(), + "best_primal": self.getPrimalbound() if self.getNSols() > 0 else None, + "best_dual": self.getDualbound(), + "gap": self.getGap(), + "nnodes": self.getNNodes(), + "nsol": self.getNSols(), + } + tracefile.write(json.dumps(event) + "\n") + tracefile.close() + self._tracefile_handle = None + + self._bestSol = Solution.create(self._scip, SCIPgetBestSol(self._scip)) + def optimizeNogil(self): """Optimize the problem without GIL.""" From 2e118d9666d304f481a5e535e1a793f275887022 Mon Sep 17 00:00:00 2001 From: mymissuniverse Date: Sat, 17 Jan 2026 06:15:46 +0900 Subject: [PATCH 06/10] Refactor: extract _write_trace_event method for trace logging --- src/pyscipopt/scip.pxi | 49 +++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index a814fd25e..3902bc983 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -2783,15 +2783,7 @@ class _TraceEventHandler(Eventhdlr): def eventexec(self, event): if event.getType() == SCIP_EVENTTYPE_BESTSOLFOUND: - f = self.model._tracefile_handle - if f: - ev = { - "type": "solution_update", - "t": self.model.getSolvingTime(), - "best_primal": self.model.getPrimalbound(), - "best_dual": self.model.getDualbound(), - } - f.write(json.dumps(ev) + "\n") + self.model._write_trace_event("solution_update") # - remove create(), includeDefaultPlugins(), createProbBasic() methods @@ -8522,22 +8514,13 @@ cdef class Model: handler = _TraceEventHandler() self.includeEventhdlr(handler, "trace_handler", "Trace event handler") - try: - PY_SCIP_CALL(SCIPsolve(self._scip)) - finally: - if tracefile: - event = { - "type": "solve_finish", - "t": self.getSolvingTime(), - "best_primal": self.getPrimalbound() if self.getNSols() > 0 else None, - "best_dual": self.getDualbound(), - "gap": self.getGap(), - "nnodes": self.getNNodes(), - "nsol": self.getNSols(), - } - tracefile.write(json.dumps(event) + "\n") - tracefile.close() - self._tracefile_handle = None + try: + PY_SCIP_CALL(SCIPsolve(self._scip)) + finally: + self._write_trace_event("solve_finish") + if tracefile: + tracefile.close() + self._tracefile_handle = None self._bestSol = Solution.create(self._scip, SCIPgetBestSol(self._scip)) @@ -11333,6 +11316,22 @@ cdef class Model: self._tracefile_path = path self._tracefile_mode = mode + def _write_trace_event(self, event_type): + """Write a trace event to the trace file.""" + f = self._tracefile_handle + if f: + ev = { + "type": event_type, + "t": self.getSolvingTime(), + "best_primal": self.getPrimalbound(), + "best_dual": self.getDualbound(), + "gap": self.getGap(), + "nnodes": self.getNNodes(), + "nsol": self.getNSols(), + } + f.write(json.dumps(ev) + "\n") + + # Parameter Methods def setBoolParam(self, name, value): From 7fc9ed832077809616fa4651f11488925e3c5831 Mon Sep 17 00:00:00 2001 From: mymissuniverse Date: Sat, 17 Jan 2026 06:30:56 +0900 Subject: [PATCH 07/10] Add tracefile tests and remove public from internal attributes --- src/pyscipopt/scip.pxd | 6 +++--- tests/test_tracefile.py | 48 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 tests/test_tracefile.py diff --git a/src/pyscipopt/scip.pxd b/src/pyscipopt/scip.pxd index 1bd1b7f3e..2dcef3b0e 100644 --- a/src/pyscipopt/scip.pxd +++ b/src/pyscipopt/scip.pxd @@ -2228,9 +2228,9 @@ cdef class Model: cdef _benders_subproblems # store iis, if found cdef SCIP_IIS* _iis - cdef public _tracefile_path - cdef public _tracefile_mode - cdef public _tracefile_handle + cdef _tracefile_path + cdef _tracefile_mode + cdef _tracefile_handle @staticmethod cdef create(SCIP* scip) diff --git a/tests/test_tracefile.py b/tests/test_tracefile.py new file mode 100644 index 000000000..34d003272 --- /dev/null +++ b/tests/test_tracefile.py @@ -0,0 +1,48 @@ +import json + +from pyscipopt import Model + + +def test_tracefile_basic(tmp_path): + """Basic tracefile functionality test.""" + trace_path = tmp_path / "trace.jsonl" + + m = Model() + m.hideOutput() + x = m.addVar("x", vtype="I", lb=0, ub=10) + m.setObjective(x, "maximize") + m.setTracefile(str(trace_path)) + m.optimize() + + assert trace_path.exists() + + with open(trace_path) as f: + lines = f.readlines() + + assert len(lines) >= 1 + + events = [json.loads(line) for line in lines] + types = [e["type"] for e in events] + + assert "solve_finish" in types + + for e in events: + assert "type" in e + assert "t" in e + assert "best_primal" in e + assert "best_dual" in e + + +def test_tracefile_none(tmp_path): + """Test disabling tracefile with None.""" + trace_path = tmp_path / "trace.jsonl" + + m = Model() + m.hideOutput() + x = m.addVar("x", vtype="I", lb=0, ub=10) + m.setObjective(x, "maximize") + m.setTracefile(str(trace_path)) + m.setTracefile(None) # Disable + m.optimize() + + assert not trace_path.exists() From da70804ebc2f2d22e4071e899b603c050054c40f Mon Sep 17 00:00:00 2001 From: mymissuniverse Date: Sat, 17 Jan 2026 15:23:30 +0900 Subject: [PATCH 08/10] Add tracefile tests, cleanup internal attributes, update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52df46228..13a7f2e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Added automated script for generating type stubs - Include parameter names in type stubs - Speed up MatrixExpr.sum(axis=...) via quicksum +- Added setTracefile() method for structured optimization progress logging ### Fixed - all fundamental callbacks now raise an error if not implemented - Fixed the type of MatrixExpr.sum(axis=...) result from MatrixVariable to MatrixExpr. From 5cccf4887b851502cc7b74d7bf878dbe0f38ab6a Mon Sep 17 00:00:00 2001 From: mymissuniverse Date: Sat, 17 Jan 2026 15:44:16 +0900 Subject: [PATCH 09/10] Rename trace fields to match SCIP log format --- src/pyscipopt/scip.pxi | 8 ++++---- tests/test_tracefile.py | 14 ++++++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/pyscipopt/scip.pxi b/src/pyscipopt/scip.pxi index 3902bc983..4368f80c5 100644 --- a/src/pyscipopt/scip.pxi +++ b/src/pyscipopt/scip.pxi @@ -11322,11 +11322,11 @@ cdef class Model: if f: ev = { "type": event_type, - "t": self.getSolvingTime(), - "best_primal": self.getPrimalbound(), - "best_dual": self.getDualbound(), + "time": self.getSolvingTime(), + "primalbound": self.getPrimalbound(), + "dualbound": self.getDualbound(), "gap": self.getGap(), - "nnodes": self.getNNodes(), + "nodes": self.getNNodes(), "nsol": self.getNSols(), } f.write(json.dumps(ev) + "\n") diff --git a/tests/test_tracefile.py b/tests/test_tracefile.py index 34d003272..cf9bc8363 100644 --- a/tests/test_tracefile.py +++ b/tests/test_tracefile.py @@ -26,11 +26,17 @@ def test_tracefile_basic(tmp_path): assert "solve_finish" in types + required_fields = { + "type", + "time", + "primalbound", + "dualbound", + "gap", + "nodes", + "nsol", + } for e in events: - assert "type" in e - assert "t" in e - assert "best_primal" in e - assert "best_dual" in e + assert required_fields <= set(e.keys()) def test_tracefile_none(tmp_path): From 6b7bd0e577adc685bb98fadf888748964e8b5600 Mon Sep 17 00:00:00 2001 From: mymissuniverse Date: Sat, 17 Jan 2026 16:00:12 +0900 Subject: [PATCH 10/10] Fix stubtest: add default value marker for mode parameter --- src/pyscipopt/scip.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyscipopt/scip.pyi b/src/pyscipopt/scip.pyi index d26f249e8..dd4f8c4ef 100644 --- a/src/pyscipopt/scip.pyi +++ b/src/pyscipopt/scip.pyi @@ -1472,7 +1472,7 @@ class Model: self, solution: Incomplete, var: Incomplete, val: Incomplete ) -> Incomplete: ... def setStringParam(self, name: Incomplete, value: Incomplete) -> Incomplete: ... - def setTracefile(self, path: Incomplete, mode: Incomplete) -> Incomplete: ... + def setTracefile(self, path: Incomplete, mode: Incomplete = ...) -> Incomplete: ... def setupBendersSubproblem( self, probnumber: Incomplete,