csbp.php 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. <?php
  2. /**
  3. * controllers/labParsers/csbp.php
  4. *
  5. * Dedicated parser for CSBP Soil Analysis Report files.
  6. *
  7. * Format (both .xls and .xlsx):
  8. * Row 1-2 empty
  9. * Row 3 "SOIL CONTROL SOIL ANALYSIS REPORT"
  10. * Row 4 "CSBP LIMITED ABN: ..."
  11. * Row 5 Units row (mg/kg, pH, dS/m …)
  12. * Row 6 Column headers (47 columns — see MAP below)
  13. * Row 7+ One sample per row
  14. *
  15. * Provides:
  16. * csbpDetect(array $rawData): bool
  17. * csbpParse(array $rawData): array — returns array of mapped sample arrays
  18. */
  19. // ─── CSBP column → soil_records field map ────────────────────────────────────
  20. //
  21. // Keys are CSBP header names (case-insensitive match).
  22. // Values are soil_records column names.
  23. const CSBP_COLUMN_MAP = [
  24. // Identity / metadata
  25. 'CLIENT NAME' => 'client_name',
  26. 'PADDOCK' => 'sample_id', // paddock = sample identifier
  27. 'CROP' => 'crop_type',
  28. 'SERIAL_NO' => 'batch_no',
  29. 'LAB_NUMBER' => 'lab_no',
  30. // Physical
  31. 'TEXTURE' => 'texture',
  32. 'GRAVEL' => 'gravel',
  33. 'COLOUR' => 'colour',
  34. 'MOISTURE' => null, // not stored in soil_records
  35. // Chemical
  36. 'CONDUCTY' => 'ec',
  37. 'PH_CACL2' => 'ph_cacl2',
  38. 'PH_H2O' => 'ph_h2o',
  39. // Nitrogen
  40. 'NITRATEN' => 'NO3_N',
  41. 'AMMONIUM' => 'NH3_N',
  42. 'TOTALN' => null,
  43. // Phosphorus
  44. 'PHOS' => 'p_morgan',
  45. 'PHOS_OLS' => 'p_bray2', // Olsen P ≈ closest mapped field
  46. 'PHOS_RETEN' => null,
  47. 'TOTALP' => null,
  48. 'PBI' => null,
  49. // Potassium
  50. 'POTASSIUM' => 'k_morgan',
  51. // Other macros
  52. 'SULPHUR' => 's_morgan',
  53. 'ORGCARBON' => 'ocarbon',
  54. 'CHLORIDE' => null,
  55. 'FIZZ' => null,
  56. // DTPA micronutrients
  57. 'DTPA_CU' => 'cu_dtpa',
  58. 'DTPA_ZN' => 'zn_dtpa',
  59. 'DTPA_MN' => 'mn_dtpa',
  60. 'DTPA_FE' => 'fe_dtpa',
  61. // EDTA micronutrients (map to same fields — DTPA takes priority if both present)
  62. 'EDTA_CU' => 'cu_dtpa',
  63. 'EDTA_ZN' => 'zn_dtpa',
  64. 'EDTA_MN' => 'mn_dtpa',
  65. 'EDTA_FE' => 'fe_dtpa',
  66. // Other micros
  67. 'IRON' => 'fe_dtpa', // generic iron reading
  68. 'BORON_HOT' => 'b_cacl2',
  69. 'ALUM_CACL2' => 'al',
  70. // Exchangeable cations (base saturation)
  71. 'EXC_CA' => 'ca_morgan',
  72. 'EXC_MG' => 'mg_morgan',
  73. 'EXC_NA' => 'na_morgan',
  74. 'EXC_K' => null, // separate from k_morgan (extractable)
  75. 'EXC_AL' => 'al_mehlick3',
  76. // Base saturation %
  77. 'SAT_Ca' => 'ca_mehlick3',
  78. 'SAT_Mg' => 'mg_mehlick3',
  79. 'SAT_K' => 'k_mehlick3',
  80. 'SAT_Na' => 'na_mehlick3',
  81. 'SAT_COND' => 'cec', // conductance of saturation extract ≈ CEC proxy
  82. 'SP%' => null,
  83. ];
  84. /**
  85. * Check if a 2-D raw data array looks like a CSBP file.
  86. * Looks for "CSBP" or "SOIL CONTROL SOIL ANALYSIS" in the first 6 rows.
  87. */
  88. function csbpDetect(array $rawData): bool
  89. {
  90. foreach (array_slice($rawData, 0, 6) as $row) {
  91. foreach ($row as $cell) {
  92. $cell = strtoupper(trim((string) $cell));
  93. if (str_contains($cell, 'CSBP') || str_contains($cell, 'SOIL CONTROL SOIL ANALYSIS')) {
  94. return true;
  95. }
  96. }
  97. }
  98. return false;
  99. }
  100. /**
  101. * Find the header row index inside $rawData.
  102. * The CSBP header row contains "LAB_NUMBER" and "PADDOCK".
  103. */
  104. function csbpFindHeaderRow(array $rawData): int
  105. {
  106. foreach ($rawData as $idx => $row) {
  107. $cells = array_map(fn($c) => strtoupper(trim((string) $c)), $row);
  108. if (in_array('LAB_NUMBER', $cells, true) && in_array('PADDOCK', $cells, true)) {
  109. return $idx;
  110. }
  111. }
  112. return 5; // fallback: row index 5 (row 6 in 1-based)
  113. }
  114. /**
  115. * Parse a CSBP raw data array into an array of mapped sample arrays.
  116. * Each sample is keyed by soil_records column names.
  117. *
  118. * @param array $rawData 2-D array from PhpSpreadsheet
  119. * @return array [ ['lab_no'=>..., 'ph_cacl2'=>..., ...], ... ]
  120. */
  121. function csbpParse(array $rawData): array
  122. {
  123. $headerRow = csbpFindHeaderRow($rawData);
  124. $headers = array_map(fn($c) => trim((string) $c), $rawData[$headerRow]);
  125. // Build index: column position → soil_records field name (or null to skip)
  126. $colIndex = [];
  127. foreach ($headers as $col => $header) {
  128. if ($header === '') continue;
  129. // Case-insensitive lookup in the map
  130. $upperHeader = strtoupper($header);
  131. foreach (CSBP_COLUMN_MAP as $csbpCol => $dbField) {
  132. if (strtoupper($csbpCol) === $upperHeader) {
  133. $colIndex[$col] = $dbField; // null means skip
  134. break;
  135. }
  136. }
  137. }
  138. $samples = [];
  139. for ($r = $headerRow + 1; $r < count($rawData); $r++) {
  140. $row = $rawData[$r];
  141. $sample = [];
  142. $hasData = false;
  143. foreach ($colIndex as $col => $dbField) {
  144. $raw = trim((string) ($row[$col] ?? ''));
  145. if ($dbField === null) continue; // unmapped column
  146. if ($raw === '') continue;
  147. // Don't overwrite a DTPA value with an EDTA value
  148. if (isset($sample[$dbField]) && $sample[$dbField] !== '') continue;
  149. $sample[$dbField] = $raw;
  150. $hasData = true;
  151. }
  152. // Skip completely empty rows
  153. if (!$hasData) continue;
  154. // Mark the lab source
  155. $sample['analysis_type'] = 'CSBP';
  156. $samples[] = $sample;
  157. }
  158. return $samples;
  159. }