/* * Copyright (C) 2011 Jason von Nieda <jason@vonnieda.org> * * This file is part of OpenPnP. * * OpenPnP is free software: you can redistribute it and/or modify it under the terms of the GNU * General Public License as published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * OpenPnP is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General * Public License for more details. * * You should have received a copy of the GNU General Public License along with OpenPnP. If not, see * <http://www.gnu.org/licenses/>. * * For more information about OpenPnP visit http://openpnp.org */ package org.openpnp.machine.reference; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; import org.openpnp.gui.support.Wizard; import org.openpnp.machine.reference.ReferencePnpJobProcessor.JobPlacement.Status; import org.openpnp.machine.reference.wizards.ReferencePnpJobProcessorConfigurationWizard; import org.openpnp.model.BoardLocation; import org.openpnp.model.Configuration; import org.openpnp.model.Job; import org.openpnp.model.LengthUnit; import org.openpnp.model.Location; import org.openpnp.model.Part; import org.openpnp.model.Placement; import org.openpnp.spi.Feeder; import org.openpnp.spi.FiducialLocator; import org.openpnp.spi.Head; import org.openpnp.spi.Machine; import org.openpnp.spi.Nozzle; import org.openpnp.spi.NozzleTip; import org.openpnp.spi.PartAlignment; import org.openpnp.spi.base.AbstractJobProcessor; import org.openpnp.spi.base.AbstractPnpJobProcessor; import org.openpnp.util.Collect; import org.openpnp.util.FiniteStateMachine; import org.openpnp.util.MovableUtils; import org.openpnp.util.Utils2D; import org.pmw.tinylog.Logger; import org.simpleframework.xml.Attribute; import org.simpleframework.xml.Root; @Root public class ReferencePnpJobProcessor extends AbstractPnpJobProcessor { enum State { Uninitialized, PreFlight, FiducialCheck, Plan, ChangeNozzleTip, Feed, Pick, Align, Place, Cleanup, Stopped } enum Message { Initialize, Next, Complete, Abort, Skip, Reset } public static class JobPlacement { public enum Status { Pending, Processing, Skipped, Complete } public final BoardLocation boardLocation; public final Placement placement; public Status status = Status.Pending; public JobPlacement(BoardLocation boardLocation, Placement placement) { this.boardLocation = boardLocation; this.placement = placement; } public double getPartHeight() { return placement.getPart().getHeight().convertToUnits(LengthUnit.Millimeters) .getValue(); } @Override public String toString() { return placement.getId(); } } public static class PlannedPlacement { public final JobPlacement jobPlacement; public final Nozzle nozzle; public Feeder feeder; public PartAlignment.PartAlignmentOffset alignmentOffsets; public boolean fed; public boolean stepComplete; public PlannedPlacement(Nozzle nozzle, JobPlacement jobPlacement) { this.nozzle = nozzle; this.jobPlacement = jobPlacement; } @Override public String toString() { return nozzle + " -> " + jobPlacement.toString(); } } @Attribute(required = false) protected boolean parkWhenComplete = false; private FiniteStateMachine<State, Message> fsm = new FiniteStateMachine<>(State.Uninitialized); protected Job job; protected Machine machine; protected Head head; protected List<JobPlacement> jobPlacements = new ArrayList<>(); protected List<PlannedPlacement> plannedPlacements = new ArrayList<>(); protected Map<BoardLocation, Location> boardLocationFiducialOverrides = new HashMap<>(); public ReferencePnpJobProcessor() { fsm.add(State.Uninitialized, Message.Initialize, State.PreFlight, this::doInitialize); fsm.add(State.PreFlight, Message.Next, State.FiducialCheck, this::doPreFlight, Message.Next); fsm.add(State.PreFlight, Message.Abort, State.Cleanup, Message.Next); fsm.add(State.FiducialCheck, Message.Next, State.Plan, this::doFiducialCheck, Message.Next); fsm.add(State.FiducialCheck, Message.Skip, State.Plan, Message.Next); fsm.add(State.FiducialCheck, Message.Abort, State.Cleanup, Message.Next); fsm.add(State.Plan, Message.Next, State.ChangeNozzleTip, this::doPlan, Message.Next); fsm.add(State.Plan, Message.Abort, State.Cleanup, Message.Next); fsm.add(State.Plan, Message.Complete, State.Cleanup, Message.Next); fsm.add(State.ChangeNozzleTip, Message.Next, State.Feed, this::doChangeNozzleTip, Message.Next); fsm.add(State.ChangeNozzleTip, Message.Skip, State.ChangeNozzleTip, this::doSkip, Message.Next); fsm.add(State.ChangeNozzleTip, Message.Abort, State.Cleanup, Message.Next); fsm.add(State.Feed, Message.Next, State.Align, this::doFeedAndPick, Message.Next); fsm.add(State.Feed, Message.Skip, State.Feed, this::doSkip, Message.Next); fsm.add(State.Feed, Message.Abort, State.Cleanup, Message.Next); // TODO: See notes on doFeedAndPick() // fsm.add(State.Feed, Message.Next, State.Pick, this::doFeed, Message.Next); // fsm.add(State.Feed, Message.Skip, State.Feed, this::doSkip, Message.Next); // fsm.add(State.Feed, Message.Abort, State.Cleanup, Message.Next); // // fsm.add(State.Pick, Message.Next, State.Align, this::doPick, Message.Next); // fsm.add(State.Pick, Message.Skip, State.Pick, this::doSkip, Message.Next); // fsm.add(State.Pick, Message.Abort, State.Cleanup, Message.Next); fsm.add(State.Align, Message.Next, State.Place, this::doAlign, Message.Next); fsm.add(State.Align, Message.Skip, State.Align, this::doSkip, Message.Next); fsm.add(State.Align, Message.Abort, State.Cleanup, Message.Next); fsm.add(State.Place, Message.Next, State.Plan, this::doPlace); fsm.add(State.Place, Message.Skip, State.Place, this::doSkip, Message.Next); fsm.add(State.Place, Message.Abort, State.Cleanup, Message.Next); fsm.add(State.Cleanup, Message.Next, State.Stopped, this::doCleanup, Message.Reset); fsm.add(State.Stopped, Message.Reset, State.Uninitialized, this::doReset); } public synchronized void initialize(Job job) throws Exception { this.job = job; fsm.send(Message.Initialize); } public synchronized boolean next() throws Exception { try{ fsm.send(Message.Next); } catch (Exception e) { this.fireJobState(this.machine.getSignalers(), AbstractJobProcessor.State.ERROR); throw(e); } if (fsm.getState() == State.Stopped) { /* * If we've reached the Stopped state the process is complete. We reset the FSM and * return false to indicate that we're finished. */ fsm.send(Message.Reset); return false; } else if (fsm.getState() == State.Plan && isJobComplete()) { /* * If we've reached the Plan state and there are no more placements to work on the job * is complete. We send the Complete Message to start the cleanup process. */ fsm.send(Message.Complete); this.fireJobState(this.machine.getSignalers(), AbstractJobProcessor.State.FINISHED); return false; } return true; } public synchronized void abort() throws Exception { fsm.send(Message.Abort); } public synchronized void skip() throws Exception { fsm.send(Message.Skip); } /* * TODO Due to the Align Skip issue I think we'd be better off replacing this API with * something like List<Message> getOptions(). This would return a list of options that the * caller can take at a given step. Need to figure out a way to make this generic enough * that other JP implementations can use it, thus it's probably not appropriate to just * use Message, but instead maybe a PnpJobProcessor specific enum. * Options would be things like: * * Skip Placement * * Try Later * * Retry Action * * Continue (Next) * * Really just need to think about the way a user will want to respond to various error * conditions that arise in each step and see if these can be generalized in a meaningful * way. */ public boolean canSkip() { return fsm.canSend(Message.Skip); } /** * Validate that there is a job set before allowing it to start. * * @throws Exception */ protected void doInitialize() throws Exception { if (job == null) { throw new Exception("Can't initialize with a null Job."); } } /** * Create some internal shortcuts to various buried objects. * * Check for obvious setup errors in the job: Feeders are available and enabled, Placements all * have valid parts, Parts all have height values set, Each part has at least one compatible * nozzle tip. * * Populate the jobPlacements list with all the placements that we'll perform for the entire * job. * * Safe-Z the machine, discard any currently picked parts. * * @throws Exception */ protected void doPreFlight() throws Exception { // Create some shortcuts for things that won't change during the run this.machine = Configuration.get().getMachine(); this.head = this.machine.getDefaultHead(); this.jobPlacements.clear(); this.boardLocationFiducialOverrides.clear(); fireTextStatus("Checking job for setup errors."); for (BoardLocation boardLocation : job.getBoardLocations()) { // Only check enabled boards if (!boardLocation.isEnabled()) { continue; } for (Placement placement : boardLocation.getBoard().getPlacements()) { // Ignore placements that aren't set to be placed if (placement.getType() != Placement.Type.Place) { continue; } // Ignore placements that aren't on the side of the board we're processing. if (placement.getSide() != boardLocation.getSide()) { continue; } JobPlacement jobPlacement = new JobPlacement(boardLocation, placement); // Make sure the part is not null if (placement.getPart() == null) { throw new Exception(String.format("Part not found for board %s, placement %s.", boardLocation.getBoard().getName(), placement.getId())); } // Verify that the part height is greater than zero. Catches a common configuration // error. if (placement.getPart().getHeight().getValue() <= 0D) { throw new Exception(String.format("Part height for %s must be greater than 0.", placement.getPart().getId())); } // Make sure there is at least one compatible nozzle tip available findNozzleTip(head, placement.getPart()); // Make sure there is at least one compatible and enabled feeder available findFeeder(machine, placement.getPart()); jobPlacements.add(jobPlacement); } } // Everything looks good, so prepare the machine. fireTextStatus("Preparing machine."); // Safe Z the machine head.moveToSafeZ(); // Discard any currently picked parts discardAll(head); } protected void doFiducialCheck() throws Exception { fireTextStatus("Performing fiducial checks."); FiducialLocator locator = Configuration.get().getMachine().getFiducialLocator(); for (BoardLocation boardLocation : job.getBoardLocations()) { if (!boardLocation.isEnabled()) { continue; } if (!boardLocation.isCheckFiducials()) { continue; } Location location = locator.locateBoard(boardLocation); boardLocationFiducialOverrides.put(boardLocation, location); Logger.debug("Fiducial check for {}", boardLocation); } } protected void doIndividualFiducialCheck(BoardLocation boardLocation) throws Exception { fireTextStatus("Performing individual fiducial check."); FiducialLocator locator = Configuration.get().getMachine().getFiducialLocator(); Location location = locator.locateBoard(boardLocation); boardLocationFiducialOverrides.put(boardLocation, location); Logger.debug("Fiducial check for {}", boardLocation); } /** * Description of the planner: * * 1. Create a List<List<JobPlacement>> where each List<JobPlacement> is a List of JobPlacements * that the corresponding (in order) Nozzle can handle in Nozzle order. * * In addition, each List<JobPlacement> contains one instance of null which represents a * solution where that Nozzle does not perform a placement. * * 2. Create the Cartesian product of all of the List<JobPlacement>. The resulting List<List * <JobPlacement>> represents possible solutions for a single cycle with each JobPlacement * corresponding to a Nozzle. * * 3. Filter out any solutions where the same JobPlacement is represented more than once. We * don't want more than one Nozzle trying to place the same Placement. * * 4. Sort the solutions by fewest nulls followed by fewest nozzle changes. The result is that * we prefer solutions that use more nozzles in a cycle and require fewer nozzle changes. * * Note: TODO: Originally planned to have this sort by part height but that went out the window * during development. Need to think about how to best combine the height requirement with the * want to fill all nozzles and perform minimal nozzle changes. Based on IRC discussion, the * part height thing might be a red herring - most machines will have enough Z to place all * parts regardless of height order. */ protected void doPlan() throws Exception { plannedPlacements.clear(); fireTextStatus("Planning placements."); // Get the list of unfinished placements and sort them by part height. List<JobPlacement> jobPlacements = getPendingJobPlacements().stream() .sorted(Comparator.comparing(JobPlacement::getPartHeight)) .collect(Collectors.toList()); if (jobPlacements.isEmpty()) { return; } // Create a List of Lists of JobPlacements that each Nozzle can handle, including // one instance of null per Nozzle. The null indicates a possible "no solution" // for that Nozzle. List<List<JobPlacement>> solutions = head.getNozzles().stream().map(nozzle -> { return Stream.concat(jobPlacements.stream().filter(jobPlacement -> { return nozzleCanHandle(nozzle, jobPlacement.placement.getPart()); }), Stream.of((JobPlacement) null)).collect(Collectors.toList()); }).collect(Collectors.toList()); // Get the cartesian product of those Lists List<JobPlacement> result = Collect.cartesianProduct(solutions).stream() // Filter out any results that contains the same JobPlacement more than once .filter(list -> { return new HashSet<JobPlacement>(list).size() == list.size(); }) // Sort by the solutions that contain the fewest nulls followed by the // solutions that require the fewest nozzle changes. .sorted(byFewestNulls.thenComparing(byFewestNozzleChanges)) // And return the top result. .findFirst().orElse(null); // Now we have a solution, so apply it to the nozzles and plan the placements. for (Nozzle nozzle : head.getNozzles()) { // The solution is in Nozzle order, so grab the next one. JobPlacement jobPlacement = result.remove(0); if (jobPlacement == null) { continue; } jobPlacement.status = Status.Processing; plannedPlacements.add(new PlannedPlacement(nozzle, jobPlacement)); } Logger.debug("Planned placements {}", plannedPlacements); } protected void doChangeNozzleTip() throws Exception { for (PlannedPlacement plannedPlacement : plannedPlacements) { if (plannedPlacement.stepComplete) { continue; } Nozzle nozzle = plannedPlacement.nozzle; JobPlacement jobPlacement = plannedPlacement.jobPlacement; Placement placement = jobPlacement.placement; Part part = placement.getPart(); // If the currently loaded NozzleTip can handle the Part we're good. if (nozzle.getNozzleTip() != null && nozzle.getNozzleTip().canHandle(part)) { Logger.debug("No nozzle change needed for nozzle {}", nozzle); plannedPlacement.stepComplete = true; continue; } fireTextStatus("Changing nozzle tip on nozzle %s.", nozzle.getId()); // Otherwise find a compatible tip and load it NozzleTip nozzleTip = findNozzleTip(nozzle, part); Logger.debug("Change nozzle tip on {} from {} to {}", new Object[] {nozzle, nozzle.getNozzleTip(), nozzleTip}); nozzle.unloadNozzleTip(); nozzle.loadNozzleTip(nozzleTip); // Mark this step as complete plannedPlacement.stepComplete = true; } clearStepComplete(); } /* * TODO: This method is a compromise due to time constraints. Below, there is doFeed and doPick, * which were intended to be used in sequence. I realized too late that I had made an error in * designing the FSM and for multiple nozzles it was doing feed, feed, pick, pick instead of * feed, pick, feed, pick. The latter is correct while the former is useless. Since I need to * release this feature before Maker Faire I've decided to just combine the methods to get this * done. * * The whole FSM system needs to be reconsidered. There are two main things to consider: 1. * current FSM cannot handle transitions within action methods. If it could then we could have * doFeed process one PlannedPlacement, continue to Pick and then have Pick either loop back to * Feed if there are more PlannedPlacements or continue to Align if not. I don't love this idea * because it makes the FSM non-deterministic and thus harder to reason about. * * 2. An ideal system would treat each step that required actions for multiple PlannedPlacements * as their own FSM, producing a hierarchy of FSMs. I've also seen this idea referred to as * "fork and join" FSMs and I have brainstormed this type of system a bit in the image at: * https://imgur.com/a/63Y1t */ protected void doFeedAndPick() throws Exception { for (PlannedPlacement plannedPlacement : plannedPlacements) { if (plannedPlacement.stepComplete) { continue; } Nozzle nozzle = plannedPlacement.nozzle; JobPlacement jobPlacement = plannedPlacement.jobPlacement; Placement placement = jobPlacement.placement; Part part = placement.getPart(); if (!plannedPlacement.fed) { while (true) { // Find a compatible, enabled feeder Feeder feeder = findFeeder(machine, part); plannedPlacement.feeder = feeder; // Feed the part try { // Try to feed the part. If it fails, retry the specified number of times // before // giving up. retry(1 + feeder.getRetryCount(), () -> { fireTextStatus("Feeding %s from %s for %s.", part.getId(), feeder.getName(), placement.getId()); Logger.debug("Attempt Feed {} from {} with {}.", new Object[] {part, feeder, nozzle}); feeder.feed(nozzle); Logger.debug("Fed {} from {} with {}.", new Object[] {part, feeder, nozzle}); }); break; } catch (Exception e) { Logger.debug("Feed {} from {} with {} failed!", new Object[] {part, feeder, nozzle}); // If the feed fails, disable the feeder and continue. If there are no // more valid feeders the findFeeder() call above will throw and exit the // loop. feeder.setEnabled(false); } } plannedPlacement.fed = true; } // Get the feeder that was used to feed Feeder feeder = plannedPlacement.feeder; // Move to the pick location MovableUtils.moveToLocationAtSafeZ(nozzle, feeder.getPickLocation()); fireTextStatus("Picking %s from %s for %s.", part.getId(), feeder.getName(), placement.getId()); // Pick nozzle.pick(part); // Retract nozzle.moveToSafeZ(); Logger.debug("Pick {} from {} with {}", part, feeder, nozzle); if (feeder != null) { feeder.postPick(nozzle); } plannedPlacement.stepComplete = true; } clearStepComplete(); } protected void doAlign() throws Exception { for (PlannedPlacement plannedPlacement : plannedPlacements) { if (plannedPlacement.stepComplete) { continue; } Nozzle nozzle = plannedPlacement.nozzle; JobPlacement jobPlacement = plannedPlacement.jobPlacement; Placement placement = jobPlacement.placement; Part part = placement.getPart(); fireTextStatus("Aligning %s for %s.", part.getId(), placement.getId()); PartAlignment.PartAlignmentOffset alignmentOffset = machine.getPartAlignment().findOffsets(part, jobPlacement.boardLocation, placement.getLocation(), nozzle); plannedPlacement.alignmentOffsets = alignmentOffset; Logger.debug("Align {} with {}", part, nozzle); plannedPlacement.stepComplete = true; } clearStepComplete(); } protected void doPlace() throws Exception { for (PlannedPlacement plannedPlacement : plannedPlacements) { if (plannedPlacement.stepComplete) { continue; } Nozzle nozzle = plannedPlacement.nozzle; JobPlacement jobPlacement = plannedPlacement.jobPlacement; Placement placement = jobPlacement.placement; Part part = placement.getPart(); BoardLocation boardLocation = plannedPlacement.jobPlacement.boardLocation; //Check if the individual piece has a fiducial check and check to see if the board is enabled if(jobPlacement.placement.getCheckFids()&&jobPlacement.boardLocation.isEnabled()) doIndividualFiducialCheck(jobPlacement.boardLocation); // Check if there is a fiducial override for the board location and if so, use it. if (boardLocationFiducialOverrides.containsKey(boardLocation)) { BoardLocation boardLocation2 = new BoardLocation(boardLocation.getBoard()); boardLocation2.setSide(boardLocation.getSide()); boardLocation2.setLocation(boardLocationFiducialOverrides.get(boardLocation)); boardLocation = boardLocation2; } Location placementLocation = Utils2D.calculateBoardPlacementLocation(boardLocation, placement.getLocation()); // If there are alignment offsets update the placement location with them if (plannedPlacement.alignmentOffsets != null) { /* preRotated means during alignment we have already rotated the component - this is useful for say an external rotating stage that the component is placed on, rotated to correct placement angle, and then picked up again. */ if(plannedPlacement.alignmentOffsets.getPreRotated()) { Location location = placementLocation; location = location.derive(null, null, null, plannedPlacement.alignmentOffsets.getLocation().getRotation()); placementLocation = location; } else { Location alignmentOffsets = plannedPlacement.alignmentOffsets.getLocation(); // Rotate the point 0,0 using the alignment offsets as a center point by the angle // that is // the difference between the alignment angle and the calculated global // placement angle. Location location = new Location(LengthUnit.Millimeters).rotateXyCenterPoint(alignmentOffsets, placementLocation.getRotation() - alignmentOffsets.getRotation()); // Set the angle to the difference mentioned above, aligning the part to the // same angle as // the placement. location = location.derive(null, null, null, placementLocation.getRotation() - alignmentOffsets.getRotation()); // Add the placement final location to move our local coordinate into global // space location = location.add(placementLocation); // Subtract the alignment offsets to move the part to the final location, // instead of // the nozzle. location = location.subtract(alignmentOffsets); placementLocation = location; } } // Add the part's height to the placement location placementLocation = placementLocation.add(new Location(part.getHeight().getUnits(), 0, 0, part.getHeight().getValue(), 0)); // Move to the placement location MovableUtils.moveToLocationAtSafeZ(nozzle, placementLocation); fireTextStatus("Placing %s for %s.", part.getId(), placement.getId()); // Place the part nozzle.place(); // Retract nozzle.moveToSafeZ(); // Mark the placement as finished jobPlacement.status = Status.Complete; plannedPlacement.stepComplete = true; Logger.debug("Place {} with {}", part, nozzle.getName()); } clearStepComplete(); } protected void doCleanup() throws Exception { fireTextStatus("Cleaning up."); // Safe Z the machine head.moveToSafeZ(); // Discard any currently picked parts discardAll(head); // Safe Z the machine head.moveToSafeZ(); if (parkWhenComplete) { fireTextStatus("Park nozzle."); MovableUtils.moveToLocationAtSafeZ(head.getDefaultNozzle(), head.getParkLocation()); } } protected void doReset() throws Exception { this.job = null; } /** * Discard the picked part, if any. Remove the currently processing PlannedPlacement from the * list and mark the JobPlacement as Skipped. * * @throws Exception */ protected void doSkip() throws Exception { if (plannedPlacements.size() > 0) { PlannedPlacement plannedPlacement = plannedPlacements.remove(0); JobPlacement jobPlacement = plannedPlacement.jobPlacement; Nozzle nozzle = plannedPlacement.nozzle; discard(nozzle); jobPlacement.status = Status.Skipped; Logger.debug("Skipped {}", jobPlacement.placement); } } protected void clearStepComplete() { for (PlannedPlacement plannedPlacement : plannedPlacements) { plannedPlacement.stepComplete = false; } } protected List<JobPlacement> getPendingJobPlacements() { return this.jobPlacements.stream().filter((jobPlacement) -> { return jobPlacement.status == Status.Pending; }).collect(Collectors.toList()); } protected boolean isJobComplete() { return getPendingJobPlacements().isEmpty(); } @Override public Wizard getConfigurationWizard() { return new ReferencePnpJobProcessorConfigurationWizard(this); } public boolean isParkWhenComplete() { return parkWhenComplete; } public void setParkWhenComplete(boolean parkWhenComplete) { this.parkWhenComplete = parkWhenComplete; } // Sort a List<JobPlacement> by the number of nulls it contains in ascending order. Comparator<List<JobPlacement>> byFewestNulls = (a, b) -> { return Collections.frequency(a, null) - Collections.frequency(b, null); }; // Sort a List<JobPlacement> by the number of nozzle changes it will require in // descending order. Comparator<List<JobPlacement>> byFewestNozzleChanges = (a, b) -> { int countA = 0, countB = 0; for (int i = 0; i < head.getNozzles().size(); i++) { Nozzle nozzle = head.getNozzles().get(i); JobPlacement jpA = a.get(i); JobPlacement jpB = b.get(i); if (nozzle.getNozzleTip() == null) { countA++; countB++; continue; } if (jpA != null && !nozzle.getNozzleTip().canHandle(jpA.placement.getPart())) { countA++; } if (jpB != null && !nozzle.getNozzleTip().canHandle(jpB.placement.getPart())) { countB++; } } return countA - countB; }; }