⟨/⟩ Ntr/55_Backend/Calc/CalcModel.cs

569 строк · в начало

using Rugrid.Sdaid.Notation.Documents;

namespace Rugrid.Sdaid.Epas.Ntr.Backend.Calc
{
    /// <summary>
    /// Контракт модели расчёта: InputModel, OutputModel, CalcLog.
    /// Перенесено дословно из ntr-01/in-works/spec-general-arc.md, разделы 2.1–2.3.
    /// Согласованное исключение из правила «in-works/ игнорируем»: другого описания
    /// контракта в источнике нет, а маппинги ссылаются на него.
    /// </summary>
    public class DOC_CalcModel : ISdaidDocument, IFolder<NtrBackendFolder>
    {
        public static string _refName = "Модель расчёта CalcModel";

        public string Name => "2. Модель расчёта CalcModel";

        public string Description => "Контракт модели расчёта: InputModel (единая модель ТЭ + ЭЭ), OutputModel (расчётные параметры МОП), CalcLog.";

        public string Version => "1.2";
        public string Status => "changed";

        public string[] Comments => new[]
        {
            "2026-08-31, CR «Расчёт площади ВОК для четырёхскатных крыш»: в блок `roof` модели входных данных добавлена длина конька `ridgeLengthM`.",
            "2026-09-03, CR «Обработка источников климатических данных»: широта `latitudeDeg` перенесена в блок `heatingPeriod` — она определяется выбранным источником климатических данных, а не зданием; блок `building` из TeInputSection убран, других параметров в нём не было.",
        };

        public static string Body = """"
```JSON
CalcModel {
  context: {
    idMap: [
      { refId: string, serviceId: uuid }
    ]
  },
  inputModel: InputModel,
  outputModel: OutputModel,
  calcLog: CalcLog
}
```

`context.idMap` — единообразный массив пар `{ refId, serviceId }`, связывающий внутренние идентификаторы модуля расчёта с идентификаторами сущностей сервиса в `domain-data.json`.

Синглтоны (одна сущность на модель, `refId` — фиксированное well-known имя):

| refId                 | Сущность сервиса         | Коллекция в `domain-data.json` |
| --------------------- | ------------------------ | ------------------------------ |
| `consumptionModel`    | МодельОбъектаПотребления | `consumptionModels`            |
| `building`            | Здание                   | `buildings`                    |
| `geometry`            | Форма и геометрия        | `buildingGeometry`             |
| `walls`               | Стены                    | `walls`                        |
| `windowModel`         | Модель окон              | `windowModels`                 |
| `doorModel`           | Модель ворот             | `doorModels`                   |
| `roof`                | ВОК                      | `roofs`                        |
| `groundContact`       | ЗКГ                      | `groundContacts`               |
| `ventilation`         | Вентиляция               | `ventilations`                 |
| `operatingConditions` | Условия эксплуатации     | `operatingConditions`          |
| `heatingPeriod`       | Условия ОП               | `heatingPeriodConditions`      |
| `lightingModel`       | Модель освещения ЭЭ      | `lightingModels`               |
| `receiverModel`       | Модель токоприёмников ЭЭ | `receiverModels`               |

Коллекции (несколько элементов на модель, `refId` генерируется в `PrepareInputModel`):

| refId (шаблон)     | Сущность сервиса               | Коллекция в `domain-data.json` |
| ------------------ | ------------------------------ | ------------------------------ |
| `windowKind-N`     | Вид остекления проёмов         | `windows`                      |
| `doorKind-N`       | Вид воротных проёмов           | `doors`                        |
| `lightingKind-N`   | Вид освещения                  | `buildingLighting`             |
| `receiverKind-N`   | Вид токоприёмника              | `buildingEEConsumers`          |

### 2.1 InputModel (единая модель ТЭ + ЭЭ)

Единое правило для `PrepareInputModel`: если в расчёт подставляется значение из справочника, передаются:
- код записи-источника (`*Code`);
- само значение в формате `ValueObject`.

```JSON
InputModel {
  te: TeInputSection,
  ee: EeInputSection
}
```

`InputModel` не содержит сервисных идентификаторов. Сущности внутри `InputModel` идентифицируются через `refId` (см. §2).

#### 2.1.1 `te: TeInputSection`

```JSON
TeInputSection {
  geometry: {
    buildingFormCode: string,
    orientationSideACode: string,
    buildingHeightM: ValueObject,
    buildingFloorCount: ValueObject,
    upperFloorLevelM: ValueObject,
    heatedVolumeM3: ValueObject,
    heatedAreaM2: ValueObject,
    dimensionsM: {
      A: ValueObject,
      B: ValueObject,
      C: ValueObject,
      D: ValueObject,
      E: ValueObject,
      F: ValueObject,
      G: ValueObject,
      H: ValueObject
    }
  },
  walls: {
    material: {
      materialCode: string,
      thicknessMm: ValueObject,
      thermalConductivity: ValueObject
    },
    mainInsulation: {
      insulationCode: string,
      thicknessMm: ValueObject,
      thermalConductivity: ValueObject
    },
    additionalInsulation: {
      insulationCode: string,
      thicknessMm: ValueObject,
      thermalConductivity: ValueObject
    }
  },
  roof: {
    roofTypeCode: string,
    hasAttic: boolean,
    ridgeLengthM: ValueObject | null,
    customRoofAreaM2: ValueObject | null,
    customAtticWallAreaM2: ValueObject | null,
    floorLayer: {
      materialCode: string,
      thicknessMm: ValueObject,
      thermalConductivity: ValueObject
    },
    floorInsulation: {
      insulationCode: string,
      thicknessMm: ValueObject,
      thermalConductivity: ValueObject
    },
    roofInsulation: {
      insulationCode: string,
      thicknessMm: ValueObject,
      thermalConductivity: ValueObject
    }
  },
  groundContact: {
    hasBasement: boolean,
    basementCeilingHeightM: ValueObject | null,
    basementFloorLevelM: ValueObject | null,
    customGroundContactAreaM2: ValueObject | null
  },
  windows: {
    items: [
      {
        refId: string,
        openingAreaM2: ValueObject,
        openingsBySide: {
          A: ValueObject,
          B: ValueObject,
          C: ValueObject,
          D: ValueObject,
          E: ValueObject,
          F: ValueObject,
          G: ValueObject,
          H: ValueObject
        },
        thermalResistance: ValueObject,
        shadingCoefficient: ValueObject,
        solarRadiationCoefficient: ValueObject,
        airPermeabilityAt10Pa: ValueObject
      }
    ]
  },
  doors: {
    items: [
      {
        refId: string,
        openingAreaM2: ValueObject,
        openingsBySide: {
          A: ValueObject,
          B: ValueObject,
          C: ValueObject,
          D: ValueObject,
          E: ValueObject,
          F: ValueObject,
          G: ValueObject,
          H: ValueObject
        },
        thermalResistance: ValueObject,
        airPermeabilityAt10Pa: ValueObject
      }
    ]
  },
  ventilation: {
    hasMechanicalVentilation: boolean,
    hasRecuperator: boolean,
    recuperatorEfficiency: ValueObject | null
  },
  operating: {
    workplacesCount: ValueObject,
    internalTemperatureC: ValueObject,
    buildingPurposeCode: string,
    airConsumptionM3PerPersonHour: ValueObject,
    heatingRegulationTypeCode: string,
    kReg: ValueObject,
    energySupplyTypeCode: string
  },
  heatingPeriod: {
    durationDays: ValueObject,
    externalTemperatureC: ValueObject,
    windSpeedMps: ValueObject,
    latitudeDeg: ValueObject
  },
  solarRadiation: {
    items: [
      {
        directionCode: string,
        monthNumber: number,
        radiation: ValueObject
      }
    ]
  },
  normative: {
    specificHeatConsumptionNorm: {
      recordCode: string,
      valueObject: ValueObject
    },
    energyEfficiencyClasses: [
      {
        code: string,
        deviationMinPercent: ValueObject,
        deviationMaxPercent: ValueObject
      }
    ]
  },
  coefficients: {
    surfaceThermalResistance: {
      code: string,
      valueObject: ValueObject
    },
    airSpecificHeat: {
      code: string,
      valueObject: ValueObject
    },
    reductionBuildingsAirVolume: {
      code: string,
      valueObject: ValueObject
    },
    workingTimePerDayH: {
      code: string,
      valueObject: ValueObject
    },
    basementZonesThermalResistance: {
      I: {
        code: string,
        valueObject: ValueObject
      },
      II: {
        code: string,
        valueObject: ValueObject
      },
      III: {
        code: string,
        valueObject: ValueObject
      },
      IV: {
        code: string,
        valueObject: ValueObject
      }
    }
  }
}
```

Число в `te.solarRadiation.items[].radiation` берётся только из поля `value` записи справочника (`solarRadiationData` / `solarRadiationRecord` в `domain-schema.json`). В корректном наполнении справочника однозначно задаётся кортеж (сторона света, нижняя граница широтного диапазона в градусах северной широты, номер месяца); в массиве `items` для выбранного диапазона широты здания не ожидается больше одного элемента с одной и той же парой (`directionCode`, `monthNumber`).

#### 2.1.2 `ee: EeInputSection`

```JSON
EeInputSection {
  lighting: {
    items: [
      {
        refId: string,
        fixturesCount: ValueObject,
        lampsPerFixture: ValueObject,
        lampPowerW: ValueObject,
        hoursPerDay: ValueObject,
        daysPerYear: ValueObject
      }
    ]
  },
  receivers: {
    items: [
      {
        refId: string,
        totalPowerKw: ValueObject,
        hoursPerDay: ValueObject,
        daysPerYear: ValueObject
      }
    ]
  }
}
```

### 2.2 OutputModel (расчётные параметры МОП)

```JSON
OutputModel {
  te: TeOutputSection,
  ee: EeOutputSection,
  totals: CommonTotals
}
```

#### 2.2.1 `te: TeOutputSection`

Все расчётные поля ТЭ задаются в формате `ValueObject`.

```JSON
TeOutputSection {
  energyEfficiencyClass: {
    classCode: string,
    normativeSpecificCharacteristic: ValueObject,
    calcSpecificCharacteristic: ValueObject,
    deviationPercent: ValueObject
  },
  keBuilding: {
    specHeatProtectChar: ValueObject,
    areaM2: ValueObject,
    thermalResistance: ValueObject,
    heatLossEnergyThousandKwh: ValueObject,
    heatLossPowerKw: ValueObject
  },
  keWalls: {
    specHeatProtectChar: ValueObject,
    areaM2: ValueObject,
    thermalResistance: ValueObject,
    heatLossEnergyThousandKwh: ValueObject,
    heatLossPowerKw: ValueObject,
    overlapThicknessBySideM: {
      A: ValueObject, B: ValueObject, C: ValueObject, D: ValueObject,
      E: ValueObject, F: ValueObject, G: ValueObject, H: ValueObject
    },
    innerPerimeterM: ValueObject,
    innerSurfaceAreaM2: ValueObject
  },
  keWindows: {
    specHeatProtectChar: ValueObject,
    areaM2: ValueObject,
    thermalResistance: ValueObject,
    heatLossEnergyThousandKwh: ValueObject,
    heatLossPowerKw: ValueObject,
    airPermeabilityResistance: ValueObject,
    branch: { formMethodSectionId: string, orientationMethodSectionId: string },
    areaBySide: { A: ValueObject, B: ValueObject, C: ValueObject, D: ValueObject, E: ValueObject, F: ValueObject, G: ValueObject, H: ValueObject },
    tempSidesS1ToS4: { s1: ValueObject, s2: ValueObject, s3: ValueObject, s4: ValueObject },
    areaByDirection: { north: ValueObject, east: ValueObject, south: ValueObject, west: ValueObject },
    solarRadiation: {
      byDirection: [
        { directionCode: string, value: ValueObject }
      ]
    },
    items: [{
      refId: string,
      specHeatProtectChar: ValueObject,
      areaM2: ValueObject,
      thermalResistance: ValueObject,
      heatLossEnergyThousandKwh: ValueObject,
      heatLossPowerKw: ValueObject,
      airPermeabilityResistance: ValueObject,
      infiltrationAirKgH: ValueObject
    }]
  },
  keDoors: {
    specHeatProtectChar: ValueObject,
    areaM2: ValueObject,
    thermalResistance: ValueObject,
    heatLossEnergyThousandKwh: ValueObject,
    heatLossPowerKw: ValueObject,
    airPermeabilityResistance: ValueObject,
    branch: { formMethodSectionId: string, orientationMethodSectionId: string },
    areaBySide: { A: ValueObject, B: ValueObject, C: ValueObject, D: ValueObject, E: ValueObject, F: ValueObject, G: ValueObject, H: ValueObject },
    tempSidesS1ToS4: { s1: ValueObject, s2: ValueObject, s3: ValueObject, s4: ValueObject },
    areaByDirection: { north: ValueObject, east: ValueObject, south: ValueObject, west: ValueObject },
    items: [{
      refId: string,
      specHeatProtectChar: ValueObject,
      areaM2: ValueObject,
      thermalResistance: ValueObject,
      heatLossEnergyThousandKwh: ValueObject,
      heatLossPowerKw: ValueObject,
      airPermeabilityResistance: ValueObject,
      infiltrationAirKgH: ValueObject
    }]
  },
  keUpperEnclosures: {
    specHeatProtectChar: ValueObject,
    areaM2: ValueObject,
    atticWallAreaM2: ValueObject,
    roofCoverAreaM2: ValueObject,
    thermalResistance: ValueObject,
    heatLossEnergyThousandKwh: ValueObject,
    heatLossPowerKw: ValueObject,
    branch: { atticWallMethodSectionId: string, roofCoverMethodSectionId: string }
  },
  keGroundContact: {
    specHeatProtectChar: ValueObject,
    areaM2: ValueObject,
    basementWallAreaM2: ValueObject,
    floorAreaM2: ValueObject,
    thermalResistance: ValueObject,
    heatLossEnergyThousandKwh: ValueObject,
    heatLossPowerKw: ValueObject,
    branch: { basementWallMethodSectionId: string, floorAreaMethodSectionId: string, areaMethodSectionId: string, zonesFormMethodSectionId: string },
    zonesAreaM2: { I: ValueObject, II: ValueObject, III: ValueObject, IV: ValueObject },
    zoneSideLengths: object
  },
  solarRadiation: {
    byDirection: [
      { directionCode: string, value: ValueObject }
    ]
  },
  ventilation: {
    specVentChar: ValueObject,
    heatLossEnergyThousandKwh: ValueObject,
    heatLossPowerKw: ValueObject,
    infiltrationAirMassTotal: ValueObject,
    supplyAirFlowM3H: ValueObject,
    ventilationHours: ValueObject,
    recuperatorEfficiency: ValueObject,
    infiltrationAirKgH: ValueObject,
    infiltrationHours: ValueObject,
    pressureDifferencePa: ValueObject,
    airWeightOutside: ValueObject,
    airWeightInside: ValueObject,
    branch: { ventHoursMethodSectionId: string, recuperatorMethodSectionId: string, infiltrationHoursMethodSectionId: string }
  },
  heatGains: {
    specHeatGainsChar: ValueObject,
    heatGainsEnergyTotal: ValueObject,
    heatGainsPowerTotal: ValueObject,
    specDomesticHeatGainsChar: ValueObject,
    domesticHeatGainsEnergy: ValueObject,
    domesticHeatGainsPower: ValueObject,
    specSolarHeatGainsChar: ValueObject,
    solarHeatGainsEnergy: ValueObject,
    solarHeatGainsPower: ValueObject,
    coefHeatGainsEfficiency: ValueObject
  },
  heatingPeriod: {
    degreeDays: ValueObject
  }
}
```


#### 2.2.2 `ee: EeOutputSection`

```JSON
EeOutputSection {
  lighting: {
    items: [{ refId: string, installedPowerKw: ValueObject, annualConsumptionThousandKwh: ValueObject }],
    installedPowerKw: ValueObject,
    annualConsumptionThousandKwh: ValueObject
  },
  receivers: {
    items: [{ refId: string, installedPowerKw: ValueObject, annualConsumptionThousandKwh: ValueObject }],
    installedPowerKw: ValueObject,
    annualConsumptionThousandKwh: ValueObject
  },
  subtotal: {
    installedPowerKw: ValueObject,
    annualConsumptionThousandKwh: ValueObject
  }
}
```

#### 2.2.3 `totals: CommonTotals`

```JSON
CommonTotals {
  eeWithoutHeating: {
    installedPowerKw: ValueObject,
    annualConsumptionThousandKwh: ValueObject
  },
  electricHeating: {
    enabled: boolean,
    annualConsumptionThousandKwh: ValueObject
  },
  eeTotal: {
    annualConsumptionThousandKwh: ValueObject
  },
  teTotal: {
    annualConsumptionThousandKwh: ValueObject
  }
}
```

Правила агрегации итогов не дублируются в этом документе и задаются только в методиках:
- `ttr-service/domain/ttr-calc.md`
- `ttr-service/domain/ee_calc.md`

### 2.3 CalcLog

#### 2.3.1 Контракт `CalcLog` и записей `CalcLogEntry`

Оркестратор `ModelCalc` формирует журнал шагов расчёта. Каждый шаг — одна запись `CalcLogEntry` на вызов одной функции расчёта (`functionId`).

```JSON
CalcLog {
  startedAt: string,
  finishedAt: string,
  records: CalcLogEntry[],
  errorsCount: ValueObject,
  warningsCount: ValueObject
}
```

Структура `CalcLogEntry` **одинакова** для любого статуса:

```JSON
CalcLogEntry {
  functionId: string,
  methodSectionId: string,
  status: "ok" | "missing_input" | "error" | "not_found",
  inputs: object,
  outputs: object,
  formulaTemplate: string,
  formulaSubstitution: object,
  missingInputs: string[]
}
```

- `inputs` / `outputs` — объекты с привязкой полей к обозначениям методики и/или путям `InputModel` / `OutputModel` (как в карточах §4.3).
- `formulaTemplate` — шаблон формулы (в т.ч. LaTeX), с плейсхолдерами под подстановку значений из `formulaSubstitution`.
- `formulaSubstitution` — словарь «символ → подставленное значение» (число, строка с единицами, или `"-"` при отсутствии входа).

Правила формирования `formulaSubstitution`:
- заполняется на основе карточки функции (§4.3): обозначения берутся из `Входные параметры` и/или `Правило расчёта`;
- должен содержать значения всех входных параметров функции;
- для каждого параметра подставляется фактическое значение или `"-"` при отсутствии значения;
- если входной параметр — массив `X`, используется формат `X[key] = value`;
- если элементов массива `<= 10`, указываются все элементы;
- если элементов массива `> 10`, указываются первые 10 элементов и затем `"..."`.

**Правила при `status = missing_input`:**

- функция не выполняется;
- `inputs` и `outputs` в записи остаются (не очищаются);
- в `formulaSubstitution` для отсутствующих параметров подставляется `"-"`;
- `missingInputs` содержит список путей параметров, из‑за которых шаг не выполнен.

#### 2.3.2 Подсказка к значению расчётного показателя (UI)

Для отображаемого в интерфейсе значения поля `OutputModel` с путём `outputPath`:

1. **Найти запись журнала:** выбрать `CalcLogEntry` из `records`, в которой в `outputs` присутствует расчётный результат для `outputPath` (или согласованное с оркестратором правило «последняя запись, записавшая этот путь»). Если таких несколько (агрегация), UI следует правилам продукта, зафиксированным в отдельном требовании к экрану.
2. **Собрать текст подсказки:**
   - взять `formulaTemplate`;
   - подставить значения из `formulaSubstitution` вместо символов (включая отображение `"-"` для пропусков);
   - при необходимости добавить `methodSectionId` / ссылку на фрагмент методики (ТЭ или ЭЭ).
3. **При `missing_input`:** показать, что значение не получено, и вывести `missingInputs` (через запятую или списком), без выдумывания чисел в UI.
"""";
    }
}