@** A Rate of Change Indicator. \noindent A common metric used for understanding roast profiles is the rate of temperature change over a given amount of time. When roasters discuss roast profiles it is not uncommon to hear references to the change in temperature per 30 seconds or per minute, often with the sloppy shorthand $\Delta$ or with the term Rate of Rise (RoR). This is most commonly calculated from the secant line defined by two measurement points at the desired separation, however this may not be the most useful way to calculate this value. The rate of change can be considered as its own data series which happens to be derived from a primary measurement series. The interface for producing this series can sensibly match other classes which store, forward, or manipulate measurement data. @= class RateOfChange : public QObject { Q_OBJECT public: RateOfChange(int cachetime = 1, int scaletime = 1); public slots: void newMeasurement(Measurement measure); void setCacheTime(int seconds); void setScaleTime(int seconds); signals: void newData(Measurement measure); private: int ct; int st; QList cache; }; @ The interesting part of this class is in the |newMeasurement()| method. This is a slot method that will be called for every new measurement in the primary series. We require at least two measurements before calculating a rate of temperature change. @= void RateOfChange::newMeasurement(Measurement measure) { cache.append(measure); @@; if(cache.size() >= 2) { @@; } } @ To calculate the rate of temperature change we require at least two cached measurements. Using only the most recent two measurements will result in a highly volatile rate of change while using two data points that are more separated will smooth out random fluctuations but provide a less immediate response to a change in the rate of change. For this reason we provide two parameters that can be adjusted independently: the amount of time we allow a measurement to stay in the cache determines how far apart the measurements used to calculate the rate of change are while a separate scale time is used to determine how the calculated value is presented. We never allow fewer than two cached values, but we can force the most volatile calculation by setting the cache time to 0 seconds. Measurement handling is a little bit different on a date transition. @= if(cache.size() > 2) { bool done = false; while(!done) { if(cache.front().time().secsTo(cache.back().time()) > ct) { cache.removeFirst(); } else if(cache.back().time() < cache.front().time()) { cache.removeFirst(); done = true; } else { done = true; } if(cache.size() < 3) { done = true; } } } @ Rather than work directly with changes from one measurement to the next and attempting to filter out the noise, we instead calculate the slope of a simple linear regression on the current window of data. The measurement will carry the fact that it is a relative measurement. @= int N = cache.size(); double SXY = 0; double SX = 0; double SXX = 0; double SY = 0; double y; double x; for(int i = 0; i < N; i++) { y = cache.at(i).temperature(); SY += y; x = cache.at(0).time().msecsTo(cache.at(i).time()) / 1000.0; SX += x; SXX += (x*x); SXY += (x*y); } double M = ((N * SXY) - (SX * SY)) / ((N * SXX) - (SX * SX)); Measurement value(M * st, cache.back().time(), cache.back().scale()); value.insert("relative", true); emit newData(value); @ The rest of the class implementation is trivial. @= RateOfChange::RateOfChange(int cachetime, int scaletime) : ct(cachetime), st(1) { setScaleTime(scaletime); } void RateOfChange::setCacheTime(int seconds) { ct = seconds; } void RateOfChange::setScaleTime(int seconds) { st = (seconds > 0 ? seconds : 1); } @ This is exposed to the host environment in the usual way. @= QScriptValue constructRateOfChange(QScriptContext *context, QScriptEngine *engine); void setRateOfChangeProperties(QScriptValue value, QScriptEngine *engine); @ The constructor is registered with the scripting engine. @= constructor = engine->newFunction(constructRateOfChange); value = engine->newQMetaObject(&RateOfChange::staticMetaObject, constructor); engine->globalObject().setProperty("RateOfChange", value); @ The constructor takes two arguments if they are provided. @= QScriptValue constructRateOfChange(QScriptContext *context, QScriptEngine *engine) { int cachetime = 1; int scaletime = 1; if(context->argumentCount() > 0) { cachetime = argument(0, context); if(context->argumentCount() > 1) { scaletime = argument(1, context); } } QScriptValue object = engine->newQObject(new RateOfChange(cachetime, scaletime)); setRateOfChangeProperties(object, engine); return object; } void setRateOfChangeProperties(QScriptValue value, QScriptEngine *engine) { setQObjectProperties(value, engine); } @ To make use of this feature conveniently, we must integrate this with the in-program configuration system by providing a configuration widget. @= class RateOfChangeConfWidget : public BasicDeviceConfigurationWidget { Q_OBJECT public: Q_INVOKABLE RateOfChangeConfWidget(DeviceTreeModel *model, const QModelIndex &index); private slots: void updateColumn(const QString &column); void updateCacheTime(const QString &seconds); void updateScaleTime(const QString &seconds); }; @ The constructor sets up the user interface. @= RateOfChangeConfWidget::RateOfChangeConfWidget(DeviceTreeModel *model, const QModelIndex &index) : BasicDeviceConfigurationWidget(model, index) { QFormLayout *layout = new QFormLayout; QLineEdit *column = new QLineEdit; layout->addRow(tr("Primary series column name:"), column); QSpinBox *cacheTime = new QSpinBox; cacheTime->setMinimum(0); cacheTime->setMaximum(300); layout->addRow(tr("Cache time:"), cacheTime); QSpinBox *scaleTime = new QSpinBox; scaleTime->setMinimum(1); scaleTime->setMaximum(300); layout->addRow(tr("Scale time:"), scaleTime); @@; for(int i = 0; i < configData.size(); i++) { node = configData.at(i).toElement(); if(node.attribute("name") == "column") { column->setText(node.attribute("value")); } else if(node.attribute("name") == "cache") { cacheTime->setValue(node.attribute("value").toInt()); } else if(node.attribute("name") == "scale") { scaleTime->setValue(node.attribute("value").toInt()); } } updateColumn(column->text()); updateCacheTime(cacheTime->text()); updateScaleTime(scaleTime->text()); connect(column, SIGNAL(textEdited(QString)), this, SLOT(updateColumn(QString))); connect(cacheTime, SIGNAL(valueChanged(QString)), this, SLOT(updateCacheTime(QString))); connect(scaleTime, SIGNAL(valueChanged(QString)), this, SLOT(updateScaleTime(QString))); setLayout(layout); } @ A set of update methods modify the device configuration to reflect changes in the configuration widget. @= void RateOfChangeConfWidget::updateColumn(const QString &column) { updateAttribute("column", column); } void RateOfChangeConfWidget::updateCacheTime(const QString &seconds) { updateAttribute("cache", seconds); } void RateOfChangeConfWidget::updateScaleTime(const QString &seconds) { updateAttribute("scale", seconds); } @ This is registered with the configuration system. @= app.registerDeviceConfigurationWidget("rate", RateOfChangeConfWidget::staticMetaObject); @ This is accessed through the advanced features menu. @= NodeInserter *rateOfChangeInserter = new NodeInserter(tr("Rate of Change"), tr("Rate of Change"), "rate"); connect(rateOfChangeInserter, SIGNAL(triggered(QString, QString)), this, SLOT(insertChildNode(QString, QString))); advancedMenu->addAction(rateOfChangeInserter); @ Our class implementation is currently expanded into |"typica.cpp"|. @= @