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.
- algo_cli/__init__.py +3 -0
- algo_cli/__main__.py +7 -0
- algo_cli/_internal/__init__.py +12 -0
- algo_cli/_internal/policy_chain.py +259 -0
- algo_cli/action_registry.py +1047 -0
- algo_cli/agent_blocks.py +550 -0
- algo_cli/agent_pipeline.py +1457 -0
- algo_cli/agent_threads.py +308 -0
- algo_cli/animations.py +316 -0
- algo_cli/cache_admission.py +209 -0
- algo_cli/capability_mask.py +66 -0
- algo_cli/chat_protocol.py +116 -0
- algo_cli/chatgpt_auth.py +510 -0
- algo_cli/chatgpt_client.py +657 -0
- algo_cli/code_rag.py +479 -0
- algo_cli/config.py +651 -0
- algo_cli/context_budget.py +679 -0
- algo_cli/credential_helpers.py +315 -0
- algo_cli/deliberation.py +29 -0
- algo_cli/display.py +1470 -0
- algo_cli/evals/__init__.py +21 -0
- algo_cli/evals/algorithm_effectiveness.py +560 -0
- algo_cli/evals/competitive_harness_rating.py +702 -0
- algo_cli/evals/cot_quality.py +220 -0
- algo_cli/evals/harness_retrieval_benchmark.py +401 -0
- algo_cli/evals/performance_regression.py +136 -0
- algo_cli/evals/scorecard_grading.py +308 -0
- algo_cli/evals/session_distribution.py +84 -0
- algo_cli/execution_guardrails.py +806 -0
- algo_cli/extensions_manifest.py +84 -0
- algo_cli/git_evidence.py +227 -0
- algo_cli/google_workspace.py +407 -0
- algo_cli/google_workspace_auth.py +523 -0
- algo_cli/harness.py +2587 -0
- algo_cli/identity.py +557 -0
- algo_cli/index_compute_lab.py +228 -0
- algo_cli/inference_harness.py +70 -0
- algo_cli/intelligence/__init__.py +1103 -0
- algo_cli/intelligence/acrobat_config.py +307 -0
- algo_cli/intelligence/acrobat_manifests.py +338 -0
- algo_cli/intelligence/acrobat_models.py +195 -0
- algo_cli/intelligence/acrobat_pipeline.py +295 -0
- algo_cli/intelligence/acrobat_runtime.py +302 -0
- algo_cli/intelligence/acrobat_security.py +261 -0
- algo_cli/intelligence/acrobat_workflows.py +226 -0
- algo_cli/intelligence/actionability.py +165 -0
- algo_cli/intelligence/adversarial_audit.py +136 -0
- algo_cli/intelligence/agent_arena.py +92 -0
- algo_cli/intelligence/agent_benchmark.py +236 -0
- algo_cli/intelligence/agent_runtime.py +171 -0
- algo_cli/intelligence/agents_as_tools.py +70 -0
- algo_cli/intelligence/artifact_binding.py +80 -0
- algo_cli/intelligence/autonomous_engineer.py +1976 -0
- algo_cli/intelligence/backpressure.py +99 -0
- algo_cli/intelligence/bloom_filter.py +186 -0
- algo_cli/intelligence/bonferroni.py +66 -0
- algo_cli/intelligence/boundary_compaction.py +98 -0
- algo_cli/intelligence/catalog_verifier.py +172 -0
- algo_cli/intelligence/cavecrew.py +118 -0
- algo_cli/intelligence/changelog.py +176 -0
- algo_cli/intelligence/checkpoint_resume.py +92 -0
- algo_cli/intelligence/circuit_breaker.py +88 -0
- algo_cli/intelligence/clarification_gate.py +101 -0
- algo_cli/intelligence/code_graph.py +180 -0
- algo_cli/intelligence/coderank.py +97 -0
- algo_cli/intelligence/consistent_hash.py +150 -0
- algo_cli/intelligence/consortium_synthesis.py +139 -0
- algo_cli/intelligence/construction/__init__.py +241 -0
- algo_cli/intelligence/construction/common.py +273 -0
- algo_cli/intelligence/construction/documents.py +496 -0
- algo_cli/intelligence/construction/labor_units.py +1395 -0
- algo_cli/intelligence/construction/payments.py +470 -0
- algo_cli/intelligence/construction/risk.py +784 -0
- algo_cli/intelligence/content_extractor.py +132 -0
- algo_cli/intelligence/context_adaptive.py +102 -0
- algo_cli/intelligence/context_ops.py +95 -0
- algo_cli/intelligence/count_min.py +145 -0
- algo_cli/intelligence/cow_state.py +103 -0
- algo_cli/intelligence/critic_loop.py +119 -0
- algo_cli/intelligence/cross_source.py +113 -0
- algo_cli/intelligence/daemon_mode.py +99 -0
- algo_cli/intelligence/dag_orchestration.py +151 -0
- algo_cli/intelligence/deep_research.py +155 -0
- algo_cli/intelligence/degenerate_detector.py +78 -0
- algo_cli/intelligence/delta_report.py +92 -0
- algo_cli/intelligence/discovery_event_log.py +92 -0
- algo_cli/intelligence/document_ingest.py +298 -0
- algo_cli/intelligence/dual_layer_validate.py +151 -0
- algo_cli/intelligence/echo_fidelity.py +73 -0
- algo_cli/intelligence/ema_tuning.py +104 -0
- algo_cli/intelligence/event_log.py +92 -0
- algo_cli/intelligence/evidence_graph.py +114 -0
- algo_cli/intelligence/extension_host.py +162 -0
- algo_cli/intelligence/extension_manifest.py +115 -0
- algo_cli/intelligence/falsification_suite.py +178 -0
- algo_cli/intelligence/finance/__init__.py +169 -0
- algo_cli/intelligence/finance/anomalies.py +135 -0
- algo_cli/intelligence/finance/ap_ar.py +351 -0
- algo_cli/intelligence/finance/cash.py +162 -0
- algo_cli/intelligence/finance/close.py +332 -0
- algo_cli/intelligence/finance/common.py +244 -0
- algo_cli/intelligence/finance/construction.py +135 -0
- algo_cli/intelligence/finance/controls.py +172 -0
- algo_cli/intelligence/finance/evidence.py +119 -0
- algo_cli/intelligence/finance/exceptions.py +157 -0
- algo_cli/intelligence/finance/reconciliations.py +254 -0
- algo_cli/intelligence/finance/revenue.py +109 -0
- algo_cli/intelligence/finance/tax.py +74 -0
- algo_cli/intelligence/finance/workpapers.py +111 -0
- algo_cli/intelligence/finding_record.py +120 -0
- algo_cli/intelligence/flow_dag.py +267 -0
- algo_cli/intelligence/gatherer.py +223 -0
- algo_cli/intelligence/golden_master.py +98 -0
- algo_cli/intelligence/graph_rag.py +195 -0
- algo_cli/intelligence/group_chat.py +143 -0
- algo_cli/intelligence/hash_dedup.py +145 -0
- algo_cli/intelligence/hyperloglog.py +128 -0
- algo_cli/intelligence/incremental_index.py +316 -0
- algo_cli/intelligence/index_store.py +16 -0
- algo_cli/intelligence/iteration_plan.py +133 -0
- algo_cli/intelligence/kernel_plugins.py +167 -0
- algo_cli/intelligence/lesson_catalog.py +135 -0
- algo_cli/intelligence/llm_fallback.py +169 -0
- algo_cli/intelligence/log2_histogram.py +267 -0
- algo_cli/intelligence/lsp_integration.py +147 -0
- algo_cli/intelligence/memory_evolution.py +117 -0
- algo_cli/intelligence/minhash_lsh.py +182 -0
- algo_cli/intelligence/multi_model_score.py +174 -0
- algo_cli/intelligence/multi_tier_grade.py +211 -0
- algo_cli/intelligence/negative_controls.py +113 -0
- algo_cli/intelligence/numeric_clamp.py +63 -0
- algo_cli/intelligence/occ_editor.py +66 -0
- algo_cli/intelligence/output_normalize.py +112 -0
- algo_cli/intelligence/parallel_delegation.py +98 -0
- algo_cli/intelligence/parallel_fanout.py +104 -0
- algo_cli/intelligence/permission_modes.py +105 -0
- algo_cli/intelligence/pre_push_gate.py +68 -0
- algo_cli/intelligence/prefetch.py +171 -0
- algo_cli/intelligence/process_framework.py +217 -0
- algo_cli/intelligence/project_graph.py +387 -0
- algo_cli/intelligence/query_expansion.py +146 -0
- algo_cli/intelligence/ralph_loop.py +117 -0
- algo_cli/intelligence/rate_limiter.py +153 -0
- algo_cli/intelligence/refactor_transaction.py +94 -0
- algo_cli/intelligence/research_workspace.py +108 -0
- algo_cli/intelligence/retraction_ledger.py +72 -0
- algo_cli/intelligence/saga_pattern.py +88 -0
- algo_cli/intelligence/session_fork.py +100 -0
- algo_cli/intelligence/shadow_editor.py +67 -0
- algo_cli/intelligence/shell_session.py +213 -0
- algo_cli/intelligence/source_registry.py +143 -0
- algo_cli/intelligence/spawn_scales.py +99 -0
- algo_cli/intelligence/stat_stability.py +104 -0
- algo_cli/intelligence/structural_validator.py +148 -0
- algo_cli/intelligence/subagent_spawner.py +111 -0
- algo_cli/intelligence/symmetric_verify.py +70 -0
- algo_cli/intelligence/task_classifier.py +129 -0
- algo_cli/intelligence/team_execution.py +122 -0
- algo_cli/intelligence/tiered_access.py +121 -0
- algo_cli/intelligence/utility_registry.py +159 -0
- algo_cli/intuition_engine.py +560 -0
- algo_cli/intuition_injector.py +82 -0
- algo_cli/kernels/__init__.py +5 -0
- algo_cli/kernels/manifest.py +763 -0
- algo_cli/main.py +3903 -0
- algo_cli/memory_candidates.py +541 -0
- algo_cli/memory_echo_veil.py +394 -0
- algo_cli/memory_runtime.py +112 -0
- algo_cli/model_info.py +548 -0
- algo_cli/model_profile.py +160 -0
- algo_cli/model_routing.py +74 -0
- algo_cli/oneshot.py +331 -0
- algo_cli/perf_telemetry.py +389 -0
- algo_cli/plugins.py +245 -0
- algo_cli/private_event_store.py +654 -0
- algo_cli/quantization/__init__.py +24 -0
- algo_cli/quantization/lloyd_max.py +98 -0
- algo_cli/quantization/turbo_quant.py +308 -0
- algo_cli/reasoning/__init__.py +46 -0
- algo_cli/reasoning/combinatorial.py +356 -0
- algo_cli/reasoning/graph_of_thought.py +297 -0
- algo_cli/reasoning/mcts.py +220 -0
- algo_cli/reasoning/neuro_symbolic.py +250 -0
- algo_cli/reasoning/react.py +246 -0
- algo_cli/reasoning/reflexion.py +225 -0
- algo_cli/reasoning/tree_of_thought.py +241 -0
- algo_cli/reasoning_bridge.py +150 -0
- algo_cli/reconciliation.py +284 -0
- algo_cli/reflex.py +385 -0
- algo_cli/resources/docs/ALGO.md +13958 -0
- algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
- algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
- algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
- algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
- algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
- algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
- algo_cli/resources/docs/main-split-map.md +35 -0
- algo_cli/resources/docs/privacy-and-context.md +48 -0
- algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
- algo_cli/resources/skills/README.md +26 -0
- algo_cli/resources/skills/algo-cli.md +59 -0
- algo_cli/resources/skills/edit-file-precision.md +49 -0
- algo_cli/resources/skills/harness-search-first.md +47 -0
- algo_cli/resources/skills/memory-recall-ritual.md +51 -0
- algo_cli/resources/skills/qol-algorithms.md +224 -0
- algo_cli/resources/skills/smart-error-recovery.md +56 -0
- algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
- algo_cli/retrieval_algorithms.py +127 -0
- algo_cli/runtime_qos.py +236 -0
- algo_cli/runtime_services.py +320 -0
- algo_cli/session_commands.py +95 -0
- algo_cli/session_mode.py +113 -0
- algo_cli/skills.py +430 -0
- algo_cli/slash_dispatch.py +1265 -0
- algo_cli/small_context.py +206 -0
- algo_cli/spawn_budget.py +89 -0
- algo_cli/task_ledger.py +84 -0
- algo_cli/task_router.py +197 -0
- algo_cli/tool_context.py +94 -0
- algo_cli/tool_contract.py +99 -0
- algo_cli/tool_policy.py +357 -0
- algo_cli/tool_runtime.py +647 -0
- algo_cli/tools.py +3056 -0
- algo_cli/url_scheme.py +174 -0
- algo_cli/verify.py +154 -0
- algo_cli/version_manifest.py +178 -0
- algo_cli/vision_screenshot_verify.py +76 -0
- algo_cli/workspace_resolver.py +68 -0
- algo_cli/x_account.py +209 -0
- algo_cli/xai_auth.py +374 -0
- algo_cli/xai_client.py +600 -0
- algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
- algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
- algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
- algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
- algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
- 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)
|