paths.py 11 KB

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