algo-cli-runtime 0.14.0__py3-none-any.whl

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 (237) hide show
  1. algo_cli/__init__.py +3 -0
  2. algo_cli/__main__.py +7 -0
  3. algo_cli/_internal/__init__.py +12 -0
  4. algo_cli/_internal/policy_chain.py +259 -0
  5. algo_cli/action_registry.py +1047 -0
  6. algo_cli/agent_blocks.py +550 -0
  7. algo_cli/agent_pipeline.py +1457 -0
  8. algo_cli/agent_threads.py +308 -0
  9. algo_cli/animations.py +316 -0
  10. algo_cli/cache_admission.py +209 -0
  11. algo_cli/capability_mask.py +66 -0
  12. algo_cli/chat_protocol.py +116 -0
  13. algo_cli/chatgpt_auth.py +510 -0
  14. algo_cli/chatgpt_client.py +657 -0
  15. algo_cli/code_rag.py +479 -0
  16. algo_cli/config.py +651 -0
  17. algo_cli/context_budget.py +679 -0
  18. algo_cli/credential_helpers.py +315 -0
  19. algo_cli/deliberation.py +29 -0
  20. algo_cli/display.py +1470 -0
  21. algo_cli/evals/__init__.py +21 -0
  22. algo_cli/evals/algorithm_effectiveness.py +560 -0
  23. algo_cli/evals/competitive_harness_rating.py +702 -0
  24. algo_cli/evals/cot_quality.py +220 -0
  25. algo_cli/evals/harness_retrieval_benchmark.py +401 -0
  26. algo_cli/evals/performance_regression.py +136 -0
  27. algo_cli/evals/scorecard_grading.py +308 -0
  28. algo_cli/evals/session_distribution.py +84 -0
  29. algo_cli/execution_guardrails.py +806 -0
  30. algo_cli/extensions_manifest.py +84 -0
  31. algo_cli/git_evidence.py +227 -0
  32. algo_cli/google_workspace.py +407 -0
  33. algo_cli/google_workspace_auth.py +523 -0
  34. algo_cli/harness.py +2587 -0
  35. algo_cli/identity.py +557 -0
  36. algo_cli/index_compute_lab.py +228 -0
  37. algo_cli/inference_harness.py +70 -0
  38. algo_cli/intelligence/__init__.py +1103 -0
  39. algo_cli/intelligence/acrobat_config.py +307 -0
  40. algo_cli/intelligence/acrobat_manifests.py +338 -0
  41. algo_cli/intelligence/acrobat_models.py +195 -0
  42. algo_cli/intelligence/acrobat_pipeline.py +295 -0
  43. algo_cli/intelligence/acrobat_runtime.py +302 -0
  44. algo_cli/intelligence/acrobat_security.py +261 -0
  45. algo_cli/intelligence/acrobat_workflows.py +226 -0
  46. algo_cli/intelligence/actionability.py +165 -0
  47. algo_cli/intelligence/adversarial_audit.py +136 -0
  48. algo_cli/intelligence/agent_arena.py +92 -0
  49. algo_cli/intelligence/agent_benchmark.py +236 -0
  50. algo_cli/intelligence/agent_runtime.py +171 -0
  51. algo_cli/intelligence/agents_as_tools.py +70 -0
  52. algo_cli/intelligence/artifact_binding.py +80 -0
  53. algo_cli/intelligence/autonomous_engineer.py +1976 -0
  54. algo_cli/intelligence/backpressure.py +99 -0
  55. algo_cli/intelligence/bloom_filter.py +186 -0
  56. algo_cli/intelligence/bonferroni.py +66 -0
  57. algo_cli/intelligence/boundary_compaction.py +98 -0
  58. algo_cli/intelligence/catalog_verifier.py +172 -0
  59. algo_cli/intelligence/cavecrew.py +118 -0
  60. algo_cli/intelligence/changelog.py +176 -0
  61. algo_cli/intelligence/checkpoint_resume.py +92 -0
  62. algo_cli/intelligence/circuit_breaker.py +88 -0
  63. algo_cli/intelligence/clarification_gate.py +101 -0
  64. algo_cli/intelligence/code_graph.py +180 -0
  65. algo_cli/intelligence/coderank.py +97 -0
  66. algo_cli/intelligence/consistent_hash.py +150 -0
  67. algo_cli/intelligence/consortium_synthesis.py +139 -0
  68. algo_cli/intelligence/construction/__init__.py +241 -0
  69. algo_cli/intelligence/construction/common.py +273 -0
  70. algo_cli/intelligence/construction/documents.py +496 -0
  71. algo_cli/intelligence/construction/labor_units.py +1395 -0
  72. algo_cli/intelligence/construction/payments.py +470 -0
  73. algo_cli/intelligence/construction/risk.py +784 -0
  74. algo_cli/intelligence/content_extractor.py +132 -0
  75. algo_cli/intelligence/context_adaptive.py +102 -0
  76. algo_cli/intelligence/context_ops.py +95 -0
  77. algo_cli/intelligence/count_min.py +145 -0
  78. algo_cli/intelligence/cow_state.py +103 -0
  79. algo_cli/intelligence/critic_loop.py +119 -0
  80. algo_cli/intelligence/cross_source.py +113 -0
  81. algo_cli/intelligence/daemon_mode.py +99 -0
  82. algo_cli/intelligence/dag_orchestration.py +151 -0
  83. algo_cli/intelligence/deep_research.py +155 -0
  84. algo_cli/intelligence/degenerate_detector.py +78 -0
  85. algo_cli/intelligence/delta_report.py +92 -0
  86. algo_cli/intelligence/discovery_event_log.py +92 -0
  87. algo_cli/intelligence/document_ingest.py +298 -0
  88. algo_cli/intelligence/dual_layer_validate.py +151 -0
  89. algo_cli/intelligence/echo_fidelity.py +73 -0
  90. algo_cli/intelligence/ema_tuning.py +104 -0
  91. algo_cli/intelligence/event_log.py +92 -0
  92. algo_cli/intelligence/evidence_graph.py +114 -0
  93. algo_cli/intelligence/extension_host.py +162 -0
  94. algo_cli/intelligence/extension_manifest.py +115 -0
  95. algo_cli/intelligence/falsification_suite.py +178 -0
  96. algo_cli/intelligence/finance/__init__.py +169 -0
  97. algo_cli/intelligence/finance/anomalies.py +135 -0
  98. algo_cli/intelligence/finance/ap_ar.py +351 -0
  99. algo_cli/intelligence/finance/cash.py +162 -0
  100. algo_cli/intelligence/finance/close.py +332 -0
  101. algo_cli/intelligence/finance/common.py +244 -0
  102. algo_cli/intelligence/finance/construction.py +135 -0
  103. algo_cli/intelligence/finance/controls.py +172 -0
  104. algo_cli/intelligence/finance/evidence.py +119 -0
  105. algo_cli/intelligence/finance/exceptions.py +157 -0
  106. algo_cli/intelligence/finance/reconciliations.py +254 -0
  107. algo_cli/intelligence/finance/revenue.py +109 -0
  108. algo_cli/intelligence/finance/tax.py +74 -0
  109. algo_cli/intelligence/finance/workpapers.py +111 -0
  110. algo_cli/intelligence/finding_record.py +120 -0
  111. algo_cli/intelligence/flow_dag.py +267 -0
  112. algo_cli/intelligence/gatherer.py +223 -0
  113. algo_cli/intelligence/golden_master.py +98 -0
  114. algo_cli/intelligence/graph_rag.py +195 -0
  115. algo_cli/intelligence/group_chat.py +143 -0
  116. algo_cli/intelligence/hash_dedup.py +145 -0
  117. algo_cli/intelligence/hyperloglog.py +128 -0
  118. algo_cli/intelligence/incremental_index.py +316 -0
  119. algo_cli/intelligence/index_store.py +16 -0
  120. algo_cli/intelligence/iteration_plan.py +133 -0
  121. algo_cli/intelligence/kernel_plugins.py +167 -0
  122. algo_cli/intelligence/lesson_catalog.py +135 -0
  123. algo_cli/intelligence/llm_fallback.py +169 -0
  124. algo_cli/intelligence/log2_histogram.py +267 -0
  125. algo_cli/intelligence/lsp_integration.py +147 -0
  126. algo_cli/intelligence/memory_evolution.py +117 -0
  127. algo_cli/intelligence/minhash_lsh.py +182 -0
  128. algo_cli/intelligence/multi_model_score.py +174 -0
  129. algo_cli/intelligence/multi_tier_grade.py +211 -0
  130. algo_cli/intelligence/negative_controls.py +113 -0
  131. algo_cli/intelligence/numeric_clamp.py +63 -0
  132. algo_cli/intelligence/occ_editor.py +66 -0
  133. algo_cli/intelligence/output_normalize.py +112 -0
  134. algo_cli/intelligence/parallel_delegation.py +98 -0
  135. algo_cli/intelligence/parallel_fanout.py +104 -0
  136. algo_cli/intelligence/permission_modes.py +105 -0
  137. algo_cli/intelligence/pre_push_gate.py +68 -0
  138. algo_cli/intelligence/prefetch.py +171 -0
  139. algo_cli/intelligence/process_framework.py +217 -0
  140. algo_cli/intelligence/project_graph.py +387 -0
  141. algo_cli/intelligence/query_expansion.py +146 -0
  142. algo_cli/intelligence/ralph_loop.py +117 -0
  143. algo_cli/intelligence/rate_limiter.py +153 -0
  144. algo_cli/intelligence/refactor_transaction.py +94 -0
  145. algo_cli/intelligence/research_workspace.py +108 -0
  146. algo_cli/intelligence/retraction_ledger.py +72 -0
  147. algo_cli/intelligence/saga_pattern.py +88 -0
  148. algo_cli/intelligence/session_fork.py +100 -0
  149. algo_cli/intelligence/shadow_editor.py +67 -0
  150. algo_cli/intelligence/shell_session.py +213 -0
  151. algo_cli/intelligence/source_registry.py +143 -0
  152. algo_cli/intelligence/spawn_scales.py +99 -0
  153. algo_cli/intelligence/stat_stability.py +104 -0
  154. algo_cli/intelligence/structural_validator.py +148 -0
  155. algo_cli/intelligence/subagent_spawner.py +111 -0
  156. algo_cli/intelligence/symmetric_verify.py +70 -0
  157. algo_cli/intelligence/task_classifier.py +129 -0
  158. algo_cli/intelligence/team_execution.py +122 -0
  159. algo_cli/intelligence/tiered_access.py +121 -0
  160. algo_cli/intelligence/utility_registry.py +159 -0
  161. algo_cli/intuition_engine.py +560 -0
  162. algo_cli/intuition_injector.py +82 -0
  163. algo_cli/kernels/__init__.py +5 -0
  164. algo_cli/kernels/manifest.py +763 -0
  165. algo_cli/main.py +3903 -0
  166. algo_cli/memory_candidates.py +541 -0
  167. algo_cli/memory_echo_veil.py +394 -0
  168. algo_cli/memory_runtime.py +112 -0
  169. algo_cli/model_info.py +548 -0
  170. algo_cli/model_profile.py +160 -0
  171. algo_cli/model_routing.py +74 -0
  172. algo_cli/oneshot.py +331 -0
  173. algo_cli/perf_telemetry.py +389 -0
  174. algo_cli/plugins.py +245 -0
  175. algo_cli/private_event_store.py +654 -0
  176. algo_cli/quantization/__init__.py +24 -0
  177. algo_cli/quantization/lloyd_max.py +98 -0
  178. algo_cli/quantization/turbo_quant.py +308 -0
  179. algo_cli/reasoning/__init__.py +46 -0
  180. algo_cli/reasoning/combinatorial.py +356 -0
  181. algo_cli/reasoning/graph_of_thought.py +297 -0
  182. algo_cli/reasoning/mcts.py +220 -0
  183. algo_cli/reasoning/neuro_symbolic.py +250 -0
  184. algo_cli/reasoning/react.py +246 -0
  185. algo_cli/reasoning/reflexion.py +225 -0
  186. algo_cli/reasoning/tree_of_thought.py +241 -0
  187. algo_cli/reasoning_bridge.py +150 -0
  188. algo_cli/reconciliation.py +284 -0
  189. algo_cli/reflex.py +385 -0
  190. algo_cli/resources/docs/ALGO.md +13958 -0
  191. algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
  192. algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
  193. algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
  194. algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
  195. algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
  196. algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
  197. algo_cli/resources/docs/main-split-map.md +35 -0
  198. algo_cli/resources/docs/privacy-and-context.md +48 -0
  199. algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
  200. algo_cli/resources/skills/README.md +26 -0
  201. algo_cli/resources/skills/algo-cli.md +59 -0
  202. algo_cli/resources/skills/edit-file-precision.md +49 -0
  203. algo_cli/resources/skills/harness-search-first.md +47 -0
  204. algo_cli/resources/skills/memory-recall-ritual.md +51 -0
  205. algo_cli/resources/skills/qol-algorithms.md +224 -0
  206. algo_cli/resources/skills/smart-error-recovery.md +56 -0
  207. algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
  208. algo_cli/retrieval_algorithms.py +127 -0
  209. algo_cli/runtime_qos.py +236 -0
  210. algo_cli/runtime_services.py +320 -0
  211. algo_cli/session_commands.py +95 -0
  212. algo_cli/session_mode.py +113 -0
  213. algo_cli/skills.py +430 -0
  214. algo_cli/slash_dispatch.py +1265 -0
  215. algo_cli/small_context.py +206 -0
  216. algo_cli/spawn_budget.py +89 -0
  217. algo_cli/task_ledger.py +84 -0
  218. algo_cli/task_router.py +197 -0
  219. algo_cli/tool_context.py +94 -0
  220. algo_cli/tool_contract.py +99 -0
  221. algo_cli/tool_policy.py +357 -0
  222. algo_cli/tool_runtime.py +647 -0
  223. algo_cli/tools.py +3056 -0
  224. algo_cli/url_scheme.py +174 -0
  225. algo_cli/verify.py +154 -0
  226. algo_cli/version_manifest.py +178 -0
  227. algo_cli/vision_screenshot_verify.py +76 -0
  228. algo_cli/workspace_resolver.py +68 -0
  229. algo_cli/x_account.py +209 -0
  230. algo_cli/xai_auth.py +374 -0
  231. algo_cli/xai_client.py +600 -0
  232. algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
  233. algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
  234. algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
  235. algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
  236. algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
  237. ollama_cli/__init__.py +67 -0
@@ -0,0 +1,1395 @@
1
+ """Electrical construction labor-unit reference database.
2
+
3
+ Provides a SQLite + FTS5 searchable database of electrical labor-unit
4
+ records from multiple sources:
5
+
6
+ - **NECA MLU 2015-2016**: 13,712 records, 3 columns (Normal / Difficult / Very Difficult)
7
+ - **Durand & Associates 2022**: 4,165 records, 5 columns (Easy / Average / Difficult / Remodel / Old Work)
8
+
9
+ Each record carries man-hours per installed item plus the unit basis
10
+ (E=each, C=per hundred, M=per thousand LF, etc.).
11
+
12
+ The database is built once from the extracted CSVs and cached at
13
+ ``~/.algo_cli/mlu_labor_units.db``. Subsequent calls open the cached DB
14
+ read-only unless a rebuild is requested.
15
+
16
+ Pattern: B27-style incremental index — file hash watermark on the CSV
17
+ sources avoids unnecessary rebuilds (see ALGO.md).
18
+
19
+ EC&M / Mike Holt adjustment factors are encoded as lookup tables for
20
+ fine-grained productivity adjustments beyond the column system.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import csv
26
+ import hashlib
27
+ import os
28
+ import re
29
+ import sqlite3
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ # ── Constants ─────────────────────────────────────────────────────────────────
35
+
36
+ DEFAULT_DB_PATH = Path(
37
+ os.environ.get("ALGO_CLI_MLU_DB", str(Path.home() / ".algo_cli" / "mlu_labor_units.db"))
38
+ )
39
+
40
+ # CSV sources live alongside the extractor scripts in the repo.
41
+ _REPO_ROOT = Path(__file__).resolve().parents[3] # algo_cli/intelligence/construction -> repo root
42
+ DEFAULT_NECA_CSV = _REPO_ROOT / "scripts" / "mlu_labor_units.csv"
43
+ DEFAULT_DURAND_CSV = _REPO_ROOT / "scripts" / "durand_labor_units.csv"
44
+
45
+ VALID_UNITS = frozenset({"E", "C", "M", "LF", "FT", "CY", "SF", "PR", "EA"})
46
+
47
+ UNIT_LABELS = {
48
+ "E": "Each",
49
+ "EA": "Each",
50
+ "C": "Per hundred items",
51
+ "M": "Per thousand linear feet",
52
+ "LF": "Linear foot",
53
+ "FT": "Foot",
54
+ "CY": "Cubic yard",
55
+ "SF": "Square foot",
56
+ "PR": "Pair",
57
+ }
58
+
59
+ # ── NECA Labor Factor Score Sheet (1-5 scale, 37 conditions, max 175) ────────
60
+ # Score 36-75 -> Normal, 76-134 -> Difficult, 135-175 -> Very Difficult
61
+ SCORE_THRESHOLDS = {
62
+ "Normal": (36, 75),
63
+ "Difficult": (76, 134),
64
+ "VeryDifficult": (135, 175),
65
+ }
66
+
67
+ # ── NECA Labor Adjustment Chart (1-3 scale, 30 situations, max 90) ────────────
68
+ # Score 30-40 -> Normal, 41-70 -> Difficult, 71-90 -> Very Difficult
69
+ CHART_SCORE_THRESHOLDS = {
70
+ "Normal": (30, 40),
71
+ "Difficult": (41, 70),
72
+ "VeryDifficult": (71, 90),
73
+ }
74
+
75
+ # Building height productivity loss: 1-2% per floor above 3 stories
76
+ HEIGHT_LOSS_PER_FLOOR = 0.015 # 1.5% average
77
+
78
+ # ── EC&M / Mike Holt adjustment factors ──────────────────────────────────────
79
+ # Source: "Adjusting Labor Units the Smart Way" (EC&M, Mike Holt)
80
+ # These are percentage adjustments applied to base labor hours.
81
+
82
+ # Building height adjustment (add to total labor)
83
+ BUILDING_HEIGHT_FACTORS: dict[int, float] = {
84
+ 1: 0.00, # 1-2 floors
85
+ 3: 0.01, # 3-6 floors (+1%)
86
+ 7: 0.02, # 7-8 floors (+2%)
87
+ 9: 0.05, # 9-14 floors (+5%)
88
+ 15: 0.07, # 15-19 floors (+7%)
89
+ 20: 0.13, # 20-30 floors (+13%)
90
+ }
91
+
92
+ # Ladder/scaffold work adjustment by working height (add to total labor)
93
+ LADDER_HEIGHT_FACTORS: dict[int, float] = {
94
+ 12: 0.03, # 12 ft (+3%)
95
+ 13: 0.05, # 13 ft (+5%)
96
+ 14: 0.08, # 14 ft (+8%)
97
+ 15: 0.10, # 15 ft (+10%)
98
+ 16: 0.13, # 16 ft (+13%)
99
+ 17: 0.16, # 17 ft (+16%)
100
+ 18: 0.19, # 18 ft (+19%)
101
+ 19: 0.22, # 19 ft (+22%)
102
+ 20: 0.25, # 20 ft (+25%)
103
+ }
104
+
105
+ # Fixed scaffold: +40% plus setup/move/takedown labor
106
+ FIXED_SCAFFOLD_FACTOR = 0.40
107
+
108
+ # Concealed/exposed wiring adjustments (multiplier on base labor)
109
+ CONCEALED_FACTORS: dict[str, float] = {
110
+ "concrete_wall": 0.50, # Concealed in concrete walls: +50%
111
+ "concrete_column": 1.00, # Concealed in concrete columns: +100%
112
+ "exposed_enclosure": 0.10, # Exposed enclosures: +10%
113
+ "exposed_raceway": 0.20, # Exposed raceways: +20%
114
+ }
115
+
116
+ # Repetitive work productivity gain (multiplier on base labor)
117
+ REPETITIVE_FACTORS: dict[int, float] = {
118
+ 2: 0.10, # 1-2 repeats: 10% of base
119
+ 5: 0.15, # 3-5 repeats: 15%
120
+ 10: 0.25, # 6-10 repeats: 25%
121
+ 15: 0.35, # 11-15 repeats: 35%
122
+ 16: 0.45, # 16+ repeats: 45%
123
+ }
124
+
125
+ # Remodeling adjustment (up to +200% for fish-in wire / cut-in boxes)
126
+ REMODEL_FACTOR = 2.0
127
+
128
+ # ── Standard labor unit composition (from Estimating 101 field study) ─────────
129
+ LABOR_COMPOSITION: dict[str, float] = {
130
+ "plan_spec_study": 0.06, # 6% (3.5 min)
131
+ "ordering": 0.01, # 1% (0.5 min)
132
+ "receiving_storing": 0.04, # 4% (2.5 min)
133
+ "handling_to_location": 0.07, # 7% (4 min)
134
+ "tooling_up": 0.05, # 5% (3 min)
135
+ "layout": 0.05, # 5% (3 min)
136
+ "installation": 0.62, # 62% (37.5 min)
137
+ "non_productive": 0.10, # 10% (6 min)
138
+ }
139
+
140
+ # ── Fennec Lab residential defaults (NECA MLU-based minutes) ─────────────────
141
+ RESIDENTIAL_DEFAULTS: dict[str, float] = {
142
+ "receptacle_outlet": 35.0, # 35 min rough-in
143
+ "switch": 25.0, # 25 min rough-in
144
+ "light_fixture": 45.0, # 45 min rough-in
145
+ "panel_circuit": 60.0, # 60 min per circuit
146
+ "low_voltage_run": 20.0, # 20 min per run
147
+ }
148
+
149
+ # Residential service multiplier (finished walls, occupied, smaller crews)
150
+ RESIDENTIAL_SERVICE_MULTIPLIER = (1.1, 1.3) # 1.1-1.3x MLU
151
+
152
+ # Finish/trim-out: add 30-50% of rough-in
153
+ FINISH_TRIM_PERCENT = (0.30, 0.50)
154
+
155
+ # Inspection rework buffer: 5-10%
156
+ INSPECTION_REWORK_BUFFER = (0.05, 0.10)
157
+
158
+
159
+ # ── Data structures ───────────────────────────────────────────────────────────
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class LaborUnit:
164
+ """A single labor-unit entry from NECA or Durand."""
165
+
166
+ section: str
167
+ subsection: str
168
+ description: str
169
+ unit: str
170
+ source: str = "NECA" # "NECA" or "Durand"
171
+ # NECA 3-column system
172
+ normal: float = 0.0
173
+ difficult: float = 0.0
174
+ very_difficult: float = 0.0
175
+ # Durand 5-column system
176
+ easy: float = 0.0
177
+ average: float = 0.0
178
+ hard: float = 0.0 # Durand's "Difficult" (renamed to avoid collision)
179
+ remodel: float = 0.0
180
+ old_work: float = 0.0
181
+ # Common
182
+ rev: str = ""
183
+ company_experience: str = ""
184
+ table_index: int = 0
185
+
186
+ @property
187
+ def unit_label(self) -> str:
188
+ return UNIT_LABELS.get(self.unit, self.unit)
189
+
190
+ def hours_for(self, difficulty: str = "Normal") -> float:
191
+ """Return the labor hours for the given difficulty column.
192
+
193
+ Supports both NECA (Normal/Difficult/VeryDifficult) and
194
+ Durand (Easy/Average/Difficult/Remodel/OldWork) systems.
195
+
196
+ Args:
197
+ difficulty: One of 'Normal', 'Difficult', 'VeryDifficult'
198
+ (NECA) or 'Easy', 'Average', 'Hard', 'Remodel', 'OldWork'
199
+ (Durand). Case-insensitive, accepts short forms.
200
+ """
201
+ key = difficulty.strip().lower().replace("-", "_").replace(" ", "_")
202
+ # NECA columns
203
+ if key in ("normal", "n"):
204
+ return self.normal
205
+ if key in ("difficult", "d", "diff"):
206
+ # If NECA source, use NECA difficult; if Durand, use hard
207
+ return self.hard if self.source == "Durand" and self.hard else self.difficult
208
+ if key in ("very_difficult", "verydifficult", "very", "v"):
209
+ return self.very_difficult
210
+ # Durand columns
211
+ if key in ("easy", "e"):
212
+ return self.easy
213
+ if key in ("average", "avg", "a"):
214
+ return self.average
215
+ if key in ("hard",):
216
+ return self.hard
217
+ if key in ("remodel", "r"):
218
+ return self.remodel
219
+ if key in ("old_work", "oldwork", "old", "o"):
220
+ return self.old_work
221
+ raise ValueError(f"Unknown difficulty: {difficulty!r}")
222
+
223
+ def available_difficulties(self) -> list[str]:
224
+ """Return the difficulty columns that have non-zero values."""
225
+ all_cols = [
226
+ ("Easy", self.easy),
227
+ ("Normal", self.normal),
228
+ ("Average", self.average),
229
+ ("Difficult", self.difficult),
230
+ ("Hard", self.hard),
231
+ ("VeryDifficult", self.very_difficult),
232
+ ("Remodel", self.remodel),
233
+ ("OldWork", self.old_work),
234
+ ]
235
+ return [name for name, val in all_cols if val and val > 0]
236
+
237
+ def adjusted_hours(
238
+ self,
239
+ difficulty: str = "Normal",
240
+ quantity: float = 1.0,
241
+ building_floors: int = 0,
242
+ ) -> float:
243
+ """Compute total labor hours for a given quantity and building height.
244
+
245
+ Args:
246
+ difficulty: Difficulty column name (see hours_for).
247
+ quantity: Number of units (each, hundred, thousand LF, etc.).
248
+ building_floors: Number of floors above 3 stories (0 = ground/low-rise).
249
+
250
+ Returns:
251
+ Total adjusted labor hours.
252
+ """
253
+ base = self.hours_for(difficulty)
254
+ total = base * quantity
255
+ if building_floors > 0:
256
+ loss = 1.0 + (HEIGHT_LOSS_PER_FLOOR * building_floors)
257
+ total *= loss
258
+ return round(total, 2)
259
+
260
+
261
+ @dataclass
262
+ class SearchResult:
263
+ """A ranked search hit from the FTS5 query."""
264
+
265
+ labor_unit: LaborUnit
266
+ rank: float
267
+ snippet: str = ""
268
+
269
+
270
+ # ── Database builder ──────────────────────────────────────────────────────────
271
+
272
+
273
+ def _csv_hash(csv_path: Path) -> str:
274
+ """Return a short hash of the CSV file contents for change detection."""
275
+ h = hashlib.sha256()
276
+ with open(csv_path, "rb") as f:
277
+ for chunk in iter(lambda: f.read(8192), b""):
278
+ h.update(chunk)
279
+ return h.hexdigest()[:16]
280
+
281
+
282
+ def _combined_hash(paths: list[Path]) -> str:
283
+ """Hash multiple CSV files together for change detection."""
284
+ h = hashlib.sha256()
285
+ for p in paths:
286
+ if p.exists():
287
+ h.update(p.read_bytes())
288
+ return h.hexdigest()[:16]
289
+
290
+
291
+ def _needs_rebuild(db_path: Path, csv_paths: list[Path]) -> bool:
292
+ """Check if the DB needs rebuilding based on CSV hash watermark."""
293
+ if not db_path.exists():
294
+ return True
295
+ if not any(p.exists() for p in csv_paths):
296
+ return False # can't rebuild without CSVs; use existing DB
297
+ try:
298
+ conn = sqlite3.connect(str(db_path))
299
+ stored = conn.execute(
300
+ "SELECT value FROM _meta WHERE key = 'csv_hash'"
301
+ ).fetchone()
302
+ conn.close()
303
+ if stored is None:
304
+ return True
305
+ return stored[0] != _combined_hash(csv_paths)
306
+ except sqlite3.OperationalError:
307
+ return True
308
+
309
+
310
+ def build_database(
311
+ csv_paths: Path | str | list[Path | str] | None = None,
312
+ db_path: Path | str | None = None,
313
+ force: bool = False,
314
+ ) -> Path:
315
+ """Build (or rebuild) the SQLite + FTS5 labor-units database.
316
+
317
+ Loads both NECA and Durand CSVs by default. Each CSV is auto-detected
318
+ as NECA (has 'normal' column) or Durand (has 'easy' column).
319
+
320
+ Args:
321
+ csv_paths: Path or list of paths to CSV files. Defaults to
322
+ ``[<repo>/scripts/mlu_labor_units.csv, <repo>/scripts/durand_labor_units.csv]``.
323
+ db_path: Path for the SQLite database. Defaults to
324
+ ``~/.algo_cli/mlu_labor_units.db``.
325
+ force: Rebuild even if the CSV hash hasn't changed.
326
+
327
+ Returns:
328
+ Path to the built database.
329
+ """
330
+ # Normalize csv_paths to a list
331
+ if csv_paths is None:
332
+ csv_paths = [DEFAULT_NECA_CSV, DEFAULT_DURAND_CSV]
333
+ elif isinstance(csv_paths, (str, Path)):
334
+ csv_paths = [csv_paths]
335
+ csv_paths = [Path(p) for p in csv_paths]
336
+
337
+ db_path = Path(db_path) if db_path else DEFAULT_DB_PATH
338
+
339
+ existing = [p for p in csv_paths if p.exists()]
340
+ if not existing:
341
+ raise FileNotFoundError(f"No labor-unit CSVs found in: {csv_paths}")
342
+
343
+ if not force and not _needs_rebuild(db_path, existing):
344
+ return db_path
345
+
346
+ db_path.parent.mkdir(parents=True, exist_ok=True)
347
+
348
+ # Remove stale DB + WAL/SHM
349
+ for suffix in ("", "-wal", "-shm"):
350
+ p = Path(str(db_path) + suffix) if suffix else db_path
351
+ if p.exists():
352
+ p.unlink()
353
+
354
+ conn = sqlite3.connect(str(db_path))
355
+ conn.execute("PRAGMA journal_mode=WAL")
356
+ conn.execute("PRAGMA synchronous=NORMAL")
357
+
358
+ # Main table — supports both NECA and Durand columns
359
+ conn.execute("""
360
+ CREATE TABLE labor_units (
361
+ id INTEGER PRIMARY KEY,
362
+ source TEXT NOT NULL DEFAULT 'NECA',
363
+ section TEXT NOT NULL DEFAULT '',
364
+ subsection TEXT NOT NULL DEFAULT '',
365
+ description TEXT NOT NULL,
366
+ normal REAL DEFAULT 0,
367
+ difficult REAL DEFAULT 0,
368
+ very_difficult REAL DEFAULT 0,
369
+ easy REAL DEFAULT 0,
370
+ average REAL DEFAULT 0,
371
+ hard REAL DEFAULT 0,
372
+ remodel REAL DEFAULT 0,
373
+ old_work REAL DEFAULT 0,
374
+ unit TEXT NOT NULL DEFAULT '',
375
+ rev TEXT DEFAULT '',
376
+ company_experience TEXT DEFAULT '',
377
+ table_index INTEGER DEFAULT 0
378
+ )
379
+ """)
380
+
381
+ # FTS5 full-text index on description + subsection
382
+ conn.execute("""
383
+ CREATE VIRTUAL TABLE fts_labor_units
384
+ USING fts5(
385
+ description,
386
+ subsection,
387
+ content='labor_units',
388
+ content_rowid='id',
389
+ tokenize='porter unicode61'
390
+ )
391
+ """)
392
+
393
+ # Bulk insert from each CSV
394
+ total_rows = 0
395
+ for csv_path in existing:
396
+ with open(csv_path, encoding="utf-8", newline="") as f:
397
+ reader = csv.DictReader(f)
398
+ rows = []
399
+ for r in reader:
400
+ # Auto-detect source from CSV columns
401
+ if "source" in r and r["source"]:
402
+ source = r["source"]
403
+ elif "easy" in r and r.get("easy"):
404
+ source = "Durand"
405
+ else:
406
+ source = "NECA"
407
+
408
+ rows.append((
409
+ source,
410
+ r.get("section", ""),
411
+ r.get("subsection", ""),
412
+ r.get("description", ""),
413
+ _safe_float(r.get("normal")),
414
+ _safe_float(r.get("difficult")),
415
+ _safe_float(r.get("very_difficult")),
416
+ _safe_float(r.get("easy")),
417
+ _safe_float(r.get("average")),
418
+ _safe_float(r.get("hard") or r.get("difficult_durand")),
419
+ _safe_float(r.get("remodel")),
420
+ _safe_float(r.get("old_work")),
421
+ r.get("unit", ""),
422
+ r.get("rev", ""),
423
+ r.get("company_experience", ""),
424
+ int(r.get("table_index", 0) or 0),
425
+ ))
426
+
427
+ conn.executemany(
428
+ "INSERT INTO labor_units "
429
+ "(source, section, subsection, description, "
430
+ "normal, difficult, very_difficult, "
431
+ "easy, average, hard, remodel, old_work, "
432
+ "unit, rev, company_experience, table_index) "
433
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
434
+ rows,
435
+ )
436
+ total_rows += len(rows)
437
+
438
+ # Populate FTS index
439
+ conn.execute(
440
+ "INSERT INTO fts_labor_units (rowid, description, subsection) "
441
+ "SELECT id, description, subsection FROM labor_units"
442
+ )
443
+
444
+ # Indexes for non-FTS lookups
445
+ conn.execute("CREATE INDEX idx_source ON labor_units(source)")
446
+ conn.execute("CREATE INDEX idx_subsection ON labor_units(subsection)")
447
+ conn.execute("CREATE INDEX idx_section ON labor_units(section)")
448
+ conn.execute("CREATE INDEX idx_unit ON labor_units(unit)")
449
+
450
+ # Meta table for change detection
451
+ conn.execute("CREATE TABLE _meta (key TEXT PRIMARY KEY, value TEXT)")
452
+ conn.execute(
453
+ "INSERT INTO _meta (key, value) VALUES ('csv_hash', ?)",
454
+ (_combined_hash(existing),),
455
+ )
456
+ conn.execute(
457
+ "INSERT INTO _meta (key, value) VALUES ('record_count', ?)",
458
+ (str(total_rows),),
459
+ )
460
+ conn.execute(
461
+ "INSERT INTO _meta (key, value) VALUES ('sources', ?)",
462
+ (",".join(p.stem for p in existing),),
463
+ )
464
+
465
+ conn.commit()
466
+ conn.close()
467
+ return db_path
468
+
469
+
470
+ def _safe_float(v: Any) -> float:
471
+ if v is None or v == "":
472
+ return 0.0
473
+ try:
474
+ return float(v)
475
+ except (ValueError, TypeError):
476
+ return 0.0
477
+
478
+
479
+ def _row_to_labor_unit(row: sqlite3.Row) -> LaborUnit:
480
+ return LaborUnit(
481
+ source=row["source"],
482
+ section=row["section"],
483
+ subsection=row["subsection"],
484
+ description=row["description"],
485
+ normal=row["normal"] or 0.0,
486
+ difficult=row["difficult"] or 0.0,
487
+ very_difficult=row["very_difficult"] or 0.0,
488
+ easy=row["easy"] or 0.0,
489
+ average=row["average"] or 0.0,
490
+ hard=row["hard"] or 0.0,
491
+ remodel=row["remodel"] or 0.0,
492
+ old_work=row["old_work"] or 0.0,
493
+ unit=row["unit"],
494
+ rev=row["rev"] or "",
495
+ company_experience=row["company_experience"] or "",
496
+ table_index=row["table_index"],
497
+ )
498
+
499
+
500
+ # ── Query API ─────────────────────────────────────────────────────────────────
501
+
502
+
503
+ class LaborUnitDatabase:
504
+ """Searchable labor-unit database backed by SQLite + FTS5.
505
+
506
+ Usage::
507
+
508
+ db = LaborUnitDatabase() # opens or builds cached DB
509
+ results = db.search("fire alarm control panel")
510
+ for r in results:
511
+ print(r.labor_unit.description, r.labor_unit.normal)
512
+ """
513
+
514
+ def __init__(
515
+ self,
516
+ db_path: Path | str | None = None,
517
+ csv_paths: Path | str | list[Path | str] | None = None,
518
+ auto_build: bool = True,
519
+ ):
520
+ self._db_path = Path(db_path) if db_path else DEFAULT_DB_PATH
521
+ if csv_paths is None:
522
+ self._csv_paths = [DEFAULT_NECA_CSV, DEFAULT_DURAND_CSV]
523
+ elif isinstance(csv_paths, (str, Path)):
524
+ self._csv_paths = [Path(csv_paths)]
525
+ else:
526
+ self._csv_paths = [Path(p) for p in csv_paths]
527
+ self._conn: sqlite3.Connection | None = None
528
+
529
+ if auto_build and _needs_rebuild(self._db_path, self._csv_paths):
530
+ existing = [p for p in self._csv_paths if p.exists()]
531
+ if existing:
532
+ build_database(existing, self._db_path)
533
+ self._open()
534
+
535
+ def _open(self) -> None:
536
+ if not self._db_path.exists():
537
+ raise FileNotFoundError(
538
+ f"Labor-unit database not found at {self._db_path}. "
539
+ f"Run build_database() first or ensure CSVs are available."
540
+ )
541
+ self._conn = sqlite3.connect(
542
+ str(self._db_path),
543
+ check_same_thread=False,
544
+ )
545
+ self._conn.row_factory = sqlite3.Row
546
+
547
+ @property
548
+ def conn(self) -> sqlite3.Connection:
549
+ if self._conn is None:
550
+ self._open()
551
+ assert self._conn is not None
552
+ return self._conn
553
+
554
+ def close(self) -> None:
555
+ if self._conn:
556
+ self._conn.close()
557
+ self._conn = None
558
+
559
+ def __enter__(self) -> LaborUnitDatabase:
560
+ return self
561
+
562
+ def __exit__(self, *args: Any) -> None:
563
+ self.close()
564
+
565
+ # ── Search ─────────────────────────────────────────────────────────────
566
+
567
+ def search(
568
+ self,
569
+ query: str,
570
+ limit: int = 20,
571
+ subsection: str | None = None,
572
+ source: str | None = None,
573
+ ) -> list[SearchResult]:
574
+ """Full-text search on description + subsection.
575
+
576
+ Args:
577
+ query: Search terms (FTS5 syntax: AND/OR/NOT, phrase "quotes").
578
+ limit: Max results.
579
+ subsection: Optional filter to narrow to a subsection.
580
+ source: Optional filter ('NECA' or 'Durand').
581
+
582
+ Returns:
583
+ Ranked list of SearchResult.
584
+ """
585
+ fts_query = _sanitize_fts_query(query)
586
+ if not fts_query:
587
+ return []
588
+
589
+ sql = (
590
+ "SELECT l.*, f.rank, snippet(fts_labor_units, 0, '[', ']', '...', 10) as snip "
591
+ "FROM fts_labor_units f "
592
+ "JOIN labor_units l ON l.id = f.rowid "
593
+ "WHERE fts_labor_units MATCH ? "
594
+ )
595
+ params: list[Any] = [fts_query]
596
+ if subsection:
597
+ sql += "AND l.subsection = ? "
598
+ params.append(subsection)
599
+ if source:
600
+ sql += "AND l.source = ? "
601
+ params.append(source)
602
+ sql += "ORDER BY f.rank LIMIT ?"
603
+ params.append(limit)
604
+
605
+ rows = self.conn.execute(sql, params).fetchall()
606
+ return [
607
+ SearchResult(
608
+ labor_unit=_row_to_labor_unit(r),
609
+ rank=r["rank"],
610
+ snippet=r["snip"] or "",
611
+ )
612
+ for r in rows
613
+ ]
614
+
615
+ def by_subsection(self, subsection: str, limit: int = 500, source: str | None = None) -> list[LaborUnit]:
616
+ """Return all labor units for a given subsection (exact match)."""
617
+ if source:
618
+ rows = self.conn.execute(
619
+ "SELECT * FROM labor_units WHERE subsection = ? AND source = ? "
620
+ "ORDER BY id LIMIT ?",
621
+ (subsection, source, limit),
622
+ ).fetchall()
623
+ else:
624
+ rows = self.conn.execute(
625
+ "SELECT * FROM labor_units WHERE subsection = ? "
626
+ "ORDER BY id LIMIT ?",
627
+ (subsection, limit),
628
+ ).fetchall()
629
+ return [_row_to_labor_unit(r) for r in rows]
630
+
631
+ def by_section(self, section: str, limit: int = 2000, source: str | None = None) -> list[LaborUnit]:
632
+ """Return all labor units for a given section (exact match)."""
633
+ if source:
634
+ rows = self.conn.execute(
635
+ "SELECT * FROM labor_units WHERE section = ? AND source = ? "
636
+ "ORDER BY id LIMIT ?",
637
+ (section, source, limit),
638
+ ).fetchall()
639
+ else:
640
+ rows = self.conn.execute(
641
+ "SELECT * FROM labor_units WHERE section = ? "
642
+ "ORDER BY id LIMIT ?",
643
+ (section, limit),
644
+ ).fetchall()
645
+ return [_row_to_labor_unit(r) for r in rows]
646
+
647
+ def list_subsections(self, section: str | None = None, source: str | None = None) -> list[str]:
648
+ """List all subsections, optionally filtered by section and/or source."""
649
+ if section and source:
650
+ rows = self.conn.execute(
651
+ "SELECT DISTINCT subsection FROM labor_units "
652
+ "WHERE section = ? AND source = ? ORDER BY subsection",
653
+ (section, source),
654
+ ).fetchall()
655
+ elif section:
656
+ rows = self.conn.execute(
657
+ "SELECT DISTINCT subsection FROM labor_units "
658
+ "WHERE section = ? ORDER BY subsection",
659
+ (section,),
660
+ ).fetchall()
661
+ elif source:
662
+ rows = self.conn.execute(
663
+ "SELECT DISTINCT subsection FROM labor_units "
664
+ "WHERE source = ? ORDER BY subsection",
665
+ (source,),
666
+ ).fetchall()
667
+ else:
668
+ rows = self.conn.execute(
669
+ "SELECT DISTINCT subsection FROM labor_units ORDER BY subsection"
670
+ ).fetchall()
671
+ return [r[0] for r in rows if r[0]]
672
+
673
+ def list_sections(self, source: str | None = None) -> list[str]:
674
+ """List all sections, optionally filtered by source."""
675
+ if source:
676
+ rows = self.conn.execute(
677
+ "SELECT DISTINCT section FROM labor_units WHERE source = ? ORDER BY section",
678
+ (source,),
679
+ ).fetchall()
680
+ else:
681
+ rows = self.conn.execute(
682
+ "SELECT DISTINCT section FROM labor_units ORDER BY section"
683
+ ).fetchall()
684
+ return [r[0] for r in rows if r[0]]
685
+
686
+ def list_sources(self) -> list[str]:
687
+ """List all data sources in the database."""
688
+ rows = self.conn.execute(
689
+ "SELECT DISTINCT source FROM labor_units ORDER BY source"
690
+ ).fetchall()
691
+ return [r[0] for r in rows]
692
+
693
+ def get(self, description: str, source: str | None = None) -> LaborUnit | None:
694
+ """Exact-match lookup by description."""
695
+ if source:
696
+ row = self.conn.execute(
697
+ "SELECT * FROM labor_units WHERE description = ? AND source = ? LIMIT 1",
698
+ (description, source),
699
+ ).fetchone()
700
+ else:
701
+ row = self.conn.execute(
702
+ "SELECT * FROM labor_units WHERE description = ? LIMIT 1",
703
+ (description,),
704
+ ).fetchone()
705
+ return _row_to_labor_unit(row) if row else None
706
+
707
+ def estimate(
708
+ self,
709
+ description: str,
710
+ quantity: float = 1.0,
711
+ difficulty: str = "Normal",
712
+ building_floors: int = 0,
713
+ working_height_ft: int | None = None,
714
+ concealed: str | None = None,
715
+ repetitive_count: int | None = None,
716
+ remodel: bool = False,
717
+ source: str | None = None,
718
+ ) -> dict[str, Any] | None:
719
+ """Look up a labor unit and compute total adjusted hours.
720
+
721
+ Applies NECA height adjustment plus optional EC&M fine-grained
722
+ adjustments (working height, concealed wiring, repetitive work,
723
+ remodeling).
724
+
725
+ Args:
726
+ description: Item description (exact or FTS search).
727
+ quantity: Number of units.
728
+ difficulty: Difficulty column name (see hours_for).
729
+ building_floors: Floors above 3 stories for NECA height adjustment.
730
+ working_height_ft: Working height in feet (for EC&M ladder adjustment).
731
+ concealed: Type of concealed/exposed wiring ('concrete_wall',
732
+ 'concrete_column', 'exposed_enclosure', 'exposed_raceway').
733
+ repetitive_count: Number of repetitive installations (for productivity gain).
734
+ remodel: True if remodeling work (applies REMODEL_FACTOR).
735
+ source: Filter to a specific source ('NECA' or 'Durand').
736
+
737
+ Returns:
738
+ Dict with labor_unit, base_hours, adjustments, total_hours,
739
+ or None if not found.
740
+ """
741
+ unit = self.get(description, source=source)
742
+ if unit is None:
743
+ results = self.search(description, limit=1, source=source)
744
+ if not results:
745
+ return None
746
+ unit = results[0].labor_unit
747
+
748
+ base = unit.hours_for(difficulty)
749
+ adjustments: dict[str, float] = {}
750
+
751
+ # NECA building height adjustment
752
+ if building_floors > 0:
753
+ height_adj = HEIGHT_LOSS_PER_FLOOR * building_floors
754
+ adjustments["building_height"] = height_adj
755
+
756
+ # EC&M working height (ladder/scaffold) adjustment
757
+ if working_height_ft is not None and working_height_ft >= 12:
758
+ wh_factor = _lookup_factor(LADDER_HEIGHT_FACTORS, working_height_ft)
759
+ adjustments["working_height"] = wh_factor
760
+
761
+ # EC&M concealed/exposed wiring adjustment
762
+ if concealed and concealed in CONCEALED_FACTORS:
763
+ adjustments["concealed"] = CONCEALED_FACTORS[concealed]
764
+
765
+ # EC&M repetitive work productivity gain
766
+ if repetitive_count is not None and repetitive_count > 1:
767
+ rep_factor = _lookup_factor(REPETITIVE_FACTORS, repetitive_count)
768
+ adjustments["repetitive"] = -rep_factor # negative = productivity gain
769
+
770
+ # Remodeling adjustment
771
+ if remodel:
772
+ adjustments["remodel"] = REMODEL_FACTOR
773
+
774
+ # Apply adjustments
775
+ total_multiplier = 1.0
776
+ for adj_name, adj_val in adjustments.items():
777
+ total_multiplier += adj_val
778
+
779
+ total = base * quantity * total_multiplier
780
+ total = round(total, 2)
781
+
782
+ return {
783
+ "description": unit.description,
784
+ "subsection": unit.subsection,
785
+ "source": unit.source,
786
+ "unit": unit.unit,
787
+ "unit_label": unit.unit_label,
788
+ "difficulty": difficulty,
789
+ "base_hours": base,
790
+ "quantity": quantity,
791
+ "building_floors": building_floors,
792
+ "adjustments": adjustments,
793
+ "adjustment_multiplier": round(total_multiplier, 4),
794
+ "total_hours": total,
795
+ "available_difficulties": unit.available_difficulties(),
796
+ }
797
+
798
+ @property
799
+ def record_count(self) -> int:
800
+ row = self.conn.execute("SELECT COUNT(*) FROM labor_units").fetchone()
801
+ return row[0]
802
+
803
+ def source_counts(self) -> dict[str, int]:
804
+ """Return record count per source."""
805
+ rows = self.conn.execute(
806
+ "SELECT source, COUNT(*) FROM labor_units GROUP BY source"
807
+ ).fetchall()
808
+ return {r[0]: r[1] for r in rows}
809
+
810
+
811
+ # ── EC&M adjustment helpers ───────────────────────────────────────────────────
812
+
813
+
814
+ def _lookup_factor(table: dict[int, float], value: int) -> float:
815
+ """Look up a factor from a threshold table (keys are min thresholds)."""
816
+ result = 0.0
817
+ for threshold in sorted(table.keys()):
818
+ if value >= threshold:
819
+ result = table[threshold]
820
+ else:
821
+ break
822
+ return result
823
+
824
+
825
+ def building_height_adjustment(floors: int) -> float:
826
+ """Return the EC&M building height adjustment factor.
827
+
828
+ Args:
829
+ floors: Total building floors (not floors above 3).
830
+
831
+ Returns:
832
+ Multiplier adjustment (0.0 to 0.13).
833
+ """
834
+ return _lookup_factor(BUILDING_HEIGHT_FACTORS, floors)
835
+
836
+
837
+ def ladder_height_adjustment(height_ft: int) -> float:
838
+ """Return the EC&M ladder/scaffold height adjustment factor.
839
+
840
+ Args:
841
+ height_ft: Working height in feet.
842
+
843
+ Returns:
844
+ Multiplier adjustment (0.0 to 0.25).
845
+ """
846
+ return _lookup_factor(LADDER_HEIGHT_FACTORS, height_ft)
847
+
848
+
849
+ def repetitive_adjustment(count: int) -> float:
850
+ """Return the EC&M repetitive work productivity gain factor.
851
+
852
+ Args:
853
+ count: Number of repetitive installations.
854
+
855
+ Returns:
856
+ Productivity gain (0.10 to 0.45). Negative when applied as adjustment.
857
+ """
858
+ return _lookup_factor(REPETITIVE_FACTORS, count)
859
+
860
+
861
+ # ── Difficulty scoring ────────────────────────────────────────────────────────
862
+
863
+
864
+ def difficulty_from_score(score: int) -> str:
865
+ """Map a NECA Labor Factor Score Sheet total to a difficulty column.
866
+
867
+ Uses the 1-5 scale, 37 conditions, max 175 points system.
868
+
869
+ Score ranges:
870
+ 36-75 -> Normal
871
+ 76-134 -> Difficult
872
+ 135-175 -> VeryDifficult
873
+ """
874
+ if score < 36:
875
+ return "Normal"
876
+ if score <= 75:
877
+ return "Normal"
878
+ if score <= 134:
879
+ return "Difficult"
880
+ return "VeryDifficult"
881
+
882
+
883
+ def difficulty_from_chart_score(score: int) -> str:
884
+ """Map a NECA Labor Adjustment Chart total to a difficulty column.
885
+
886
+ Uses the 1-3 scale, 30 situations, max 90 points system.
887
+
888
+ Score ranges:
889
+ 30-40 -> Normal
890
+ 41-70 -> Difficult
891
+ 71-90 -> VeryDifficult
892
+ """
893
+ if score <= 40:
894
+ return "Normal"
895
+ if score <= 70:
896
+ return "Difficult"
897
+ return "VeryDifficult"
898
+
899
+
900
+ def residential_minutes(task: str, service: bool = False) -> float:
901
+ """Return estimated minutes for a residential task.
902
+
903
+ Uses Fennec Lab NECA MLU-based defaults with optional service multiplier.
904
+
905
+ Args:
906
+ task: One of 'receptacle_outlet', 'switch', 'light_fixture',
907
+ 'panel_circuit', 'low_voltage_run'.
908
+ service: If True, apply residential service multiplier (1.2x midpoint).
909
+
910
+ Returns:
911
+ Estimated minutes.
912
+ """
913
+ base = RESIDENTIAL_DEFAULTS.get(task, 0.0)
914
+ if service and base > 0:
915
+ # Use midpoint of 1.1-1.3 range
916
+ base *= (RESIDENTIAL_SERVICE_MULTIPLIER[0] + RESIDENTIAL_SERVICE_MULTIPLIER[1]) / 2
917
+ return base
918
+
919
+
920
+ # ── FTS5 helpers ──────────────────────────────────────────────────────────────
921
+
922
+ _FTS5_SPECIAL = re.compile(r'["\'\*\(\)\+\-\^:]')
923
+
924
+
925
+ def _sanitize_fts_query(query: str) -> str:
926
+ """Sanitize a user query for FTS5 MATCH.
927
+
928
+ Wraps each token in double quotes to prevent syntax errors from
929
+ special characters. Preserves AND/OR/NOT operators.
930
+ """
931
+ query = query.strip()
932
+ if not query:
933
+ return ""
934
+ tokens = re.findall(r'"[^"]*"|\S+', query)
935
+ sanitized = []
936
+ for tok in tokens:
937
+ if tok.upper() in ("AND", "OR", "NOT"):
938
+ sanitized.append(tok.upper())
939
+ elif tok.startswith('"') and tok.endswith('"'):
940
+ sanitized.append(tok)
941
+ else:
942
+ clean = _FTS5_SPECIAL.sub("", tok)
943
+ if clean:
944
+ sanitized.append(f'"{clean}"')
945
+ return " ".join(sanitized)
946
+
947
+
948
+ # ── NECA Labor Factor Score Sheet (37 conditions, 1-5 scale) ──────────────────
949
+
950
+
951
+ @dataclass
952
+ class ScoreCondition:
953
+ """One condition in the NECA Labor Factor Score Sheet (1-5 scale).
954
+
955
+ Each level (1-5) has a description. Score 1 = best conditions,
956
+ 5 = worst conditions. Total score range: 37-185.
957
+ """
958
+
959
+ id: str
960
+ description: str
961
+ level_1: str = ""
962
+ level_2: str = ""
963
+ level_3: str = ""
964
+ level_4: str = ""
965
+ level_5: str = ""
966
+ score: int = 0
967
+
968
+ def __post_init__(self) -> None:
969
+ if not 0 <= self.score <= 5:
970
+ raise ValueError(f"Score must be 0-5, got {self.score}")
971
+
972
+ @property
973
+ def is_scored(self) -> bool:
974
+ return self.score > 0
975
+
976
+
977
+ @dataclass
978
+ class LaborFactorScoreSheet:
979
+ """NECA Labor Factor Score Sheet — 37 conditions, 1-5 scale.
980
+
981
+ Total score 36-75 → Normal, 76-134 → Difficult, 135-175 → Very Difficult.
982
+ """
983
+
984
+ conditions: list[ScoreCondition]
985
+
986
+ @classmethod
987
+ def standard(cls) -> LaborFactorScoreSheet:
988
+ """Create the standard 37-condition NECA score sheet."""
989
+ return cls(conditions=list(_STANDARD_SCORE_SHEET_CONDITIONS()))
990
+
991
+ @property
992
+ def total_score(self) -> int:
993
+ return sum(c.score for c in self.conditions if c.is_scored)
994
+
995
+ @property
996
+ def max_possible_score(self) -> int:
997
+ return len(self.conditions) * 5
998
+
999
+ @property
1000
+ def scored_count(self) -> int:
1001
+ return sum(1 for c in self.conditions if c.is_scored)
1002
+
1003
+ @property
1004
+ def difficulty(self) -> str:
1005
+ return difficulty_from_score(self.total_score)
1006
+
1007
+ def set_score(self, condition_id: str, score: int) -> None:
1008
+ for c in self.conditions:
1009
+ if c.id == condition_id:
1010
+ c.score = score
1011
+ return
1012
+ raise KeyError(f"Unknown condition: {condition_id}")
1013
+
1014
+ def reset(self) -> None:
1015
+ for c in self.conditions:
1016
+ c.score = 0
1017
+
1018
+ def summary(self) -> dict[str, object]:
1019
+ return {
1020
+ "scored": self.scored_count,
1021
+ "total_conditions": len(self.conditions),
1022
+ "total_score": self.total_score,
1023
+ "difficulty": self.difficulty,
1024
+ }
1025
+
1026
+
1027
+ def _STANDARD_SCORE_SHEET_CONDITIONS() -> list[ScoreCondition]:
1028
+ """Build the 37 standard NECA Labor Factor Score Sheet conditions."""
1029
+ return [
1030
+ ScoreCondition("working_height", "Working Height",
1031
+ "≤10'", "10'-15'", "15'-20'", "20'-30'", ">30'"),
1032
+ ScoreCondition("building_height", "Building Height (floors)",
1033
+ "1-3 floors", "4-7 floors", "8-14 floors", "15-30 floors", ">30 floors"),
1034
+ ScoreCondition("site_size", "Site Size",
1035
+ "Large/open", "Medium", "Small", "Confined", "Very confined"),
1036
+ ScoreCondition("job_condition", "Job Condition",
1037
+ "New construction", "Remodel—unoccupied", "Remodel—occupied", "Tenant finish—occupied", "Historic/asbestos"),
1038
+ ScoreCondition("conduit_type", "Conduit Type",
1039
+ "EMT/flex", "Mixed EMT/RMC", "RMC/PVC", "Rigid aluminum/PVC-coated", "Explosion-proof"),
1040
+ ScoreCondition("voltage", "Voltage Level",
1041
+ "0-600V", "600V-5kV", "5kV-15kV", "15kV-35kV", ">35kV"),
1042
+ ScoreCondition("drawings_complete", "Drawings % Complete",
1043
+ "100%", "90%", "75%", "50%", "<50%"),
1044
+ ScoreCondition("change_order_quantity", "Change Order Quantity",
1045
+ "None", "Few (<5%)", "Moderate (5-10%)", "Many (10-20%)", "Extensive (>20%)"),
1046
+ ScoreCondition("change_order_timing", "Change Order Timing",
1047
+ "Before start", "Early phase", "Mid-project", "Late phase", "Near completion"),
1048
+ ScoreCondition("craft_coordination", "Craft Coordination",
1049
+ "Single trade", "2-3 trades", "4-6 trades", "7-10 trades", ">10 trades"),
1050
+ ScoreCondition("ahj_experience", "AHJ Experience with Project Type",
1051
+ "Considerable", "Moderate", "Limited", "None", "Hostile"),
1052
+ ScoreCondition("hours_worked", "Hours Worked per Week",
1053
+ "40 hrs", "45 hrs", "50 hrs", "55 hrs", "60+ hrs"),
1054
+ ScoreCondition("shifts", "Shifts",
1055
+ "Day only", "Day + occasional OT", "2 shifts", "3 shifts", "Rotating shifts"),
1056
+ ScoreCondition("job_documents", "Job Documents",
1057
+ "Complete specs", "Partial specs", "Minimal specs", "Drawings only", "No documents"),
1058
+ ScoreCondition("working_conditions", "Working Conditions",
1059
+ "Controlled indoor", "Outdoor—moderate", "Outdoor—extreme weather", "Hazardous environment", "Extreme hazardous"),
1060
+ ScoreCondition("crew_density", "Crew Density",
1061
+ "Low (≤3 workers)", "Medium (4-6)", "High (7-10)", "Very high (11-15)", "Extreme (>15)"),
1062
+ ScoreCondition("job_duration", "Job Duration",
1063
+ "Long (>6 months)", "Medium (3-6 months)", "Short (1-3 months)", "Very short (<1 month)", "Crash schedule"),
1064
+ ScoreCondition("building_sqft", "Building Square Footage",
1065
+ ">100k sq ft", "50k-100k", "10k-50k", "1k-10k", "<1k sq ft"),
1066
+ ScoreCondition("project_size", "Project Size ($)",
1067
+ ">$750k", "$100k-750k", "$25k-100k", "$5k-25k", "<$5k"),
1068
+ ScoreCondition("safety", "Safety Requirements",
1069
+ "Low risk", "Standard PPE", "High risk (fall protection)", "Very high (confined space)", "Extreme (HAZWOPER)"),
1070
+ ScoreCondition("clean_up", "Clean-up Requirements",
1071
+ "Minimal", "Standard", "Extensive", "Daily full clean", "White-glove/PHI"),
1072
+ ScoreCondition("installation_repetition", "Installation Repetition",
1073
+ "Highly repetitive", "Moderate repetition", "Some repetition", "Low repetition", "One-off/custom"),
1074
+ ScoreCondition("construction_type", "Construction Type",
1075
+ "New—steel frame", "New—wood frame", "Addition", "Remodel", "Tenant finish"),
1076
+ ScoreCondition("systems_complexity", "Systems Complexity",
1077
+ "Simple (power/lighting)", "Moderate (power+low-voltage)", "Complex (IBS+automation)", "Very complex (BMS+process)", "Extreme (mission critical)"),
1078
+ ScoreCondition("project_access", "Project Access",
1079
+ "Easy—open site", "Moderate—some restrictions", "Difficult—limited hours", "Very difficult—security", "Extreme—escorted only"),
1080
+ ScoreCondition("tools", "Tools & Equipment",
1081
+ "Adequate", "Mostly adequate", "Some shortages", "Frequent shortages", "Inadequate"),
1082
+ ScoreCondition("labor_base", "Labor Base Experience",
1083
+ "5+ years, company crew", "3-5 years, company", "1-3 years, mixed", "JW from hall", "Apprentices/helpers"),
1084
+ ScoreCondition("information_flow", "Information Flow",
1085
+ "Real-time RFIs answered", "24-hr RFI response", "48-hr RFI response", "Weekly RFI response", "Poor/no response"),
1086
+ ScoreCondition("decision_making", "Decision Making Speed",
1087
+ "Same day", "1-2 days", "3-5 days", "1 week", ">1 week"),
1088
+ ScoreCondition("job_continuity", "Job Continuity",
1089
+ "Continuous work", "Minor breaks", "Some downtime", "Frequent breaks", "Start-stop"),
1090
+ ScoreCondition("job_schedule", "Job Schedule Pressure",
1091
+ "Relaxed", "Normal", "Moderate pressure", "Tight schedule", "Crash/accelerated"),
1092
+ ScoreCondition("job_meetings", "Job Meetings Frequency",
1093
+ "Monthly", "Bi-weekly", "Weekly", "2-3x/week", "Daily"),
1094
+ # ── 5 conditions missing from original model (Tier 2 addition) ──
1095
+ ScoreCondition("bim_usage", "BIM Usage",
1096
+ "Proactive BIM coordination", "Moderately proactive", "Contract-only BIM", "2D drawings only", "No BIM/no drawings"),
1097
+ ScoreCondition("gc_count", "General Contractors on Jobsite",
1098
+ "Single prime", "Two primes", "Three primes", "Four+ primes", "Multiple primes + subs"),
1099
+ ScoreCondition("shared_responsibility", "Shared Responsibility (ECs on site)",
1100
+ "Sole EC", "Two ECs", "Three ECs", "Four+ ECs", "Fragmented responsibility"),
1101
+ ScoreCondition("material_proximity", "Proximity of Stored Materials",
1102
+ "On site—staged at work area", "On site—general area", "Off site—nearby", "Off site—distant", "Remote warehouse"),
1103
+ ScoreCondition("ahj_project_type", "AHJ Experience with This Project Type",
1104
+ "Considerable—same AHJ", "Moderate—familiar AHJ", "Limited—new AHJ", "None—unfamiliar AHJ", "Hostile AHJ"),
1105
+ ]
1106
+
1107
+
1108
+ # ── NECA Labor Adjustment Chart (30 situations, 1-3 scale) ────────────────────
1109
+
1110
+
1111
+ @dataclass
1112
+ class ChartSituation:
1113
+ """One situation in the NECA Labor Adjustment Chart (1-3 scale).
1114
+
1115
+ Score 1 = best conditions, 3 = worst. Total range: 30-90.
1116
+ """
1117
+
1118
+ id: str
1119
+ description: str
1120
+ level_1: str = ""
1121
+ level_2: str = ""
1122
+ level_3: str = ""
1123
+ score: int = 0
1124
+
1125
+ def __post_init__(self) -> None:
1126
+ if not 0 <= self.score <= 3:
1127
+ raise ValueError(f"Score must be 0-3, got {self.score}")
1128
+
1129
+ @property
1130
+ def is_scored(self) -> bool:
1131
+ return self.score > 0
1132
+
1133
+
1134
+ @dataclass
1135
+ class LaborAdjustmentChart:
1136
+ """NECA Labor Adjustment Chart — 30 situations, 1-3 scale.
1137
+
1138
+ Total score 30-40 → Normal, 41-70 → Difficult, 71-90 → Very Difficult.
1139
+ """
1140
+
1141
+ situations: list[ChartSituation]
1142
+
1143
+ @classmethod
1144
+ def standard(cls) -> LaborAdjustmentChart:
1145
+ return cls(situations=list(_STANDARD_CHART_SITUATIONS()))
1146
+
1147
+ @property
1148
+ def total_score(self) -> int:
1149
+ return sum(s.score for s in self.situations if s.is_scored)
1150
+
1151
+ @property
1152
+ def max_possible_score(self) -> int:
1153
+ return len(self.situations) * 3
1154
+
1155
+ @property
1156
+ def scored_count(self) -> int:
1157
+ return sum(1 for s in self.situations if s.is_scored)
1158
+
1159
+ @property
1160
+ def difficulty(self) -> str:
1161
+ return difficulty_from_chart_score(self.total_score)
1162
+
1163
+ def set_score(self, situation_id: str, score: int) -> None:
1164
+ for s in self.situations:
1165
+ if s.id == situation_id:
1166
+ s.score = score
1167
+ return
1168
+ raise KeyError(f"Unknown situation: {situation_id}")
1169
+
1170
+ def reset(self) -> None:
1171
+ for s in self.situations:
1172
+ s.score = 0
1173
+
1174
+ def summary(self) -> dict[str, object]:
1175
+ return {
1176
+ "scored": self.scored_count,
1177
+ "total_situations": len(self.situations),
1178
+ "total_score": self.total_score,
1179
+ "difficulty": self.difficulty,
1180
+ }
1181
+
1182
+
1183
+ def _STANDARD_CHART_SITUATIONS() -> list[ChartSituation]:
1184
+ """Build the 30 standard NECA Labor Adjustment Chart situations."""
1185
+ return [
1186
+ ChartSituation("hours_worked", "Hours Worked", "40 hrs", "50 hrs", ">50 hrs"),
1187
+ ChartSituation("shifts", "Shifts", "Day", "2nd shift", "3rd shift"),
1188
+ ChartSituation("job_documents", "Job Documents", "Standard", "Poor", "None"),
1189
+ ChartSituation("working_conditions", "Working Conditions", "Controlled indoor", "Outdoor—moderate", "Extreme weather"),
1190
+ ChartSituation("crew_density", "Crew Density", "Low", "Medium", "High"),
1191
+ ChartSituation("working_height", "Working Height", "≤10'", "10'-20'", ">20'"),
1192
+ ChartSituation("floors", "Floors", "0-3", "4-7", "8+"),
1193
+ ChartSituation("job_duration", "Job Duration", "Long", "Medium", "Short"),
1194
+ ChartSituation("building_sqft", "Building Sq Ft", "Large", "Medium", "Small"),
1195
+ ChartSituation("project_size", "Project Size", "≤$100k", "$100k-750k", ">$750k"),
1196
+ ChartSituation("site_size", "Site Size", "Large", "Medium", "Small"),
1197
+ ChartSituation("safety", "Safety", "Low risk", "Medium", "High"),
1198
+ ChartSituation("job_condition", "Job Condition", "New", "Remodel", "Occupied"),
1199
+ ChartSituation("clean_up", "Clean-up", "Minimal", "Moderate", "Extensive"),
1200
+ ChartSituation("installation_repetition", "Installation Repetition", "High", "Medium", "Low"),
1201
+ ChartSituation("construction_type", "Construction Type", "New", "Addition", "Remodel"),
1202
+ ChartSituation("systems_complexity", "Systems Complexity", "Simple", "Moderate", "Complex"),
1203
+ ChartSituation("project_access", "Project Access", "Easy", "Moderate", "Difficult"),
1204
+ ChartSituation("voltage", "Voltage", "0-600V", "600V-5kV", ">5kV"),
1205
+ ChartSituation("tools", "Tools", "Adequate", "Limited", "Inadequate"),
1206
+ ChartSituation("craft_coordination", "Craft Coordination", "Single trade", "Few trades", "Many trades"),
1207
+ ChartSituation("labor_base", "Labor Base", "Experienced", "Moderate", "Inexperienced"),
1208
+ ChartSituation("information_flow", "Information Flow", "Good", "Moderate", "Poor"),
1209
+ ChartSituation("decision_making", "Decision Making", "Quick", "Moderate", "Slow"),
1210
+ ChartSituation("job_continuity", "Job Continuity", "Continuous", "Some breaks", "Frequent breaks"),
1211
+ ChartSituation("change_order_quantity", "Change Order Quantity", "None", "Few", "Many"),
1212
+ ChartSituation("change_order_timing", "Change Order Timing", "Early", "Mid", "Late"),
1213
+ ChartSituation("job_schedule", "Job Schedule", "Relaxed", "Moderate", "Compressed"),
1214
+ ChartSituation("job_meetings", "Job Meetings", "Few", "Weekly", "Daily"),
1215
+ ChartSituation("conduit_type", "Conduit Type", "Simple", "Mixed", "Complex"),
1216
+ ]
1217
+
1218
+
1219
+ # ── Durand Multi-Level Construction Adjustments (Section 23) ──────────────────
1220
+
1221
+
1222
+ def durand_vertical_multiplier(level: int) -> float:
1223
+ """Durand vertical-work multiplier for multi-level construction.
1224
+
1225
+ Vertical work (risers, vertical cable runs) becomes harder as
1226
+ building height increases. Formula: 1.03 + (level-1) * 0.02.
1227
+
1228
+ Args:
1229
+ level: Building level (2-40). Level 1 = ground floor (1.0).
1230
+
1231
+ Returns:
1232
+ Multiplier to apply to total vertical labor hours.
1233
+
1234
+ Example:
1235
+ >>> durand_vertical_multiplier(20)
1236
+ 1.41
1237
+ >>> durand_vertical_multiplier(40)
1238
+ 1.81
1239
+ """
1240
+ if level <= 1:
1241
+ return 1.0
1242
+ return round(1.03 + (level - 1) * 0.02, 2)
1243
+
1244
+
1245
+ def durand_horizontal_setup_hours(level: int) -> int:
1246
+ """Durand horizontal-work setup hours for multi-level construction.
1247
+
1248
+ Horizontal work (branch circuits, same-level wiring) needs setup
1249
+ time for material/tool/personnel transport between floors.
1250
+ Formula: (level - 1) * 32 hours.
1251
+
1252
+ Args:
1253
+ level: Building level (2-40). Level 1 = ground floor (0 hours).
1254
+
1255
+ Returns:
1256
+ Total setup hours for that level.
1257
+ """
1258
+ if level <= 1:
1259
+ return 0
1260
+ return (level - 1) * 32
1261
+
1262
+
1263
+ # Durand core-work multipliers (repetitive typical floors reduce labor).
1264
+ # Encoded from the Section 23 table (page 23-4 of the Durand manual).
1265
+ _DURAND_CORE_TABLE: dict[int, float] = {
1266
+ 2: 0.95, 3: 0.93, 4: 0.91, 5: 0.89, 6: 0.87, 7: 0.86, 8: 0.85,
1267
+ 9: 0.84, 10: 0.84, 11: 0.84, 12: 0.84, 13: 0.84, 14: 0.82, 15: 0.82,
1268
+ 16: 0.82, 17: 0.82, 18: 0.82, 19: 0.82, 20: 0.81, 21: 0.81, 22: 0.81,
1269
+ 23: 0.81, 24: 0.81, 25: 0.80, 26: 0.80, 27: 0.80, 28: 0.80, 29: 0.80,
1270
+ 30: 0.78, 31: 0.78, 32: 0.78, 33: 0.78, 34: 0.78, 35: 0.78, 36: 0.75,
1271
+ 37: 0.75, 38: 0.75, 39: 0.75, 40: 0.75,
1272
+ }
1273
+
1274
+
1275
+ def durand_core_multiplier(level: int) -> float:
1276
+ """Durand core-work multiplier for repetitive typical floors.
1277
+
1278
+ Core areas (stairways, elevator lobbies, restrooms, electrical rooms)
1279
+ are typical floor-to-floor. Repetitive work *reduces* labor.
1280
+
1281
+ Args:
1282
+ level: Building level (2-40). Level 1 = ground floor (1.0).
1283
+
1284
+ Returns:
1285
+ Multiplier to apply to core-area labor hours.
1286
+
1287
+ Example:
1288
+ >>> durand_core_multiplier(20)
1289
+ 0.81
1290
+ """
1291
+ if level <= 1:
1292
+ return 1.0
1293
+ return _DURAND_CORE_TABLE.get(level, 0.75)
1294
+
1295
+
1296
+ # ── Crew Size Calculator (Durand Section 23) ─────────────────────────────────
1297
+
1298
+
1299
+ @dataclass
1300
+ class CrewSizeEstimate:
1301
+ """Result of a crew-size calculation."""
1302
+
1303
+ average_crew_size: float
1304
+ maximum_crew_size: float
1305
+ working_days: int
1306
+ non_working_foremen: int
1307
+ total_crew_including_supervision: int
1308
+ method: str
1309
+
1310
+ def summary(self) -> dict[str, object]:
1311
+ return {
1312
+ "average_crew_size": round(self.average_crew_size, 2),
1313
+ "maximum_crew_size": round(self.maximum_crew_size, 2),
1314
+ "working_days": self.working_days,
1315
+ "non_working_foremen": self.non_working_foremen,
1316
+ "total_crew_including_supervision": self.total_crew_including_supervision,
1317
+ "method": self.method,
1318
+ }
1319
+
1320
+
1321
+ def crew_size_straight_line(
1322
+ man_hours: float,
1323
+ calendar_days: int,
1324
+ work_days_per_week: int = 5,
1325
+ work_hours_per_day: int = 8,
1326
+ ) -> CrewSizeEstimate:
1327
+ """Calculate crew size using the Durand straight-line method.
1328
+
1329
+ Formula:
1330
+ WD = CD / 7 * WDPW
1331
+ ACS = MH / WHPD / WD
1332
+ MCS = ACS * 1.4
1333
+
1334
+ Non-working supervision: 1 foreman per 6 electricians (when MCS >= 6).
1335
+
1336
+ Args:
1337
+ man_hours: Total estimated man-hours for the project.
1338
+ calendar_days: Project duration in calendar days.
1339
+ work_days_per_week: Working days per week (default 5).
1340
+ work_hours_per_day: Work hours per day (default 8).
1341
+
1342
+ Returns:
1343
+ CrewSizeEstimate with average/max crew size and supervision needs.
1344
+
1345
+ Example:
1346
+ >>> est = crew_size_straight_line(3000, 90)
1347
+ >>> est.average_crew_size
1348
+ 5.86
1349
+ >>> est.maximum_crew_size
1350
+ 8.2
1351
+ """
1352
+ working_days = max(1, round(calendar_days / 7 * work_days_per_week))
1353
+ acs = man_hours / work_hours_per_day / working_days
1354
+ mcs = acs * 1.4
1355
+ foremen = int(mcs // 6) if mcs >= 6 else 0
1356
+ total_crew = int(round(mcs)) + foremen
1357
+ return CrewSizeEstimate(
1358
+ average_crew_size=acs,
1359
+ maximum_crew_size=mcs,
1360
+ working_days=working_days,
1361
+ non_working_foremen=foremen,
1362
+ total_crew_including_supervision=total_crew,
1363
+ method="straight_line",
1364
+ )
1365
+
1366
+
1367
+ def crew_size_curve(average_crew_size: float) -> float:
1368
+ """Calculate maximum crew size using the Durand curve method.
1369
+
1370
+ MCS = ACS * 1.4. The curve method accounts for overlapping
1371
+ activities (conduit, wire, fixtures, trim) that peak mid-project.
1372
+
1373
+ Args:
1374
+ average_crew_size: Average crew size from straight-line method.
1375
+
1376
+ Returns:
1377
+ Maximum crew size at project peak.
1378
+ """
1379
+ return average_crew_size * 1.4
1380
+
1381
+
1382
+ def non_working_supervision(crew_size: int | float) -> int:
1383
+ """Calculate non-working foremen needed.
1384
+
1385
+ Rule: 1 non-working foreman per 6 working electricians.
1386
+
1387
+ Args:
1388
+ crew_size: Number of working electricians.
1389
+
1390
+ Returns:
1391
+ Number of non-working foremen required.
1392
+ """
1393
+ if crew_size < 6:
1394
+ return 0
1395
+ return int(crew_size // 6)