Creation date: 2020-02-19
This is kinda part of the constraint solver series => Building a constraint solver in Julia but is useful for more than this and might be its own package (yeah I say that often :D)
There were some changes to the constraint solver which still need a post and I'm not satisfied yet to write a post about it but I should...
When I started with Julia and Juniper.jl I needed to write a table logger to display the current status of the solver. By status I mean information like best solution found so far (incumbent) and best bound as well as how long did it run so far.
Basically the table from this:
Now I of course also need this for the constraint solver project but I didn't like the code that much and spoiler alert: I'm not completely satisfied with the current implementation now but I think I should put it out here and maybe some others have ideas on how to improve it.
I want to easily define the table by giving the header information which includes the type, the width and some other information.
In the solver I want to push something to the table and given some criteria the table should decide whether this is a new row or whether there it's not worth it i.e in the constraint solver there can be a lot of backtracking steps but they can be quite fast for example in the case of solving sudokus. I don't want a line for each step in the backtrack!
function. Normally the user wants to get updates to see that it's running or if a new solution was found. Therefore I use the rule that a new line should be added every five seconds or if there is a new solution.
Additionally the type of the column should define some formatting rules which the developer should be able to change.
Not too sure how to make it as extensible as possible so this is my try of doing it and I welcome your comments/issues/PRs.
TableLogger.jl
mutable struct TableCol
id :: Symbol
name :: String
type :: DataType
width :: Int
alignment :: Symbol # :left, :center, :right
b_format :: Bool
end
mutable struct TableEntry{T}
col_id :: Symbol
value :: T
end
mutable struct TableSetup
cols :: Vector{TableCol}
col_idx :: Dict{Symbol,Int}
new_row_criteria :: Bool
diff_criteria :: Dict{Symbol, Any}
last_row :: Vector{TableEntry}
end
The table is defined by the columns cols
might have some criteria to check if a new row should be added or now which depends on last_row
and diff_criteria
will be passed to the function of deciding whether a new row will be added. I need the col_idx
to know which column is at which position. Maybe that can be done in a different fashion.
TableCol
has a type which defines whether some formatting should be used. Each row is a vector of TableEntry
which defines the column id id
of TableCol
and the value. The value type doesn't have to be the same as the column type but is normally advised. I wanted to be able to directly set the incumbent value to "-"
if no solution has been found yet. That could be done in formatting as well or having a union of Nothing
and T
for that.
Furthermore I created some constructor functions but those are boring. They check whether format_table_value
exists for the given type i.e
function format_table_value(val::Int, len::Int)
s_val = string(val)
if length(s_val) > len
return val > 0 ? ">>" : "<<"
end
return s_val
end
If we have an integer value it will be formatted like this. I also have a function for Float64
: function format_table_value(val::Float64, len::Int)
.
Then we have the function to create the header:
function get_header(table::TableSetup)
ln = ""
sum_width = 0
for col in table.cols
width = col.width
sum_width += width
padding = width-length(col.name)
if padding < 2
padding = 2
col.width = length(col.name)+2
end
ln *= repeat(" ",fld(padding, 2)+1)
ln *= col.name
ln *= repeat(" ",cld(padding, 2)+1)
end
equals = repeat("=", sum(sum_width)+2*length(table.cols))
header = "$ln\n$equals"
return header
end
Here fld
and cld
which are shorter and faster forms of convert(Int, floor(padding/2))
and c
for ceil
instead of floor
. Additionally I add another space on both sides so that the actual width is actually width+2
. Basically some kind of margin.
This creates a header like:
#Open #Closed Incumbent Best Bound Time [s]
======================================================================
if
table = TableSetup(
[
CS.TableCol(:open_nodes, "#Open", Int, 10, :center),
CS.TableCol(:closed_nodes, "#Closed", Int, 10, :center),
CS.TableCol(:incumbent, "Incumbent", Float64, 20, :right),
CS.TableCol(:best_bound, "Best Bound", Float64, 20, :right),
CS.TableCol(:duration, "Time [s]", Float64, 10, :center)
],
Dict(:min_diff_duration=>5.0)
)
Currently I don't use the alignment for the header.
Then the same thing for get_row
:
function get_row(table::TableSetup, row::Vector{TableEntry})
ln = ""
for c=1:length(table.cols)
width = table.cols[c].width
if isassigned(row, c)
val = row[c].value
if table.cols[c].b_format && isa(val, table.cols[c].type)
s_val = format_table_value(val, width)
else
s_val = string(val)
end
else
s_val = "-"
end
padding = width-length(s_val)
if table.cols[c].alignment == :center
ln *= repeat(" ",fld(padding, 2)+1)
ln *= s_val
ln *= repeat(" ",cld(padding, 2)+1)
elseif table.cols[c].alignment == :left
ln *= " "
ln *= s_val
ln *= repeat(" ",padding+1)
elseif table.cols[c].alignment == :right
ln *= repeat(" ",padding+1)
ln *= s_val
ln *= " "
else
@warn "Only the alignments :left, :right and :center are defined."
end
end
return ln
end
As mentioned before b_format
is set if we have the format_table_value
function with the right input. That can be done with hasmethod
help?> hasmethod
search: hasmethod
hasmethod(f, t::Type{<:Tuple}[, kwnames]; world=typemax(UInt)) -> Bool
Determine whether the given generic function has a method matching the given Tuple of argument types with the upper bound of world age given by world.
If a tuple of keyword argument names kwnames is provided, this also checks whether the method of f matching t has the given keyword argument names. If the matching method
accepts a variable number of keyword arguments, e.g. with kwargs..., any names given in kwnames are considered valid. Otherwise the provided names must be a subset of the
method's keyword arguments.
We should only format it this way if the value in the TableEntry
actually has the column type. Otherwise it will be converted to a String
.
The last thing we need to do is define a way of how to add something to the table. I thought it might be handy to for the developer/user to write functions like:
push_to_table!(table; open_nodes=12, incumbent=100, closed_nodes=17)
Therefore we work with keyword arguments in push_to_table
.
function push_to_table!(table::TableSetup; force=false, kwargs...)
row = Vector{TableEntry}(undef, length(table.cols))
for p in kwargs
col_idx = get(table.col_idx, p.first, 0)
if col_idx != 0
row[col_idx] = TableEntry(p.first, p.second)
end
end
if force || !table.new_row_criteria || is_new_row(row, table.last_row, table.diff_criteria)
println(get_row(table, row))
table.last_row = row
end
return
end
I also added the possibility to force
a new row which seems reasonable and I use it if a new solution was found. If a is_new_row
function exists the new_row_criteria
field will be true
and then it is checked given the new row
, last_row
and the diff_criteria
which is a dictionary.
Currently I don't know what might be the best way of using your own formatting rules for Ints. I think mine can be a reasonable default but how to use a different one without removing the default function. One way is to define a new Type
like
mutable struct MyInt
value :: Int
end
but that is a bit inconvenient. Otherwise the new function needs to be more specific then the default function i.e I could define function format_table_value(val::Int, len)
and you define:
function format_table_value(val::Int, len::Int)
(len::Int
) is more specific than len
but maybe there is a better way in Julia.
Sometimes for debugging (the printing out style :D) it would be nice to have an extra column called watch
or something where you can print out the search space of certain variables without changing code inside the solver. Then the ConstraintSolverModel
needs to be added to push_to_table!
but I'm not too sure how to do that in the best fashion yet.
Would love to hear from you about ways to improve it and whether this might be helpful for your own project.
If you haven't seen my previous post about the Enigma or missed the video about it: Check it out!
I am thinking about making some videos for some topics now. Check out my YouTube channel: Opensources.
New Constraint Solver post: Linear bounds
Thanks for reading and special thanks to my five patrons!
List of patrons paying more than 4<span>$</span> per month:
Site Wang
Gurvesh Sanghera
Szymon Bęczkowski
Currently I get 12<span>$</span> per Month using Patreon and PayPal when I reach my next goal of 50<span>$</span> per month I'll create a faster blog which will also have a better layout:
Code which I refer to on the side
Code diffs for changes
Easier navigation with Series like Simplex and Constraint Programming
instead of having X constraint programming posts on the home page
And more
If you have ideas => Please contact me!
For a donation of a single dollar per month you get early access to the posts. Try it out at the start of a month and if you don't enjoy it just cancel your subscription before pay day (end of month). Check out Patreon!
Any questions, comments or improvements? Please comment here and/or write me an e-mail o.kroeger@opensourc.es and for code feel free to open a PR/issue on GitHub ConstraintSolver.jl
I'll keep you updated on Twitter OpenSourcES as well as my more personal one: Twitter Wikunia_de