fantastic.php 68 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522
  1. <?php
  2. // stuff relating to HighCharts
  3. function hcSeriesOpts($opts) {
  4. $arr = /* yes, include junk? */ true ?$opts :array();
  5. // defaults...
  6. $type = "line";
  7. $lineWidth = "1";
  8. $color = ""; // empty allows highchats to pick defaults
  9. $yAxis = "1";
  10. $zIndex = "0";
  11. $showInLegend = "true";
  12. $enableMouseTracking = "false";
  13. $dashStyle = ""; /* longdash dot ... */
  14. // list of relevant highchart::series properties
  15. $hcp = "type lineWidth color yAxis zIndex showInLegend enableMouseTracking";
  16. // either use default values or make use of user provided options
  17. foreach(explode(' ', $hcp) as $v) {
  18. $arr[$v] = isset($opts[$v]) ?$opts[$v] :$$v;
  19. }
  20. return $arr;
  21. }
  22. /* converts PHP native array into HighCharts compatible series, by default
  23. * returns as the printed Series subset or property of the HighCharts Series
  24. * Option Structure. It looks like a plain text, JavaScript Language syntax
  25. * formatted string that is used when creating the `option` variable that is
  26. * passed to the `HighCharts.chart('container', options);` in
  27. * `/var/www/radon/graph.php`.
  28. *
  29. * This matches the original `graph.php` code, but the number of lines of code
  30. * that separate the `id` of each created `HighCharts::Series` is more than ten
  31. * and sometimes spans abstractions. Normally one would use
  32. * `HighCharts::Chart::get(id)` to manipulate the Series at runtime.
  33. *
  34. * An alternative approach would be to store `HighCharts::Series` objects and
  35. * manipulate them directly, this might allow for less mistakes. To test this
  36. * set `HcOptsStruct` to `false` and make use of some inline JavaScript
  37. *
  38. */
  39. function makeSeries($name, $dataset, $opts = array(), $HcOptsStruct = true) {
  40. $tstart = microtime(true);
  41. // fill in any missing highcharts series options
  42. $opts = hcSeriesOpts($opts);
  43. $t1 = microtime(true);
  44. $temp = "";
  45. // convert [[time=>{..}, reading=>{..}], ...] to JavaScript compatible string
  46. foreach($dataset as $koi=>$vul) {
  47. $temp .= "[{$vul['time']}000,".($vul['reading'])."],";
  48. }
  49. $t2 = microtime(true);
  50. // process highcharts series options
  51. $available = explode(' ', "type color yAxis zIndex");
  52. $seriesOpts = implode("\n", array(
  53. "{type:'{$opts["type"]}',",
  54. "lineWidth:{$opts["lineWidth"]},",
  55. "color:'{$opts["color"]}',",
  56. "id:'{$name}',",
  57. "name:'{$name}',",
  58. "data: [{$temp}],",
  59. "enableMouseTracking: {$opts["enableMouseTracking"]},",
  60. "showInLegend: {$opts["showInLegend"]},",
  61. "yAxis:{$opts["yAxis"]},",
  62. "zIndex:{$opts["zIndex"]},",
  63. "},"
  64. ));
  65. // print the HighChart compatible series dataset
  66. if(true === $HcOptsStruct) { echo $seriesOpts; }
  67. $tend = microtime(true);
  68. $i1 = round(1000*($t1-$tstart));
  69. $i2 = round(1000*($t2-$t1));
  70. $i3 = round(1000*($tend-$t2));
  71. $tot = round(1000*($tend-$tstart));
  72. error_log("makeseries - i1:".$i1.", i2:".$i2.", i3:".$i3." (t:".$tot."ms)");
  73. // allow usage for inline JavaScript
  74. return $seriesOpts;
  75. }
  76. require_once("environmental.php");
  77. require_once("rdp.php");
  78. class Sensor {
  79. /* should be A,B,C,D, ..., Z
  80. * there is some code, that caches readings for the web front-end and uses
  81. * a SQLite3 database, `radonCache.db` where a column puts some restrictions
  82. * on what the sensor name should be. Initially the sensor names were just
  83. * single characters, but this class allowed more than that. But now, with
  84. * `radonCache.db` name should be able to be converted into an id with a
  85. * value of [0..16777215]. Using [A..Z] is fine, it kind of limits us to
  86. * 24 different sensor clusters, but should be easy enough to add more with
  87. * a look-up-table or something
  88. */
  89. public $name;
  90. /* this is a serial number that identifies the device, some devices have
  91. * an electronically identifiable serial, eg: Bluetooth media access control
  92. */
  93. public $serial;
  94. public $sensors;
  95. public $options;
  96. public function __construct($name = '', $serial = '', $sensors = 0, $options = NULL) {
  97. $this->name = $name;
  98. $this->serial = $serial;
  99. $this->sensors = $sensors;
  100. // sensor option, `squishFactor`, is most commonly used option
  101. if(is_numeric($options)) $this->options = new SensorOptions((float) $options);
  102. else if("SensorOptions" === get_class($options)) $this->options = clone $options;
  103. else $this->options = NULL === $options ?new SensorOptions() :$options;
  104. }
  105. public function getRadon($qo) {
  106. if(NULL !== /* query options */ $qo) {
  107. $rows = CACHE_DB::getCachedData($this->name, "radon",
  108. $qo->zoom, $qo->fir, $qo->start, $qo->end
  109. );
  110. //return $rows;
  111. /* add the most recent non-cached radon reading [mySQL]
  112. *
  113. * [!] getting this reading is unbelievably slow, retrieving
  114. * ten thousand rows from the cache database takes the same
  115. * time to retrieve a single row from the mySQL radonLog, but
  116. * we pay the price since the cache database is infrequently
  117. * updated, it is a pity, and a bit disappointing
  118. */
  119. $lastReading = $this->radonLogQuery(/* get last reading */ true);
  120. if($lastReading) {
  121. if(defined("ARRAY_OF_TIME_READING_PAIRS")) {
  122. $rows[] = array_pop($lastReading);
  123. } else {
  124. $rows[0][] = $lastReading[0]["time"];
  125. $rows[1][] = $lastReading[0]["reading"];
  126. }
  127. }
  128. return $rows;
  129. } // else
  130. return $this->radonLogQuery();
  131. }
  132. private function radonLogQuery($lastReading = false) {
  133. // connect to mysql database radondb
  134. $db = mysql_connect("localhost", "root", "secom8703");
  135. mysql_select_db("radondb", $db);
  136. $sql = "SELECT time, reading FROM radonLog WHERE id='{$this->name}' ORDER BY time ASC";
  137. if($lastReading) {
  138. $sql = "SELECT time, reading FROM radonLog WHERE id='{$this->name}' ORDER BY time DESC LIMIT 1";
  139. }
  140. if(false === ($result = mysql_query($sql, $db))) { return false; }
  141. $rows = array();
  142. while($row = mysql_fetch_array($result, MYSQL_ASSOC)) { $rows[] = $row; }
  143. mysql_free_result($result);
  144. // mysql needs clean-up
  145. mysql_close($db);
  146. return $rows;
  147. }
  148. public function getHumidity($qo){return get_BME860_humidityData($this->name,$qo);}
  149. public function getFahrenheit($qo){return get_BME860_fahrenheitData($this->name,$qo);}
  150. public function getIaq($qo){return get_BME860_iaqData($this->name,$qo);}
  151. public function getUkrumayl($qo){return get_BME860_ukrumaylData($this->name,$qo);}
  152. public function getWesDust($qo){return get_BME860_wesDust($this->name,$qo);}
  153. /* requests for the monkey data, that is when a monkey records the temperature
  154. *
  155. * these sensors are all manually recorded, I do not see a reason at this
  156. * point to add query option related features to these datasets
  157. * - the data for these sensors are not stored in the cache database
  158. * - we are not reliable enough to input data at regular intervals
  159. * - because we record because we are reacting to weather, trend lines
  160. * will be extraordinarily misleading
  161. * - start end ranges are pointless because, again manual saves means
  162. * insignificant amount of recordings
  163. */
  164. public function getAuxiliary($qo, $kind) {
  165. switch($kind) {
  166. case "snow": return getSnowData();
  167. case "rain": return getRainData();
  168. case "temperature": return getTemperatureData();
  169. case "humidity": return getHumidityData();
  170. default: return array();
  171. }
  172. }
  173. }
  174. /* all queries should use query options
  175. *
  176. * - what date range are you looking for?
  177. * I want every single temperature sample from millions of recorded samples
  178. * starting from the beginning of time so that I can look at the last one
  179. * to see what the temperature is right now - probably a bad idea?
  180. * - how zoomed in is the user?
  181. * Is anyone interested in counting ant antenna viewing a wall sized image
  182. * of the entire earth? Most users ask for this, but we know that it
  183. * would be ridiculous to service this request; for viewing small details
  184. * you also want a proportionally small viewing window
  185. * - what is the interest of the user?
  186. * Maybe they want general trend lines
  187. * Maybe they just need to know the exact temperature an hour ago
  188. * Maybe they want atmospheric noise / currents for random number generation
  189. *
  190. * This is just here to standardize requests, we can add validation, and
  191. * whatever later
  192. */
  193. class QueryOptions {
  194. public $start;
  195. public $end;
  196. public $zoom;
  197. public $fir;
  198. // [!] defaults means that we do not want to use the cache
  199. public function __construct($start=false, $end=false, $zoom=NO_USE_RADON_CACHE, $fir=false) {
  200. $this->start = $start;
  201. $this->end = $end;
  202. $this->zoom = $zoom;
  203. $this->fir = $fir;
  204. }
  205. /* [?] setting zoom to false will cause `environmental.php`
  206. * `getColumnHelper` to retrieve data from the MySQL database.
  207. *
  208. * okay? okay.
  209. */
  210. public function useCache() { return NO_USE_RADON_CACHE !== $this->zoom; }
  211. }
  212. /* sensor type masks (_FANTASTIC_SENSOR_TYPE)
  213. * when adding a sensor type:
  214. * add a mask here
  215. * the interface to store and access the data is probably done in
  216. * `environmental.php`
  217. * look at the Dataset::typeIdLUT because you'll probably want to add something
  218. * there for improving performance of looking at samples.
  219. * SensorOptions::squishFactor should probably be extended to add a default
  220. * value for how much samples deviate from each other (sample noise).
  221. */
  222. define("RADON", 1);
  223. define("CELSIUS", 2);
  224. define("FAHRENHEIT", 4);
  225. define("UKRUMAYL", 8);
  226. define("IAQ", 16);
  227. define("WESDUST", 32);
  228. define("HUMIDITY", 64);
  229. /* see sensor type masks (_FANTASTIC_SENSOR_TYPE)
  230. * the interface in `db-frontend.php` does not understand sensor type
  231. * masks and this Class compresses them anyways (Dataset::getBit), so
  232. * we are just going to map the two things together here.
  233. *
  234. * when I was researching I found `environmental.php` that uses these
  235. * constants but the interface is pretty thin (just a set of identical
  236. * routines)
  237. *
  238. * the key is the bit mask offset, the value is the `radonLog` MYSQL
  239. * database column header
  240. *
  241. * [!] in PHP 5 define only allows scalar (int, float, string, bool, or null)
  242. * had to make a class just to do the same thing...
  243. * //define("TYPE_ID_TO_COL_HEADER_LUT", array(
  244. */
  245. Class TYPE_ID_TO_COL_HEADER {
  246. static public function LUT($type) {
  247. $lookUpTable = array(
  248. Dataset::getBit(RADON) => "radon",
  249. Dataset::getBit(CELSIUS) => "celsius",
  250. //Dataset::getBit(FAHRENHEIT) => "does not exist in database, see Celsius",
  251. Dataset::getBit(UKRUMAYL) => "pascal",
  252. Dataset::getBit(IAQ) => "indoor_air_quality",
  253. Dataset::getBit(WESDUST) => "gas_resistance",
  254. Dataset::getBit(HUMIDITY) => "humidity"
  255. );
  256. return $lookUpTable[$type];
  257. }
  258. }
  259. /* [!] not id
  260. *
  261. * external users do not use the id, they use masks, id is what is used by
  262. * Dataset internally and in the cached database
  263. *
  264. */
  265. Class COL_HEADER_TO_TYPE_MASK {
  266. static public function LUT($col) {
  267. $lookUpTable = array(
  268. "radon" => RADON,
  269. "celsius" => CELSIUS,
  270. "pascal" => UKRUMAYL,
  271. "indoor_air_quality" => IAQ,
  272. "gas_resistance" => WESDUST,
  273. "humidity" => HUMIDITY,
  274. );
  275. return $lookUpTable[$col];
  276. }
  277. }
  278. // sensor clusters
  279. define("BOSCH_BME680", CELSIUS | UKRUMAYL | IAQ | WESDUST | HUMIDITY);
  280. define("RADON_EYE", RADON);
  281. define("AIR_THINGS", RADON);
  282. // BME680 sensors H, I, J
  283. Class DEFAULT_BOSCH_BME680 {
  284. static public function SQUISH_FACTOR() {
  285. /* 2022 October - these initial values are a good starting place
  286. * and were calculated after a year of recording, getting the standard
  287. * deviation, and finally dividing by 100
  288. *
  289. * while these values probably offer very little in terms of reducing
  290. * the number of points needed represent a line, they are going to be
  291. * close, in a logarithmic sense, to where change occurs more frequently
  292. */
  293. return array(
  294. "celsius" => 6.0 / 100,
  295. "humidity" => 5.9 / 100,
  296. "pascal" => 745.0 / 100,
  297. "gas_resistance" => 250000.0 / 100,
  298. "indoor_air_quality" => 63.0 / 100,
  299. "iaq_accuracy" => 0.92 / 100
  300. );
  301. }
  302. }
  303. Class Dataset {
  304. public $parentSensorCluster;
  305. private $sensorId;
  306. public $mask;
  307. private $typeId;
  308. public $datasetId;
  309. private $zoom;
  310. private $fir;
  311. public $stats;
  312. public function __construct($sensor) {
  313. $this->parentSensorCluster = $sensor;
  314. // make sure name is converted to an integer number if it is a letter
  315. $this->sensorId = Dataset::toSensorId($sensor->name);
  316. $this->stats = new DatasetStatistics();
  317. }
  318. /* because our parent is a cluster of sensors, we use late binding to
  319. * split the cluster into individual sensors. This is somewhat
  320. * obscure so users might accidentally create a Dataset without proper
  321. * initialization (for each sensor in the sensor mask bit field, each
  322. * zoom, and each fir, create a Dataset, see:
  323. * Dataset::makeAllDatasetsFromInternet(...) for an example
  324. *
  325. * applies to:
  326. * Dataset::getTypeName()
  327. * Dataset::getZoom()
  328. * Dataset::getFIR()
  329. */
  330. private function errNotReady($property) {
  331. throw new Exception(
  332. "Dataset {$property} is not known until after " .
  333. __CLASS__ .
  334. "->setDatasetId is called."
  335. );
  336. }
  337. // returns sensor id
  338. public function getInternalSensorId() { return $this->sensorId; }
  339. // returns database header type as text, (Celsius, radon, etc)
  340. public function getTypeName() {
  341. if(!isset($this->typeId)) $this->errNotReady("type");
  342. return TYPE_ID_TO_COL_HEADER::LUT($this->typeId);
  343. }
  344. public function getZoom() {
  345. if(!isset($this->zoom)) $this->errNotReady("zoom");
  346. return $this->zoom;
  347. }
  348. public function getFIR() {
  349. if(!isset($this->fir)) $this->errNotReady("fir");
  350. return $this->fir;
  351. }
  352. static public function toSensorId($name) {
  353. if(is_int($name)) return $name;
  354. if(ctype_alpha($name)) return ord($name);
  355. throw new Exception(
  356. "radonCache.db wants sensor names to be convertible into integer type"
  357. );
  358. }
  359. public function setDatasetId($sensorMask, $zoom, $fir) {
  360. $this->zoom = $zoom;
  361. $this->fir = $fir;
  362. $this->mask = $sensorMask;
  363. $this->typeId = Dataset::getBit($sensorMask);
  364. $this->datasetId = Dataset::generateDatasetId(
  365. $this->sensorId,
  366. $this->typeId,
  367. $zoom,
  368. $fir
  369. );
  370. return $this->datasetId;
  371. }
  372. /* converts sensor type (masks) to a more compressed format, eg: `HUMIDITY` is
  373. * 6. Sensor type masks are kinda clunky but make it easy to specify a sensor
  374. * type as well as a cluster of sensors as a single named thing, like
  375. * `BOSCH_BME680`. The only problem is that we may want to add more sensor
  376. * types later and the `sensorLog` in `radonCache.db` has limited space to
  377. * store unique sensor identifying information. There are 8 bits to store 255
  378. * unique sensor types and 24 bits to store 16,777,215 unique sensor clusters
  379. *
  380. * using sensor type masks directly would only allow 8 unique sensor types
  381. *
  382. * see: _FANTASTIC_SENSOR_TYPE
  383. */
  384. static public function getBit($sensorTypeMask, $o = 0) {
  385. while(++$o) if(!intval($sensorTypeMask /= 2)) return $o - 1;
  386. }
  387. /* each cluster may have multiple sensors, but are stored in a bit-field
  388. * we need a way to split the bit field into separate sensors without knowing
  389. * what the sensors are, this is fine, we do not care if it is a RADON or
  390. * HUMIDITY sensor, each have their own bit indice, we just need to know if
  391. * one or both, or some other sensor
  392. */
  393. static public function getSensorMasks($sensorBitField) {
  394. $arr = array();
  395. $idx = /* sensor type masks start at one */ 1;
  396. while(true) {
  397. if(1 /* bit-wise AND */ & $sensorBitField) $arr[] = $idx;
  398. // shift bits to the right
  399. $sensorBitField >>= 1;
  400. // shift indice bit to the left
  401. $idx <<= 1;
  402. if(0 === $sensorBitField) break;
  403. }
  404. return $arr;
  405. }
  406. /* just in case SQLITE3 INTEGERS or whatever language we are using is
  407. * signed, we can only use 31 of 32 bits, here is one possibility:
  408. *
  409. * 14 bits = 16,384 sensors
  410. * 7 bits = 128 types of sensors
  411. * 5 bits = 32 different zooms
  412. * 5 bits = 32 different FIR
  413. */
  414. static public function generateDatasetId($sensorName, $sensorType, $zoom, $fir) {
  415. // restrict variables to prevent overlap in bitfield
  416. if((0 > $sensorName) || (16383 < $sensorName))
  417. throw new Exception("sensor name must be less than 16384");
  418. if((0 > $sensorType) || (127 < $sensorType))
  419. throw new Exception("sensor type mask must be less than 128");
  420. if((0 > $zoom) || (31 < $zoom))
  421. throw new Exception("zoom must be less than 32");
  422. if((0 > $fir) || (31 < $fir))
  423. throw new Exception("fir must be less than 32, was [{$fir}]");
  424. return
  425. (131072 * $sensorName) |
  426. (1024 * $sensorType) |
  427. (32 * $zoom) |
  428. (1 * $fir);
  429. }
  430. /* flatten out all combinations of sensors, zoom, and fir
  431. * also break individual sensors out of their clusters
  432. *
  433. * [?] with three BOSCH_BME680, three AIR_THINGS, and two RADON_EYE
  434. * sensors, three zoom and five fir we should have 300 combinations
  435. * of different data sets
  436. *
  437. * [?] this is just a helper function to abstract the messy stuff regarding
  438. * making all the different datasets for each sensor, uses TheInternet
  439. * to initialize all the different identifying properties for each sensor
  440. * we get an array of all the different properties of each dataset
  441. */
  442. static public function makeAllDatasetsFromInternet($internet) {
  443. $sensors = $internet->cloneAllSensors();
  444. $datasets = array();
  445. foreach($sensors as $cluster) {
  446. $nameRule = false || is_int($cluster->name) ||
  447. (ctype_alpha($cluster->name) && 1 === strlen($cluster->name));
  448. // ignore names that we cannot handle
  449. if(false === $nameRule) continue;
  450. /* separate clusters of sensors, a cluster contains a bit-field that
  451. * marks which sensors are onboard, for example a BOSCH_BME860 has
  452. * air quality sensor, temperature sensor, etc
  453. */
  454. foreach(Dataset::getSensorMasks($cluster->sensors) as $sensorMask) {
  455. if(false) var_dump(
  456. "sensor: {$cluster->name}",
  457. Dataset::toSensorId($cluster->name),
  458. "mask: {$sensorMask}"
  459. );
  460. // and another for each fir
  461. foreach($internet->availableFIR as $fir) {
  462. // a dataset is created for each zoom
  463. foreach($internet->availableZoom as $zoom) {
  464. $dataset = new Dataset($cluster);
  465. $dataset->setDatasetId($sensorMask, $zoom, $fir);
  466. if(false) var_dump(
  467. "dataset zoom[{$zoom}] fir[{$fir}]: ",
  468. $dataset->setDatasetId($zoom, $fir)
  469. );
  470. // store each dataset in a flattened array
  471. $datasets[] = $dataset;
  472. }
  473. }
  474. }
  475. }
  476. /* with the messy stuff handled, we can work on all datasets without worrying
  477. * about the specifics of each one
  478. */
  479. if(false) var_dump(count($datasets));
  480. return $datasets;
  481. }
  482. // helper routine to get filtered and simplified data for a given dataset
  483. static public function retrieveReducedData($dataset, $start = false, $end = false, &$cache = NULL) {
  484. // get all input data (or partial if a start and end range are given)
  485. if((NULL === $cache) or (NULL === $cache->rows)) {
  486. // we need an interface to get data from the database
  487. $data = new DataSqueeze();
  488. $cache = new CachedDatasetData($data->getRows(
  489. $dataset->parentSensorCluster,
  490. $dataset->getTypeName(),
  491. $start,
  492. $end
  493. ));
  494. $cache->sql = $data->sql;
  495. } // otherwise use cached rows
  496. if(defined("PRINT_DATA_SOURCE_SQL")) var_dump("sql: {$cache->sql}");
  497. // run the FIR filter on our data, try to use cached work
  498. if(NULL === $cache->filtered) {
  499. // figure out the default FIR delay
  500. $delay = $dataset->parentSensorCluster->options->getSampleDepth();
  501. // update the default FIR with this dataset's multiplier
  502. $delay *= FIR_DELAY_MULTIPLIER::LUT($dataset->fir);
  503. // get the sample period for this sensor (to regenerate data)
  504. $period = $dataset->parentSensorCluster->options->getSamplePeriod();
  505. $cache->filtered = DataSqueeze::simpleFIR($cache->rows, $delay, $period);
  506. /* this is not really needed, but probably will not trigger under normal
  507. *
  508. * if it does, might be helpful to a debugger
  509. */
  510. if(0 === count($cache->filtered[0])) {
  511. var_dump("[!] there were {$cache->rowCount} but no rows after filtering");
  512. for($i = count($cache->someRows); $i--;) {
  513. if(!isset($cache->someRows[0][$i])) break;
  514. echo(
  515. "time {$cache->someRows[0][$i]} " .
  516. "reading {$cache->someRows[1][$i]}" . PHP_EOL
  517. );
  518. }
  519. var_dump("[!] there is a problem, probably simple FIR to blame");
  520. var_dump("delay was [{$delay}] period was [{$period}]");
  521. return array();
  522. }
  523. }
  524. // initialize algorithm to reduce number of points needed to represent dataset
  525. $rdp = new RamerDouglasPeucker($cache->filtered);
  526. // get predefined epsilon for this type of sensor
  527. $epsilon = $dataset->parentSensorCluster->options->getSquishFactor(
  528. $dataset->getTypeName()
  529. );
  530. if(defined("USE_SMALLER_FLOATS")) {
  531. $epsilon /= 100.0;
  532. }
  533. if(defined("USE_INTEGER_MATHS")) {
  534. $epsilon = intval($epsilon * 100);
  535. }
  536. // recompute epsilon baseline using FIR to bias adjustment
  537. $epsilon *= FIR_DELAY_MULTIPLIER::SQUISH_FACTOR_ADJUSTMENT($dataset->fir);
  538. $res = $rdp->getRDP($epsilon);
  539. // update statistics
  540. $dataset->stats->epsilon = $epsilon;
  541. $dataset->stats->rowCount = $cache->rowCount;
  542. $dataset->stats->filteredCount = count($cache->filtered[0]);
  543. $dataset->stats->rdpCount = count($res);
  544. $dataset->stats->rdpPerpendicular = $rdp->called;
  545. return $res;
  546. }
  547. }
  548. /* minimal structure that can be saved between calls to Dataset::retrieveReducedData
  549. * in order to save CPU time
  550. */
  551. class CachedDatasetData {
  552. public $filtered = NULL;
  553. public $rows = NULL;
  554. public $sql;
  555. // since rows are cleared, this must be saved so it can later be reported on
  556. public $rowCount;
  557. // some of the rows are saved, to help facilitate debugging
  558. public $someRows;
  559. public function __construct($rows = NULL) {
  560. if(NULL !== $rows) {
  561. $this->someRows = array(array(), array());
  562. // copy 10 rows
  563. for($i = 10; $i--;) {
  564. if(!isset($rows[0][$i])) break;
  565. $this->someRows[0][$i] = $rows[0][$i];
  566. $this->someRows[1][$i] = $rows[1][$i];
  567. }
  568. $this->rows = $rows;
  569. $this->rowCount = count($rows[0]);
  570. }
  571. }
  572. }
  573. class DatasetStatistics {
  574. public $rowCount;
  575. public $filteredCount;
  576. public $rdpCount;
  577. public $epsilon;
  578. public $rdpPerpendicular;
  579. }
  580. /* sensor options
  581. *
  582. * we started running into issues with the sensors collecting too much data,
  583. * or at least too much for our hardware's bandwidth
  584. *
  585. * each sensor needs to be looked at more carefully, and the data that it
  586. * produces needs to be interpreted by a computer before a person looks at it
  587. *
  588. * to keep the sensors together in the same place (this document), the list
  589. * of sensors needs to accommodate additional properties being added to sensors
  590. */
  591. class SensorOptions {
  592. /* the epsilon that will be used for this sensor when data is processed
  593. * by the Ramer-Douglas-Peucker algorithm
  594. * each sensor will have it's own epsilon depending on it's minimum,
  595. * maximum, and overall signal noise
  596. */
  597. private $squishFactor;
  598. /* read the documentation for your sensor carefully, or maybe even contact
  599. * the manufacturer of the sensor to find out how many samples need to be
  600. * collected before one can get the advertised precision
  601. */
  602. private $sampleDepth;
  603. /* used to regenerate or decompress data stored on disk, the sample period
  604. * is the time in between samples, this is generally set by the individual
  605. * sensors, a scheme was created to save disk space by only saving samples
  606. * when a value changes from the previous value. This value can be determined
  607. * by finding the smallest period between any two samples, but explicitly
  608. * setting it saves us the work.
  609. */
  610. private $samplePeriod;
  611. public function __construct($squishFactor = NULL, $sampleDepth = 0, $samplePeriod = 1) {
  612. /* some standard deviations for sensors after running for several
  613. * months (the /10 is not part of the standard deviation, it is
  614. * used to give a "wide birth", we are giving ten times the estimated
  615. * needed detail when drawing simplified lines)
  616. *
  617. * these are used to help draw simplified lines or graphs of sensor
  618. * data. the value represents the distance a simplified line can be
  619. * from a reading before it needs to be redrawn.
  620. *
  621. * each sensor captures reading using different units, for example the
  622. * radon sensor might capture picocurries per liter and store values
  623. * as an integer resulting in values covering two or three magnitudes,
  624. * where an air quality sensor might capture parts per million with
  625. * values spanning more than six magnitudes.
  626. *
  627. * for complex sensors like the BME680 where each sensor is made of
  628. * multiple sensors, we use an array to represent each squish factor
  629. * per individual type of sensor, for the radon only sensors, initialize
  630. * squish factor as a (double) or (float), we will take care of the rest
  631. *
  632. * also for by default just use these pre-recorded epsilon values for
  633. * all BME680 sensors
  634. */
  635. $this->squishFactor = NULL === $squishFactor
  636. ? DEFAULT_BOSCH_BME680::SQUISH_FACTOR()
  637. : array("radon" => $squishFactor)
  638. ;
  639. $this->sampleDepth = intval($sampleDepth);
  640. $this->samplePeriod = intval($samplePeriod /* seconds */);
  641. }
  642. public function setSamplePeriod($period = 1) {
  643. $this->samplePeriod = intval($period /* in seconds */);
  644. }
  645. // gets the number of seconds between samples
  646. public function getSamplePeriod() {
  647. return $this->samplePeriod;
  648. }
  649. public function setSampleDepth($samples = 0) {
  650. $this->sampleDepth = intval($samples);
  651. }
  652. // gets the amount of delay that should be used for a simple FIR filter
  653. public function getSampleDepth() {
  654. return $this->sampleDepth;
  655. }
  656. // gets appropriate epsilon for sensor reading Ramer-Douglas-Peucker simplification
  657. public function getSquishFactor($type = "radon") {
  658. /* radon type sensors each have their own epsilon, they are placed in
  659. * areas where radon fluctuates at different rates, using the same
  660. * epsilon might lead to missing small changes or getting too many
  661. * data-points
  662. */
  663. return !isset($this->squishFactor[$type])
  664. ? 0.001
  665. : $this->squishFactor[$type];
  666. }
  667. }
  668. /* zooms are cached, these names identify three views of the same data
  669. * that represent different data-sets that appear similar to each other
  670. */
  671. define("ZOOM_96_HOURS", 1);
  672. define("ZOOM_MONTH", 2);
  673. define("ZOOM_YEAR", 3);
  674. define("ZOOM_DEFAULT", ZOOM_96_HOURS);
  675. /* [!] not a valid "zoom", but is the default query option that disables
  676. * the use of the cache and all features that come with it
  677. */
  678. define("NO_USE_RADON_CACHE", false);
  679. /* finite impulse responses are cached, these names identify views of the same
  680. * data that represent data-sets that are distinct from each other but share
  681. * similar trends
  682. */
  683. define("FIR_OFF", 0);
  684. define("FIR_1_HOUR", 1);
  685. define("FIR_96_HOURS", 2);
  686. define("FIR_MONTH", 3);
  687. define("FIR_YEAR", 4);
  688. define("FIR_DEFAULT", FIR_1_HOUR);
  689. /* used to increase the delay by some multiple in order to increase visibility
  690. * of trend lines in noisy signals
  691. */
  692. class FIR_DELAY_MULTIPLIER {
  693. static public function LUT($fir) {
  694. $arr = array( /* these are unit-less, but we
  695. assume other operand is an hour */
  696. FIR_OFF => /* 0 */ 0,
  697. FIR_1_HOUR => /* 1 */ 1,
  698. FIR_96_HOURS => /* 96 */ 8,
  699. FIR_MONTH => /* 744 */ 62,
  700. FIR_YEAR => /* 8760 */ 730,
  701. // see above: copy the FIR_1_HOUR value
  702. FIR_DEFAULT => /* */ 1
  703. );
  704. return isset($arr[$fir]) ? $arr[$fir] : $arr[FIR_DEFAULT];
  705. }
  706. static public function SQUISH_FACTOR_ADJUSTMENT($fir) {
  707. $arr = array(
  708. FIR_1_HOUR => 1.0,
  709. FIR_96_HOURS => 10.0,
  710. FIR_MONTH => 100.0,
  711. FIR_YEAR => 1000.0,
  712. /* [!] squish factor is only used for RDP, all values in cache are
  713. * subject to the RDP algorithm, we do not have enough space
  714. * to store all decompress corrected readings from sensors
  715. */
  716. FIR_OFF => 1.0,
  717. FIR_DEFAULT => 1.0
  718. );
  719. return isset($arr[$fir]) ? $arr[$fir] : $arr[FIR_DEFAULT];
  720. }
  721. }
  722. /* necessary data caching
  723. *
  724. * After testing the filter and RDP algorithm I found that the RadonEye and other
  725. * devices that save data every five minutes takes too much CPU time. The original
  726. * method for displaying data to the end user had some deficiencies, for example
  727. * the data was saved only when a change was discovered, the previous output
  728. * would extrapolate a line between the two saved values, but as all sampled data
  729. * should have only displayed the captured data points. To incorporate extrapolated
  730. * lines we need to consider all points sampled between changes, then decide a
  731. * strategy for extrapolating a line. For sensor 'R', 95 thousand samples it
  732. * takes about 7 seconds to regenerate / decompress missing samples, and run all
  733. * samples through RDP, where nearly 2 million square root calcuations are
  734. * performed. Using more RAM does speed up the process but with the limitation
  735. * of 128MB under PHP we'd need around 3 orders of magnitude more RAM. (128
  736. * Gigabytes would be reasonable)
  737. *
  738. * Even with the increased RAM we'd only improve speed by ten, definitely less
  739. * than a hundred giving us unacceptable delay. For example ten years down the
  740. * road means 70 seconds, divide by less than 1 hundred is going to be around
  741. * a second for a single sensor.
  742. *
  743. * So we need to cache.
  744. *
  745. * What should be cached:
  746. * zoom: 3 days, month, year
  747. * levels of FIR: same as above, but additionally default
  748. *
  749. * zoom has to do with how many data points are provided to the end user, affects
  750. * should not be noticeable to an end user, but data is heavily modified, this
  751. * is a form of down-sampling
  752. *
  753. * FIR levels have to do with overall trends, affects are noticeable to an end
  754. * user, this is a form of averaging in the time domain
  755. *
  756. * there will be zoom * levels different data-sets per sensor, so [12] total
  757. * per sensor, but, those 12 sets will still be smaller than the original data,
  758. * I hope.
  759. *
  760. * sensorLog table in SQLite3 cached database:
  761. * id, time, sensor, dataset, value
  762. * dataset is a multiple of 128 and the zoom, then sum with the level of FIR
  763. * sensor is a multiple of 128 and the name, then sum with the sensor type
  764. *
  765. * sensorLog combines both the radonLog and the boche_bme680 tables, data
  766. * is interpolated and may change from minute to minute. Because of the
  767. * interpolation timestamps for a given event may not match exactly with the
  768. * original data, values for a given event may not match exactly values in the
  769. * original data, the goal of the downsampling and trend averaging techniques
  770. * is to represent the signal in the ways that is most useful to an end user
  771. * down sampling has to be cached at multiple sample depths to prevent a user
  772. * from noticing the "missing" data, trends have to be cached at different FIR
  773. * levels to provide different views of the same data to a user so that they
  774. * can see the signal through the noise
  775. *
  776. * create an ephemeral database that has less data, this way the end user can
  777. * continue to request `all` of the possible data
  778. */
  779. class CACHE_DB {
  780. const DB_FILE = "radonCache.db";
  781. const DATASET_INDEX =
  782. "CREATE INDEX IF NOT EXISTS datasetIndex ON sensorLog(dataset);";
  783. const TIME_INDEX =
  784. "CREATE INDEX IF NOT EXISTS timeIndex ON sensorLog(time);";
  785. /* when querying, always use `time` and `dataset` column to make use of index
  786. */
  787. static private function QUERY($query = NULL, $order = "") {
  788. // setup a default query
  789. if(NULL === $query) {
  790. $query = "SELECT time, reading";
  791. $order = "ORDER BY time ASC";
  792. }
  793. return "
  794. {$query}
  795. FROM sensorLog
  796. WHERE
  797. dataset = :dataset
  798. AND time >= :start
  799. AND time <= :end
  800. {$order};
  801. ";
  802. }
  803. static private function CREATE($disk = false) {
  804. return "
  805. CREATE TABLE IF NOT EXISTS sensorLog(
  806. time INTEGER NOT NULL,
  807. dataset INTEGER NOT NULL,
  808. reading FLOAT" . ($disk ? ", \nUNIQUE(time, dataset)" : "") ."
  809. );
  810. ";
  811. }
  812. /* cluster is what we call the sensor name, naming scheme has been: 'A', 'B',
  813. * 'C', etc. This rule has been solidified as the cached database, `radonCache.db`
  814. * uses a scheme to store all sensor readings in a single table with a single
  815. * column identifying both which sensor and from what cluster a reading came
  816. * from.
  817. *
  818. * cluster is a sensor that is actually multiple sensors, a single device that
  819. * because the sensors are soldered to it, that it is impossible to move
  820. * individual sensors without moving the other sensors in the cluster
  821. *
  822. * the sensor type identifies which sensor in the sensor cluster that data is
  823. * being requested for
  824. *
  825. * zoom is what data the user is looking at
  826. * - ZOOM_DEFAULT
  827. * - ZOOM_96_HOURS
  828. * - ZOOM_MONTH
  829. * - ZOOM_YEAR
  830. * fir is how much filtering the user wants
  831. * - FIR_DEFAULT
  832. * - FIR_OFF
  833. * - FIR_1_HOURS
  834. * - FIR_96_HOURS
  835. * - FIR_MONTH
  836. * - FIR_YEAR
  837. */
  838. static public function getCachedData($clusterName, $sensorType, $zoom = ZOOM_DEFAULT, $fir = FIR_DEFAULT, $start = 0, $end = PHP_INT_MAX) {
  839. $dbDisk = new SQLite3(CACHE_DB::DB_FILE);
  840. $diskSelect = $dbDisk->prepare(CACHE_DB::QUERY());
  841. /* cluster can either be a letter: 'A', 'B', 'C', etc
  842. * or the integer id of the sensor
  843. */
  844. $sensorId = Dataset::toSensorId($clusterName);
  845. /* sensor type can either be string: 'radon', 'celsius', etc
  846. * or mask:: RADON, CELSIUS, etc
  847. */
  848. $mask = is_int($sensorType)
  849. ? $sensorType
  850. : COL_HEADER_TO_TYPE_MASK::LUT($sensorType);
  851. $typeId = Dataset::getBit($mask);
  852. $datasetId = Dataset::generateDatasetId($sensorId, $typeId, $zoom, $fir);
  853. // update our SQL statement
  854. $diskSelect->bindValue("dataset", $datasetId);
  855. $diskSelect->bindValue("start", $start);
  856. $diskSelect->bindValue("end", $end);
  857. // run it
  858. $results = $diskSelect->execute();
  859. // PHP does not do so well storing non-scalar values
  860. $rows = array(array(/* times */), array(/* readings */));
  861. /* get results
  862. * cached database values are not aware of MySQL integer based storage
  863. * scheme, where integers are used in place of floating point values
  864. * by multiplying the float by 100 and then removing all decimal points
  865. *
  866. * the cached database stores FIR values of the above MySQL values
  867. * which reintroduces unnecessary precision (any precision beyond two
  868. * decimal points) and stores those values as float
  869. *
  870. * [!] make sure to get rid of the unnecessary precision, it is
  871. * wasteful and misleading - round readings to no decimal points
  872. * or maybe just convert to integers
  873. */
  874. while($row = $results->fetchArray()) {
  875. $rows[0][] = $row["time"];
  876. $rows[1][] = intval($row["reading"],0);
  877. }
  878. // done unless we need to support older frontends
  879. if(false === defined("ARRAY_OF_TIME_READING_PAIRS")) return $rows;
  880. /* CACHE_DB uses reduced memory: array([time array], [reading array])
  881. * the frontend was written before this optimization was found, so that
  882. * nothing needs to be changed in the frontend code we would need to
  883. * put the results back to a format that matches -or- we could optimze
  884. * the frontend
  885. *
  886. * I am certainly being lazy, I don't want to look through the frontend
  887. * code, the optimizations are probably not even relevant on versions
  888. * of PHP older than 5.3, for which the optimizations were written
  889. *
  890. * [?] I think the memory limits have been lifted, so re-inflating the
  891. * array without changes to the frontend should be fine, besides
  892. * that, less memory is uses retrieving data than processing it
  893. */
  894. $arr = array();
  895. for($i = count($rows[0]); $i--;)
  896. $arr[] = array(
  897. "time"=>array_pop($rows[0]),
  898. "reading"=>array_pop($rows[1])
  899. );
  900. return array_reverse($arr);
  901. }
  902. /* [?] 20 sensors, about 10 months of data takes about 25 minutes to process on
  903. * an 800MHz CPU and 16 Gigabyte RAM, produces a 17 Megabyte file
  904. *
  905. * After testing to see if one can see the difference between compressed
  906. * data and uncompressed data and determining if there was value in
  907. * filtering input data we needed a way to validate if it was possible to
  908. * bring these features to the frontend. Here we create all the different
  909. * outputs from our input data and store them to disk in a cache. Various
  910. * ways of processing and storing the data are explored. Currently it takes
  911. * a bit too long to generate all the data, with optimizations it went from
  912. * 25 minutes to 13 minutes.
  913. *
  914. * A feature was added to partially update the cache, this makes it so one
  915. * can update a single sensor in less than a second. Still pretty slow.
  916. *
  917. * The time needed to store data is dominated by the need to update, with
  918. * updates, actually the DELETE taking almost all the CPU time. Processing
  919. * data is also slow, with the PHP implementation of the algorithm being
  920. * really slow.
  921. *
  922. * some notes about SQLite3 database optimizations - adding indices
  923. * increases disk space usage:
  924. * 36 Megabytes with UNIQUE constraint on dataset and time
  925. * 48 Megabytes with INDEX on dataset
  926. * 43 Megabytes with INDEX on time and dataset (no UNIQUE constraint)
  927. *
  928. * the fourth parameter, clusterName was added to allow a single cluster
  929. * to do a partial update on just it's sensors. It is important to set
  930. * reasonable start and end times when doing a partial update, this is
  931. * somewhat complicated to figure out and currently not implemented well
  932. * as one would probably want to vary the start and end times based on
  933. * the level of finite impulse response filtering (this is kinda handled
  934. * internally, but you still need to provide a good start time)
  935. *
  936. * We added an additional Finite Impulse Response filter, FIR_OFF, this
  937. * means there are more datasets, the disk requirements doubled and I
  938. * think we are sitting at just under 100 Megabytes for the cache after
  939. * one year of recording
  940. */
  941. static public function createCacheDb($startTime = 0, $endTime = PHP_INT_MAX, $reset = false, $clusterName = false) {
  942. //define("PRINT_DATA_SOURCE_SQL", NULL);
  943. /* this seems to make things worse, possibly because larger epsilons passed
  944. * to RDP algorithm are taking longer to compute
  945. *
  946. * this makes the database bigger (possibly)
  947. * and does not improve performance
  948. */
  949. //define("USE_SMALLER_FLOATS", NULL);
  950. /* this does not seem to help much: 23 minutes
  951. * without we get nearly 25 minutes
  952. */
  953. //define("USE_INTEGER_MATHS", NULL);
  954. /* storing rows in memory between look-ups saves a very small amount of
  955. * time when compared to the amount of time doing other work
  956. *
  957. * investigated when storing 12 datasets for each of all sensors was taking
  958. * between 25 minutes and 45 minutes to complete
  959. *
  960. * (the time difference seems to be the chosen epsilon and RDP algorithm
  961. * where larger epsilons are taking longer to compute)
  962. *
  963. * might save a minute or two
  964. */
  965. //define("VERBOSE_DB_INSERT_LOGGING", NULL);
  966. define("USE_CACHED_ROWS", NULL);
  967. define("USE_CACHED_FIR", NULL);
  968. /* improves DELETE which is used a bit when doing partial updates to
  969. * database. It does increase disk space used by 30% but gives a big
  970. * boost to speed. I am not sure why the DELETE was not using the UNIQUE
  971. * constraint index, but whatever. Normally DELETE does not use a large
  972. * portion of time, but in the case where we are doing a partial update
  973. * and deleting two rows and adding a couple the DELETE operation was
  974. * consuming 98% of the CPU time.
  975. */
  976. define("USE_INDEX_ON_DATASET", NULL);
  977. /* the unique constraint gives an SQL index when queries use the `time`
  978. * and `dataset` columns. We pay a price for this when writing new
  979. * entries to the database, but gain speed whenever we read
  980. *
  981. * also we never query the :memory: database so creating indices does not
  982. * make sense, only do this for the disk backed database
  983. */
  984. //define("USE_CONSTRAINT", NULL);
  985. define("USE_INDEX_ON_TIME", NULL);
  986. define("FLUSH_DB_AT_ONCE", NULL); // faster, uses more RAM
  987. $CREATE = function($disk = false) { return "
  988. CREATE TABLE IF NOT EXISTS sensorLog(
  989. time INTEGER NOT NULL,
  990. dataset INTEGER NOT NULL,
  991. reading FLOAT" . ($disk ? ", \nUNIQUE(time, dataset)" : "") ."
  992. );
  993. "; };
  994. /* [?] maybe print a warning later if database exists and user wants to
  995. * perform a full cache refresh (this is the most probable use, but
  996. * also the most wasteful)
  997. *
  998. * [??] regarding disk backed using magnetic VS ssd VS ramdisk
  999. * `sudo mount -t tmpfs -o rw,size=64M tmpfs tmpfs` did not improve
  1000. * SQLite3 performance, so we are hitting CPU before disk
  1001. */
  1002. if(true === $reset) unlink(CACHE_DB::DB_FILE);
  1003. $fileExists = file_exists(CACHE_DB::DB_FILE);
  1004. $dbDisk = new SQLite3(CACHE_DB::DB_FILE);
  1005. $db = new SQLite3(":memory:");
  1006. /* enable the write ahead log for disk backed storage
  1007. * this does not help the :memory: backed storage as it cannot use `wal`
  1008. * after testing this it does not seem like these optimizations do not
  1009. * seem to help me
  1010. */
  1011. //$dbDisk->exec("PRAGMA journal_mode = wal;");
  1012. //$dbDisk->exec("PRAGMA synchronous = NORMAL;");
  1013. // double speed on write, but power failure during write will corrupt db
  1014. $dbDisk->exec("PRAGMA synchronous = OFF;");
  1015. // set 64Mb cache (up from 2Mb default) - this does not seem to help
  1016. //$dbDisk->exec("PRAGMA cache_size = -64000;");
  1017. $db->exec(CACHE_DB::CREATE());
  1018. $dbDisk->exec(CACHE_DB::CREATE(/* unique index */ defined("USE_CONSTRAINT")));
  1019. if(defined("USE_INDEX_ON_DATASET")) $dbDisk->exec(CACHE_DB::DATASET_INDEX);
  1020. if(defined("USE_INDEX_ON_TIME")) $dbDisk->exec(CACHE_DB::TIME_INDEX);
  1021. // temporarily give memory db access to the disk db
  1022. $db->exec("ATTACH DATABASE './" . CACHE_DB::DB_FILE . "' AS disk");
  1023. // prepare all queries, use standard prepared interface
  1024. $diskSelect = $dbDisk->prepare(CACHE_DB::QUERY(/* use defaults */));
  1025. $diskDelete = $dbDisk->prepare(CACHE_DB::QUERY("DELETE"));
  1026. $diskDeleteCheck = $dbDisk->prepare(CACHE_DB::QUERY("SELECT COUNT(*)"));
  1027. // setup the column mapping for inserts
  1028. $arr = array("time", "dataset", "reading");
  1029. $cols = implode(", ", $arr);
  1030. array_walk($arr, function (&$v) { $v = ":{$v}"; });
  1031. $bindings = implode(", ", $arr);
  1032. // [!] careful, importing data uses PHP bound variables
  1033. $boundTime; $boundDataset; $boundReading;
  1034. $stmt = $db->prepare("INSERT INTO sensorLog({$cols}) VALUES({$bindings})");
  1035. $stmt->bindParam(":time", $boundTime, SQLITE3_INTEGER);
  1036. $stmt->bindParam(":dataset", $boundDataset, SQLITE3_INTEGER);
  1037. $stmt->bindParam(":reading", $boundReading, SQLITE3_FLOAT);
  1038. // TheInternet has a list of all the sensor clusters
  1039. $internet = new TheInternet();
  1040. // expand the list into all possible views of the data (Datasets)
  1041. $datasets = Dataset::makeAllDatasetsFromInternet($internet);
  1042. // see: USE_CACHED_ROWS
  1043. $cache = new CachedDatasetData();
  1044. $previous = array(NULL, NULL, NULL);
  1045. // work on all datasets -- unless profiling for speed optimizations
  1046. //define("PROFILE_S_SLOWEST", NULL);
  1047. // allow partial updates for just a single cluster of sensors
  1048. if(false !== $clusterName) $clusterName = Dataset::toSensorId($clusterName);
  1049. foreach($datasets as $dataset) {
  1050. /* make sure start and end are integers before we go stuffing those
  1051. * values into SQL
  1052. * [?] also since start time is modified, re-initialize it every
  1053. * iteration
  1054. */
  1055. $start = intval($startTime);
  1056. $end = intval($endTime);
  1057. $lastSameTimestamp = $start;
  1058. if(defined("PROFILE_S_SLOWEST") or (false !== $clusterName)) {
  1059. $skip = false;
  1060. if($clusterName !== $dataset->getInternalSensorId()) $skip = true;
  1061. if('S' !== $dataset->parentSensorCluster->name) $skip = true;
  1062. //if(FIR_DEFAULT !== $dataset->getFIR()) $skip = true;
  1063. //if(FIR_YEAR !== $dataset->getFIR()) $skip = true;
  1064. if($skip) {
  1065. //echo "skipping..." . PHP_EOL;
  1066. continue;
  1067. }
  1068. }
  1069. /* we should backtrack the time a little bit to try to reduce
  1070. * discontinuity when doing a partial update to the cache
  1071. *
  1072. * this is a minimum, it is more than likely that the database will
  1073. * not have a reading at this timestamp, and there is a somewhat
  1074. * likely chance that the reading after this timestamp will be so far
  1075. * away that the amount of data going into the filter would not be
  1076. * enough to fill it
  1077. *
  1078. * TODO:
  1079. * we should make sure the filter knows our start time so it can start
  1080. * there...
  1081. */
  1082. if(0 !== $start) {
  1083. $seconds = FIR_DELAY_MULTIPLIER::LUT($dataset->getFIR()) *
  1084. 60 /* minutes in an hour */ *
  1085. 60 /* seconds in an hour */
  1086. ;
  1087. //var_dump("need to go back {$seconds} seconds before {$start}");
  1088. if($start >= $seconds) $start -= $seconds;
  1089. }
  1090. $filterDepth = $dataset->parentSensorCluster->options->getSampleDepth();
  1091. $filterDepth *= FIR_DELAY_MULTIPLIER::LUT($dataset->getFIR());
  1092. /* using a row cache means we can skip SQL reads when a dataset uses
  1093. * the same rows
  1094. *
  1095. * [?] if we are careful about how the datasets are organized we can
  1096. * hit this optimization more often, worse case we might miss this
  1097. * optimization every time ::frown face::
  1098. *
  1099. * use some truth tables to determine if we can use cached data
  1100. */
  1101. $previousCluster = $dataset->getInternalSensorId() === $previous[0];
  1102. $previousType = $dataset->getTypeName() === $previous[1];
  1103. $previousDepth = $filterDepth === $previous[2];
  1104. if(defined("USE_CACHED_ROWS") and NULL !== $cache->rows) {
  1105. $cache->rows = ($previousCluster and $previousType)
  1106. ? /* no change needed */ $cache->rows
  1107. : /* need to read SQL */ NULL;
  1108. if(NULL !== $cache->rows) {
  1109. if(defined("VERBOSE_DB_INSERT_LOGGING"))
  1110. echo "[?] using cached rows..." . PHP_EOL;
  1111. }
  1112. } else $cache->rows = NULL;
  1113. if(defined("USE_CACHED_FIR") and NULL !== $cache->filtered) {
  1114. $cache->filtered = ($previousCluster and $previousType and $previousDepth)
  1115. ? /* no change needed */ $cache->filtered
  1116. : /* need to read SQL */ NULL;
  1117. if(NULL !== $cache->filtered) {
  1118. if(defined("VERBOSE_DB_INSERT_LOGGING"))
  1119. echo "[?] using cached fir data..." . PHP_EOL;
  1120. }
  1121. } else $cache->filtered = NULL;
  1122. // record the previous SQL read markers, in-case we want to use caching
  1123. $previous[0] = $dataset->getInternalSensorId();
  1124. $previous[1] = $dataset->getTypeName();
  1125. $previous[2] = $filterDepth;
  1126. $t0 = microtime(true);
  1127. // store some data to later run a health-check on what we are doing
  1128. $data = Dataset::retrieveReducedData($dataset, $start, $end, $cache);
  1129. //var_dump($dataset->stats);
  1130. $data = array_reverse($data, /* keep keys */ true);
  1131. $t0 = "(cpu time " . number_format(1000 * (microtime(true) - $t0), 2) . "ms)";
  1132. //if(empty($data)) { var_dump("no records found"); }
  1133. if(defined("VERBOSE_DB_INSERT_LOGGING")) var_dump(
  1134. "{$t0} done processing cluster {$dataset->parentSensorCluster->name} " .
  1135. "input " . count($cache->filtered[0]) . " rows " .
  1136. "output " . count($data) . " rows " .
  1137. "sensor: {$dataset->getTypeName()} " .
  1138. "datasetId:{$dataset->datasetId} " .
  1139. "filter depth:{$filterDepth}"
  1140. );
  1141. $t0 = microtime(true);
  1142. $keptRows = 0;
  1143. /* checking before delete takes some time, but on average ends up
  1144. * saving a bit of time, go figure
  1145. *
  1146. * I cannot find out why this is, SQLite3 documentation and internet
  1147. * people say DELETE is expensive so if we can avoid it we save time
  1148. */
  1149. if(0 !== $start) {
  1150. /* try to reduce the amount of writing to the database, find the
  1151. * timestamp when values start to change and only write those
  1152. */
  1153. $diskSelect->bindValue("dataset", $dataset->datasetId);
  1154. $diskSelect->bindValue("start", $start);
  1155. $diskSelect->bindValue("end", $end);
  1156. $results = $diskSelect->execute();
  1157. while($row = $results->fetchArray()) {
  1158. if(isset($data[$row["time"]])) {
  1159. // compare if float-point values are similar
  1160. $a = $row["reading"];
  1161. $b = $data[$row["time"]];
  1162. //var_dump("compare [{$a}] and [{$b}]");
  1163. if(abs($a - $b) > .00001) break;
  1164. $keptRows++;
  1165. $lastSameTimestamp = $row["time"] + 1;
  1166. //var_dump("keeping {$lastSameTimestamp}");
  1167. unset($data[$row["time"]]);
  1168. }
  1169. }
  1170. //var_dump("keeping [{$keptRows}] rows");
  1171. $diskSelect->reset();
  1172. }
  1173. $t0 = "(select cpu time " . number_format(1000 * (microtime(true) - $t0), 2) . "ms)";
  1174. if(defined("VERBOSE_DB_INSERT_LOGGING")) var_dump(
  1175. "{$t0} done checking for extra writes" .
  1176. " avoided {$keptRows} rows"
  1177. );
  1178. $t0 = microtime(true);
  1179. // prepare to remove all entries from the disk
  1180. if(0 === count($data)) {
  1181. //var_dump("skipping DELETE operation, nothing to add");
  1182. } else {
  1183. $diskDeleteCheck->bindValue("dataset", $dataset->datasetId);
  1184. $diskDeleteCheck->bindValue("start", $lastSameTimestamp);
  1185. $diskDeleteCheck->bindValue("end", $end);
  1186. $res = $diskDeleteCheck->execute();
  1187. $res = $res->fetchArray();
  1188. $rowsWouldDelete = $res["COUNT(*)"];
  1189. //var_dump("rows caught by delete: {$rowsWouldDelete}");
  1190. if(0 === $rowsWouldDelete) {
  1191. //var_dump("skipping DELETE operation, nothing would delete");
  1192. } else {
  1193. $diskDelete->bindValue("dataset", $dataset->datasetId);
  1194. $diskDelete->bindValue("start", $lastSameTimestamp);
  1195. $diskDelete->bindValue("end", $end);
  1196. $diskDeleteCheck->reset();
  1197. $diskDelete->execute();
  1198. $diskDelete->reset();
  1199. }
  1200. }
  1201. $t0 = "(delete cpu time " .
  1202. number_format(1000 * (microtime(true) - $t0), 2) . "ms)";
  1203. if(defined("VERBOSE_DB_INSERT_LOGGING")) var_dump(
  1204. "{$t0} done clearing old data"
  1205. );
  1206. $t0 = microtime(true);
  1207. $boundDataset = intval($dataset->datasetId);
  1208. //$db->exec("BEGIN TRANSACTION;");
  1209. while($row = array_slice($data, -1, 1, true)) {
  1210. // remove sliced indice
  1211. array_pop($data);
  1212. // load the database
  1213. $boundTime = intval(key($row));
  1214. $boundReading = (float) array_pop($row);
  1215. $stmt->execute();
  1216. $stmt->reset();
  1217. }
  1218. //$db->exec("END TRANSACTION;");
  1219. if(!defined("FLUSH_DB_AT_ONCE")) {
  1220. // flush memory to disk on every dataset to prevent using too much memory
  1221. $db->exec(
  1222. "INSERT INTO disk.sensorLog ({$cols}) SELECT {$cols} FROM sensorLog;");
  1223. $db->exec("DELETE FROM sensorLog;");
  1224. }
  1225. $t0 = "(cpu time " . number_format(1000 * (microtime(true) - $t0), 2) . "ms)";
  1226. if(defined("VERBOSE_DB_INSERT_LOGGING")) var_dump(
  1227. "{$t0} done saving cluster {$dataset->parentSensorCluster->name} " .
  1228. "rdp: {$dataset->stats->rdpCount} " .
  1229. "sensor: {$dataset->getTypeName()} " .
  1230. "datasetId:{$dataset->datasetId}"
  1231. );
  1232. if(defined("VERBOSE_DB_INSERT_LOGGING")) echo PHP_EOL;
  1233. }
  1234. if(defined("FLUSH_DB_AT_ONCE")) {
  1235. $db->exec("INSERT INTO disk.sensorLog ({$cols}) SELECT {$cols} FROM sensorLog;");
  1236. }
  1237. if((true === $reset) and (true === $fileExists)) {
  1238. trigger_error(
  1239. "[?] removing `" .
  1240. CACHE_DB::DB_FILE .
  1241. "` would be significantly faster " .
  1242. "for full cache refreshing",
  1243. E_USER_WARNING
  1244. );
  1245. }
  1246. }
  1247. }
  1248. /* unnecessary cruft, automatically names stuff, sits in-between the routines
  1249. * that get the data and the code that draws stuff
  1250. *
  1251. * the code that was supplying the highcharts wanted data, names, colors and
  1252. * then on top of that there were a bunch of sensors that have names like
  1253. * 'A', 'B', 'C', and nothing taking responsibility for these things
  1254. *
  1255. * user wants a highchart series
  1256. *
  1257. * maybe user wants to specify some stuff, color, time range, etc, try to
  1258. * accommodate those things
  1259. *
  1260. * [?] availableZoom and availableFIR can be combined to represent multiple
  1261. * views of some data. A data-set is cached for each combination of zoom
  1262. * and FIR, when this was written there were 3 available zooms and 4
  1263. * available FIR meaning there would be 12 data-sets cached per sensor.
  1264. * a sensor cluster, like the BOSCH_BME680, with it's 5 sensors will have
  1265. * 60 cached data-sets
  1266. */
  1267. Class TheInternet {
  1268. public $inline = false;
  1269. private $output = array();
  1270. private $sensors = array();
  1271. private $whatUsersWant;
  1272. public $availableZoom = array(
  1273. ZOOM_DEFAULT,
  1274. ZOOM_96_HOURS,
  1275. ZOOM_MONTH,
  1276. ZOOM_YEAR,
  1277. );
  1278. public $availableFIR = array(
  1279. FIR_OFF,
  1280. FIR_DEFAULT,
  1281. FIR_1_HOUR,
  1282. FIR_96_HOURS,
  1283. FIR_MONTH,
  1284. FIR_YEAR,
  1285. );
  1286. public function __construct() {
  1287. /* [?] notes about the values saved here
  1288. *
  1289. * the sensors that we monitor are all different and in general try
  1290. * their best to report accurate information. They all have their own
  1291. * way of maintaining precision, some, I've made some mistakes and
  1292. * thrown away, for example the temperature sensors, I am pretty
  1293. * sure I lost resolution trying save space on the server. (I should
  1294. * have multiplied the values by a few magnitudes and saved integer
  1295. * values to avoid floating point storage) Anyways, all the sensors
  1296. * have very little memory and either make great use of that memory,
  1297. * or not at all. Either way, the filters on the sensors are limited.
  1298. *
  1299. * We can make really big filters, adjust for phase shift, down sample,
  1300. * all the things - because we have lots of memory and processing power
  1301. *
  1302. * `TheInternet` abstracts some of the complexities of the sensors
  1303. * so that users can ask for things without specifying specific details
  1304. * that are kinda necessary for what they are asking for. There is no
  1305. * place for these things in the underlying routines either, so they
  1306. * end up here, in the middle. For example, the Ramer-Douglas-Peucker
  1307. * algorithm that is used for line simplification wants an `epsilon`
  1308. * value so it knows how tightly to keep it's output to the input it
  1309. * is given.
  1310. *
  1311. * notes about specific values:
  1312. * squish factor: see frontend/tests-and-demos/rdp-algo.php
  1313. * sample depth: resolution should be 1 hour for most the sensors
  1314. * sample period: read the datasheets for each sensor
  1315. */
  1316. // this is just a list of the sensors in one place
  1317. $this->sensors['H'] = new Sensor('H', "DEADBEEF", BOSCH_BME680);
  1318. $this->sensors['I'] = new Sensor('I', "DEADPORK", BOSCH_BME680);
  1319. $this->sensors['J'] = new Sensor('J', "HELLOCAT", BOSCH_BME680);
  1320. $this->sensors['A'] = new Sensor('A', "A", AIR_THINGS, 106.0 / 100);
  1321. $this->sensors['B'] = new Sensor('B', "B", AIR_THINGS, 58.0 / 100);
  1322. $this->sensors['C'] = new Sensor('C', "C", AIR_THINGS, 335.0 / 100);
  1323. $this->sensors['R'] = new Sensor('R', "R", RADON_EYE, 62.0 / 100);
  1324. $this->sensors['S'] = new Sensor('S', "S", RADON_EYE, 24.0 / 100);
  1325. $this->sensors["monkey"] = new Sensor("Ohhh Ahhhh!");
  1326. // inform sensor API about the sampleDepth for the automated sensors
  1327. $this->sensors['R']->options->setSampleDepth(/*every 5 min is*/ 12 /*per hour*/);
  1328. $this->sensors['S']->options->setSampleDepth(/*every 5 min is*/ 12 /*per hour*/);
  1329. $this->sensors['H']->options->setSampleDepth(/*every 15 min is*/ 4 /*per hour*/);
  1330. $this->sensors['I']->options->setSampleDepth(/*every 15 min is*/ 4 /*per hour*/);
  1331. $this->sensors['J']->options->setSampleDepth(/*every 15 min is*/ 4 /*per hour*/);
  1332. // inform sensor API about the samplePeriod for the automated sensors
  1333. $this->sensors['R']->options->setSamplePeriod( 5 * 60 /* seconds */);
  1334. $this->sensors['S']->options->setSamplePeriod( 5 * 60 /* seconds */);
  1335. $this->sensors['H']->options->setSamplePeriod(15 * 60 /* seconds */);
  1336. $this->sensors['I']->options->setSamplePeriod(15 * 60 /* seconds */);
  1337. $this->sensors['J']->options->setSamplePeriod(15 * 60 /* seconds */);
  1338. // user does not want Fahrenheit, they want temperature, translate request
  1339. $this->whatUsersWant = TheCutePaletteAndSomeOtherStuff::wordWords();
  1340. // available zoom and FIR might have unnecessary defaults
  1341. $this->availableZoom = array_unique($this->availableZoom);
  1342. $this->availableFIR = array_unique($this->availableFIR);
  1343. }
  1344. // returns a *copy* of the sensor
  1345. public function cloneAllSensors() {
  1346. $arr = array();
  1347. foreach($this->sensors as $sensor) $arr[] = new Sensor(
  1348. $sensor->name,
  1349. $sensor->serial,
  1350. $sensor->sensors,
  1351. $sensor->options
  1352. );
  1353. return $arr;
  1354. }
  1355. // [!] returns a copy of the sensors
  1356. public function getSensor($letter) {
  1357. /* clone would only do a shallow copy and we would lose SensorOptions
  1358. * instead, try to create a new sensor and provide it all the same
  1359. * parameters as the original
  1360. *
  1361. * [alternatively we can give the user our sensor, but that might get
  1362. * a bit out of hand, maybe this does, idontknow]
  1363. */
  1364. return isset($this->sensors[$letter])
  1365. ? new Sensor(
  1366. $this->sensors[$letter]->name,
  1367. $this->sensors[$letter]->serial,
  1368. $this->sensors[$letter]->sensors,
  1369. $this->sensors[$letter]->options
  1370. )
  1371. : NULL;
  1372. }
  1373. /* The front end does not currently know what sensors are available, nor
  1374. * does it provide an interface to select which sensors are of interest
  1375. * to the end user.
  1376. *
  1377. * Names, colors, or general user options are not set, or known, when
  1378. * requests are made, they tend to ask for things that the backend cannot
  1379. * provide
  1380. *
  1381. * This interface protects the backend from these unknowns and tries to pick
  1382. * things that make sense. In a way, it is kind of a dumpster fire with
  1383. * nobody taking responsibility for these kinds of things, but it does serve
  1384. * a purpose and will exist until it doesn't
  1385. *
  1386. * other has a bunch of parameters that are jumbled together
  1387. * some of them are for highcharts, some are for running last minute routines
  1388. * on the data, some that affect what data is queried...
  1389. */
  1390. public function iotSmartAlexaSiriCloud($em = 'H', $what = "radon", $other = array()) {
  1391. $auxFn = isset($other["auxFn"]) ?$other["auxFn"] :NULL;
  1392. // manual read auxiliary measurements have different titles
  1393. $title = isset($other["auxFn"]) ?ucfirst($other["auxFn"]) :"{$em}_{$what}";
  1394. // check if we were passed query options, otherwise assign none
  1395. $queryOptions = (
  1396. isset($other["queryOptions"]) and
  1397. ("QueryOptions" === get_class($other["queryOptions"]))
  1398. ) ? $other["queryOptions"] : NULL;
  1399. // radon detectors get special id
  1400. $title = "radon" === $what ?"detector{$em}" :$title;
  1401. // switch out implicit "temperature" for explicit "Fahrenheit"
  1402. $get = $this->whatUsersWant[$what];
  1403. $td1 = microtime(true);
  1404. // read data from disk or wherever it comes from
  1405. $data = $this->sensors[$em]->$get($queryOptions, $auxFn);
  1406. $td2 = microtime(true);
  1407. // run a user supplied function on the data before presentation
  1408. if(isset($other["dataFn"])) { array_walk($data, $other["dataFn"]); }
  1409. $td3 = microtime(true);
  1410. error_log("data - i1: ".(round(($td2-$td1)*1000))."ms, i2: ".(round(($td3-$td2)*1000))."ms");
  1411. // [!] this prints to STDOUT, do not let the assignment fool you
  1412. // generate the highcharts series
  1413. $this->output[] = new Series(
  1414. $em,
  1415. $get,
  1416. makeSeries($title, $data, $other, false === $this->inline)
  1417. );
  1418. }
  1419. public function getInlineJS_objs($var) {
  1420. $arr = array();
  1421. foreach($this->output as $o) {
  1422. $arr[] = "{$var}.push(new Series('{$o->em}','{$o->type}',{$o->data}));";
  1423. }
  1424. return implode(PHP_EOL, $arr);
  1425. }
  1426. }
  1427. Class Series {
  1428. public $em;
  1429. public $type;
  1430. public $data;
  1431. public function __construct($em = 'H', $type = 'radon', $data = NULL) {
  1432. // no need to initialize anything when not used
  1433. if(NULL === $data) return;
  1434. $this->em = $em;
  1435. $this->type = $type;
  1436. $this->data = $data;
  1437. }
  1438. static public function asJavaScript() {
  1439. return implode(' ', array_map("trim", explode(PHP_EOL,"class Series {
  1440. constructor(em, type, data) {
  1441. this.em = em;
  1442. this.type=type;
  1443. this.data=data;
  1444. }
  1445. };")));
  1446. }
  1447. }
  1448. /* scheme to override the color choices made for highcharts
  1449. * and provide some optional UI functionality like grouping similar graph
  1450. * data, like all radon measurements could are of the same class, they could
  1451. * be averaged, turned off together, whatever
  1452. *
  1453. * after reading documentation, highcharts says that yes chart can be modified
  1454. * at runtime using highchart::chart api as well as highchart::series api
  1455. *
  1456. * charts API for adding series at runtime, do not use at render time
  1457. * highcharts::addSeries(options, [,redraw] [,animation]) returns highcharts::Series
  1458. * highcharts::get(id) returns series or whatever by id
  1459. *
  1460. * also provide a common reference for translating user requested data types
  1461. * and actual datatypes, for example, mapping temperature to Fahrenheit
  1462. *
  1463. */
  1464. require_once("test-pattern.php");
  1465. Class TheCutePaletteAndSomeOtherStuff {
  1466. // user does not want Fahrenheit, they want temperature, translate request
  1467. static public function wordWords() {
  1468. // order these in the same order you want to pull from colors
  1469. return array(
  1470. "radon" => "getRadon",
  1471. "humidity" => "getHumidity",
  1472. "temperature" => "getFahrenheit",
  1473. "indoor_air_quality" => "getIaq",
  1474. "pressure" => "getUkrumayl",
  1475. "wesDust" => "getWesDust",
  1476. "aux" => "getAuxiliary"
  1477. );
  1478. }
  1479. public function activateJavaScriptSuperPowers() {
  1480. }
  1481. }