paths.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. # -*- coding: utf-8 -*-
  2. r"""观澜 v2 路径中心 —— 跨平台部署的唯一路径真源 (2026-09-11 用户令)。
  3. ## 约定 (硬约束)
  4. 1. **代码与配置里只写相对路径**, 相对 **安装根 (ROOT)** —— 不写机器相关绝对路径
  5. (`/Users/…`、`/Volumes/…`、`C:\…`、`D:\…`、`F:\…`)。
  6. 2. 运行时**由本模块解析成绝对路径**, 解析基准是 ROOT —— **不是 cwd**。
  7. cwd 相对是隐形坑: 从别处调用同一个脚本 (`python <绝对路径>/scripts/x.py`) 或换工作目录,
  8. 路径就会指到别处; 本仓 2026-09-11 实测有 20+ 处 `Path('outputs/rudong/…')` 属此类, 已全部归到本模块。
  9. 3. 需要写进产物/清单/页面的路径字符串, 用 **POSIX 相对形式** (`outputs/rudong/…`, 见 `rel()`),
  10. Windows 与 Linux 通用; 不要用 `os.sep` 拼库存字符串 (那只适合"给人看"的显示, 见 `disp()`)。
  11. 4. ROOT 的确定: 环境变量 `WINDSCADA_ROOT` (冻结打包时由启动器设) → 否则按本文件位置回溯
  12. (`src/paths.py` 的上一级) —— 因此**整个安装目录可以整个拷到别的电脑/别的盘**, 不需要改任何路径。
  13. ## 跨平台要点
  14. - 分隔符: 一律 `pathlib`, 需要字符串时 `.as_posix()`;
  15. - 大小写: Linux 区分大小写 —— 目录名只用本模块常量, 不靠大小写变体;
  16. - 解释器: venv 按平台探测 (`.venv/Scripts/python.exe` / `.venv/bin/python`, 见 `venv_python()`);
  17. - 中文目录名 (如 `data/raw/如东`、`故障报警`): UTF-8 存写, 两侧平台均可; 页面显示走 `disp()`。
  18. """
  19. from __future__ import annotations
  20. try:
  21. from ._root import install_root as _install_root
  22. except ImportError: # 直接当脚本跑(python <本文件>)时没有包上下文
  23. from _root import install_root as _install_root
  24. import os
  25. import pathlib
  26. import sys
  27. # ---- 安装根: env → 本文件位置回溯 (src/paths.py → <安装目录>) ----------------------------
  28. ROOT = pathlib.Path(os.environ.get('WINDSCADA_ROOT') or _install_root(__file__))
  29. # ---- 输入侧 (离线数据): data/raw, 下一级目录 = 场站名 (见 docs/数据目录结构与落位约定) ----
  30. RAW_ROOT = pathlib.Path(os.environ.get('WINDSCADA_RUDONG_SRC') or (ROOT / 'data' / 'raw'))
  31. # ---- 非场站维度的固定位置 (全部为 ROOT 相对) -------------------------------------------
  32. CONFIGS = ROOT / 'configs'
  33. # P9(2026-09-28 用户令):模块专属配置下放到各模块 configs/;安装根 configs/ 只留**跨模块共用单件**。
  34. # 次序固定(先安装根,再按模块),同一逻辑配置只许出现在一处 —— 出现两处即报错(配置必须单源)。
  35. MODULE_CONFIG_ROOTS = tuple(ROOT / m / 'configs' for m in
  36. ('app_ETL', 'app_frontEnd', 'app_backEnd', 'app_qualityGate'))
  37. REFERENCE = ROOT / 'reference'
  38. RELEASE = ROOT / 'release'
  39. PORTAL = RELEASE / 'portal.html'
  40. VIEWER = RELEASE / 'viewer'
  41. RESOURCES = ROOT / 'resources'
  42. SIM_DIR = RESOURCES / 'oem_envision_sc1_rudong2014'
  43. DOCS = ROOT / 'docs'
  44. LOGS = ROOT / 'logs'
  45. RUN = ROOT / 'run'
  46. SCRIPTS = ROOT / 'scripts'
  47. SRC = ROOT / 'src'
  48. WHEELS = ROOT / 'wheels'
  49. DATA = ROOT / 'data'
  50. SERVE_JSON = CONFIGS / 'serve.json'
  51. MODELS_JSON = CONFIGS / 'models.json'
  52. # ---- 配置目录约定与唯一取用口 (2026-09-17 用户令 2: 统一配置目录及配置文件) ----------------
  53. # 为什么要有这一节: 原来各模块自己拼 `ROOT / 'configs' / 'xxx.yaml'` (实测 9 个模块各拼各的),
  54. # 于是"配置放哪"随时间漂移: 顶层散着 serve.json/models.json/portal_pages.yaml, 还混进过一个
  55. # `serve.json.bak-bomfix`; `scripts/audit_chinese_terms.py` 甚至要**试三个位置**才找得到 terms 库。
  56. # 现在: 目录分工写死在下面, 代码**只从 `config()` / `config_dir()` 取路径**, 不许再手拼字符串;
  57. # `scripts/config_audit.py` 会按这套约定查实物与代码(见 docs §11)。
  58. #
  59. # configs/<域>/<名字>.<yaml|json|csv> 域 = canonical | contracts | farms | terms | <新增>
  60. # configs/serve.json 运行期单件配置 (端口/路径真源)
  61. # configs/models.json 运行期单件配置 (本机模型档)
  62. # configs/portal_pages.yaml 运行期单件配置 (门户页面归口登记表, 见 §10)
  63. TOP_LEVEL_CONFIGS = ('serve.json', 'models.json', 'portal_pages.yaml')
  64. CONFIG_DOMAINS = ('canonical', 'contracts', 'farms', 'terms')
  65. CONFIG_EXTS = ('.yaml', '.yml', '.json', '.csv')
  66. def _config_roots() -> tuple[pathlib.Path, ...]:
  67. """配置的候选根(安装根在前,模块在后)。"""
  68. return (CONFIGS,) + MODULE_CONFIG_ROOTS
  69. def _config_hits(*parts: str, kind: str) -> list[pathlib.Path]:
  70. check = (lambda q: q.is_file()) if kind == 'file' else (lambda q: q.is_dir())
  71. return [r.joinpath(*parts) for r in _config_roots() if check(r.joinpath(*parts))]
  72. def config(*parts: str, must_exist: bool = False) -> pathlib.Path:
  73. """配置文件的唯一取用口: `P.config('terms', 'display_map.yaml')` / `P.config('serve.json')`。
  74. 只做路径解析, 不读文件 (读法由调用方决定: yaml/json/csv 各不相同);
  75. P9 起**多根查找**:安装根 `configs/` → 各模块 `configs/`(次序固定)。
  76. 同一逻辑配置出现在两处 ⇒ 直接抛错(配置必须单源, 否则"改了没生效"是必然的)。
  77. `must_exist=True` 时不存在就抛 FileNotFoundError —— 配置缺失应该在启动时报出来, 别静默用默认值。
  78. """
  79. hits = _config_hits(*parts, kind='file')
  80. if len(hits) > 1:
  81. raise RuntimeError('同一配置出现在多处(配置必须单源): '
  82. + ' · '.join(rel(q) for q in hits))
  83. p = hits[0] if hits else CONFIGS.joinpath(*parts)
  84. if must_exist and not p.is_file():
  85. raise FileNotFoundError(f'缺配置文件 {p} (约定见 src/paths.py 配置一节 / docs §11)')
  86. return p
  87. def config_dir(*parts: str) -> pathlib.Path:
  88. """配置目录 (域) 的取用口: `P.config_dir('farms')`(多根查找 + 单源校验)。"""
  89. hits = _config_hits(*parts, kind='dir')
  90. if len(hits) > 1:
  91. raise RuntimeError('同一配置域出现在多处(配置必须单源): '
  92. + ' · '.join(rel(q) for q in hits))
  93. return hits[0] if hits else CONFIGS.joinpath(*parts)
  94. FARMS = config_dir('farms') # 场定义目录(P9 起在 app_ETL/configs/farms)
  95. def farm_config(name: str | None = None) -> pathlib.Path | None:
  96. """场定义配置文件 —— **格式统一为 YAML**, 兼容历史 `.json` (有就优先用)。
  97. 2026-09-17 实测的坑: `app_ETL/configs/farms/` 下有 8 个场定义是 `.yaml`, 而 `available()` 只认 `*.json`
  98. ⇒ 这些场**根本列不出来**(等于配置写了没人看见); 目录里那个模板还叫 `_模板.json.example`,
  99. 与实物格式相反。现在两边都认, 且约定"新的场定义写 yaml"。
  100. """
  101. f = farm(name)
  102. for ext in ('.yaml', '.yml', '.json'):
  103. p = FARMS / f'{f}{ext}'
  104. if p.is_file():
  105. return p
  106. return None
  107. def farm(name: str | None = None) -> str:
  108. """当前场名: 显式 → env WINDSCADA_FARM → 'rudong'。
  109. 这里不 import src.windscada.config 以免循环 (config 反过来要用本模块)。
  110. 需要 set_current() 那种运行期切换时, 调用方把场名显式传进来即可。"""
  111. return name or os.environ.get('WINDSCADA_FARM') or 'rudong'
  112. # ---- 产物侧: outputs/<场名>/… (页面取数的仓) -------------------------------------------
  113. def out_root(name: str | None = None) -> pathlib.Path:
  114. return ROOT / 'outputs' / farm(name)
  115. def store(name: str | None = None) -> pathlib.Path:
  116. """L0 标准仓 (parquet) —— 页面主取数处。"""
  117. return out_root(name) / 'windscada'
  118. def ont(name: str | None = None) -> pathlib.Path:
  119. """本体对象库目录 (objects.json / 检索索引 / turbine_params)。"""
  120. return out_root(name) / 'ontology'
  121. def objects_json(name: str | None = None) -> pathlib.Path:
  122. return ont(name) / 'objects.json'
  123. def cms(name: str | None = None) -> pathlib.Path:
  124. """CMS 振动诊断产物目录。"""
  125. return out_root(name) / 'windcms'
  126. def m5(name: str | None = None) -> pathlib.Path:
  127. """振动线 handoff / TCM 兼容件目录。"""
  128. return out_root(name) / 'm5_cms_tcm'
  129. def tcm_replay(name: str | None = None) -> pathlib.Path:
  130. return out_root(name) / 'tcm_compatible_replay'
  131. def sop(name: str | None = None) -> pathlib.Path:
  132. """SOP 中间件与评审落盘目录。"""
  133. return out_root(name) / 'sop'
  134. def guanlan(name: str | None = None) -> pathlib.Path:
  135. """事实契约与对外派生 (可上云面孔)。"""
  136. return out_root(name) / 'guanlan'
  137. def pitch(name: str | None = None) -> pathlib.Path:
  138. return out_root(name) / 'pitch'
  139. def paradigm(name: str | None = None) -> pathlib.Path:
  140. """范式实验件 (E3/E5/E8 底稿) —— 事实契约的输入之一 (2026-09-16 补: 原先直接用
  141. `ROOT/'outputs'/'rudong'/'paradigm_r1'` 拼, 既写死场名又绕过了本模块)。"""
  142. return out_root(name) / 'paradigm_r1'
  143. def report_dir(name: str | None = None) -> pathlib.Path:
  144. """报告交付件目录 (`交接_振动→状态评估报告_*.md` / `现场单_*.md`) —— 由振动线出件,
  145. 并被 `src/windcms/config.py` 的 knowledge_docs 引用 (2026-09-16 补: 该目录在 v0.2.0 里
  146. 没有明确归属, 一直以 `out_root()/'report'` 的裸拼形式出现)。"""
  147. return out_root(name) / 'report'
  148. def cloud(name: str | None = None) -> pathlib.Path:
  149. """可上云面孔 (脱敏后的契约/派生件/页面) —— `scripts/guanlan_cloud_*.py` 的落点。"""
  150. return guanlan(name) / 'cloud'
  151. def contract(name: str | None = None) -> pathlib.Path:
  152. """场契约 (机型判据参数), 属 reference 侧, 不在 outputs。"""
  153. return REFERENCE / farm(name) / 'windscada_contract.yaml'
  154. def station_dir(name: str | None = None) -> pathlib.Path:
  155. """本场原始件目录 data/raw/<场站名称> —— 具体由场配置扫描结果决定,
  156. 这里只给"约定位置"兜底 (场配置里 windscada.config.farm()['raw_station_dir'] 才是权威)。"""
  157. return RAW_ROOT / farm(name)
  158. # ---- 解释器与字符串形式 ----------------------------------------------------------------
  159. def venv_python() -> pathlib.Path | None:
  160. """本安装目录下的 venv 解释器 (跨平台); 不存在返回 None。"""
  161. for rel in (('Scripts', 'python.exe'), ('bin', 'python'), ('bin', 'python3')):
  162. p = ROOT / '.venv' / pathlib.Path(*rel)
  163. if p.exists():
  164. return p
  165. return None
  166. def resolve(p) -> pathlib.Path:
  167. """把"可能是相对路径"的值解析成绝对路径: 相对基准是 ROOT (**不是 cwd**)。"""
  168. q = pathlib.Path(p)
  169. return q if q.is_absolute() else (ROOT / q)
  170. def rel(p) -> str:
  171. """给人/给清单的**相对**路径字符串 (POSIX 形式); 不在 ROOT 内则给绝对 POSIX。"""
  172. q = pathlib.Path(p)
  173. try:
  174. return q.resolve().relative_to(ROOT.resolve()).as_posix()
  175. except ValueError:
  176. return q.as_posix()
  177. def disp(p) -> str:
  178. """给人看的显示路径: 安装目录内的写成 `<安装目录>/…`, 分隔符随本机; 其余原样绝对路径。
  179. (只用于显示 —— 不要把它写进产物或清单。)"""
  180. q = pathlib.Path(p)
  181. try:
  182. return str(pathlib.Path('<安装目录>') / q.resolve().relative_to(ROOT.resolve()))
  183. except ValueError:
  184. return str(q)
  185. # 显示用分隔符: 只服务于给人看的文本 (页面「位置」列)。写进产物/清单的路径请用 rel() 的 POSIX 形式。
  186. SEP = os.sep
  187. def disp_dir(p) -> str:
  188. """目录的**显示形**: 末尾带本机分隔符 (页面「位置」列用).
  189. 路径本身一律走 `rel()` 的 POSIX 形式; 这里的 os.sep 只服务于"给人看"。"""
  190. return disp(p) + os.sep
  191. def python_exe() -> str:
  192. """跑子进程/脚本用的解释器: venv → 否则当前解释器。"""
  193. v = venv_python()
  194. return str(v) if v else sys.executable
  195. def platform_tag() -> str:
  196. """平台标识 (探测脚本/日志用): windows / linux / darwin。"""
  197. return {'nt': 'windows', 'posix': 'linux'}.get(os.name, os.name) if sys.platform != 'darwin' else 'darwin'
  198. if __name__ == '__main__': # 自检: python -m src.paths 或 python src/paths.py
  199. print(f'ROOT : {ROOT} (存在: {ROOT.is_dir()})')
  200. print(f'平台 : {platform_tag()} cwd: {pathlib.Path.cwd()}')
  201. print(f'原始件根 : {rel(RAW_ROOT)} (存在: {RAW_ROOT.is_dir()})')
  202. print(f'venv 解释器: {venv_python() or "(无, 用 " + sys.executable + ")"}')
  203. for label, p in (('store', store()), ('ontology', ont()), ('windcms', cms()),
  204. ('m5', m5()), ('sop', sop()), ('guanlan', guanlan()),
  205. ('release', RELEASE), ('sim_dir', SIM_DIR)):
  206. print(f' {label:9s} {rel(p):42s} 存在={p.exists()}')