Doc improvements + rename DebuggingMain to MainTest
This commit is contained in:
		
							parent
							
								
									741eeb02f3
								
							
						
					
					
						commit
						0b551e3b4e
					
				
					 8 changed files with 108 additions and 63 deletions
				
			
		|  | @ -17,14 +17,10 @@ import net.sourceforge.argparse4j.inf.ArgumentParserException; | ||||||
| import net.sourceforge.argparse4j.inf.Namespace; | import net.sourceforge.argparse4j.inf.Namespace; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * This class is the main entry point for testing solver on instances. |  * This class is the main entry point for doing comparative performance tests of solvers. | ||||||
|  * It provides |  | ||||||
|  */ |  */ | ||||||
| public class Main { | public class Main { | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     public static void main(String[] args) { |     public static void main(String[] args) { | ||||||
|         // configure the argument parser |         // configure the argument parser | ||||||
|         ArgumentParser parser = ArgumentParsers.newFor("jsp-solver").build() |         ArgumentParser parser = ArgumentParsers.newFor("jsp-solver").build() | ||||||
|  |  | ||||||
|  | @ -1,13 +1,15 @@ | ||||||
| package jobshop; | package jobshop; | ||||||
| 
 | 
 | ||||||
| import jobshop.encodings.JobNumbers; | import jobshop.encodings.JobNumbers; | ||||||
|  | import jobshop.encodings.ResourceOrder; | ||||||
| import jobshop.encodings.Schedule; | import jobshop.encodings.Schedule; | ||||||
|  | import jobshop.encodings.Task; | ||||||
| import jobshop.solvers.GreedySolver; | import jobshop.solvers.GreedySolver; | ||||||
| 
 | 
 | ||||||
| import java.io.IOException; | import java.io.IOException; | ||||||
| import java.nio.file.Paths; | import java.nio.file.Paths; | ||||||
| 
 | 
 | ||||||
| public class DebuggingMain { | public class MainTest { | ||||||
| 
 | 
 | ||||||
|     public static void main(String[] args) { |     public static void main(String[] args) { | ||||||
|         try { |         try { | ||||||
|  | @ -25,12 +27,18 @@ public class DebuggingMain { | ||||||
| 
 | 
 | ||||||
|             System.out.println("\nENCODING: " + enc); |             System.out.println("\nENCODING: " + enc); | ||||||
| 
 | 
 | ||||||
|  |             // convert to a schedule and display | ||||||
|             Schedule schedule = enc.toSchedule(); |             Schedule schedule = enc.toSchedule(); | ||||||
|             System.out.println("VALID: " + schedule.isValid()); |             System.out.println("VALID: " + schedule.isValid()); | ||||||
|             System.out.println("MAKESPAN: " + schedule.makespan()); |             System.out.println("MAKESPAN: " + schedule.makespan()); | ||||||
|             System.out.println("SCHEDULE: " + schedule.toString()); |             System.out.println("SCHEDULE: " + schedule.toString()); | ||||||
|             System.out.println("GANTT: " + schedule.asciiGantt()); |             System.out.println("GANTT: " + schedule.asciiGantt()); | ||||||
| 
 | 
 | ||||||
|  |             Schedule manualSchedule = new Schedule(instance); | ||||||
|  |             // TODO: encode the same solution | ||||||
|  | 
 | ||||||
|  |             ResourceOrder manualRO = new ResourceOrder(instance); | ||||||
|  |             // TODO: encode the same solution | ||||||
|         } catch (IOException e) { |         } catch (IOException e) { | ||||||
|             e.printStackTrace(); |             e.printStackTrace(); | ||||||
|             System.exit(1); |             System.exit(1); | ||||||
|  | @ -2,6 +2,7 @@ package jobshop; | ||||||
| 
 | 
 | ||||||
| import jobshop.encodings.Schedule; | import jobshop.encodings.Schedule; | ||||||
| 
 | 
 | ||||||
|  | /** Class representing the result of a solver. */ | ||||||
| public class Result { | public class Result { | ||||||
| 
 | 
 | ||||||
|     public Result(Instance instance, Schedule schedule, ExitCause cause) { |     public Result(Instance instance, Schedule schedule, ExitCause cause) { | ||||||
|  | @ -10,12 +11,23 @@ public class Result { | ||||||
|         this.cause = cause; |         this.cause = cause; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** Documents the reason why a solver returned the solution. */ | ||||||
|     public enum ExitCause { |     public enum ExitCause { | ||||||
|         Timeout, ProvedOptimal, Blocked |         /** The solver ran out of time and had to exit. */ | ||||||
|  |         Timeout, | ||||||
|  |         /** The solution has been proved optimal and thus can no longer be improved. */ | ||||||
|  |         ProvedOptimal, | ||||||
|  |         /** The solver was not able to further improve the solution (e.g. blocked in a local minima. */ | ||||||
|  |         Blocked | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** Instance that was solved. */ | ||||||
|     public final Instance instance; |     public final Instance instance; | ||||||
|  | 
 | ||||||
|  |     /** A schedule of the solution or null if no solution was found. */ | ||||||
|     public final Schedule schedule; |     public final Schedule schedule; | ||||||
|  | 
 | ||||||
|  |     /** Reason why the solver exited with this solution. */ | ||||||
|     public final ExitCause cause; |     public final ExitCause cause; | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,13 +2,19 @@ package jobshop.encodings; | ||||||
| 
 | 
 | ||||||
| import jobshop.Instance; | import jobshop.Instance; | ||||||
| 
 | 
 | ||||||
|  | /** Common class for all encodings. | ||||||
|  |  * | ||||||
|  |  * The only requirement for this class is to provide a conversion from the encoding into a Schedule. | ||||||
|  |  */ | ||||||
| public abstract class Encoding { | public abstract class Encoding { | ||||||
| 
 | 
 | ||||||
|  |     /** Problem instance of which this is the solution. */ | ||||||
|     public final Instance instance; |     public final Instance instance; | ||||||
| 
 | 
 | ||||||
|     public Encoding(Instance instance) { |     public Encoding(Instance instance) { | ||||||
|         this.instance = instance; |         this.instance = instance; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** Convert into a schedule. */ | ||||||
|     public abstract Schedule toSchedule(); |     public abstract Schedule toSchedule(); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -24,7 +24,7 @@ public class JobNumbers extends Encoding { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public JobNumbers(Schedule schedule) { |     public JobNumbers(Schedule schedule) { | ||||||
|         super(schedule.pb); |         super(schedule.instance); | ||||||
| 
 | 
 | ||||||
|         this.jobs = new int[instance.numJobs * instance.numTasks]; |         this.jobs = new int[instance.numJobs * instance.numTasks]; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -30,13 +30,13 @@ public class ResourceOrder extends Encoding { | ||||||
|     /** Creates a resource order from a schedule. */ |     /** Creates a resource order from a schedule. */ | ||||||
|     public ResourceOrder(Schedule schedule) |     public ResourceOrder(Schedule schedule) | ||||||
|     { |     { | ||||||
|         super(schedule.pb); |         super(schedule.instance); | ||||||
|         Instance pb = schedule.pb; |         Instance pb = schedule.instance; | ||||||
| 
 | 
 | ||||||
|         this.tasksByMachine = new Task[pb.numMachines][]; |         this.tasksByMachine = new Task[pb.numMachines][]; | ||||||
|         this.nextFreeSlot = new int[instance.numMachines]; |         this.nextFreeSlot = new int[instance.numMachines]; | ||||||
| 
 | 
 | ||||||
|         for(int m = 0 ; m<schedule.pb.numMachines ; m++) { |         for(int m = 0; m<schedule.instance.numMachines ; m++) { | ||||||
|             final int machine = m; |             final int machine = m; | ||||||
| 
 | 
 | ||||||
|             // for this machine, find all tasks that are executed on it and sort them by their start time |             // for this machine, find all tasks that are executed on it and sort them by their start time | ||||||
|  | @ -51,6 +51,10 @@ public class ResourceOrder extends Encoding { | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     public void addToMachine(int machine, int jobNumber) { | ||||||
|  |         addTaskToMachine(machine, new Task(jobNumber, instance.task_with_machine(jobNumber, machine))); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     public void addTaskToMachine(int machine, Task task) { |     public void addTaskToMachine(int machine, Task task) { | ||||||
|         tasksByMachine[machine][nextFreeSlot[machine]] = task; |         tasksByMachine[machine][nextFreeSlot[machine]] = task; | ||||||
|         nextFreeSlot[machine] += 1; |         nextFreeSlot[machine] += 1; | ||||||
|  |  | ||||||
|  | @ -5,21 +5,46 @@ import jobshop.Instance; | ||||||
| import java.util.*; | import java.util.*; | ||||||
| import java.util.stream.IntStream; | import java.util.stream.IntStream; | ||||||
| 
 | 
 | ||||||
| public class Schedule { | /** Direct encoding of the solution to JobShop problem. | ||||||
|     public final Instance pb; |  * | ||||||
|  |  * Associates every task to its start time. | ||||||
|  |  */ | ||||||
|  | public class Schedule extends Encoding { | ||||||
|  | 
 | ||||||
|     // start times of each job and task |     // start times of each job and task | ||||||
|     // times[j][i] is the start time of task (j,i) : i^th task of the j^th job |     // times[j][i] is the start time of task (j,i) : i^th task of the j^th job | ||||||
|     final int[][] times; |     final int[][] times; | ||||||
| 
 | 
 | ||||||
|     /** Creates a new schedule for the given instance where all start times are uninitialized. */ |     /** Creates a new schedule for the given instance where all start times are uninitialized. */ | ||||||
|     public Schedule(Instance pb) { |     public Schedule(Instance instance) { | ||||||
|         this.pb = pb; |         super(instance); | ||||||
|         this.times = new int[pb.numJobs][]; |         this.times = new int[instance.numJobs][]; | ||||||
|         for(int j = 0 ; j < pb.numJobs ; j++) { |         for(int j = 0; j < instance.numJobs ; j++) { | ||||||
|             this.times[j] = new int[pb.numTasks]; |             this.times[j] = new int[instance.numTasks]; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  |     /** Start time of the given task. */ | ||||||
|  |     public int startTime(int job, int task) { | ||||||
|  |         return times[job][task]; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** Start time of the given task. */ | ||||||
|  |     public int startTime(Task task) { | ||||||
|  |         return startTime(task.job, task.task); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** End time of the given task. */ | ||||||
|  |     public int endTime(int job, int task) { | ||||||
|  |         return startTime(job, task) + instance.duration(job, task); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** End time of the given task. */ | ||||||
|  |     public int endTime(Task task) { | ||||||
|  |         return endTime(task.job, task.task); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     /** Sets the start time of the given task. */ |     /** Sets the start time of the given task. */ | ||||||
|     public void setStartTime(int job, int task, int startTime) { |     public void setStartTime(int job, int task, int startTime) { | ||||||
|         times[job][task] = startTime; |         times[job][task] = startTime; | ||||||
|  | @ -27,25 +52,25 @@ public class Schedule { | ||||||
| 
 | 
 | ||||||
|     /** Returns true if this schedule is valid (no constraint is violated) */ |     /** Returns true if this schedule is valid (no constraint is violated) */ | ||||||
|     public boolean isValid() { |     public boolean isValid() { | ||||||
|         for(int j = 0 ; j<pb.numJobs ; j++) { |         for(int j = 0; j<instance.numJobs ; j++) { | ||||||
|             for(int t = 1 ; t<pb.numTasks ; t++) { |             for(int t = 1; t< instance.numTasks ; t++) { | ||||||
|                 if(startTime(j, t-1) + pb.duration(j, t-1) > startTime(j, t)) |                 if(startTime(j, t-1) + instance.duration(j, t-1) > startTime(j, t)) | ||||||
|                     return false; |                     return false; | ||||||
|             } |             } | ||||||
|             for(int t = 0 ; t<pb.numTasks ; t++) { |             for(int t = 0; t< instance.numTasks ; t++) { | ||||||
|                 if(startTime(j, t) < 0) |                 if(startTime(j, t) < 0) | ||||||
|                     return false; |                     return false; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         for (int machine = 0 ; machine < pb.numMachines ; machine++) { |         for (int machine = 0; machine < instance.numMachines ; machine++) { | ||||||
|             for(int j1=0 ; j1<pb.numJobs ; j1++) { |             for(int j1 = 0; j1< instance.numJobs ; j1++) { | ||||||
|                 int t1 = pb.task_with_machine(j1, machine); |                 int t1 = instance.task_with_machine(j1, machine); | ||||||
|                 for(int j2=j1+1 ; j2<pb.numJobs ; j2++) { |                 for(int j2 = j1+1; j2< instance.numJobs ; j2++) { | ||||||
|                     int t2 = pb.task_with_machine(j2, machine); |                     int t2 = instance.task_with_machine(j2, machine); | ||||||
| 
 | 
 | ||||||
|                     boolean t1_first = startTime(j1, t1) + pb.duration(j1, t1) <= startTime(j2, t2); |                     boolean t1_first = startTime(j1, t1) + instance.duration(j1, t1) <= startTime(j2, t2); | ||||||
|                     boolean t2_first = startTime(j2, t2) + pb.duration(j2, t2) <= startTime(j1, t1); |                     boolean t2_first = startTime(j2, t2) + instance.duration(j2, t2) <= startTime(j1, t1); | ||||||
| 
 | 
 | ||||||
|                     if(!t1_first && !t2_first) |                     if(!t1_first && !t2_first) | ||||||
|                         return false; |                         return false; | ||||||
|  | @ -61,32 +86,12 @@ public class Schedule { | ||||||
|      */ |      */ | ||||||
|     public int makespan() { |     public int makespan() { | ||||||
|         int max = -1; |         int max = -1; | ||||||
|         for(int j = 0 ; j<pb.numJobs ; j++) { |         for(int j = 0; j< instance.numJobs ; j++) { | ||||||
|             max = Math.max(max, endTime(j, pb.numTasks-1)); |             max = Math.max(max, endTime(j, instance.numTasks-1)); | ||||||
|         } |         } | ||||||
|         return max; |         return max; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** Start time of the given task. */ |  | ||||||
|     public int startTime(int job, int task) { |  | ||||||
|         return times[job][task]; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** Start time of the given task. */ |  | ||||||
|     public int startTime(Task task) { |  | ||||||
|         return startTime(task.job, task.task); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** End time of the given task. */ |  | ||||||
|     public int endTime(int job, int task) { |  | ||||||
|         return startTime(job, task) + pb.duration(job, task); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** End time of the given task. */ |  | ||||||
|     public int endTime(Task task) { |  | ||||||
|         return endTime(task.job, task.task); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** Returns true if the given sequence of task is a critical path of the schedule. */ |     /** Returns true if the given sequence of task is a critical path of the schedule. */ | ||||||
|     public boolean isCriticalPath(List<Task> path) { |     public boolean isCriticalPath(List<Task> path) { | ||||||
|         if(startTime(path.get(0)) != 0) { |         if(startTime(path.get(0)) != 0) { | ||||||
|  | @ -108,8 +113,8 @@ public class Schedule { | ||||||
|      */ |      */ | ||||||
|     public List<Task> criticalPath() { |     public List<Task> criticalPath() { | ||||||
|         // select task with greatest end time |         // select task with greatest end time | ||||||
|         Task ldd = IntStream.range(0, pb.numJobs) |         Task ldd = IntStream.range(0, instance.numJobs) | ||||||
|                 .mapToObj(j -> new Task(j, pb.numTasks-1)) |                 .mapToObj(j -> new Task(j, instance.numTasks-1)) | ||||||
|                 .max(Comparator.comparing(this::endTime)) |                 .max(Comparator.comparing(this::endTime)) | ||||||
|                 .get(); |                 .get(); | ||||||
|         assert endTime(ldd) == makespan(); |         assert endTime(ldd) == makespan(); | ||||||
|  | @ -124,7 +129,7 @@ public class Schedule { | ||||||
|         // starts a time 0 |         // starts a time 0 | ||||||
|         while(startTime(path.getFirst()) != 0) { |         while(startTime(path.getFirst()) != 0) { | ||||||
|             Task cur = path.getFirst(); |             Task cur = path.getFirst(); | ||||||
|             int machine = pb.machine(cur.job, cur.task); |             int machine = instance.machine(cur.job, cur.task); | ||||||
| 
 | 
 | ||||||
|             // will contain the task that was delaying the start |             // will contain the task that was delaying the start | ||||||
|             // of our current task |             // of our current task | ||||||
|  | @ -140,8 +145,8 @@ public class Schedule { | ||||||
|             } |             } | ||||||
|             if(latestPredecessor.isEmpty()) { |             if(latestPredecessor.isEmpty()) { | ||||||
|                 // no latest predecessor found yet, look among tasks executing on the same machine |                 // no latest predecessor found yet, look among tasks executing on the same machine | ||||||
|                 latestPredecessor = IntStream.range(0, pb.numJobs) |                 latestPredecessor = IntStream.range(0, instance.numJobs) | ||||||
|                         .mapToObj(j -> new Task(j, pb.task_with_machine(j, machine))) |                         .mapToObj(j -> new Task(j, instance.task_with_machine(j, machine))) | ||||||
|                         .filter(t -> endTime(t) == startTime(cur)) |                         .filter(t -> endTime(t) == startTime(cur)) | ||||||
|                         .findFirst(); |                         .findFirst(); | ||||||
|             } |             } | ||||||
|  | @ -158,11 +163,11 @@ public class Schedule { | ||||||
|     public String toString() { |     public String toString() { | ||||||
|         StringBuilder sb = new StringBuilder(); |         StringBuilder sb = new StringBuilder(); | ||||||
|         sb.append("\nStart times of all tasks:\n"); |         sb.append("\nStart times of all tasks:\n"); | ||||||
|         for(int job=0; job<pb.numJobs; job++) { |         for(int job = 0; job< instance.numJobs; job++) { | ||||||
|             sb.append("Job "); |             sb.append("Job "); | ||||||
|             sb.append(job); |             sb.append(job); | ||||||
|             sb.append(": "); |             sb.append(": "); | ||||||
|             for(int task=0; task<pb.numTasks; task++) { |             for(int task = 0; task< instance.numTasks; task++) { | ||||||
|                 sb.append(String.format("%5d",  startTime(job, task))); |                 sb.append(String.format("%5d",  startTime(job, task))); | ||||||
| 
 | 
 | ||||||
|             } |             } | ||||||
|  | @ -182,15 +187,15 @@ public class Schedule { | ||||||
|      */ |      */ | ||||||
|     public String asciiGantt() { |     public String asciiGantt() { | ||||||
|         var criticalPath = this.criticalPath(); |         var criticalPath = this.criticalPath(); | ||||||
|         int minTaskDur = IntStream.range(0, pb.numJobs).flatMap(job -> IntStream.range(0, pb.numTasks).map(task -> pb.duration(job, task))).min().getAsInt(); |         int minTaskDur = IntStream.range(0, instance.numJobs).flatMap(job -> IntStream.range(0, instance.numTasks).map(task -> instance.duration(job, task))).min().getAsInt(); | ||||||
|         // time units by character |         // time units by character | ||||||
|         int charsPerTimeUnit = minTaskDur >= 5 ? 1 : (5 / minTaskDur) +1; |         int charsPerTimeUnit = minTaskDur >= 5 ? 1 : (5 / minTaskDur) +1; | ||||||
|         StringBuilder sb = new StringBuilder(); |         StringBuilder sb = new StringBuilder(); | ||||||
|         sb.append("\nGantt Chart\n"); |         sb.append("\nGantt Chart\n"); | ||||||
|         for(int job=0; job<pb.numJobs; job++) { |         for(int job = 0; job< instance.numJobs; job++) { | ||||||
|             sb.append(String.format("Job %2d: ", job)); |             sb.append(String.format("Job %2d: ", job)); | ||||||
|             int cursor = 0; |             int cursor = 0; | ||||||
|             for(int task=0; task<pb.numTasks; task++) { |             for(int task = 0; task< instance.numTasks; task++) { | ||||||
|                 Task t = new Task(job, task); |                 Task t = new Task(job, task); | ||||||
|                 var st = startTime(job, task); |                 var st = startTime(job, task); | ||||||
|                 // add spaces until the start of our task |                 // add spaces until the start of our task | ||||||
|  | @ -214,8 +219,8 @@ public class Schedule { | ||||||
|     String formatTask(Task t, int charPerTimeUnit, boolean isCritical) { |     String formatTask(Task t, int charPerTimeUnit, boolean isCritical) { | ||||||
|         StringBuilder sb = new StringBuilder(); |         StringBuilder sb = new StringBuilder(); | ||||||
|         String fill = isCritical ? "*" : "-"; |         String fill = isCritical ? "*" : "-"; | ||||||
|         int dur = pb.duration(t); |         int dur = instance.duration(t); | ||||||
|         int machine = pb.machine(t); |         int machine = instance.machine(t); | ||||||
|         int stringLength = dur * charPerTimeUnit; |         int stringLength = dur * charPerTimeUnit; | ||||||
|         int charsForMachine = machine < 10 ? 1 : 2; |         int charsForMachine = machine < 10 ? 1 : 2; | ||||||
|         int numSpaces = stringLength - 2 - charsForMachine; // we use 2 chars for '[' and '[' + 1 or 2 for the machine number |         int numSpaces = stringLength - 2 - charsForMachine; // we use 2 chars for '[' and '[' + 1 or 2 for the machine number | ||||||
|  | @ -230,4 +235,10 @@ public class Schedule { | ||||||
|         sb.append("]"); |         sb.append("]"); | ||||||
|         return sb.toString(); |         return sb.toString(); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     @Override | ||||||
|  |     public Schedule toSchedule() { | ||||||
|  |         return this; | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -3,8 +3,16 @@ package jobshop.solvers; | ||||||
| import jobshop.Instance; | import jobshop.Instance; | ||||||
| import jobshop.Result; | import jobshop.Result; | ||||||
| 
 | 
 | ||||||
|  | /** Common interface that must implemented by all solvers. */ | ||||||
| public interface Solver { | public interface Solver { | ||||||
| 
 | 
 | ||||||
|  |     /** Look for a solution until blocked or a deadline has been met. | ||||||
|  |      * | ||||||
|  |      * @param instance Jobshop instance that should be solved. | ||||||
|  |      * @param deadline Absolute time at which the solver should have returned a solution. | ||||||
|  |      *                 This time is in milliseconds and can be compared with System.currentTimeMilliseconds() | ||||||
|  |      * @return A Result containing the solution found and an explanation of why the solver exited. | ||||||
|  |      */ | ||||||
|     Result solve(Instance instance, long deadline); |     Result solve(Instance instance, long deadline); | ||||||
| 
 | 
 | ||||||
|     /** Static factory method to create a new solver based on its name. */ |     /** Static factory method to create a new solver based on its name. */ | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue