gea-program 0.18.0__tar.gz → 0.19.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. {gea_program-0.18.0 → gea_program-0.19.0}/PKG-INFO +20 -2
  2. {gea_program-0.18.0 → gea_program-0.19.0}/README.md +19 -1
  3. {gea_program-0.18.0 → gea_program-0.19.0}/gea/__init__.py +1 -1
  4. {gea_program-0.18.0 → gea_program-0.19.0}/gea/__main__.py +8 -2
  5. {gea_program-0.18.0 → gea_program-0.19.0}/gea/acceptance_tests.py +125 -0
  6. gea_program-0.19.0/gea/help/month-end.md +11 -0
  7. {gea_program-0.18.0 → gea_program-0.19.0}/gea/helplib.py +1 -0
  8. gea_program-0.19.0/gea/month_end.py +290 -0
  9. {gea_program-0.18.0 → gea_program-0.19.0}/gea/service.py +27 -0
  10. {gea_program-0.18.0 → gea_program-0.19.0}/gea/web/app.html +6 -0
  11. {gea_program-0.18.0 → gea_program-0.19.0}/gea/workspace.py +8 -0
  12. {gea_program-0.18.0 → gea_program-0.19.0}/gea_program.egg-info/PKG-INFO +20 -2
  13. {gea_program-0.18.0 → gea_program-0.19.0}/gea_program.egg-info/SOURCES.txt +2 -0
  14. {gea_program-0.18.0 → gea_program-0.19.0}/pyproject.toml +1 -1
  15. {gea_program-0.18.0 → gea_program-0.19.0}/LICENSE +0 -0
  16. {gea_program-0.18.0 → gea_program-0.19.0}/gea/BENCH_TEST_PROTOCOL.md +0 -0
  17. {gea_program-0.18.0 → gea_program-0.19.0}/gea/accuracy_statement.py +0 -0
  18. {gea_program-0.18.0 → gea_program-0.19.0}/gea/alarm_engine.py +0 -0
  19. {gea_program-0.18.0 → gea_program-0.19.0}/gea/backup.py +0 -0
  20. {gea_program-0.18.0 → gea_program-0.19.0}/gea/bench.py +0 -0
  21. {gea_program-0.18.0 → gea_program-0.19.0}/gea/blind_harness.py +0 -0
  22. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/acex_lomonosov_age_depth_model.provenance.json +0 -0
  23. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/acex_lomonosov_age_depth_model.txt +0 -0
  24. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/agassiz77_canada_temperature.csv +0 -0
  25. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/agassiz77_canada_temperature.provenance.json +0 -0
  26. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/barbados_110_consolidation.provenance.json +0 -0
  27. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/barbados_110_consolidation.txt +0 -0
  28. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/bengal_u1452_grain_size.provenance.json +0 -0
  29. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/bengal_u1452_grain_size.txt +0 -0
  30. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/blake_164_methane_isotopes.provenance.json +0 -0
  31. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/blake_164_methane_isotopes.txt +0 -0
  32. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/chicxulub_m0077a_pwave_velocity.provenance.json +0 -0
  33. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/chicxulub_m0077a_pwave_velocity.txt +0 -0
  34. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/collingwood_1_28_ks_complete.las +0 -0
  35. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/collingwood_1_28_ks_complete.provenance.json +0 -0
  36. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/costa_rica_odp_friction_envelope.provenance.json +0 -0
  37. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/costa_rica_odp_friction_envelope.txt +0 -0
  38. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/dead_sea_5017_debrite_xrf_ms.provenance.json +0 -0
  39. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/dead_sea_5017_debrite_xrf_ms.txt +0 -0
  40. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/dsdp_504b_physical_properties.provenance.json +0 -0
  41. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/dsdp_504b_physical_properties.txt +0 -0
  42. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/dsdp_504b_sound_velocity.provenance.json +0 -0
  43. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/dsdp_504b_sound_velocity.txt +0 -0
  44. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/elgygytgyn_5011_turbidites.provenance.json +0 -0
  45. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/elgygytgyn_5011_turbidites.txt +0 -0
  46. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/epica_domec_co2_800kyr.provenance.json +0 -0
  47. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/epica_domec_co2_800kyr.txt +0 -0
  48. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/fram_909_organic_petrography.provenance.json +0 -0
  49. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/fram_909_organic_petrography.txt +0 -0
  50. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/gbr_325_coral_uth_ages.provenance.json +0 -0
  51. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/gbr_325_coral_uth_ages.txt +0 -0
  52. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/gisp2_greenland_temperature.csv +0 -0
  53. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/gisp2_greenland_temperature.provenance.json +0 -0
  54. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/gom_308_t2p_insitu.provenance.json +0 -0
  55. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/gom_308_t2p_insitu.txt +0 -0
  56. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/guaymas_385_dom_d13c.provenance.json +0 -0
  57. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/guaymas_385_dom_d13c.txt +0 -0
  58. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/hikurangi_u1520_friction_insitu.provenance.json +0 -0
  59. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/hikurangi_u1520_friction_insitu.txt +0 -0
  60. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/hydrate_ridge_204_ncr_hydrate.provenance.json +0 -0
  61. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/hydrate_ridge_204_ncr_hydrate.txt +0 -0
  62. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/iodp_u1324_pore_pressure.provenance.json +0 -0
  63. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/iodp_u1324_pore_pressure.txt +0 -0
  64. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/jfast_c0019_slow_slip_events.provenance.json +0 -0
  65. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/jfast_c0019_slow_slip_events.txt +0 -0
  66. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/kennetcook_2_p129_excerpt.las +0 -0
  67. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/kennetcook_2_p129_excerpt.provenance.json +0 -0
  68. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_hb_bhgm_density.dat +0 -0
  69. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_hb_bhgm_density.provenance.json +0 -0
  70. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_hb_complog_6020_excerpt.provenance.json +0 -0
  71. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_hb_complog_6020_excerpt.txt +0 -0
  72. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_hb_hlog246_temperature.dat +0 -0
  73. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_hb_hlog246_temperature.provenance.json +0 -0
  74. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_hb_rockmech_compress.dat +0 -0
  75. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_hb_rockmech_compress.provenance.json +0 -0
  76. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_hb_tvd_0_9080_excerpt.dat +0 -0
  77. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_hb_tvd_0_9080_excerpt.provenance.json +0 -0
  78. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_vb_rockmech_compress.dat +0 -0
  79. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_vb_rockmech_compress.provenance.json +0 -0
  80. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_vb_vlog251_temperature.dat +0 -0
  81. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ktb_vb_vlog251_temperature.provenance.json +0 -0
  82. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/l06_06_nl_survey.csv +0 -0
  83. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/l06_06_nl_survey.provenance.json +0 -0
  84. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/l07_01_nl_excerpt.las +0 -0
  85. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/l07_01_nl_excerpt.provenance.json +0 -0
  86. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/mariana_1200_serpentinite_geochem.provenance.json +0 -0
  87. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/mariana_1200_serpentinite_geochem.txt +0 -0
  88. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/med_160_sapropels.provenance.json +0 -0
  89. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/med_160_sapropels.txt +0 -0
  90. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/nankai_megasplay_shear_strength.provenance.json +0 -0
  91. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/nankai_megasplay_shear_strength.txt +0 -0
  92. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_1027b_thermal_conductivity.provenance.json +0 -0
  93. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_1027b_thermal_conductivity.txt +0 -0
  94. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_1027c_cork_temperature.provenance.json +0 -0
  95. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_1027c_cork_temperature.txt +0 -0
  96. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_1165b_thermal_conductivity.provenance.json +0 -0
  97. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_1165b_thermal_conductivity.txt +0 -0
  98. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_1274a_mantle_peridotite_mad.provenance.json +0 -0
  99. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_1274a_mantle_peridotite_mad.txt +0 -0
  100. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_504b_dike_elastic_moduli.provenance.json +0 -0
  101. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_504b_dike_elastic_moduli.txt +0 -0
  102. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_504b_leg137_borehole_fluids.provenance.json +0 -0
  103. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_504b_leg137_borehole_fluids.txt +0 -0
  104. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_735b_gabbro_elastic_moduli.provenance.json +0 -0
  105. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/odp_735b_gabbro_elastic_moduli.txt +0 -0
  106. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/peru_201_sulfate_reduction.provenance.json +0 -0
  107. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/peru_201_sulfate_reduction.txt +0 -0
  108. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/scorpio_e1_sa_excerpt.las +0 -0
  109. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/scorpio_e1_sa_excerpt.provenance.json +0 -0
  110. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/sumatra_362_cohesion.provenance.json +0 -0
  111. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/sumatra_362_cohesion.txt +0 -0
  112. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/university_6_17_no1_tx_excerpt.las +0 -0
  113. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/university_6_17_no1_tx_excerpt.provenance.json +0 -0
  114. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ursa_308_xrd_mineralogy.provenance.json +0 -0
  115. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/ursa_308_xrd_mineralogy.txt +0 -0
  116. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/volve_15_9_19_sr_excerpt.las +0 -0
  117. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/volve_15_9_19_sr_excerpt.provenance.json +0 -0
  118. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/volve_15_9_19a_core_excerpt.csv +0 -0
  119. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/volve_15_9_19a_core_excerpt.provenance.json +0 -0
  120. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/volve_f12_f14_production_excerpt.csv +0 -0
  121. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/volve_f12_f14_production_excerpt.provenance.json +0 -0
  122. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/walvis_208_petm_carbonate.provenance.json +0 -0
  123. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/walvis_208_petm_carbonate.txt +0 -0
  124. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/woodlark_1109_rock_eval.provenance.json +0 -0
  125. {gea_program-0.18.0 → gea_program-0.19.0}/gea/catalog/woodlark_1109_rock_eval.txt +0 -0
  126. {gea_program-0.18.0 → gea_program-0.19.0}/gea/certificates.py +0 -0
  127. {gea_program-0.18.0 → gea_program-0.19.0}/gea/cli.py +0 -0
  128. {gea_program-0.18.0 → gea_program-0.19.0}/gea/client_reports.py +0 -0
  129. {gea_program-0.18.0 → gea_program-0.19.0}/gea/config_versioning.py +0 -0
  130. {gea_program-0.18.0 → gea_program-0.19.0}/gea/conformance.py +0 -0
  131. {gea_program-0.18.0 → gea_program-0.19.0}/gea/correlation.py +0 -0
  132. {gea_program-0.18.0 → gea_program-0.19.0}/gea/dashboard.py +0 -0
  133. {gea_program-0.18.0 → gea_program-0.19.0}/gea/deviation.py +0 -0
  134. {gea_program-0.18.0 → gea_program-0.19.0}/gea/doctor.py +0 -0
  135. {gea_program-0.18.0 → gea_program-0.19.0}/gea/downhole_engine.py +0 -0
  136. {gea_program-0.18.0 → gea_program-0.19.0}/gea/drift_monitor.py +0 -0
  137. {gea_program-0.18.0 → gea_program-0.19.0}/gea/earth_model.py +0 -0
  138. {gea_program-0.18.0 → gea_program-0.19.0}/gea/etp.py +0 -0
  139. {gea_program-0.18.0 → gea_program-0.19.0}/gea/etp12_schemas.json +0 -0
  140. {gea_program-0.18.0 → gea_program-0.19.0}/gea/example_register_map.json +0 -0
  141. {gea_program-0.18.0 → gea_program-0.19.0}/gea/fat_sat.py +0 -0
  142. {gea_program-0.18.0 → gea_program-0.19.0}/gea/files.py +0 -0
  143. {gea_program-0.18.0 → gea_program-0.19.0}/gea/follower.py +0 -0
  144. {gea_program-0.18.0 → gea_program-0.19.0}/gea/forward_model.py +0 -0
  145. {gea_program-0.18.0 → gea_program-0.19.0}/gea/gamma.py +0 -0
  146. {gea_program-0.18.0 → gea_program-0.19.0}/gea/gauge_aging.py +0 -0
  147. {gea_program-0.18.0 → gea_program-0.19.0}/gea/gauge_specs.py +0 -0
  148. {gea_program-0.18.0 → gea_program-0.19.0}/gea/geodesy.py +0 -0
  149. {gea_program-0.18.0 → gea_program-0.19.0}/gea/gravity_reference.py +0 -0
  150. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/TESTER_GUIDE.md +0 -0
  151. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/alarms.md +0 -0
  152. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/audit-update.md +0 -0
  153. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/backup.md +0 -0
  154. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/conformance.md +0 -0
  155. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/data-in.md +0 -0
  156. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/doctor.md +0 -0
  157. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/drift.md +0 -0
  158. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/etp.md +0 -0
  159. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/files.md +0 -0
  160. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/instruments.md +0 -0
  161. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/notifications.md +0 -0
  162. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/osdu.md +0 -0
  163. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/patches.md +0 -0
  164. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/ppdm.md +0 -0
  165. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/quakeml.md +0 -0
  166. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/quality.md +0 -0
  167. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/seedlink.md +0 -0
  168. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/seismic.md +0 -0
  169. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/site.md +0 -0
  170. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/sites.md +0 -0
  171. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/sra.md +0 -0
  172. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/start.md +0 -0
  173. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/transients.md +0 -0
  174. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/upkeep.md +0 -0
  175. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/vibration.md +0 -0
  176. {gea_program-0.18.0 → gea_program-0.19.0}/gea/help/well-tests.md +0 -0
  177. {gea_program-0.18.0 → gea_program-0.19.0}/gea/housekeeping.py +0 -0
  178. {gea_program-0.18.0 → gea_program-0.19.0}/gea/inverse_engine.py +0 -0
  179. {gea_program-0.18.0 → gea_program-0.19.0}/gea/jobs.py +0 -0
  180. {gea_program-0.18.0 → gea_program-0.19.0}/gea/live_ports.py +0 -0
  181. {gea_program-0.18.0 → gea_program-0.19.0}/gea/loadtest.py +0 -0
  182. {gea_program-0.18.0 → gea_program-0.19.0}/gea/matplotlib_demo.py +0 -0
  183. {gea_program-0.18.0 → gea_program-0.19.0}/gea/modbus.py +0 -0
  184. {gea_program-0.18.0 → gea_program-0.19.0}/gea/model_card.py +0 -0
  185. {gea_program-0.18.0 → gea_program-0.19.0}/gea/mqtt_port.py +0 -0
  186. {gea_program-0.18.0 → gea_program-0.19.0}/gea/notify.py +0 -0
  187. {gea_program-0.18.0 → gea_program-0.19.0}/gea/opcua_port.py +0 -0
  188. {gea_program-0.18.0 → gea_program-0.19.0}/gea/operator_app.py +0 -0
  189. {gea_program-0.18.0 → gea_program-0.19.0}/gea/osdu.py +0 -0
  190. {gea_program-0.18.0 → gea_program-0.19.0}/gea/patches.py +0 -0
  191. {gea_program-0.18.0 → gea_program-0.19.0}/gea/permits.py +0 -0
  192. {gea_program-0.18.0 → gea_program-0.19.0}/gea/ports.py +0 -0
  193. {gea_program-0.18.0 → gea_program-0.19.0}/gea/ppdm.py +0 -0
  194. {gea_program-0.18.0 → gea_program-0.19.0}/gea/profile_catalog.py +0 -0
  195. {gea_program-0.18.0 → gea_program-0.19.0}/gea/project.py +0 -0
  196. {gea_program-0.18.0 → gea_program-0.19.0}/gea/qt6_downhole_app.py +0 -0
  197. {gea_program-0.18.0 → gea_program-0.19.0}/gea/quakeml.py +0 -0
  198. {gea_program-0.18.0 → gea_program-0.19.0}/gea/reconciler.py +0 -0
  199. {gea_program-0.18.0 → gea_program-0.19.0}/gea/reference/IU_ANMO_10_BHZ_response.provenance.json +0 -0
  200. {gea_program-0.18.0 → gea_program-0.19.0}/gea/reference/IU_ANMO_10_BHZ_response.xml +0 -0
  201. {gea_program-0.18.0 → gea_program-0.19.0}/gea/reference/libmseed_steim.provenance.json +0 -0
  202. {gea_program-0.18.0 → gea_program-0.19.0}/gea/reference/libmseed_steim1_4096.mseed +0 -0
  203. {gea_program-0.18.0 → gea_program-0.19.0}/gea/reference/libmseed_steim2_4096.mseed +0 -0
  204. {gea_program-0.18.0 → gea_program-0.19.0}/gea/reference/libmseed_steim2_512.mseed +0 -0
  205. {gea_program-0.18.0 → gea_program-0.19.0}/gea/rock_inventory.py +0 -0
  206. {gea_program-0.18.0 → gea_program-0.19.0}/gea/sample_record.py +0 -0
  207. {gea_program-0.18.0 → gea_program-0.19.0}/gea/sample_well_profile.csv +0 -0
  208. {gea_program-0.18.0 → gea_program-0.19.0}/gea/sbom.py +0 -0
  209. {gea_program-0.18.0 → gea_program-0.19.0}/gea/seedlink.py +0 -0
  210. {gea_program-0.18.0 → gea_program-0.19.0}/gea/segy.py +0 -0
  211. {gea_program-0.18.0 → gea_program-0.19.0}/gea/seismic.py +0 -0
  212. {gea_program-0.18.0 → gea_program-0.19.0}/gea/seismic_array.py +0 -0
  213. {gea_program-0.18.0 → gea_program-0.19.0}/gea/seismic_assoc.py +0 -0
  214. {gea_program-0.18.0 → gea_program-0.19.0}/gea/seismic_detect.py +0 -0
  215. {gea_program-0.18.0 → gea_program-0.19.0}/gea/seismic_film.py +0 -0
  216. {gea_program-0.18.0 → gea_program-0.19.0}/gea/seismic_harmonic.py +0 -0
  217. {gea_program-0.18.0 → gea_program-0.19.0}/gea/seismic_qc.py +0 -0
  218. {gea_program-0.18.0 → gea_program-0.19.0}/gea/seismic_response.py +0 -0
  219. {gea_program-0.18.0 → gea_program-0.19.0}/gea/seismic_signature.py +0 -0
  220. {gea_program-0.18.0 → gea_program-0.19.0}/gea/seismic_track.py +0 -0
  221. {gea_program-0.18.0 → gea_program-0.19.0}/gea/seismic_uncertainty.py +0 -0
  222. {gea_program-0.18.0 → gea_program-0.19.0}/gea/seismic_unlisted.py +0 -0
  223. {gea_program-0.18.0 → gea_program-0.19.0}/gea/sensor_swap.py +0 -0
  224. {gea_program-0.18.0 → gea_program-0.19.0}/gea/service_life.py +0 -0
  225. {gea_program-0.18.0 → gea_program-0.19.0}/gea/shell.py +0 -0
  226. {gea_program-0.18.0 → gea_program-0.19.0}/gea/shut_in.py +0 -0
  227. {gea_program-0.18.0 → gea_program-0.19.0}/gea/sla_report.py +0 -0
  228. {gea_program-0.18.0 → gea_program-0.19.0}/gea/sra.py +0 -0
  229. {gea_program-0.18.0 → gea_program-0.19.0}/gea/store_forward.py +0 -0
  230. {gea_program-0.18.0 → gea_program-0.19.0}/gea/strata_join.py +0 -0
  231. {gea_program-0.18.0 → gea_program-0.19.0}/gea/supervisor.py +0 -0
  232. {gea_program-0.18.0 → gea_program-0.19.0}/gea/survey_cmd.py +0 -0
  233. {gea_program-0.18.0 → gea_program-0.19.0}/gea/survey_view.py +0 -0
  234. {gea_program-0.18.0 → gea_program-0.19.0}/gea/telemetry.py +0 -0
  235. {gea_program-0.18.0 → gea_program-0.19.0}/gea/tool_library.py +0 -0
  236. {gea_program-0.18.0 → gea_program-0.19.0}/gea/transient.py +0 -0
  237. {gea_program-0.18.0 → gea_program-0.19.0}/gea/vibration.py +0 -0
  238. {gea_program-0.18.0 → gea_program-0.19.0}/gea/well_assembler.py +0 -0
  239. {gea_program-0.18.0 → gea_program-0.19.0}/gea/well_test_validation.py +0 -0
  240. {gea_program-0.18.0 → gea_program-0.19.0}/gea/wits0.py +0 -0
  241. {gea_program-0.18.0 → gea_program-0.19.0}/gea/witsml.py +0 -0
  242. {gea_program-0.18.0 → gea_program-0.19.0}/gea_program.egg-info/dependency_links.txt +0 -0
  243. {gea_program-0.18.0 → gea_program-0.19.0}/gea_program.egg-info/entry_points.txt +0 -0
  244. {gea_program-0.18.0 → gea_program-0.19.0}/gea_program.egg-info/requires.txt +0 -0
  245. {gea_program-0.18.0 → gea_program-0.19.0}/gea_program.egg-info/top_level.txt +0 -0
  246. {gea_program-0.18.0 → gea_program-0.19.0}/setup.cfg +0 -0
  247. {gea_program-0.18.0 → gea_program-0.19.0}/tests/test_acceptance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gea-program
3
- Version: 0.18.0
3
+ Version: 0.19.0
4
4
  Summary: GEA-Program: downhole gauge monitoring - canonical measurement records, quality rules, drift evaluation with a re-fit change log, well-test validation, alarm management, model cards, store-and-forward, monthly SLA measurement, FAT/SAT protocols and a dashboard, generated from live objects in the client's reporting outline.
5
5
  Author: Daniel T. Murphy
6
6
  License: MPL-2.0
@@ -125,7 +125,7 @@ gea dashboard --catalog-well volve_f12_f14_production_excerpt:15/9-F-12:10000 --
125
125
  gea client-report --report accuracy --out client_report
126
126
  gea model-cards --out model_cards
127
127
  gea sbom --out sbom
128
- gea accept # the product gate (342 checks)
128
+ gea accept # the product gate (344 checks)
129
129
  gea help drift # the help library, by the job (16 pages; the same text is on every dashboard page)
130
130
  gea guide # the click-by-click tester guide (docs/TESTER_GUIDE.md)
131
131
  gea gui # the desktop window (pip install "gea-program[desktop]")
@@ -710,6 +710,24 @@ after the report was written - and "Update every report from its source"
710
710
  dashboard for every well, then every seismic station, errors collected and
711
711
  listed, never hidden.
712
712
 
713
+ ### The month-end packet
714
+
715
+ A client's month was five or six commands and a walk through the reports
716
+ folder. `gea workspace --path C:\site --action month-end --period 2026-09`
717
+ (or `--period previous`) runs the refresh for the period and collects every
718
+ report the program writes - each well's drift report, alarm month, well-test
719
+ validation, shut-in report, data resilience, machine vibration and the
720
+ certificate register's ISO/IEC 17025 7.8 conformity; each site's SRA packet
721
+ for the period and site report; every seismic station and track; the
722
+ accuracy statement, the SLA month, the FAT/SAT protocol, the model cards and
723
+ the SBOM - into `reports/month_end/<period>/` with `INDEX` (html, md, json:
724
+ every item INCLUDED with its files or NOT AVAILABLE with the reason that
725
+ names what the site lacks; every failed step; OPEN for a month that has not
726
+ ended) and `MANIFEST.json` (every file's SHA-256), zipped beside it. Nothing
727
+ is recomputed in the index. On the Reports page: the packets on record,
728
+ Assemble as a job, and Schedule monthly (day and hour, the previous month).
729
+ `gea help month-end`.
730
+
713
731
  ### Before a site goes live
714
732
 
715
733
  `deploy/README.md` is the checklist: keep the service on the loopback
@@ -86,7 +86,7 @@ gea dashboard --catalog-well volve_f12_f14_production_excerpt:15/9-F-12:10000 --
86
86
  gea client-report --report accuracy --out client_report
87
87
  gea model-cards --out model_cards
88
88
  gea sbom --out sbom
89
- gea accept # the product gate (342 checks)
89
+ gea accept # the product gate (344 checks)
90
90
  gea help drift # the help library, by the job (16 pages; the same text is on every dashboard page)
91
91
  gea guide # the click-by-click tester guide (docs/TESTER_GUIDE.md)
92
92
  gea gui # the desktop window (pip install "gea-program[desktop]")
@@ -671,6 +671,24 @@ after the report was written - and "Update every report from its source"
671
671
  dashboard for every well, then every seismic station, errors collected and
672
672
  listed, never hidden.
673
673
 
674
+ ### The month-end packet
675
+
676
+ A client's month was five or six commands and a walk through the reports
677
+ folder. `gea workspace --path C:\site --action month-end --period 2026-09`
678
+ (or `--period previous`) runs the refresh for the period and collects every
679
+ report the program writes - each well's drift report, alarm month, well-test
680
+ validation, shut-in report, data resilience, machine vibration and the
681
+ certificate register's ISO/IEC 17025 7.8 conformity; each site's SRA packet
682
+ for the period and site report; every seismic station and track; the
683
+ accuracy statement, the SLA month, the FAT/SAT protocol, the model cards and
684
+ the SBOM - into `reports/month_end/<period>/` with `INDEX` (html, md, json:
685
+ every item INCLUDED with its files or NOT AVAILABLE with the reason that
686
+ names what the site lacks; every failed step; OPEN for a month that has not
687
+ ended) and `MANIFEST.json` (every file's SHA-256), zipped beside it. Nothing
688
+ is recomputed in the index. On the Reports page: the packets on record,
689
+ Assemble as a job, and Schedule monthly (day and hour, the previous month).
690
+ `gea help month-end`.
691
+
674
692
  ### Before a site goes live
675
693
 
676
694
  `deploy/README.md` is the checklist: keep the service on the loopback
@@ -123,7 +123,7 @@ from .profile_catalog import (
123
123
  read_pangaea_txt,
124
124
  )
125
125
 
126
- __version__ = "0.18.0"
126
+ __version__ = "0.19.0"
127
127
  __all__ = [
128
128
  "aging_rate", "rate_psi_yr", "accuracy_psi",
129
129
  "Sensor", "SimulatorConfig", "DownholeEngine",
@@ -285,7 +285,7 @@ def main(argv=None) -> int:
285
285
  "add-seismic", "refresh-seismic", "remove-seismic", "sar-film", "refresh-all", "add-track", "refresh-track", "remove-track",
286
286
  "seismic-dataset", "seismic-field", "add-site", "sites", "site-report", "remove-site", "rename",
287
287
  "set-well", "sra-define", "sra-report", "associate", "osdu-export", "quakeml-export", "well-identity", "vibration-report",
288
- "add-live-station", "live-fold", "live-run"])
288
+ "add-live-station", "live-fold", "live-run", "month-end"])
289
289
  p_ws.add_argument("--stations", type=str, nargs="+", default=None, help="add-track: two or more array station ids of this workspace")
290
290
  p_ws.add_argument("--site", type=str, default=None, help="site-report/remove-site: the site id")
291
291
  p_ws.add_argument("--api", type=str, default=None, help="set-well: the API number")
@@ -307,7 +307,7 @@ def main(argv=None) -> int:
307
307
  p_ws.add_argument("--checkpoint-months", type=int, default=3, help="sra-define: months between checkpoints with Commission staff (3)")
308
308
  p_ws.add_argument("--formation-boundary", type=str, default="base of the Wolfcamp", help="sra-define: the stratigraphic boundary between the two tiers")
309
309
  p_ws.add_argument("--triggering-event", type=str, default="", help="sra-define: the catalogue id of the event the plan answers")
310
- p_ws.add_argument("--catalog", type=str, default=None, help="sra-report: a TexNet earthquake-catalogue export (CSV)")
310
+ p_ws.add_argument("--catalog", type=str, default=None, help="sra-report / month-end: a TexNet earthquake-catalogue export (CSV)")
311
311
  p_ws.add_argument("--aftershocks", type=str, nargs="*", default=None, help="sra-report: catalogue ids the operator declares as aftershocks (exempt)")
312
312
  p_ws.add_argument("--start", type=str, default=None, help="sra-report: first day of the record, YYYY-MM-DD")
313
313
  p_ws.add_argument("--end", type=str, default=None, help="sra-report: last day of the record, YYYY-MM-DD")
@@ -359,6 +359,8 @@ def main(argv=None) -> int:
359
359
  p_ws.add_argument("--seedlink-config", type=str, default=None, help="add-live-station: the SeedLink config JSON (gea seedlink --write-example-config)")
360
360
  p_ws.add_argument("--include-open", action="store_true", help="live-fold: fold the current day's files too (they are still being written)")
361
361
  p_ws.add_argument("--seconds", type=float, default=None, help="live-run: how long to run (default: until Ctrl+C)")
362
+ p_ws.add_argument("--period", type=str, default="previous", help="month-end: the month, YYYY-MM, or 'previous' (the last closed month)")
363
+ p_ws.add_argument("--no-refresh", action="store_true", help="month-end: collect the reports as they stand without refreshing first")
362
364
  p_ws.add_argument("--name", type=str, default=None, help="init: site name; add-*: display name")
363
365
  p_ws.add_argument("--file", type=str, default=None, help="add-file: the client's data file; add-live: the port configuration JSON")
364
366
  p_ws.add_argument("--entry", type=str, default=None, help="add-catalog: catalogue entry")
@@ -1944,6 +1946,10 @@ def main(argv=None) -> int:
1944
1946
  raise SystemExit("well-identity needs --well <id>")
1945
1947
  r = ws.well_identity(a.well)
1946
1948
  print(_ppdm_text(r)); return 0
1949
+ elif a.action == "month-end":
1950
+ from .month_end import report_text as _me_text
1951
+ r = ws.month_end(a.period, actor=a.actor, refresh=not a.no_refresh, catalog_csv=a.catalog)
1952
+ print(_me_text(r)); return 0 if not r["errors"] else 1
1947
1953
  elif a.action == "add-live-station":
1948
1954
  if not a.seedlink_config or not a.name:
1949
1955
  raise SystemExit("add-live-station needs --name <display> and --seedlink-config <json>")
@@ -5872,6 +5872,130 @@ def section_bl_backup(tmp: str) -> None:
5872
5872
  "`--verify` says OK or names the tampered file, `--restore` brings the accounts back; the help page says a backup never restored is a hope"
5873
5873
  + (f" [failed: {', '.join(failed2)}; r_st0={r_st0.stdout[-120:]!r} {r_st0.stderr[-120:]!r}]" if failed2 else ""))
5874
5874
 
5875
+
5876
+ def section_bm_month_end(tmp: str) -> None:
5877
+ """Section BM - the month-end packet: the refresh for the period, every report collected with INCLUDED or NOT
5878
+ AVAILABLE and the reason, the index and the manifest, the archive, the records; the page, the job and the
5879
+ monthly schedule."""
5880
+ import http.cookiejar
5881
+ import subprocess as _sp
5882
+ import urllib.request
5883
+ import urllib.error
5884
+ import zipfile
5885
+ from . import month_end as ME
5886
+ from . import helplib as H
5887
+ from .telemetry import TelemetryRecorder, TelemetryConfig
5888
+ from .downhole_engine import DownholeEngine, SimulatorConfig
5889
+ from .workspace import Workspace
5890
+ from .service import Service, Users
5891
+ pkg = Path(__file__).parent
5892
+ d = Path(tmp, "bm"); d.mkdir()
5893
+ hist = str(d / "hist.csv")
5894
+ TelemetryRecorder(engine=DownholeEngine(SimulatorConfig()), config=TelemetryConfig(duration_hours=2.0, seed=5)).run().export_csv(hist)
5895
+ wsp = str(d / "ws"); ws = Workspace.create(wsp, "Pad 3", actor="tester")
5896
+ w = ws.add_well_file(hist, display="SWD 1", actor="tester")
5897
+ site = ws.add_site("Pad 3 site", wells=[w["id"]], actor="tester")
5898
+ bad_period = None
5899
+ try:
5900
+ ME.period_bounds("2026-9")
5901
+ except ValueError as e:
5902
+ bad_period = str(e)
5903
+ pb = ME.period_bounds("2026-02")
5904
+ rec = ME.assemble(ws, "2026-09", actor="tester")
5905
+ idx = json.loads((Path(rec["folder"]) / "INDEX.json").read_text(encoding="utf-8"))
5906
+ man = json.loads((Path(rec["folder"]) / "MANIFEST.json").read_text(encoding="utf-8"))
5907
+ by_name = {i["name"]: i for i in idx["items"]}
5908
+ v_folder = ME.verify(rec["folder"]); v_zip = ME.verify(rec["archive"])
5909
+ with zipfile.ZipFile(rec["archive"]) as z:
5910
+ znames = set(z.namelist())
5911
+ # a second assembly of the same period rebuilds whole; a future month is OPEN
5912
+ rec2 = ME.assemble(ws, "2026-09", actor="tester", refresh=False)
5913
+ from datetime import datetime as _dt, timezone as _tz
5914
+ now = _dt.now(_tz.utc)
5915
+ cur = f"{now.year:04d}-{now.month:02d}"
5916
+ rec3 = ME.assemble(ws, cur, actor="tester", refresh=False)
5917
+ pk = ME.packets(ws)
5918
+ audit = [a["action"] for a in ws.audit_log()]
5919
+ c1 = {
5920
+ "period_checked": bool(bad_period) and "YYYY-MM" in bad_period and pb["days"] == 28 and pb["start"] == "2026-02-01T00:00:00Z" and pb["end"] == "2026-02-28T23:59:59Z" and pb["closed"] is True,
5921
+ "included": by_name["SWD 1: gauge drift report"]["status"] == "INCLUDED" and by_name["SWD 1: alarm event report"]["status"] == "INCLUDED"
5922
+ and by_name["SLA month 2026-09"]["status"] == "INCLUDED" and by_name["Accuracy statement"]["status"] == "INCLUDED"
5923
+ and by_name["Pad 3 site: site report"]["status"] == "INCLUDED" and by_name["Model cards and SBOM"]["status"] == "INCLUDED",
5924
+ "not_available_with_reason": by_name["SWD 1: machine vibration"]["status"] == "NOT AVAILABLE" and "no machine record" in by_name["SWD 1: machine vibration"]["reason"]
5925
+ and by_name["Pad 3 site: SRA packet for 2026-09"]["status"] == "NOT AVAILABLE" and "no Seismicity Response Area" in by_name["Pad 3 site: SRA packet for 2026-09"]["reason"]
5926
+ and "no certificate register" in by_name["SWD 1: certificate conformity (ISO/IEC 17025 7.8)"]["reason"],
5927
+ "files_hashed": all(f["sha256"] and f["bytes"] > 0 for i in idx["items"] for f in i["files"]) and man["n_files"] == len(man["files"]) and man["n_files"] >= 20
5928
+ and "wells/SWD-1/gauge_drift_report.html" in man["files"] and "site/sla_report_2026-09.html" in man["files"] and "INDEX.html" in man["files"],
5929
+ "record": rec["included"] == len([i for i in idx["items"] if i["status"] == "INCLUDED"]) and rec["closed"] is True and rec["errors"] == [] and os.path.isfile(rec["archive"])
5930
+ and os.path.isfile(rec["archive"] + "") and rec["archive"].endswith("month_end_Pad-3_2026-09.zip"),
5931
+ "verify": v_folder["status"] == "OK" and v_zip["status"] == "OK" and v_zip["n_files"] == man["n_files"] and "MANIFEST.json" in znames and "INDEX.html" in znames,
5932
+ "rebuilt_whole_and_open": rec2["generated_utc"] >= rec["generated_utc"] and rec2["refresh"] is False and rec3["closed"] is False and "OPEN" in (Path(rec3["folder"]) / "INDEX.md").read_text(encoding="utf-8")
5933
+ and len(pk) == 3 and all(p["present"] for p in pk) and pk[-1]["period"] == cur,
5934
+ "audited": audit.count("month_end.assemble") == 3 and "dashboard.refresh" in audit and "site.report" in audit,
5935
+ "not_a_measurement": "does not call a measurement" in (Path(rec["folder"]) / "INDEX.md").read_text(encoding="utf-8"),
5936
+ }
5937
+ failed1 = [k for k, v in c1.items() if not v]
5938
+ ok(not failed1,
5939
+ "BM1 the month-end packet: the period checked (YYYY-MM; February's 28 days; a past month closed), the refresh run for it, every report "
5940
+ "collected - the well's drift and alarm reports, the SLA month, the accuracy statement, the site report, the model cards - INCLUDED with "
5941
+ "hashed files, and the vibration report, the SRA packet and the certificate conformity NOT AVAILABLE with the reason that names what the "
5942
+ "site lacks; INDEX and MANIFEST written, the archive verifies against the manifest, a second assembly rebuilds the period whole, the "
5943
+ "current month is marked OPEN, every packet on record, every assembly in the audit"
5944
+ + (f" [failed: {', '.join(failed1)}]" if failed1 else ""))
5945
+
5946
+ # -- the page, the job, the schedule, the CLI, the help --------------------------------------------------------
5947
+ users = Users(str(Path(wsp, "users.json"))); users.add("op", "operator-pass-1", "operator"); users.add("adm", "admin-pass-1", "admin")
5948
+ svc = Service(wsp, port=0, scheduler=False).start()
5949
+ base = svc.url.rstrip("/")
5950
+ opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
5951
+
5952
+ def call(method, path, body=None):
5953
+ req = urllib.request.Request(base + path, method=method, data=(json.dumps(body).encode() if body is not None else None))
5954
+ req.add_header("Content-Type", "application/json"); req.add_header("X-GEA-Action", "1")
5955
+ try:
5956
+ with opener.open(req, timeout=300) as rr_:
5957
+ return rr_.status, json.loads(rr_.read())
5958
+ except urllib.error.HTTPError as e:
5959
+ return e.code, json.loads(e.read())
5960
+ try:
5961
+ call("POST", "/api/login", {"name": "op", "password": "operator-pass-1"})
5962
+ st_l, lst = call("GET", "/api/month-end")
5963
+ st_bad = call("POST", "/api/month-end/run", {"period": "Sept 2026"})[0]
5964
+ st_sched_op = call("POST", "/api/month-end/schedule", {"day": 1, "at": "03:00"})[0]
5965
+ st_run, job = call("POST", "/api/month-end/run", {"period": "2026-08", "no_refresh": True})
5966
+ done = svc.app.runner.wait(job["id"], 300)
5967
+ st_l2, lst2 = call("GET", "/api/month-end")
5968
+ req = urllib.request.Request(base + "/reports/month_end/2026-08/INDEX.html"); req.add_header("X-GEA-Action", "1")
5969
+ with opener.open(req, timeout=60) as rr_:
5970
+ html_ok = rr_.status == 200 and b"Month-End Packet" in rr_.read()
5971
+ call("POST", "/api/login", {"name": "adm", "password": "admin-pass-1"})
5972
+ st_sched_bad = call("POST", "/api/month-end/schedule", {"day": 31, "at": "03:00"})[0]
5973
+ st_sched, sch = call("POST", "/api/month-end/schedule", {"day": 2, "at": "03:15"})
5974
+ st_l3, lst3 = call("GET", "/api/month-end")
5975
+ finally:
5976
+ svc.stop()
5977
+ page_src = (pkg / "web" / "app.html").read_text(encoding="utf-8")
5978
+ help_md = (pkg / "help" / "month-end.md").read_text(encoding="utf-8")
5979
+ r_cli = _sp.run([sys.executable, "-m", "gea", "workspace", "--path", wsp, "--action", "month-end", "--period", "2026-07", "--no-refresh"], capture_output=True, text=True)
5980
+ r_prev = _sp.run([sys.executable, "-m", "gea", "workspace", "--path", wsp, "--action", "month-end", "--no-refresh"], capture_output=True, text=True)
5981
+ c2 = {
5982
+ "list": st_l == 200 and len(lst["packets"]) == 3 and lst["previous"] == ME.previous_period() and lst["schedule"] is None,
5983
+ "refusals": st_bad == 400 and st_sched_op == 403 and st_sched_bad == 400,
5984
+ "job": st_run == 200 and done["status"] == "DONE" and st_l2 == 200 and any(p["period"] == "2026-08" and p["present"] for p in lst2["packets"]) and html_ok,
5985
+ "schedule": st_sched == 200 and sch["name"] == "month-end-monthly" and sch["monthly_day"] == 2 and sch["daily_at"] == "03:15" and "previous" in sch["args"]
5986
+ and st_l3 == 200 and lst3["schedule"]["name"] == "month-end-monthly",
5987
+ "page": "Month-end packet" in page_src and "/api/month-end/run" in page_src and "/api/month-end/schedule" in page_src,
5988
+ "cli": r_cli.returncode == 0 and "month-end 2026-07 (closed)" in r_cli.stdout and r_prev.returncode == 0 and f"month-end {ME.previous_period()} (closed)" in r_prev.stdout,
5989
+ "help": any(t["topic"] == "month-end" for t in H.topics()) and not H.check("month-end") and "does not recompute" in help_md.split("will not call a measurement")[1],
5990
+ }
5991
+ failed2 = [k for k, v in c2.items() if not v]
5992
+ ok(not failed2,
5993
+ "BM2 the Reports page carries the packet card: the packets on record with their index and archive, the period defaulting to the last closed "
5994
+ "month, Assemble as a job (a malformed period refused) whose INDEX the page then serves, Schedule monthly for an administrator only (day 1..28) "
5995
+ "as the `month-end-monthly` entry for the previous month; `gea workspace --action month-end` with a period or with 'previous'; the help page "
5996
+ "says the index collects and does not recompute"
5997
+ + (f" [failed: {', '.join(failed2)}; cli={r_cli.stdout[-200:]!r} {r_cli.stderr[-200:]!r}]" if failed2 else ""))
5998
+
5875
5999
  def main() -> int:
5876
6000
  print("GEA-Program - ACCEPTANCE SUITE (the product gate)")
5877
6001
  # a handle a job subprocess still holds at the end must not turn a finished gate into a traceback on Windows:
@@ -5939,6 +6063,7 @@ def main() -> int:
5939
6063
  section_bj_etp(tmp)
5940
6064
  section_bk_seedlink(tmp)
5941
6065
  section_bl_backup(tmp)
6066
+ section_bm_month_end(tmp)
5942
6067
  if _FAILS:
5943
6068
  print(f"[ACCEPTANCE] {len(_FAILS)} FAILURES ({_PASS} passed):")
5944
6069
  for f in _FAILS:
@@ -0,0 +1,11 @@
1
+ # The month-end packet
2
+
3
+ > A client's month was five or six commands and a walk through the reports folder. The packet is that month assembled once: the refresh for the period, then every report the program writes for every well, site, station and track, collected into one dated folder and one archive, with an index that says item by item what is there and what is not, and a manifest of every file's hash.
4
+
5
+ **The command**: `gea workspace --path C:\site --action month-end --period 2026-09` (or `--period previous`, the last closed month; `--no-refresh` collects the reports as they stand; `--catalog` hands the regulator's catalogue CSV to the SRA packet). On the dashboard: Reports -> Month-end packet -> the period and Assemble (a job), and for an administrator Schedule monthly (a schedule entry, `month-end-monthly`, on the day and hour given, for the previous month). The result is `reports/month_end/<period>/` with `INDEX.html` / `.md` / `.json` and `MANIFEST.json`, and `reports/month_end/month_end_<site>_<period>.zip`; each packet is logged in `records/month_end.jsonl` and in the audit (`month_end.assemble`).
6
+
7
+ **What it writes**: for the site - the dashboard index, the accuracy statement, the SLA month for that period, the FAT/SAT protocol, the model cards and the SBOM; for every well - the gauge drift report, the alarm event report, the well-test validation, the shut-in / build-up report, the data resilience report, the machine vibration report, and the certificate register's conformity against ISO/IEC 17025 7.8 as it stands; for every site - the SRA packet for the period (when a Seismicity Response Area is defined) and the site report; for every seismic station and track - their reports. Each item is INCLUDED with its files or NOT AVAILABLE with the reason (no machine record, no SRA defined, no certificate register, no well test detected, never refreshed); every step that failed is listed; a month that has not ended is marked OPEN.
8
+
9
+ **The number to check**: `N INCLUDED, M NOT AVAILABLE, 0 FAILED` on the index's front page, and `closed: yes` for a month that has ended.
10
+
11
+ **What this page will not call a measurement**: an item that is NOT AVAILABLE - the reason names what the site lacks, and the packet never stands in for it. A number in any of the reports - each carries its own method and declarations; the index collects and does not recompute. A month that has not ended - the packet says OPEN and the one assembled after the month closes supersedes it. A packet whose files no longer match its manifest - `MANIFEST.json` is the check, and a packet is rebuilt whole, never merged.
@@ -46,6 +46,7 @@ INDEX: List[tuple] = [
46
46
  ('conformance', 'Conformity, in the standards\' words', 'certificates against ISO/IEC 17025 7.8 and the bias against its class under a named ILAC-G8 decision rule'),
47
47
  ('etp', 'WITSML 2.x over ETP', 'the live port on Energistics ETP v1.2: subscribe to the store\'s channels and it pushes'),
48
48
  ('seedlink', 'Live stations (SeedLink)', 'the seismic leg\'s live port: a station\'s records as it writes them, resumed by sequence number, folded into its record list'),
49
+ ('month-end', 'The month-end packet', 'the month\'s deliverable assembled once: every report collected, INCLUDED or NOT AVAILABLE with the reason, hashed'),
49
50
  ('backup', 'The backup', 'the site copied off the machine, verified, and restored once before it is trusted'),
50
51
  ('doctor', 'Which code is running', 'gea doctor and the serve start-up gate'),
51
52
  ('audit-update', 'Audit / Update', 'the whole audit log with filters, the program against PyPI, every report against its source'),
@@ -0,0 +1,290 @@
1
+ """month_end - the month's deliverable, assembled once, from the reports the program already writes.
2
+
3
+ A client's month has, until now, been five or six commands and a walk through reports/: the drift report of
4
+ each well, the alarm month, the well-test validations, the vibration report where a machine record exists,
5
+ the certificate conformity, the SRA packet of each site that has one, the site report, the seismic station
6
+ and track reports, the accuracy statement and the SLA month. This module runs the refresh for the period,
7
+ then collects every one of those into one dated folder and one archive - with an index that says, item by
8
+ item, INCLUDED (with the file's hash) or NOT AVAILABLE (with the reason: no machine record, no SRA defined,
9
+ no certificate register, never refreshed) - and a manifest of every file. Nothing is recomputed here beyond
10
+ the refresh the program would run anyway; the packet is what the reports said, collected, so it can be
11
+ handed over and later verified against the files it came from.
12
+
13
+ What it will not do: make up an item that has no source (it is listed as NOT AVAILABLE with the reason);
14
+ include a report older than the refresh that produced the packet unless told to skip the refresh (and then
15
+ the index says every report's own generated time); name a month that has not ended as closed (it is marked
16
+ OPEN).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import calendar
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import shutil
25
+ import zipfile
26
+ from datetime import datetime, timezone
27
+ from typing import Dict, List, Optional
28
+
29
+ PROTOCOL = 'gea.month_end/1'
30
+
31
+
32
+ def _utc() -> str:
33
+ return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
34
+
35
+
36
+ def _sha256(path: str) -> str:
37
+ h = hashlib.sha256()
38
+ with open(path, 'rb') as f:
39
+ for chunk in iter(lambda: f.read(1 << 20), b''):
40
+ h.update(chunk)
41
+ return h.hexdigest()
42
+
43
+
44
+ def period_bounds(period: str) -> Dict[str, str]:
45
+ """'2026-09' -> the first and last instants of that month (UTC) and whether the month has ended."""
46
+ try:
47
+ y, m = int(period[:4]), int(period[5:7])
48
+ if period[4] != '-' or not (1 <= m <= 12) or len(period) != 7:
49
+ raise ValueError
50
+ except (ValueError, IndexError):
51
+ raise ValueError(f'period must be YYYY-MM, got {period!r}')
52
+ last = calendar.monthrange(y, m)[1]
53
+ start = f'{y:04d}-{m:02d}-01T00:00:00Z'
54
+ end = f'{y:04d}-{m:02d}-{last:02d}T23:59:59Z'
55
+ now = datetime.now(timezone.utc)
56
+ closed = (y, m) < (now.year, now.month)
57
+ return {'period': period, 'start': start, 'end': end, 'closed': closed, 'days': last}
58
+
59
+
60
+ def previous_period(now: Optional[datetime] = None) -> str:
61
+ """The last closed month as YYYY-MM - what a monthly schedule entry asks for."""
62
+ now = now or datetime.now(timezone.utc)
63
+ y, m = (now.year, now.month - 1) if now.month > 1 else (now.year - 1, 12)
64
+ return f'{y:04d}-{m:02d}'
65
+
66
+
67
+ def _copy_family(src_dir: str, names: List[str], dst_dir: str) -> List[dict]:
68
+ """Copy the files of one report family (any extension of each basename) and return their entries."""
69
+ out = []
70
+ if not os.path.isdir(src_dir):
71
+ return out
72
+ for fn in sorted(os.listdir(src_dir)):
73
+ stem = fn.split('.')[0]
74
+ if any(fn == n or fn.startswith(n + '.') or fn.startswith(n + '_') for n in names) or stem in names:
75
+ s = os.path.join(src_dir, fn)
76
+ if os.path.isfile(s):
77
+ os.makedirs(dst_dir, exist_ok=True)
78
+ d = os.path.join(dst_dir, fn)
79
+ shutil.copyfile(s, d)
80
+ out.append({'file': fn, 'sha256': _sha256(d), 'bytes': os.path.getsize(d),
81
+ 'generated_utc': datetime.fromtimestamp(os.path.getmtime(s), timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')})
82
+ return out
83
+
84
+
85
+ def assemble(ws, period: str, actor: str = 'system', refresh: bool = True, catalog_csv: Optional[str] = None) -> dict:
86
+ """Run the refresh for the period (unless refresh=False), collect every report into reports/month_end/<period>/,
87
+ write INDEX (markdown, html, json) and MANIFEST.json, zip it, audit it. Returns the packet record."""
88
+ from . import __version__
89
+ from .client_reports import Document, Section, Table, write as _write, PROGRAM_NAME
90
+ pb = period_bounds(period)
91
+ items: List[dict] = []
92
+ errors: List[str] = []
93
+ if refresh:
94
+ try:
95
+ ws.refresh_dashboard(actor=actor, month=period)
96
+ except Exception as e: # the refresh's own failure is an item, never a silent gap
97
+ errors.append(f'dashboard refresh: {e}')
98
+ for st in ws.seismic_stations():
99
+ try:
100
+ ws.refresh_seismic(st['id'], actor=actor)
101
+ except Exception as e:
102
+ errors.append(f"seismic {st['id']}: {e}")
103
+ for tk in ws.tracks():
104
+ try:
105
+ ws.refresh_track(tk['id'], actor=actor)
106
+ except Exception as e:
107
+ errors.append(f"track {tk['id']}: {e}")
108
+ out = ws.dir('reports', 'month_end', period)
109
+ for old in os.listdir(out): # a packet for a period is rebuilt whole, never merged
110
+ p = os.path.join(out, old)
111
+ shutil.rmtree(p) if os.path.isdir(p) else os.remove(p)
112
+ rep = ws.reports_dir
113
+
114
+ def item(kind: str, ident: str, name: str, files: List[dict], reason: str = '') -> None:
115
+ items.append({'kind': kind, 'id': ident, 'name': name, 'status': 'INCLUDED' if files else 'NOT AVAILABLE',
116
+ 'reason': '' if files else (reason or 'no report written'), 'files': files})
117
+
118
+ # -- the site as a whole ---------------------------------------------------------------------------------------
119
+ item('site', '-', 'Dashboard index', _copy_family(rep, ['index', 'dashboard'], os.path.join(out, 'site')), 'never refreshed')
120
+ item('site', '-', 'Accuracy statement', _copy_family(rep, ['accuracy_statement'], os.path.join(out, 'site')), 'no accuracy statement written')
121
+ item('site', '-', f'SLA month {period}', _copy_family(rep, [f'sla_report_{period}'], os.path.join(out, 'site')),
122
+ 'the SLA month is written by the refresh with --month; none for this period')
123
+ item('site', '-', 'FAT/SAT protocol', _copy_family(rep, ['fat_protocol', 'sat_protocol'], os.path.join(out, 'site')), 'no protocol written (gea fat-sat, or the refresh with --sat)')
124
+ item('site', '-', 'Model cards and SBOM', _copy_family(rep, ['model_cards_index', 'sbom'], os.path.join(out, 'site')), 'never refreshed')
125
+ # -- every well -------------------------------------------------------------------------------------------------
126
+ for w in ws.wells():
127
+ wd = os.path.join(rep, 'wells', w['id'])
128
+ dst = os.path.join(out, 'wells', w['id'])
129
+ item('well', w['id'], f"{w['display']}: gauge drift report", _copy_family(wd, ['gauge_drift_report'], dst), 'never refreshed')
130
+ item('well', w['id'], f"{w['display']}: alarm event report", _copy_family(wd, ['alarm_event_report'], dst), 'no alarm report (no alarm config, or never refreshed)')
131
+ item('well', w['id'], f"{w['display']}: well-test validation", _copy_family(wd, ['well_test_validation'], dst), 'no well test detected in the record, or never refreshed')
132
+ item('well', w['id'], f"{w['display']}: shut-in / build-up", _copy_family(wd, ['pressure_transient_report'], dst), 'no shut-in found in the record, or never refreshed')
133
+ item('well', w['id'], f"{w['display']}: data resilience", _copy_family(wd, ['data_resilience_report'], dst), 'no store-and-forward outage was given to the refresh')
134
+ item('well', w['id'], f"{w['display']}: machine vibration", _copy_family(wd, ['vibration_report'], dst),
135
+ 'no machine record for this well (gea workspace --action vibration-report)')
136
+ # certificate conformity: the register's standing, written here from the register as it stands
137
+ reg = os.path.join(ws.path, 'wells', w['id'], 'records', 'certificates.jsonl')
138
+ if os.path.isfile(reg):
139
+ try:
140
+ from .certificates import CertificateRegister
141
+ conf = CertificateRegister(reg).conformance()
142
+ os.makedirs(dst, exist_ok=True)
143
+ p = os.path.join(dst, 'certificate_conformance.json')
144
+ with open(p, 'w', encoding='utf-8') as f:
145
+ json.dump(conf, f, indent=1, default=str)
146
+ item('well', w['id'], f"{w['display']}: certificate conformity (ISO/IEC 17025 7.8)",
147
+ [{'file': 'certificate_conformance.json', 'sha256': _sha256(p), 'bytes': os.path.getsize(p), 'generated_utc': _utc()}])
148
+ except Exception as e:
149
+ item('well', w['id'], f"{w['display']}: certificate conformity (ISO/IEC 17025 7.8)", [], f'register could not be read: {e}')
150
+ else:
151
+ item('well', w['id'], f"{w['display']}: certificate conformity (ISO/IEC 17025 7.8)", [], 'no certificate register for this well')
152
+ # -- every site: the SRA packet and the site report ---------------------------------------------------------------
153
+ for st in ws.sites():
154
+ sd = os.path.join(rep, 'sites', st['id'])
155
+ dst = os.path.join(out, 'sites', st['id'])
156
+ if st.get('sra'):
157
+ try:
158
+ ws.write_sra_packet(st['id'], catalog_csv=catalog_csv, start=pb['start'], end=pb['end'], actor=actor)
159
+ except Exception as e:
160
+ errors.append(f"sra {st['id']}: {e}")
161
+ item('site-sra', st['id'], f"{st['display']}: SRA packet for {period}", _copy_family(sd, ['sra_packet'], dst), 'the SRA packet failed to write (see errors)')
162
+ else:
163
+ item('site-sra', st['id'], f"{st['display']}: SRA packet for {period}", [], 'no Seismicity Response Area defined for this site (gea workspace --action sra-define)')
164
+ try:
165
+ ws.write_site_report(st['id'], actor=actor)
166
+ except Exception as e:
167
+ errors.append(f"site report {st['id']}: {e}")
168
+ item('site-report', st['id'], f"{st['display']}: site report", _copy_family(sd, ['site_report', 'association', 'quakeml'], dst), 'the site report failed to write')
169
+ # -- seismic stations and tracks --------------------------------------------------------------------------------
170
+ for s in ws.seismic_stations():
171
+ item('seismic', s['id'], f"{s['display']}: seismic station report",
172
+ _copy_family(os.path.join(rep, 'seismic', s['id']), ['seismic_station_report', 'summary', 'detect', 'lines'], os.path.join(out, 'seismic', s['id'])), 'never refreshed')
173
+ for t in ws.tracks():
174
+ item('track', t['id'], f"{t['display']}: seismic track report",
175
+ _copy_family(os.path.join(rep, 'tracks', t['id']), ['seismic_track_report', 'summary'], os.path.join(out, 'tracks', t['id'])), 'never refreshed')
176
+ # -- the index and the manifest ----------------------------------------------------------------------------------
177
+ inc = [i for i in items if i['status'] == 'INCLUDED']
178
+ na = [i for i in items if i['status'] != 'INCLUDED']
179
+ now = _utc()
180
+ report_id = f"MONTH-{period}-{now.replace('-', '').replace(':', '')}"
181
+ rows = [[i['kind'], i['id'], i['name'], i['status'], i['reason'] or ', '.join(f['file'] for f in i['files'])] for i in items]
182
+ secs = [Section('1', 'The month', [
183
+ f"{ws.manifest['name']}: the deliverable for {period} ({pb['start'][:10]} to {pb['end'][:10]}, {pb['days']} days), "
184
+ f"{'a closed month' if pb['closed'] else 'an OPEN month - the period has not ended, and this packet will be superseded'}. "
185
+ f"{len(inc)} item(s) included, {len(na)} not available. "
186
+ + ('The refresh ran before collection, so every report is from the sources as they stand now. ' if refresh else
187
+ 'The refresh was skipped: each report carries its own generated time below, which may predate this packet. ')
188
+ + (f"{len(errors)} step(s) failed and are listed in section 3." if errors else 'No step failed.')]),
189
+ Section('2', 'What is in the packet', ['Every report the program writes for a well, a site, a station or a track, collected as written - '
190
+ 'INCLUDED with the file names, or NOT AVAILABLE with the reason. Hashes are in MANIFEST.json.'],
191
+ [Table(['Kind', 'Id', 'Item', 'Status', 'Files / reason'], rows)]),
192
+ Section('3', 'What failed', errors or ['Nothing.']),
193
+ Section('4', 'What this packet does not call a measurement',
194
+ ['An item that is NOT AVAILABLE - the reason names what the site lacks, and the packet does not stand in for it.',
195
+ 'A number in any report - each report carries its own method and its own declarations; this index collects, it does not recompute.',
196
+ 'A month that has not ended - the packet says OPEN and is superseded by the one made after the month closes.'])]
197
+ front = [['Report ID', report_id], ['Site', ws.manifest['name']], ['Period', period], ['Closed', 'yes' if pb['closed'] else 'NO - open month'],
198
+ ['Generated (UTC)', now], ['Program', f'{PROGRAM_NAME} build {__version__}'], ['Result', f'{len(inc)} INCLUDED, {len(na)} NOT AVAILABLE, {len(errors)} FAILED']]
199
+ doc = Document(title=f'{PROGRAM_NAME} - Month-End Packet', report_id=report_id, front=front, sections=secs,
200
+ footer='Collected from the reports as written; nothing is recomputed in this index.',
201
+ data={'protocol': PROTOCOL, 'report_id': report_id, 'site': ws.manifest['name'], 'period': pb, 'generated_utc': now,
202
+ 'program_version': __version__, 'refresh': refresh, 'items': items, 'errors': errors, '_records': [], '_catalogue_obj': None})
203
+ paths = _write(doc, out, 'INDEX')
204
+ # the manifest: every file in the packet folder
205
+ files = {}
206
+ for root, _, fns in os.walk(out):
207
+ for fn in sorted(fns):
208
+ if fn == 'MANIFEST.json':
209
+ continue
210
+ p = os.path.join(root, fn)
211
+ rel = os.path.relpath(p, out).replace(os.sep, '/')
212
+ files[rel] = {'sha256': _sha256(p), 'bytes': os.path.getsize(p)}
213
+ manifest = {'protocol': PROTOCOL, 'site': ws.manifest['name'], 'period': period, 'closed': pb['closed'], 'generated_utc': now, 'program_version': __version__,
214
+ 'report_id': report_id, 'n_files': len(files), 'files': files, 'included': len(inc), 'not_available': len(na), 'errors': errors}
215
+ with open(os.path.join(out, 'MANIFEST.json'), 'w', encoding='utf-8') as f:
216
+ json.dump(manifest, f, indent=1)
217
+ slug = ''.join(c if c.isalnum() or c in '-_' else '-' for c in ws.manifest['name']).strip('-') or 'site'
218
+ zpath = os.path.join(ws.dir('reports', 'month_end'), f'month_end_{slug}_{period}.zip')
219
+ with zipfile.ZipFile(zpath + '.part', 'w', compression=zipfile.ZIP_DEFLATED) as z:
220
+ for root, _, fns in os.walk(out):
221
+ for fn in sorted(fns):
222
+ p = os.path.join(root, fn)
223
+ z.write(p, os.path.relpath(p, out).replace(os.sep, '/'))
224
+ os.replace(zpath + '.part', zpath)
225
+ rec = {'period': period, 'closed': pb['closed'], 'folder': out, 'archive': zpath, 'archive_sha256': _sha256(zpath), 'index': paths['html'], 'report_id': report_id,
226
+ 'included': len(inc), 'not_available': len(na), 'errors': errors, 'n_files': len(files), 'generated_utc': now, 'refresh': refresh}
227
+ with open(os.path.join(ws.dir('records'), 'month_end.jsonl'), 'a', encoding='utf-8') as f:
228
+ f.write(json.dumps(rec) + '\n')
229
+ ws.audit(actor, 'month_end.assemble', {k: rec[k] for k in ('period', 'closed', 'archive', 'archive_sha256', 'included', 'not_available', 'n_files')}
230
+ | ({'errors': errors} if errors else {}))
231
+ return rec
232
+
233
+
234
+ def packets(ws) -> List[dict]:
235
+ """Every packet assembled from this workspace, newest last, with whether its archive is still there."""
236
+ p = os.path.join(ws.path, 'records', 'month_end.jsonl')
237
+ out = []
238
+ if os.path.isfile(p):
239
+ with open(p, encoding='utf-8') as f:
240
+ for line in f:
241
+ if line.strip():
242
+ try:
243
+ r = json.loads(line)
244
+ except ValueError:
245
+ continue
246
+ r['present'] = os.path.isfile(r.get('archive', ''))
247
+ out.append(r)
248
+ return out
249
+
250
+
251
+ def verify(folder_or_zip: str) -> dict:
252
+ """A packet folder or its archive against its MANIFEST.json."""
253
+ out = {'path': folder_or_zip, 'status': 'OK', 'n_files': 0, 'mismatched': [], 'missing': []}
254
+ if zipfile.is_zipfile(folder_or_zip):
255
+ with zipfile.ZipFile(folder_or_zip) as z:
256
+ if 'MANIFEST.json' not in z.namelist():
257
+ return {**out, 'status': 'FAIL', 'error': 'no MANIFEST.json'}
258
+ man = json.loads(z.read('MANIFEST.json'))
259
+ names = set(z.namelist())
260
+ for rel, meta in man['files'].items():
261
+ if rel not in names:
262
+ out['missing'].append(rel); continue
263
+ if hashlib.sha256(z.read(rel)).hexdigest() != meta['sha256']:
264
+ out['mismatched'].append(rel)
265
+ out['n_files'] += 1
266
+ else:
267
+ mp = os.path.join(folder_or_zip, 'MANIFEST.json')
268
+ if not os.path.isfile(mp):
269
+ return {**out, 'status': 'FAIL', 'error': 'no MANIFEST.json'}
270
+ man = json.load(open(mp, encoding='utf-8'))
271
+ for rel, meta in man['files'].items():
272
+ p = os.path.join(folder_or_zip, *rel.split('/'))
273
+ if not os.path.isfile(p):
274
+ out['missing'].append(rel); continue
275
+ if _sha256(p) != meta['sha256']:
276
+ out['mismatched'].append(rel)
277
+ out['n_files'] += 1
278
+ out['period'] = man.get('period'); out['site'] = man.get('site'); out['generated_utc'] = man.get('generated_utc')
279
+ if out['mismatched'] or out['missing']:
280
+ out['status'] = 'FAIL'
281
+ return out
282
+
283
+
284
+ def report_text(rec: dict) -> str:
285
+ lines = [f"month-end {rec['period']} ({'closed' if rec['closed'] else 'OPEN month'}): {rec['included']} included, {rec['not_available']} not available, "
286
+ f"{len(rec['errors'])} failed; {rec['n_files']} files",
287
+ f" folder: {rec['folder']}", f" archive: {rec['archive']} sha256 {rec['archive_sha256'][:16]}...", f" index: {rec['index']}"]
288
+ for e in rec['errors']:
289
+ lines.append(f" FAILED: {e}")
290
+ return '\n'.join(lines)
@@ -1602,6 +1602,10 @@ def make_handler(app: App):
1602
1602
  return self._json(200, {'entries': app.catalog()})
1603
1603
  if path == '/api/reports':
1604
1604
  return self._json(200, app.reports_index())
1605
+ if path == '/api/month-end':
1606
+ from . import month_end as ME
1607
+ sched = next((e for e in app.scheduler.entries() if e['name'] == 'month-end-monthly'), None)
1608
+ return self._json(200, {'packets': ME.packets(app.ws), 'previous': ME.previous_period(), 'schedule': sched})
1605
1609
  if path == '/api/jobs':
1606
1610
  return self._json(200, {'jobs': app.runner.list(int(qs.get('limit', ['50'])[0]))})
1607
1611
  if path.startswith('/api/jobs/') and path.endswith('/log'):
@@ -1799,6 +1803,29 @@ def make_handler(app: App):
1799
1803
  'detail': ('the launcher will start the control panel again by itself if it stops unexpectedly'
1800
1804
  if on else
1801
1805
  'the launcher will hand the window to a PowerShell prompt when the control panel stops')})
1806
+ if path == '/api/month-end/run':
1807
+ App.require(user, 'operator')
1808
+ from . import month_end as ME
1809
+ period = str(body.get('period') or 'previous')
1810
+ if period != 'previous':
1811
+ try:
1812
+ ME.period_bounds(period)
1813
+ except ValueError as e:
1814
+ raise ApiError(400, str(e))
1815
+ args = ['workspace', '--path', app.ws.path, '--action', 'month-end', '--period', period, '--actor', user['name']]
1816
+ if body.get('no_refresh'):
1817
+ args.append('--no-refresh')
1818
+ return self._json(200, app._job(args, user, f'month-end packet {period}'))
1819
+ if path == '/api/month-end/schedule':
1820
+ App.require(user, 'admin')
1821
+ try:
1822
+ day = int(body.get('day', 1)); at = str(body.get('at') or '03:00')
1823
+ except (TypeError, ValueError):
1824
+ raise ApiError(400, 'day is a whole number 1..28')
1825
+ if not (1 <= day <= 28) or len(at) != 5 or at[2] != ':' or not (at[:2] + at[3:]).isdigit():
1826
+ raise ApiError(400, 'day is 1..28 and at is HH:MM')
1827
+ args = ['workspace', '--path', app.ws.path, '--action', 'month-end', '--period', 'previous', '--actor', 'schedule']
1828
+ return self._json(200, app.scheduler.add('month-end-monthly', args, user['name'], daily_at=at, monthly_day=day))
1802
1829
  if path == '/api/backup/config':
1803
1830
  App.require(user, 'admin')
1804
1831
  return self._json(200, app.backup_set_config(user, body))