"""Два самостоятельных электромагнита: встроенный Python FEMM 5.

Методический источник: О. Б. Буль, «Методы расчета магнитных систем
электрических аппаратов. Магнитные цепи, поля и программа FEMM».
Геометрия и условная B(H) созданы для курса; численные примеры книги
не копируются. Нет промышленного прототипа, механики или гидравлики.

Откройте этот файл командой Run Python Script в FEMM 5. Каждый запуск
создаёт новый calculated-NN рядом со сценарием. Для короткой проверки
перед exec() задайте BUL_MODE='smoke'; по умолчанию выполняется full.
BUL_OUTPUT_ROOT допускает отдельный существующий каталог результатов.
FEMM хранит ID групп числом: русская расшифровка записана в комментарии
каждой .fem и в geometry-contract.json. Все имена свойств русские.
"""
import os
import sys
import math
import json

MODE = globals().get('BUL_MODE', 'full')
source_path = globals().get('__file__', '') or (sys.argv[0] if sys.argv else '')
BASE = globals().get('BUL_OUTPUT_ROOT', os.path.dirname(os.path.abspath(source_path)) if source_path else os.getcwd())
number = 1
while os.path.exists(BASE + '/calculated-%02d' % number):
    number += 1
OUT = BASE + '/calculated-%02d' % number
os.mkdir(OUT)
MU0 = 4e-7 * math.pi
TURNS = 300
CIRCUIT = 'Катушка возбуждения'
AIR = 'Воздух'
WINDING = 'Обмотка — немагнитная область'
LINEAR = 'Сталь — линейная учебная, мю 2000'
NONLINEAR = 'Сталь — нелинейная учебная'
BOUNDARY = 'Внешняя граница A=0'
GROUPS = {'1': 'Неподвижный магнитопровод', '2': 'Подвижный якорь',
          '3': 'Катушка возбуждения', '4': 'Окружающий воздух', '5': 'Полное пассивное кольцо'}
RING = 'Пассивное кольцо — проводимость задаётся опытом'
BH = [[0, 0], [.4, 80], [.8, 160], [1.2, 300], [1.45, 600],
      [1.6, 1400], [1.75, 4500], [1.9, 14000], [2.1, 50000], [2.3, 150000]]
CONTRACT = {'revision': 'bul-independent-apparatus-v1', 'length_unit': 'mm',
    'source_scope': 'Собственные модели для изучения методики книги О. Б. Буля; не копии численных примеров.',
    'groups': GROUPS, 'turns': TURNS, 'linear_relative_permeability': 2000,
    'BH_T_A_per_m': BH, 'BH_origin': 'Условная монотонная безгистерезисная кривая; не паспорт марки стали.',
    'u-core': {'problem_type': 'planar', 'depth_mm': 20,
        'core_polygon_mm': [[-30,0],[30,0],[30,50],[20,50],[20,10],[-20,10],[-20,50],[-30,50]],
        'armature_mm': [-30, '50+gap', 30, '60+gap'],
        'winding_rectangles_mm': [[-40,16,-32,44],[-18,16,-10,44]],
        'signed_turns': [TURNS,-TURNS], 'physical_gaps': 2,
        'pole_area_each_m2': .010*.020,
        'coil_scope': 'Две стороны одной замкнутой катушки на левом стержне; знаки N противоположны.'},
    'pot-core': {'problem_type': 'axi', 'axis': 'r=0; координата z растёт вверх',
        'core_polygon_mm': [[0,0],[30,0],[30,40],[22,40],[22,8],[8,8],[8,40],[0,40]],
        'armature_mm': [0,'40+gap',30,'48+gap'],
        'winding_rectangle_mm': [10,10,20,35], 'signed_turns': [TURNS], 'physical_gaps': 2,
        'pole_area_central_m2': math.pi*.008**2,
        'pole_area_outer_m2': math.pi*(.030**2-.022**2),
        'device_scope': 'Чашечный электромагнит с дисковым якорем: принцип втяжного привода. Шток, клапан, пружина и среда не моделируются.'},
    'force_sign': 'Сила Fy/Fz отрицательна при притяжении вниз; положительный gap открывает оба зазора.',
    'coenergy_sign': 'F_open = dWprime/dgap при неизменном токе; F_attraction = -dWprime/dgap.',
    'ac-ring': {'base_family':'pot-core','ring_rectangle_rz_mm':[8.5,35.5,9.5,38.5],
        'ring_scope':'Полное осесимметричное замкнутое кольцо; не частичный затеняющий виток контактора.',
        'conductivity_MSm':[0,58],'frequency_Hz':50,'source_current_peak_A':1,
        'steel_relative_permeability':2000,'steel_conductivity_MSm':0,
        'source_winding_conductivity_MSm':0,'eddy_currents':'Только в отдельном полном кольце.'},
    'omissions': ['торцевое рассеяние плоской модели', 'гистерезис', 'вихревые токи в DC-примерах',
        'нагрев', 'динамика', 'трение', 'упругость', 'промышленная аттестация']}
with open(OUT+'/geometry-contract.json','w') as handle:
    json.dump(CONTRACT,handle)
RECORDS = []
CHECKS = []
NODES = {}
SEGMENTS = {}


def real(value):
    return float(complex(value).real)


def node(point):
    key = (round(point[0],9),round(point[1],9))
    if key not in NODES:
        mi_addnode(key[0],key[1])
        NODES[key] = True
    return key


def segment(a,b,mesh,group=0,boundary='<None>'):
    a,b = node(a),node(b)
    key = tuple(sorted((a,b)))
    if key not in SEGMENTS:
        mi_addsegment(a[0],a[1],b[0],b[1])
        SEGMENTS[key] = True
    mi_selectsegment((a[0]+b[0])/2,(a[1]+b[1])/2)
    mi_setsegmentprop(boundary,mesh,0,0,group)
    mi_clearselected()


def polygon(points,mesh,group):
    for k in range(len(points)):
        segment(points[k],points[(k+1)%len(points)],mesh,group)


def rectangle(x0,y0,x1,y1,mesh,group):
    polygon([[x0,y0],[x1,y0],[x1,y1],[x0,y1]],mesh,group)


def label(x,y,material,mesh,group,circuit='<None>',turns=0):
    mi_addblocklabel(x,y)
    mi_selectlabel(x,y)
    mi_setblockprop(material,0,mesh,circuit,0,group,turns)
    mi_clearselected()


def build(family,gap,current,nonlinear,mesh_factor,domain_factor,frequency=0,ring_sigma=None):
    global NODES,SEGMENTS
    NODES,SEGMENTS = {},{}
    newdocument(0)
    mi_probdef(frequency,'millimeters','planar' if family=='u-core' else 'axi',1e-9,20,25,0)
    mi_setcomment('Самостоятельная учебная модель по методике О. Б. Буля. '+
        'Не копия примера книги. Группы: 1 — неподвижный магнитопровод; '+
        '2 — подвижный якорь; 3 — катушка возбуждения; 4 — воздух; 5 — полное кольцо. '+
        'Два физических зазора по %.8g мм. Ток %.8g А; N=300. '%(gap,current)+
        'Положительная координата y/z направлена вверх; притяжение имеет отрицательный знак. '+
        ('Нелинейная условная B(H).' if nonlinear else 'Линейная учебная сталь, мю=2000.')+
        ' Частота %.8g Гц. '%frequency+
        ('Кольцо полное, осесимметричное, sigma=%g МСм/м. Ток катушки амплитудный.'%ring_sigma if ring_sigma is not None else ''))
    for name,mu in [(AIR,1),(WINDING,1),(LINEAR,2000),(NONLINEAR,2000)]:
        mi_addmaterial(name,mu,mu,0,0,0)
    for b,h in BH:
        mi_addbhpoint(NONLINEAR,b,h)
    if ring_sigma is not None:
        mi_addmaterial(RING,1,1,0,0,ring_sigma)
    steel = NONLINEAR if nonlinear else LINEAR
    mi_addcircprop(CIRCUIT,current,1)
    mi_addboundprop(BOUNDARY,0,0,0,0,0,0,0,0,0)
    hs = .9*mesh_factor
    hg = min(.18,gap/6)*mesh_factor
    ha = 2.2*mesh_factor
    if family=='u-core':
        polygon(CONTRACT[family]['core_polygon_mm'],hs,1)
        rectangle(-30,50+gap,30,60+gap,hs,2)
        for x0,x1,n in [(-40,-32,TURNS),(-18,-10,-TURNS)]:
            rectangle(x0,16,x1,44,hs,3)
            label((x0+x1)/2,30,WINDING,hs,3,CIRCUIT,n)
        label(-25,25,steel,hs,1)
        label(0,55+gap,steel,hs,2)
        for x in [-25,25]:
            mi_selectsegment(x,50)
            mi_setsegmentprop('<None>',hg,0,0,1)
            mi_clearselected()
        mi_selectsegment(0,50+gap)
        mi_setsegmentprop('<None>',hg,0,0,2)
        mi_clearselected()
        x0,x1,y0,y1 = -110*domain_factor,110*domain_factor,-60*domain_factor,120*domain_factor
        for a,b in [((x0,y0),(x1,y0)),((x1,y0),(x1,y1)),((x1,y1),(x0,y1)),((x0,y1),(x0,y0))]:
            segment(a,b,ha,4,BOUNDARY)
        label(0,-20,AIR,ha,4)
    else:
        polygon(CONTRACT[family]['core_polygon_mm'],hs,1)
        rectangle(0,40+gap,30,48+gap,hs,2)
        rectangle(10,10,20,35,hs,3)
        label(15,25,WINDING,hs,3,CIRCUIT,TURNS)
        if ring_sigma is not None:
            rectangle(8.5,35.5,9.5,38.5,.2*mesh_factor,5)
            label(9,37,RING,.2*mesh_factor,5)
        label(4,25,steel,hs,1)
        label(15,44+gap,steel,hs,2)
        for r in [4,26]:
            mi_selectsegment(r,40)
            mi_setsegmentprop('<None>',hg,0,0,1)
            mi_clearselected()
        mi_selectsegment(15,40+gap)
        mi_setsegmentprop('<None>',hg,0,0,2)
        mi_clearselected()
        outer,z0,z1 = 100*domain_factor,-50*domain_factor,110*domain_factor
        # Ось разделена в каждой точке примыкания тела: нет перекрывающихся рёбер.
        cuts = [z0,0,40,40+gap,48+gap,z1]
        for k in range(len(cuts)-1):
            segment((0,cuts[k]),(0,cuts[k+1]),hg if k==2 else hs,4,BOUNDARY)
        for a,b in [((0,z0),(outer,z0)),((outer,z0),(outer,z1)),((outer,z1),(0,z1))]:
            segment(a,b,ha,4,BOUNDARY)
        label(50,20,AIR,ha,4)
    return {'steel_mm':hs,'gap_edge_mm':hg,'air_mm':ha}


def flux(a,b):
    mo_clearcontour()
    mo_addcontour(a[0],a[1])
    mo_addcontour(b[0],b[1])
    values = mo_lineintegral(0)
    mo_clearcontour()
    return abs(real(values[0]))


def measure(family,gap):
    current,voltage,linkage = mo_getcircuitproperties(CIRCUIT)
    mo_clearblock()
    mo_groupselectblock(2)
    force = [real(mo_blockintegral(18)),real(mo_blockintegral(19))]
    volume = real(mo_blockintegral(10))
    mo_clearblock()
    mo_groupselectblock()
    energy = real(mo_blockintegral(2))
    coenergy = real(mo_blockintegral(17))
    mo_clearblock()
    if family=='u-core':
        phi = flux((-29.999,50+gap/2),(-20.001,50+gap/2))
        phi2 = flux((20.001,50+gap/2),(29.999,50+gap/2))
        core_phi = flux((-29.999,12),(-20.001,12))
        probes = [(-25,25),(25,25),(0,5),(0,55+gap)]
        areas = [.0002,.0002]
        expected_volume = 60*10*20*1e-9
    else:
        phi = flux((.001,40+gap/2),(7.999,40+gap/2))
        phi2 = flux((22.001,40+gap/2),(29.999,40+gap/2))
        core_phi = flux((.001,9),(7.999,9))
        probes = [(4,20),(26,20),(15,4),(15,44+gap)]
        areas = [math.pi*.008**2,math.pi*(.030**2-.022**2)]
        expected_volume = math.pi*.030**2*.008
    fields = []
    for x,y in probes:
        values = mo_getpointvalues(x,y)
        fields.append({'point_mm':[x,y],'B_T':[real(values[1]),real(values[2])],
            'B_abs_T':math.sqrt(real(values[1])**2+real(values[2])**2),
            'H_A_per_m':[real(values[5]),real(values[6])],
            'relative_permeability':[real(values[9]),real(values[10])]})
    gap_only_reluctance = gap*.001/MU0*(1/areas[0]+1/areas[1])
    ideal_phi = TURNS*real(current)/gap_only_reluctance
    ideal_force = ideal_phi**2/(2*MU0)*(1/areas[0]+1/areas[1])
    return {'current_A':real(current),'linkage_Wb':real(linkage),'force_xy_or_rz_N':force,
        'attraction_N':-force[1],'energy_J':energy,'coenergy_J':coenergy,
        'energy_identity_relative_error':abs(energy+coenergy-real(current)*real(linkage))/max(abs(real(current)*real(linkage)),1e-20),
        'armature_volume_m3':volume,'armature_volume_exact_m3':expected_volume,
        'armature_volume_relative_error':abs(volume-expected_volume)/expected_volume,
        'central_or_left_gap_flux_Wb':phi,'outer_or_right_gap_flux_Wb':phi2,
        'core_section_flux_Wb':core_phi,'core_to_gap_flux_ratio':core_phi/max(phi,1e-20),
        'flux_ratio_scope':'Отношение интегралов двух объявленных сечений; не универсальный коэффициент всего рассеяния.',
        'gap_average_B_T':[phi/areas[0],phi2/areas[1]],
        'gap_only_circuit_force_N':ideal_force,'gap_only_circuit_flux_Wb':ideal_phi,
        'circuit_scope':'Два зазора последовательно; железо бесконечно проницаемо, поле равномерно, рассеяние отсутствует. Учебное приближение.',
        'steel_probes':fields,'elements':mo_numelements(),'nodes':mo_numnodes()}


def solve(family,tag,gap=1,current=1,nonlinear=False,mesh_factor=1,domain_factor=1):
    mesh = build(family,gap,current,nonlinear,mesh_factor,domain_factor)
    prefix = 'П-магнит' if family=='u-core' else 'Чашечный-магнит'
    stem = '%02d-%s-%s' % (len(RECORDS)+1,prefix,tag)
    path = OUT+'/'+stem+'.fem'
    mi_saveas(path)
    mi_close()
    opendocument(path)
    mi_analyze(1)
    mi_loadsolution()
    row = {'family':family,'tag':tag,'model':stem+'.fem','solution':stem+'.ans',
        'gap_each_mm':gap,'turns':TURNS,'nonlinear':nonlinear,'mesh_factor':mesh_factor,
        'domain_factor':domain_factor,'mesh':mesh,'saved_reopened_solved':True}
    row.update(measure(family,gap))
    mo_close()
    mi_close()
    # Читаем сохранённое решение ещё раз: русское имя цепи обязано сохраниться.
    opendocument(path)
    mi_loadsolution()
    reopened = real(mo_getcircuitproperties(CIRCUIT)[2])
    row['solution_reopened_linkage_Wb'] = reopened
    row['solution_reopened_matches'] = abs(reopened-row['linkage_Wb'])<1e-12
    mo_close()
    mi_close()
    RECORDS.append(row)
    with open(OUT+'/progress.json','w') as handle:
        json.dump({'completed_solves':len(RECORDS),'last':stem},handle)
    print('BUL_SOLVED',family,tag,row['elements'],row['attraction_N'])
    return row


def relative(a,b):
    return abs(a-b)/max(abs(b),1e-15)


def check(name,passed,observed,limit,scope):
    CHECKS.append({'id':name,'passed':bool(passed),'observed':observed,'limit':limit,'scope':scope})


def campaign(family):
    base = solve(family,'базовый')
    check(family+'-volume',base['armature_volume_relative_error']<1e-8,base['armature_volume_relative_error'],1e-8,'Объём выбранного якоря и правильная область силы.')
    if MODE=='smoke':
        return
    medium = solve(family,'сетка-средняя',mesh_factor=.7)
    fine = solve(family,'сетка-мелкая',mesh_factor=.5)
    error = relative(medium['attraction_N'],fine['attraction_N'])
    check(family+'-mesh',error<.02,error,.02,'Последняя пара локально сгущаемых сеток; тот же размер внешней области.')
    domain = solve(family,'область-больше',mesh_factor=.5,domain_factor=1.4)
    error = relative(domain['attraction_N'],fine['attraction_N'])
    check(family+'-domain',error<.015,error,.015,'Внешний прямоугольник увеличен 1.4 раза; локальная сетка сохранена.')
    derivatives = []
    for step in [.1,.05]:
        minus = solve(family,'коэнергия-минус-%g'%step,gap=1-step,mesh_factor=.5)
        plus = solve(family,'коэнергия-плюс-%g'%step,gap=1+step,mesh_factor=.5)
        force = (minus['coenergy_J']-plus['coenergy_J'])/(2*step*.001)
        derivatives.append({'step_mm':step,'attraction_from_coenergy_N':force,
            'weighted_Maxwell_N':fine['attraction_N'],'relative_difference':relative(force,fine['attraction_N']),
            'minus_model':minus['model'],'plus_model':plus['model']})
    error = derivatives[-1]['relative_difference']
    check(family+'-force-methods',error<.04,error,.04,'Взвешенный Maxwell и -dWprime/dgap; ток, оба зазора и длина неизменны.')
    error = relative(derivatives[0]['attraction_from_coenergy_N'],derivatives[1]['attraction_from_coenergy_N'])
    check(family+'-derivative-step',error<.02,error,.02,'Два центральных шага; производная относится к открытию обоих зазоров сразу.')
    linear2 = solve(family,'ток-2А',current=2,mesh_factor=.7)
    error = relative(linear2['attraction_N'],4*medium['attraction_N'])
    check(family+'-I2',error<1e-6,error,1e-6,'Линейная модель: F(2I)=4F(I) на одинаковой геометрии и сетке.')
    for gap in [.5,2,4]:
        solve(family,'зазор-%g'%gap,gap=gap,mesh_factor=.7)
    for current in [1,3,6,10]:
        solve(family,'насыщение-%gА'%current,current=current,nonlinear=True,mesh_factor=.7)
    with open(OUT+'/'+family+'-force-derivative.json','w') as handle:
        json.dump(derivatives,handle)


def pair(value):
    value = complex(value)
    return [value.real,value.imag]


def ac_flux(a,b):
    mo_clearcontour()
    mo_addcontour(a[0],a[1])
    mo_addcontour(b[0],b[1])
    value = complex(mo_lineintegral(0)[0])
    mo_clearcontour()
    return value


def ac_solve(tag,frequency,sigma,mesh_factor=.7,domain_factor=1):
    mesh = build('pot-core',1,1,False,mesh_factor,domain_factor,frequency,sigma)
    stem = '%02d-Чашечный-магнит-%s'%(len(RECORDS)+1,tag)
    path = OUT+'/'+stem+'.fem'
    mi_saveas(path)
    mi_close()
    opendocument(path)
    mi_analyze(1)
    mi_loadsolution()
    source_current,voltage,linkage = mo_getcircuitproperties(CIRCUIT)
    mo_clearblock()
    mo_groupselectblock(2)
    mean = complex(mo_blockintegral(19))
    twice = complex(mo_blockintegral(21)) if frequency else complex(mean)
    mo_clearblock()
    mo_groupselectblock(5)
    ring_current = complex(mo_blockintegral(7))
    ring_joule = real(mo_blockintegral(4))
    ring_volume = real(mo_blockintegral(10))
    mo_clearblock()
    phi = ac_flux((.001,40.5),(7.999,40.5))
    phi2 = ac_flux((22.001,40.5),(29.999,40.5))
    row = {'family':'ac-ring','tag':tag,'model':stem+'.fem','solution':stem+'.ans',
        'frequency_Hz':frequency,'ring_conductivity_MSm':sigma,'gap_each_mm':1,'turns':TURNS,
        'current_peak_A':pair(source_current),'source_current_convention':'peak phasor for AC; 1 A DC for DC control',
        'source_voltage_V':pair(voltage),'linkage_Wb_complex':pair(linkage),
        'mean_force_z_N':pair(mean),'twice_frequency_force_z_N':pair(twice),
        'mean_attraction_N':-mean.real,'twice_frequency_attraction_complex_N':pair(-twice),
        'force_modulation_ratio':abs(twice)/max(abs(mean),1e-20) if frequency else None,
        'ring_current_A':pair(ring_current),'ring_joule_W':ring_joule,'ring_volume_m3':ring_volume,
        'ring_volume_exact_m3':math.pi*(.0095**2-.0085**2)*.003,
        'central_gap_flux_Wb_complex':pair(phi),'outer_gap_flux_Wb_complex':pair(phi2),
        'central_gap_flux_phase_deg':math.atan2(phi.imag,phi.real)*180/math.pi,
        'outer_gap_flux_phase_deg':math.atan2(phi2.imag,phi2.real)*180/math.pi,
        'mesh_factor':mesh_factor,'domain_factor':domain_factor,'mesh':mesh,
        'elements':mo_numelements(),'nodes':mo_numnodes(),'saved_reopened_solved':True,
        'force_reconstruction':'Fz(t)=Fmean+Re(F2f*exp(j*2*omega*t)); только линейный установившийся гармонический опыт.'}
    mo_close()
    mi_close()
    opendocument(path)
    mi_loadsolution()
    reopened = complex(mo_getcircuitproperties(CIRCUIT)[2])
    row['solution_reopened_matches'] = abs(reopened-complex(linkage))<1e-12
    mo_close()
    mi_close()
    RECORDS.append(row)
    with open(OUT+'/progress.json','w') as handle:
        json.dump({'completed_solves':len(RECORDS),'last':stem},handle)
    print('BUL_SOLVED','ac-ring',tag,row['elements'],row['mean_attraction_N'],pair(ring_current))
    return row


def ac_campaign():
    dc = ac_solve('DC-контроль-кольцо0',0,0)
    nonconducting = ac_solve('AC-кольцо0',50,0)
    conducting = ac_solve('AC-кольцо58',50,58)
    fine = ac_solve('AC-кольцо58-сетка',50,58,.5)
    domain = ac_solve('AC-кольцо58-область',50,58,.5,1.4)
    error = relative(nonconducting['mean_attraction_N'],dc['mean_attraction_N']/2)
    check('ac-peak-mean-force',error<1e-6,error,1e-6,'Линейная модель sigma0: одинаковая peak амплитуда AC и DC, средняя сила AC вдвое меньше.')
    error = abs(nonconducting['force_modulation_ratio']-1)
    check('ac-no-ring-modulation',error<1e-6,error,1e-6,'Без проводимости все поля синфазны: амплитуда 2f равна модулю средней силы.')
    check('ac-ring-loss',conducting['ring_joule_W']>0,conducting['ring_joule_W'],0,'Пассивное полное кольцо рассеивает положительную среднюю мощность.')
    check('ac-ring-current',abs(complex(*conducting['ring_current_A']))>1e-5,conducting['ring_current_A'],1e-5,'Ненулевой наведённый ток в полном кольце.')
    for key in ['mean_attraction_N','ring_joule_W']:
        error = relative(conducting[key],fine[key])
        check('ac-mesh-'+key,error<.02,error,.02,'Сгущение локальной сетки, остальные параметры прежние.')
        error = relative(domain[key],fine[key])
        check('ac-domain-'+key,error<.015,error,.015,'Расширенная внешняя область, локальная сетка прежняя.')
    for row in [dc,nonconducting,conducting,fine,domain]:
        error = relative(row['ring_volume_m3'],row['ring_volume_exact_m3'])
        check(row['tag']+'-volume',error<1e-8,error,1e-8,'Объём полного кольца по интегралу 10, с полным 2pi.')
        check(row['tag']+'-roundtrip',row['solution_reopened_matches'],row['solution_reopened_matches'],True,'Русские имена; модель и сохранённое решение открыты повторно.')


for family in ['u-core','pot-core']:
    campaign(family)
for row in RECORDS:
    check(row['model']+'-roundtrip',row['solution_reopened_matches'],row['solution_reopened_linkage_Wb'],row['linkage_Wb'],'Повторное открытие точного сохранённого поля, русское имя цепи.')
    check(row['model']+'-energy',row['energy_identity_relative_error']<.003,row['energy_identity_relative_error'],.003,'W+Wprime=I*Psi для одной цепи без постоянных магнитов.')
    check(row['model']+'-attraction',row['attraction_N']>0,row['attraction_N'],0,'Притяжение к неподвижному магнитопроводу: Fy/Fz<0.')
if MODE!='smoke':
    ac_campaign()
result = {'schema':'bul-apparatus-native-v1','implementation':repr(sys.implementation),'mode':MODE,
    'geometry_revision':CONTRACT['revision'],'source_scope':CONTRACT['source_scope'],
    'records':RECORDS,'checks':CHECKS,'passed':all(row['passed'] for row in CHECKS),
    'scope':'Два магнитостатических семейства и линейный гармонический контроль полного кольца FEMM 5; сохранение-переоткрытие-расчёт с русскими именами. Не испытание аппарата.'}
with open(OUT+'/result.json','w') as handle:
    json.dump(result,handle)
print('BUL_CAMPAIGN_PASSED',result['passed'],len(RECORDS),OUT)
if not result['passed']:
    raise RuntimeError('Проверка не пройдена; сохраните все результаты и изучите checks в result.json')
