Skip to content
Snippets Groups Projects
Commit 4e13cdc2 authored by Paul A. Rubin's avatar Paul A. Rubin
Browse files

Initial commit.

parents
No related branches found
No related tags found
No related merge requests found
package models;
import ilog.concert.IloException;
import ilog.concert.IloLinearNumExpr;
import ilog.concert.IloNumVar;
import ilog.cplex.IloCplex;
import java.util.HashSet;
import java.util.Set;
import socialnet.Graph;
/**
* DistanceModel implements the binary integer program proposed by the author
* of the problem, where binary variables are indexed by vertex and distance.
*
* @author Paul A. Rubin (rubin@msu.edu)
*/
public final class DistanceModel implements AutoCloseable {
private static final double HALF = 0.5; // for rounding tests
private final int nVertices; // number of vertices in the graph
private final int diameter; // the graph diameter
// CPLEX objects.
private final IloCplex mip; // the MIP model
private final IloNumVar[][] x; // x[v][d] = 1 if vertex v has distance d
// to the selection set
/**
* Constructor.
* @param graph the source graph
* @param nSelect the number of nodes to select
* @throws IloException if model construction fails
*/
public DistanceModel(final Graph graph, final int nSelect)
throws IloException {
nVertices = graph.getnVertices();
diameter = graph.getDiameter();
// Initialize the model.
mip = new IloCplex();
// Create the variables and simultaneously set up the objective function.
x = new IloNumVar[nVertices][diameter + 1];
IloLinearNumExpr obj = mip.linearNumExpr();
for (int i = 0; i < nVertices; i++) {
for (int d = 0; d <= diameter; d++) {
x[i][d] = mip.boolVar("x_" + i + "_" + d);
obj.addTerm(d, x[i][d]);
}
}
// Add the objective function.
mip.addMinimize(obj);
// The number of variables selected (having distance 0 to the selection
// set) must be correct.
IloLinearNumExpr expr = mip.linearNumExpr();
for (int i = 0; i < nVertices; i++) {
expr.addTerm(1.0, x[i][0]);
}
mip.addEq(expr, nSelect, "selection_size");
// Every node has exactly one distance to the selection set.
for (int i = 0; i < nVertices; i++) {
mip.addEq(mip.sum(x[i]), 1.0, "unique_distance_" + i);
}
// For a node to have distance d to the selection set, one of the nodes
// at distance d from it must be in the set.
for (int i = 0; i < nVertices; i++) {
for (int d = 1; d <= diameter; d++) {
// Sum the selection variables for nodes at distance d from i.
expr = mip.linearNumExpr();
for (int j : graph.getNeighborhood(i, d)) {
expr.addTerm(1.0, x[j][0]);
}
mip.addLe(x[i][d], expr, "distance_" + i + "_" + d);
}
}
}
/**
* Solves the model.
* @param timeLimit the time limit for the solver (in seconds)
* @return the final solver status
* @throws IloException if the solution fails
*/
public IloCplex.Status solve(final double timeLimit) throws IloException {
mip.setParam(IloCplex.DoubleParam.TimeLimit, timeLimit);
mip.solve();
return mip.getStatus();
}
/**
* Gets the final objective value.
* @return the objective value
* @throws IloException if there is no objective value
*/
public double getObjValue() throws IloException {
return mip.getObjValue();
}
/**
* Gets the set of nodes selected by the solution.
* @return the set of selected nodes
* @throws IloException if no solution exists
*/
public Set<Integer> getSolution() throws IloException {
HashSet<Integer> sol = new HashSet<>();
for (int i = 0; i < nVertices; i++) {
if (mip.getValue(x[i][0]) > HALF) {
sol.add(i);
}
}
return sol;
}
/**
* Closes the model.
*/
@Override
public void close() {
mip.close();
}
}
package models;
import ilog.concert.IloException;
import ilog.concert.IloLinearNumExpr;
import ilog.concert.IloNumVar;
import ilog.cplex.IloCplex;
import java.util.HashSet;
import java.util.Set;
import socialnet.Graph;
/**
* DistanceModel implements the binary integer program proposed by the author
* of the problem, modified using a suggestion from Rob Pratt.
*
* @author Paul A. Rubin (rubin@msu.edu)
*/
public final class DistanceModel2 implements AutoCloseable {
private static final double HALF = 0.5; // for rounding tests
private final int nVertices; // number of vertices in the graph
private final int diameter; // the graph diameter
// CPLEX objects.
private final IloCplex mip; // the MIP model
private final IloNumVar[][] x; // x[v][d] = 1 if vertex v has distance d
// to the selection set
/**
* Constructor.
* @param graph the source graph
* @param nSelect the number of nodes to select
* @throws IloException if model construction fails
*/
public DistanceModel2(final Graph graph, final int nSelect)
throws IloException {
nVertices = graph.getnVertices();
diameter = graph.getDiameter();
// Initialize the model.
mip = new IloCplex();
// Create the variables and simultaneously set up the objective function.
x = new IloNumVar[nVertices][diameter + 1];
IloLinearNumExpr obj = mip.linearNumExpr();
for (int i = 0; i < nVertices; i++) {
for (int d = 0; d <= diameter; d++) {
x[i][d] = mip.boolVar("x_" + i + "_" + d);
obj.addTerm(d, x[i][d]);
}
}
// Add the objective function.
mip.addMinimize(obj);
// The number of variables selected (having distance 0 to the selection
// set) must be correct.
IloLinearNumExpr expr = mip.linearNumExpr();
for (int i = 0; i < nVertices; i++) {
expr.addTerm(1.0, x[i][0]);
}
mip.addEq(expr, nSelect, "selection_size");
// Every node has exactly one distance to the selection set.
for (int i = 0; i < nVertices; i++) {
mip.addEq(mip.sum(x[i]), 1.0, "unique_distance_" + i);
}
// Rob Pratt's modification: For a node to be at distance d from the
// selection set, one of its neighbors must be at distance d-1.
for (int i = 0; i < nVertices; i++) {
for (int d = 1; d <= diameter; d++) {
// Sum the indicators for neighbors being at distance d-1.
expr = mip.linearNumExpr();
for (int j : graph.getNeighborhood(i, 1)) {
expr.addTerm(1.0, x[j][d - 1]);
}
mip.addLe(x[i][d], expr, "distance_" + i + "_" + d);
}
}
}
/**
* Solves the model.
* @param timeLimit the time limit for the solver (in seconds)
* @return the final solver status
* @throws IloException if the solution fails
*/
public IloCplex.Status solve(final double timeLimit) throws IloException {
mip.setParam(IloCplex.DoubleParam.TimeLimit, timeLimit);
mip.solve();
return mip.getStatus();
}
/**
* Gets the final objective value.
* @return the objective value
* @throws IloException if there is no objective value
*/
public double getObjValue() throws IloException {
return mip.getObjValue();
}
/**
* Gets the set of nodes selected by the solution.
* @return the set of selected nodes
* @throws IloException if no solution exists
*/
public Set<Integer> getSolution() throws IloException {
HashSet<Integer> sol = new HashSet<>();
for (int i = 0; i < nVertices; i++) {
if (mip.getValue(x[i][0]) > HALF) {
sol.add(i);
}
}
return sol;
}
/**
* Closes the model.
*/
@Override
public void close() {
mip.close();
}
}
package models;
import ilog.concert.IloException;
import ilog.concert.IloLinearNumExpr;
import ilog.concert.IloNumVar;
import ilog.cplex.IloCplex;
import java.util.HashSet;
import java.util.Set;
import socialnet.Graph;
/**
* DistanceModel3 implements author's original binary integer program with
* one modification: binary variables are indexed by vertex and distance, and
* only the distance zero variables are binary (the others are continuous).
*
* @author Paul A. Rubin (rubin@msu.edu)
*/
public final class DistanceModel3 implements AutoCloseable {
private static final double HALF = 0.5; // for rounding tests
private final int nVertices; // number of vertices in the graph
private final int diameter; // the graph diameter
// CPLEX objects.
private final IloCplex mip; // the MIP model
private final IloNumVar[][] x; // x[v][d] = 1 if vertex v has distance d
// to the selection set
/**
* Constructor.
* @param graph the source graph
* @param nSelect the number of nodes to select
* @throws IloException if model construction fails
*/
public DistanceModel3(final Graph graph, final int nSelect)
throws IloException {
nVertices = graph.getnVertices();
diameter = graph.getDiameter();
// Initialize the model.
mip = new IloCplex();
// Create the variables and simultaneously set up the objective function.
x = new IloNumVar[nVertices][diameter + 1];
IloLinearNumExpr obj = mip.linearNumExpr();
for (int i = 0; i < nVertices; i++) {
x[i][0] = mip.boolVar("x_" + i + "_0");
for (int d = 1; d <= diameter; d++) {
x[i][d] = mip.numVar(0, 1, "x_" + i + "_" + d);
obj.addTerm(d, x[i][d]);
}
}
// Add the objective function.
mip.addMinimize(obj);
// The number of variables selected (having distance 0 to the selection
// set) must be correct.
IloLinearNumExpr expr = mip.linearNumExpr();
for (int i = 0; i < nVertices; i++) {
expr.addTerm(1.0, x[i][0]);
}
mip.addEq(expr, nSelect, "selection_size");
// Every node has exactly one distance to the selection set.
for (int i = 0; i < nVertices; i++) {
mip.addEq(mip.sum(x[i]), 1.0, "unique_distance_" + i);
}
// For a node to have distance d to the selection set, one of the nodes
// at distance d from it must be in the set.
for (int i = 0; i < nVertices; i++) {
for (int d = 1; d <= diameter; d++) {
// Sum the selection variables for nodes at distance d from i.
expr = mip.linearNumExpr();
for (int j : graph.getNeighborhood(i, d)) {
expr.addTerm(1.0, x[j][0]);
}
mip.addLe(x[i][d], expr, "distance_" + i + "_" + d);
}
}
}
/**
* Solves the model.
* @param timeLimit the time limit for the solver (in seconds)
* @return the final solver status
* @throws IloException if the solution fails
*/
public IloCplex.Status solve(final double timeLimit) throws IloException {
mip.setParam(IloCplex.DoubleParam.TimeLimit, timeLimit);
mip.solve();
return mip.getStatus();
}
/**
* Gets the final objective value.
* @return the objective value
* @throws IloException if there is no objective value
*/
public double getObjValue() throws IloException {
return mip.getObjValue();
}
/**
* Gets the set of nodes selected by the solution.
* @return the set of selected nodes
* @throws IloException if no solution exists
*/
public Set<Integer> getSolution() throws IloException {
HashSet<Integer> sol = new HashSet<>();
for (int i = 0; i < nVertices; i++) {
if (mip.getValue(x[i][0]) > HALF) {
sol.add(i);
}
}
return sol;
}
/**
* Closes the model.
*/
@Override
public void close() {
mip.close();
}
}
package models;
import ilog.concert.IloException;
import ilog.concert.IloLinearNumExpr;
import ilog.concert.IloNumVar;
import ilog.cplex.IloCplex;
import ilog.cplex.IloCplex.Status;
import java.util.HashSet;
import java.util.Set;
import socialnet.Graph;
/**
* FlowModel implements a MIP model for the problem based on flows between
* nodes.
*
* @author Paul A. Rubin (rubin@msu.edu)
*/
public final class FlowModel implements AutoCloseable {
private static final double HALF = 0.5; // for rounding tests
private final int nVertices; // number of vertices in the graph
private final int nEdges; // number of edges
// CPLEX objects.
private final IloCplex mip; // the MIP model
private final IloNumVar[] select; // indicators for selecting vertices
private final IloNumVar[] flowF; // forward edge flows
private final IloNumVar[] flowR; // reverse edge flows
/**
* Constructor.
* @param graph the graph to solve
* @param nSelect the number of nodes to select
* @throws IloException if the model cannot be constructed
*/
public FlowModel(final Graph graph, final int nSelect) throws IloException {
nVertices = graph.getnVertices();
nEdges = graph.getnEdges();
// Initialize the model.
mip = new IloCplex();
// Create the variables.
select = new IloNumVar[nVertices];
flowF = new IloNumVar[nEdges];
flowR = new IloNumVar[nEdges];
for (int i = 0; i < nVertices; i++) {
select[i] = mip.boolVar("select_" + i);
}
for (int i = 0; i < nEdges; i++) {
flowF[i] = mip.numVar(0, nVertices, "forward_" + i);
flowR[i] = mip.numVar(0, nVertices, "backward_" + i);
}
// The objective is to minimize the sum of all flows.
mip.addMinimize(mip.sum(mip.sum(flowF), mip.sum(flowR)));
// The number of nodes selected must match the requirement.
mip.addEq(mip.sum(select), nSelect, "selection_count");
// At every node, there must be exactly one unit more of outflow than of
// inflow unless the node is selected.
int m = nVertices - nSelect; // "big M"
for (int i = 0; i < nVertices; i++) {
// Form an expression of the form flow out of i minus flow into i.
IloLinearNumExpr expr = mip.linearNumExpr();
// Check each edge incident at i.
for (int j : graph.incidentAt(i)) {
if (graph.isHead(i, j)) {
// The forward direction enters i, the reverse direction leaves i.
expr.addTerm(1.0, flowR[j]);
expr.addTerm(-1.0, flowF[j]);
} else {
// The forward direction leaves i, the reverse direction enters i.
expr.addTerm(-1.0, flowR[j]);
expr.addTerm(1.0, flowF[j]);
}
}
// Flow out - flow in must be >= 1 - m * select[i].
mip.addGe(expr, mip.diff(1.0, mip.prod(m, select[i])), "balance_" + i);
}
}
/**
* Solves the model.
* @param timeLimit the time limit for the solver (in seconds)
* @return the final solver status
* @throws IloException if the solution fails
*/
public Status solve(final double timeLimit) throws IloException {
mip.setParam(IloCplex.DoubleParam.TimeLimit, timeLimit);
mip.solve();
return mip.getStatus();
}
/**
* Gets the final objective value.
* @return the objective value
* @throws IloException if there is no objective value
*/
public double getObjValue() throws IloException {
return mip.getObjValue();
}
/**
* Gets the set of nodes selected by the solution.
* @return the set of selected nodes
* @throws IloException if no solution exists
*/
public Set<Integer> getSolution() throws IloException {
HashSet<Integer> sol = new HashSet<>();
double[] x = mip.getValues(select);
for (int i = 0; i < nVertices; i++) {
if (x[i] > HALF) {
sol.add(i);
}
}
return sol;
}
/**
* Closes the model.
*/
@Override
public void close() {
mip.close();
}
}
package socialnet;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
/**
* Graph holds a problem instance.
*
* @author Paul A. Rubin (rubin@msu.edu)
*/
public final class Graph {
private final int nVertices; // number of vertices
private final edge[] edges; // list of edges
private final int[][] distance; // distance matrix
private final int diameter; // graph diameter
private final ArrayList<HashSet<Integer>> incident;
// indices of edges incident at each node
/**
* Constructor.
* @param nV the number of vertices
* @param nE the target number of edges
* @param seed a seed for the random number generator
* @param prog print a progress report after every `prog` edges are added
*/
public Graph(final int nV, final int nE, final long seed, final int prog) {
System.out.println("Building the graph ...");
nVertices = nV;
HashSet<edge> edgeSet = new HashSet<>();
// Create a random number generator.
Random rng = new Random(seed);
// Track which pairs of nodes are connected.
boolean[][] connected = new boolean[nVertices][nVertices];
for (int i = 0; i < nVertices; i++) {
connected[i][i] = true;
}
int edgeCount = 0;
boolean allConnected = false;
// Add edges until the target count is reached and the graph is connected.
while (edgeCount < nE || !allConnected) {
// Pick random endpoints.
int i = rng.nextInt(0, nVertices - 1); // lower endpoint
int j = rng.nextInt(i + 1, nVertices); // higher endpoint
// Check if the edge already exists.
edge e = new edge(i, j);
if (!edgeSet.contains(e)) {
edgeSet.add(e);
allConnected = connect(connected, i, j);
// Increment the edge count.
edgeCount += 1;
if (edgeCount % prog == 0) {
System.out.println("... edge count now " + edgeCount);
}
}
}
// For the purpose of checking solutions, we need to construct a
// distance matrix for the graph.
distance = new int[nVertices][nVertices];
// Initially, all distance are "infinity" except for the distance between
// a node and itself.
for (int i = 0; i < nVertices; i++) {
Arrays.fill(distance[i], nVertices);
distance[i][i] = 0;
}
// The distance between two nodes is 1 if they are connected by an edge.
for (edge e : edgeSet) {
distance[e.head()][e.tail()] = 1;
distance[e.tail()][e.head()] = 1;
}
// We use the Floyd-Warshall algorithm to compute the remaining distances.
for (int k = 0; k < nVertices; k++) {
for (int i = 0; i < nVertices; i++) {
for (int j = i + 1; j < nVertices; j++) {
int x = distance[i][k] + distance[k][j];
if (x < distance[i][j]) {
distance[i][j] = x;
distance[j][i] = x;
}
}
}
}
// Compute the graph diameter.
int d = 0;
for (int i = 0; i < nVertices; i++) {
d = Math.max(d, Arrays.stream(distance[i]).max().getAsInt());
}
diameter = d;
// Convert the edge set into an array, while recording which edges are
// incident at each node.
edges = new edge[edgeSet.size()];
incident = new ArrayList<>();
for (int i = 0; i < nVertices; i++) {
incident.add(new HashSet<>());
}
int index = 0;
for (edge e : edgeSet) {
edges[index] = e;
incident.get(e.head()).add(index);
incident.get(e.tail()).add(index);
index++;
}
}
/**
* Connects two nodes, updates the connectedness array, and checks whether
* all node pairs are connected.
* @param connected matrix signaling connectedness of node pairs
* @param i first node to connect
* @param j second node to connect
* @return true if all node pairs are connected
*/
private boolean connect(final boolean[][] connected,
final int i, final int j) {
connected[i][j] = true;
connected[j][i] = true;
boolean allConnected = true; // are all node pairs connected?
// Check every pair of vertices.
for (int i0 = 0; i0 < nVertices; i0++) {
for (int j0 = 0; j0 < nVertices; j0++) {
// If the vertices are not connected, see if they connect through
// the designated nodes.
if (!connected[i0][j0]) {
if (connected[i0][i] && connected[j0][j]) {
connected[i0][j0] = true;
connected[j0][i0] = true;
} else {
allConnected = false;
}
}
}
}
return allConnected;
}
/**
* Generates a description of the graph.
* @return the graph description
*/
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("The graph has ").append(nVertices).append(" vertices and ")
.append(edges.length).append(" edges.\n")
.append("The graph diameter = ").append(diameter).append(".");
return sb.toString();
}
/**
* Record representing an edge.
*/
private record edge(int tail, int head) {};
/**
* Gets the number of vertices in the graph.
* @return the vertex count
*/
public int getnVertices() {
return nVertices;
}
/**
* Gets the number of edges.
* @return the edge count
*/
public int getnEdges() {
return edges.length;
}
/**
* Gets the graph diameter.
* @return the diameter
*/
public int getDiameter() {
return diameter;
}
/**
* Gets the set of indices of edges incident at a given node.
* @param node the node index
* @return the indices of edges incident at the node
*/
public Set<Integer> incidentAt(final int node) {
return new HashSet<>(incident.get(node));
}
/**
* Checks whether a vertex is the head of an edge.
* @param v the index of the vertex
* @param e the index of the edge
* @return true if the vertex is the head of the edge
*/
public boolean isHead(final int v, final int e) {
return edges[e].head() == v;
}
/**
* Checks a solution.
* @param solution the proposed solution.
* @return a string summarizing the solution
*/
public String check(final Set<Integer> solution) {
StringBuilder sb = new StringBuilder();
sb.append("The proposed solution selects ").append(solution.size())
.append(" nodes:\n");
solution.stream().sorted().forEach(i -> sb.append("\t").append(i)
.append("\n"));
// Compute the sum of all distances to selected nodes.
int sum = 0;
for (int i = 0; i < nVertices; i++) {
if (!solution.contains(i)) {
final int[] x = distance[i];
sum += solution.stream().mapToInt(j -> x[j]).min().getAsInt();
}
}
sb.append("Sum of shortest distances to selected nodes = ")
.append(sum).append(".");
return sb.toString();
}
/**
* Finds the set of nodes at a specified distance from a given node.
* @param node the target node
* @param dist the desired distance
* @return the set of nodes (if any) at that distance from the target node
*/
public Set<Integer> getNeighborhood(final int node, final int dist) {
HashSet<Integer> nbd = new HashSet<>();
for (int i = 0; i < nVertices; i++) {
if (distance[node][i] == dist) {
nbd.add(i);
}
}
return nbd;
}
/**
* Gets the distance (shortest path length) between two nodes.
* @param i the first node
* @param j the second node
* @return the length of the shortest path between the nodes
*/
public int getDistance(final int i, final int j) {
return distance[i][j];
}
}
package socialnet;
import ilog.concert.IloException;
import ilog.cplex.IloCplex.Status;
import java.util.Set;
import models.AssignmentModel;
import models.DistanceModel;
import models.DistanceModel2;
import models.DistanceModel3;
import models.FlowModel;
/**
* SocialNet tests various models for a social network-based optimization
* problem posed on OR Stack Exchange.
*
* Link: https://or.stackexchange.com/questions/8674/is-this-ilp-formulation-
* for-group-closeness-centrality-a-column-generation-appro
*
* @author Paul A. Rubin (rubin@msu.edu)
*/
public final class SocialNet {
/**
* Dummy constructor.
*/
private SocialNet() { }
/**
* Runs the experiments.
* @param args the command line arguments (unused)
*/
@SuppressWarnings({ "checkstyle:magicnumber" })
public static void main(final String[] args) {
// Parameters for the test graph.
int nVertices = 2000; // vertex count
int nEdges = 10000; // target edge count (actual may be higher)
int nSelected = 20; // size of the group of selected vertices
long seed = 220714; // random number seed
int prog = 1000; // frequency of progress reports
// Generate a graph instance.
Graph graph = new Graph(nVertices, nEdges, seed, prog);
System.out.println(graph);
// Set a time limit for each run (in seconds).
double timeLimit = 600;
// Try the flow model.
System.out.println("\n*** Trying flow model. ***\n");
try (FlowModel fm = new FlowModel(graph, nSelected)) {
Status status = fm.solve(timeLimit);
System.out.println("\nFinal solver status = " + status
+ "\nFinal objective value = " + fm.getObjValue());
// Check and print the solution.
Set<Integer> sol = fm.getSolution();
System.out.println(graph.check(sol));
} catch (IloException ex) {
System.out.println("The flow model blew up:\n" + ex.getMessage());
}
// Try the original distance model.
System.out.println("\n*** Trying original distance model. ***\n");
try (DistanceModel dm = new DistanceModel(graph, nSelected)) {
Status status = dm.solve(timeLimit);
System.out.println("\nFinal solver status = " + status
+ "\nFinal objective value = " + dm.getObjValue());
// Check and print the solution.
Set<Integer> sol = dm.getSolution();
System.out.println(graph.check(sol));
} catch (IloException ex) {
System.out.println("The distance model blew up:\n" + ex.getMessage());
}
// Try the distance model with Rob's modification.
System.out.println("\n*** Trying modified distance model. ***\n");
try (DistanceModel2 dm = new DistanceModel2(graph, nSelected)) {
Status status = dm.solve(timeLimit);
System.out.println("\nFinal solver status = " + status
+ "\nFinal objective value = " + dm.getObjValue());
// Check and print the solution.
Set<Integer> sol = dm.getSolution();
System.out.println(graph.check(sol));
} catch (IloException ex) {
System.out.println("The distance model blew up:\n" + ex.getMessage());
}
// Try the distance model with a mix of binary and continuous variables.
System.out.println("\n*** Trying mixed integer distance model. ***\n");
try (DistanceModel3 dm = new DistanceModel3(graph, nSelected)) {
Status status = dm.solve(timeLimit);
System.out.println("\nFinal solver status = " + status
+ "\nFinal objective value = " + dm.getObjValue());
// Check and print the solution.
Set<Integer> sol = dm.getSolution();
System.out.println(graph.check(sol));
} catch (IloException ex) {
System.out.println("The distance model blew up:\n" + ex.getMessage());
}
// Try the assignment model.
System.out.println("\n*** Trying assignment model. ***\n");
try (AssignmentModel am = new AssignmentModel(graph, nSelected, prog)) {
Status status = am.solve(timeLimit);
System.out.println("\nFinal solver status = " + status
+ "\nFinal objective value = " + am.getObjValue());
// Check and print the solution.
Set<Integer> sol = am.getSolution();
System.out.println(graph.check(sol));
} catch (IloException ex) {
System.out.println("The assignment model blew up:\n" + ex.getMessage());
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment