diff --git a/src/octoprint/printer/standard.py b/src/octoprint/printer/standard.py index 6925ab53..727055d3 100644 --- a/src/octoprint/printer/standard.py +++ b/src/octoprint/printer/standard.py @@ -72,6 +72,10 @@ class Printer(PrinterInterface, comm.MachineComPrintCallback): self._selectedFile = None self._timeEstimationData = None + self._timeEstimationStatsWeighingUntil = settings().getFloat(["estimation", "printTime", "statsWeighingUntil"]) + self._timeEstimationValidityRange = settings().getFloat(["estimation", "printTime", "validityRange"]) + self._timeEstimationForceDumbFromPercent = settings().getFloat(["estimation", "printTime", "forceDumbFromPercent"]) + self._timeEstimationForceDumbAfterMin = settings().getFloat(["estimation", "printTime", "forceDumbAfterMin"]) # comm self._comm = None @@ -381,20 +385,21 @@ class Printer(PrinterInterface, comm.MachineComPrintCallback): if self._selectedFile is None: return + # we are happy if the average of the estimates stays within 60s of the prior one + threshold = settings().getFloat(["estimation", "printTime", "stableThreshold"]) rolling_window = None - threshold = None countdown = None + if self._selectedFile["sd"]: # we are interesting in a rolling window of roughly the last 15s, so the number of entries has to be derived # by that divided by the sd status polling interval rolling_window = 15 / settings().get(["serial", "timeout", "sdStatus"]) - # we are happy if the average of the estimates stays within 60s of the prior one - threshold = 60 - # we are happy when one rolling window has been stable countdown = rolling_window - self._timeEstimationData = TimeEstimationHelper(rolling_window=rolling_window, threshold=threshold, countdown=countdown) + self._timeEstimationData = TimeEstimationHelper(rolling_window=rolling_window, + threshold=threshold, + countdown=countdown) self._fileManager.delete_recovery_data() @@ -632,22 +637,12 @@ class Printer(PrinterInterface, comm.MachineComPrintCallback): printTime = self._comm.getPrintTime() cleanedPrintTime = self._comm.getCleanedPrintTime() - estimatedTotalPrintTime = self._estimateTotalPrintTime(progress, cleanedPrintTime) - totalPrintTime = estimatedTotalPrintTime - - if self._selectedFile and "estimatedPrintTime" in self._selectedFile and self._selectedFile["estimatedPrintTime"]: + statisticalTotalPrintTime = None + if self._selectedFile and "estimatedPrintTime" in self._selectedFile \ + and self._selectedFile["estimatedPrintTime"]: statisticalTotalPrintTime = self._selectedFile["estimatedPrintTime"] - if progress and cleanedPrintTime: - if estimatedTotalPrintTime is None: - totalPrintTime = statisticalTotalPrintTime - else: - if progress < 0.5: - sub_progress = progress * 2 - else: - sub_progress = 1.0 - totalPrintTime = (1 - sub_progress) * statisticalTotalPrintTime + sub_progress * estimatedTotalPrintTime - printTimeLeft = totalPrintTime - cleanedPrintTime if (totalPrintTime is not None and cleanedPrintTime is not None) else None + printTimeLeft, printTimeLeftOrigin = self._estimatePrintTimeLeft(progress, printTime, cleanedPrintTime, statisticalTotalPrintTime) if progress is not None: progress_int = int(progress * 100) @@ -658,7 +653,121 @@ class Printer(PrinterInterface, comm.MachineComPrintCallback): return dict(completion=progress * 100 if progress is not None else None, filepos=filepos, printTime=int(printTime) if printTime is not None else None, - printTimeLeft=int(printTimeLeft) if printTimeLeft is not None else None) + printTimeLeft=int(printTimeLeft) if printTimeLeft is not None else None, + printTimeLeftOrigin=printTimeLeftOrigin) + + def _estimatePrintTimeLeft(self, progress, printTime, cleanedPrintTime, statisticalTotalPrintTime): + """ + Tries to estimate the print time left for the print job + + This is somewhat horrible since accurate print time estimation is pretty much impossible to + achieve, considering that we basically have only two data points (current progress in file and + time needed for that so far - former prints or a file analysis might not have happened or simply + be completely impossible e.g. if the file is stored on the printer's SD card) and + hence can only do a linear estimation of a completely non-linear process. That's a recipe + for inaccurate predictions right there. Yay. + + Anyhow, here's how this implementation works. This method gets the current progress in the + printed file (percentage based on bytes read vs total bytes), the print time that elapsed, + the same print time with the heat up times subtracted (if possible) and if available also + some statistical total print time (former prints or a result from the GCODE analysis). + + 1. First get an "intelligent" estimate based on the :class:`~octoprint.printer.estimation.TimeEstimationHelper`. + That thing tries to detect if the estimation based on our progress and time needed for that becomes + stable over time through a rolling window and only returns a result once that appears to be the + case. + 2. If we have any statistical data (former prints or a result from the GCODE analysis) + but no intelligent estimate yet, we'll use that for the next step. Otherwise, up to a certain percentage + in the print we do a percentage based weighing of the statistical data and the intelligent + estimate - the closer to the beginning of the print, the more precedence for the statistical + data, the closer to the cut off point, the more precendence for the intelligent estimate. This + is our preliminary total print time. + 3. If the total print time is set, we do a sanity check for it. Based on the total print time + estimate and the time we already spent printing, we calculate at what percentage we SHOULD be + and compare that to the percentage at which we actually ARE. If it's too far off, our total + can't be trusted and we fall back on the dumb estimate. Same if the time we spent printing is + already higher than our total estimate. + 4. If we do NOT have a total print time estimate yet but we've been printing for longer than + a configured amount of minutes or are further in the file than a configured percentage, we + also use the dumb estimate for now. + + Yes, all this still produces horribly inaccurate results. But we have to do this live during the print and + hence can't produce to much computational overhead, we do not have any insight into the firmware implementation + with regards to planner setup and acceleration settings, we might not even have access to the printed file's + contents and such we need to find something that works "mostly" all of the time without costing too many + resources. Feel free to propose a better solution within the above limitations (and I mean that, this solution + here makes me unhappy). + + Args: + progress (float or None): Current percentage in the printed file + printTime (float or None): Print time elapsed so far + cleanedPrintTime (float or None): Print time elapsed minus the time needed for getting up to temperature + (if detectable). + statisticalTotalPrintTime (float or None): Total print time of past prints against same printer profile, + or estimated total print time from GCODE analysis. + + Returns: + (float or None) estimated print time left or None if not proper estimate could be made at all + """ + + if progress is None or printTime is None or cleanedPrintTime is None: + return None + + dumbTotalPrintTime = printTime / progress + estimatedTotalPrintTime = self._estimateTotalPrintTime(progress, cleanedPrintTime) + totalPrintTime = estimatedTotalPrintTime + + printTimeLeftOrigin = "estimate" + if statisticalTotalPrintTime is not None: + if estimatedTotalPrintTime is None: + # no estimate yet, we'll use the statistical total + totalPrintTime = statisticalTotalPrintTime + printTimeLeftOrigin = "stats" + + else: + if progress < self._timeEstimationStatsWeighingUntil: + # still inside weighing range, use part stats, part current estimate + sub_progress = progress * (1 / self._timeEstimationStatsWeighingUntil) + if sub_progress > 1.0: + sub_progress = 1.0 + printTimeLeftOrigin = "mixture" + else: + # use only the current estimate + sub_progress = 1.0 + printTimeLeftOrigin = "estimate" + + # combine + totalPrintTime = (1.0 - sub_progress) * statisticalTotalPrintTime \ + + sub_progress * estimatedTotalPrintTime + + printTimeLeft = None + if totalPrintTime is not None: + # sanity check current total print time estimate + assumed_progress = cleanedPrintTime / totalPrintTime + min_progress = progress - self._timeEstimationValidityRange + max_progress = progress + self._timeEstimationValidityRange + + if min_progress <= assumed_progress <= max_progress and totalPrintTime > cleanedPrintTime: + # appears sane, we'll use it + printTimeLeft = totalPrintTime - cleanedPrintTime + + else: + # too far from the actual progress or negative, + # we use the dumb print time instead + printTimeLeft = dumbTotalPrintTime - cleanedPrintTime + printTimeLeftOrigin = "linear" + + elif progress > self._timeEstimationForceDumbFromPercent or \ + cleanedPrintTime * 60 >= self._timeEstimationForceDumbAfterMin: + # more than x% or y min printed and still no real estimate, ok, we'll use the dumb variant :/ + printTimeLeft = dumbTotalPrintTime - cleanedPrintTime + printTimeLeftOrigin = "linear" + + if printTimeLeft < 0: + # shouldn't actually happen, but let's make sure + return None, None + + return printTimeLeft, printTimeLeftOrigin def _addTemperatureData(self, temp, bedTemp): currentTimeUtc = int(time.time()) diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index c7c5c993..edea1e25 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -283,6 +283,15 @@ default_settings = { } } }, + "estimation": { + "printTime": { + "statsWeighingUntil": 0.5, + "validityRange": 0.15, + "forceDumbFromPercent": 0.3, + "forceDumbAfterMin": 30, + "stableThreshold": 60 + } + }, "devel": { "stylesheet": "css", "cache": {