298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""Сверка методик норм: WESP, Москва, Петербург."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
|
|
from app import create_app, db
|
|
from app.lab.calc.norms_derived import apply_derived_norms
|
|
from app.lab.calc.norms_resolver import (
|
|
NormsParams,
|
|
NormsResolveRequest,
|
|
merge_hybrid_norms,
|
|
resolve_norms,
|
|
)
|
|
from app.lab.calc.racion.interp import frac, lerp, popr_index
|
|
from app.lab.calc.racion.moscow import MoscowDairyParams, compute_moscow_norm, resolve_moscow_dairy_norms
|
|
from app.lab.calc.racion.npitv_map import DAIRY_LACTIR_NPITV, NPITV_TO_INDICATOR
|
|
from app.lab.calc.racion.piter import PiterDairyParams, compute_piter_norm
|
|
from app.lab.calc.racion.piter_prep import PiterPrepResult, prepare_piter_calc
|
|
from app.lab.calc.gfe_norms import apply_dynamic_norms
|
|
from app.lab.models import LabAnimalProfile, LabProfileNorm
|
|
from app.lab.services.profile_norms import sync_racion_norms_to_profile
|
|
from app.lab.services.racion_reference import import_racion_reference
|
|
from app.services.setup_state import mark_setup_complete
|
|
from tests.helpers.zootech_test_helpers import ZootechTestConfig
|
|
|
|
# Эталон Калашникова 2003, ОЭ МДж (масса 500 кг, жирность 4%)
|
|
KALASHNIKOV_OE = {
|
|
8: 104,
|
|
10: 115,
|
|
12: 126,
|
|
14: 137,
|
|
16: 148,
|
|
18: 159,
|
|
20: 170,
|
|
22: 181,
|
|
24: 192,
|
|
26: 204,
|
|
28: 216,
|
|
30: 228,
|
|
}
|
|
|
|
|
|
class RacionInterpTests(unittest.TestCase):
|
|
def test_frac(self) -> None:
|
|
self.assertAlmostEqual(frac(1.0, 4.0), 0.25)
|
|
|
|
def test_lerp(self) -> None:
|
|
self.assertAlmostEqual(lerp(5.0, 0.0, 0.0, 10.0, 10.0), 5.0)
|
|
|
|
def test_popr_index(self) -> None:
|
|
bounds = [8.5, 9.5, 10.5]
|
|
self.assertEqual(popr_index(8.0, bounds), 1)
|
|
self.assertEqual(popr_index(10.0, bounds), 3)
|
|
|
|
|
|
class NpitvMapTests(unittest.TestCase):
|
|
def test_npitv_map_complete(self) -> None:
|
|
for npitv in DAIRY_LACTIR_NPITV:
|
|
self.assertIn(npitv, NPITV_TO_INDICATOR, f"npitv={npitv}")
|
|
self.assertTrue(NPITV_TO_INDICATOR[npitv], f"npitv={npitv}")
|
|
|
|
|
|
class MoscowNormsTests(unittest.TestCase):
|
|
def test_oe_kalashnikov_points(self) -> None:
|
|
for udoy, expected in KALASHNIKOV_OE.items():
|
|
p = MoscowDairyParams(mass_kg=500, milk_yield_kg=float(udoy), milk_fat_pct=4.0)
|
|
oe = compute_moscow_norm(2, p)
|
|
self.assertIsNotNone(oe, f"udoy={udoy}")
|
|
self.assertAlmostEqual(oe, expected, delta=3.0, msg=f"udoy={udoy}")
|
|
|
|
def test_moscow_all_npitv_smoke(self) -> None:
|
|
p = MoscowDairyParams(mass_kg=500, milk_yield_kg=20, milk_fat_pct=4.0)
|
|
resolved, _ = resolve_moscow_dairy_norms(p)
|
|
for key in ("oe", "nel", "usp", "calcium", "iron", "carotene", "dry_matter"):
|
|
self.assertIn(key, resolved, key)
|
|
self.assertIsNotNone(resolved[key]["min"], key)
|
|
|
|
def test_resolver_moscow(self) -> None:
|
|
resolved, meta = resolve_norms(
|
|
NormsResolveRequest(
|
|
method="racion_moscow",
|
|
stored={},
|
|
mass_kg=500,
|
|
milk_yield_kg=20,
|
|
params=NormsParams(milk_fat_pct=4.0),
|
|
)
|
|
)
|
|
self.assertEqual(meta["normsMethod"], "racion_moscow")
|
|
self.assertIn("oe", resolved)
|
|
self.assertAlmostEqual(resolved["oe"]["min"], 170.0, delta=3.0)
|
|
self.assertIn("coverage", meta)
|
|
self.assertGreater(meta["coverage"]["withMin"], 20)
|
|
|
|
|
|
class PiterNormsTests(unittest.TestCase):
|
|
def test_piter_oe_smoke(self) -> None:
|
|
p = PiterDairyParams(mass_kg=550, milk_yield_kg=20, milk_fat_pct=4.0, konc_oe_sv=10.3)
|
|
oe_low = compute_piter_norm(2, p)
|
|
p_high = PiterDairyParams(mass_kg=550, milk_yield_kg=20, milk_fat_pct=4.0, konc_oe_sv=11.0)
|
|
oe_high = compute_piter_norm(2, p_high)
|
|
self.assertIsNotNone(oe_low)
|
|
self.assertIsNotNone(oe_high)
|
|
|
|
def test_piter_prep_mass_brackets(self) -> None:
|
|
prep = prepare_piter_calc(
|
|
mass_kg=525,
|
|
milk_yield_kg=20,
|
|
milk_fat_pct=4.0,
|
|
konc_oe_sv=10.3,
|
|
)
|
|
self.assertIsInstance(prep, PiterPrepResult)
|
|
self.assertEqual(prep.m_a, 500.0)
|
|
self.assertEqual(prep.m_b, 550.0)
|
|
self.assertEqual(prep.mass_ind, 2)
|
|
|
|
def test_piter_requires_konc(self) -> None:
|
|
with self.assertRaises(ValueError):
|
|
resolve_norms(
|
|
NormsResolveRequest(
|
|
method="racion_piter",
|
|
stored={},
|
|
mass_kg=550,
|
|
milk_yield_kg=20,
|
|
)
|
|
)
|
|
|
|
|
|
class NormsDerivedTests(unittest.TestCase):
|
|
def test_norms_derived(self) -> None:
|
|
base = {
|
|
"dry_matter": {"min": 20000.0, "max": None},
|
|
"usp": {"min": 2000.0, "max": None},
|
|
}
|
|
derived, dyn = apply_derived_norms(base, mass_kg=500)
|
|
self.assertIn("usp_per_kg_dm", derived)
|
|
self.assertAlmostEqual(derived["usp_per_kg_dm"]["min"], 100.0, places=1)
|
|
self.assertIn("usp_per_kg_dm", dyn)
|
|
|
|
|
|
class HybridMergeTests(unittest.TestCase):
|
|
def test_hybrid_fallback(self) -> None:
|
|
racion = {"oe": {"min": 170.0, "max": None}}
|
|
stored = {"metab_lysine": {"min": 50.0, "max": 80.0}}
|
|
merged, cov = merge_hybrid_norms(racion, stored)
|
|
self.assertAlmostEqual(merged["oe"]["min"], 170.0)
|
|
self.assertAlmostEqual(merged["metab_lysine"]["min"], 50.0)
|
|
self.assertAlmostEqual(merged["metab_lysine"]["max"], 80.0)
|
|
self.assertIn("metab_lysine", cov["fallback"])
|
|
|
|
|
|
class WespResolverRegressionTests(unittest.TestCase):
|
|
def test_wesp_matches_apply_dynamic(self) -> None:
|
|
stored = {"dry_matter": {"min": 10.0, "max": 20.0}}
|
|
legacy, _ = apply_dynamic_norms(stored, mass_kg=650, milk_yield_kg=30, force_dynamic=True)
|
|
resolved, meta = resolve_norms(
|
|
NormsResolveRequest(
|
|
method="wesp",
|
|
stored=stored,
|
|
mass_kg=650,
|
|
milk_yield_kg=30,
|
|
force_dynamic=True,
|
|
)
|
|
)
|
|
self.assertEqual(meta["normsMethod"], "wesp")
|
|
self.assertAlmostEqual(resolved["usp"]["min"], legacy["usp"]["min"], places=1)
|
|
self.assertAlmostEqual(resolved["nel"]["min"], legacy["nel"]["min"], places=1)
|
|
|
|
|
|
class RacionDbImportTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.app = create_app(ZootechTestConfig)
|
|
self.ctx = self.app.app_context()
|
|
self.ctx.push()
|
|
db.create_all()
|
|
|
|
def tearDown(self) -> None:
|
|
db.session.remove()
|
|
db.drop_all()
|
|
self.ctx.pop()
|
|
|
|
def test_import_racion_reference(self) -> None:
|
|
stats = import_racion_reference(replace=True)
|
|
self.assertGreater(stats.moskwa_rows, 20)
|
|
self.assertGreater(stats.piter_rows, 100)
|
|
self.assertGreater(stats.info_rows, 10)
|
|
self.assertFalse(stats.errors)
|
|
|
|
def test_sync_racion_to_db(self) -> None:
|
|
import_racion_reference(replace=True)
|
|
profile = LabAnimalProfile(
|
|
id="prof-racion-test",
|
|
profile_key="dairy_racion_test",
|
|
label="Test RACION",
|
|
ration_type="DAIRY",
|
|
mass_kg=500,
|
|
milk_yield_kg=20,
|
|
norms_method="racion_moscow",
|
|
created_by="test",
|
|
)
|
|
db.session.add(profile)
|
|
db.session.add(
|
|
LabProfileNorm(
|
|
profile_id=profile.id,
|
|
indicator_key="metab_lysine",
|
|
min_value=45.0,
|
|
max_value=60.0,
|
|
)
|
|
)
|
|
db.session.commit()
|
|
n = sync_racion_norms_to_profile(profile)
|
|
db.session.commit()
|
|
self.assertGreater(n, 20)
|
|
norms = LabProfileNorm.query.filter_by(profile_id=profile.id).all()
|
|
self.assertGreaterEqual(len(norms), 20)
|
|
oe = LabProfileNorm.query.filter_by(profile_id=profile.id, indicator_key="oe").first()
|
|
self.assertIsNotNone(oe)
|
|
self.assertIsNotNone(oe.min_value)
|
|
lys = LabProfileNorm.query.filter_by(profile_id=profile.id, indicator_key="metab_lysine").first()
|
|
self.assertIsNotNone(lys)
|
|
self.assertEqual(lys.min_value, 45.0)
|
|
|
|
|
|
class NormsPreviewApiTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.app = create_app(ZootechTestConfig)
|
|
self.client = self.app.test_client()
|
|
self.ctx = self.app.app_context()
|
|
self.ctx.push()
|
|
db.create_all()
|
|
mark_setup_complete(self.app)
|
|
self.client.post(
|
|
"/api/auth/login",
|
|
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
|
|
)
|
|
|
|
def tearDown(self) -> None:
|
|
db.session.remove()
|
|
db.drop_all()
|
|
self.ctx.pop()
|
|
|
|
def test_norms_preview_moscow(self) -> None:
|
|
r = self.client.get("/api/lab/norms-preview?method=racion_moscow&mass_kg=500&milk_yield_kg=10")
|
|
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
|
|
body = r.get_json()
|
|
self.assertEqual(body["normsMethod"], "racion_moscow")
|
|
self.assertAlmostEqual(body["resolvedIndicators"]["oe"]["min"], 115.0, delta=1.0)
|
|
self.assertIn("coverage", body)
|
|
self.assertGreater(body["coverage"]["withMin"], 20)
|
|
|
|
def test_norms_preview_piter_requires_konc(self) -> None:
|
|
r = self.client.get("/api/lab/norms-preview?method=racion_piter&mass_kg=550&milk_yield_kg=20")
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
|
|
class ProfileApiCoverageTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.app = create_app(ZootechTestConfig)
|
|
self.client = self.app.test_client()
|
|
self.ctx = self.app.app_context()
|
|
self.ctx.push()
|
|
db.create_all()
|
|
mark_setup_complete(self.app)
|
|
self.client.post(
|
|
"/api/auth/login",
|
|
json={"login": "zootech-test-admin", "password": "zootech-test-secret"},
|
|
)
|
|
|
|
def tearDown(self) -> None:
|
|
db.session.remove()
|
|
db.drop_all()
|
|
self.ctx.pop()
|
|
|
|
def test_profile_get_exposes_racion_coverage(self) -> None:
|
|
profile = LabAnimalProfile(
|
|
profile_key="cov_dairy",
|
|
label="Coverage",
|
|
ration_type="DAIRY",
|
|
mass_kg=500,
|
|
milk_yield_kg=20,
|
|
norms_method="racion_moscow",
|
|
)
|
|
db.session.add(profile)
|
|
db.session.commit()
|
|
db.session.add(
|
|
LabProfileNorm(profile_id=profile.id, indicator_key="metab_lysine", min_value=45.0, max_value=60.0)
|
|
)
|
|
db.session.commit()
|
|
r = self.client.get(f"/api/lab/animal-profiles/{profile.id}")
|
|
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
|
|
body = r.get_json()
|
|
self.assertIn("coverage", body)
|
|
self.assertIn("resolvedNorms", body)
|
|
self.assertGreater(body["coverage"]["withMin"], 20)
|
|
self.assertIn("metab_lysine", body["coverage"]["fallback"])
|
|
self.assertIn("oe", body["coverage"]["racion"])
|
|
self.assertEqual(body["norms"]["metab_lysine"]["min"], 45.0)
|